FazBrowse GitHub Viewer
|
Trending
|
URL:
|
Home
Tools:
[Download Repo ZIP]
[View Raw Code]
[Original HTTPS Page]
levelcode/tools/sync-server/server.js at develop · levelcodeai/levelcode · GitHub
Uh oh!
There was an error while loading.
Please reload this page
.
levelcodeai
/
levelcode
Public
Notifications
You must be signed in to change notification settings
Fork
0
Star
3
Code
Issues
0
Pull requests
3
Discussions
Actions
Projects
Security and quality
0
Insights
Additional navigation options
Code
Issues
Pull requests
Discussions
Actions
Projects
Security and quality
Insights
Expand file tree
Breadcrumbs
levelcode
/
tools
/
sync-server
/
server.js
Copy path
More file actions
More file actions
Latest commit
History
History
History
256 lines (226 loc) · 10.6 KB
Breadcrumbs
levelcode
/
tools
/
sync-server
/
server.js
Copy path
File metadata and controls
256 lines (226 loc) · 10.6 KB
Raw
Copy raw file
Download raw file
Open symbols panel
Edit and raw actions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
/*---------------------------------------------------------------------------------------------
* LevelCode — reference Settings Sync server
*
* A tiny, dependency-free implementation of the Code-OSS user-data-sync REST contract
* (the same protocol the editor's built-in Settings Sync speaks). Use it to:
* • develop/test LevelCode Sync end-to-end with no cloud backend, and
* • self-host the FREE sync tier (BYO storage).
* LevelCode Cloud implements the same contract for managed sync.
*
* Storage is file-backed and isolated per bearer token (account). Bodies are opaque text —
* the server never parses settings, so client-side E2E encryption is trivial to add later.
* Supports user-scoped resources AND profile collections (/v1/collection/{id}/resource/...).
*
* Run: PORT=9595 DATA_DIR=./.data node server.js
*--------------------------------------------------------------------------------------------*/
//
@ts
-check
'use strict'
;
const
http
=
require
(
'http'
)
;
const
fs
=
require
(
'fs'
)
;
const
path
=
require
(
'path'
)
;
const
crypto
=
require
(
'crypto'
)
;
const
PORT
=
parseInt
(
process
.
env
.
PORT
||
'9595'
,
10
)
;
const
DATA_DIR
=
process
.
env
.
DATA_DIR
||
path
.
join
(
__dirname
,
'.data'
)
;
const
LOG
=
require
.
main
===
module
;
// log requests only when run as a server (quiet under tests)
function
shortHash
(
s
)
{
return
crypto
.
createHash
(
'sha256'
)
.
update
(
String
(
s
)
)
.
digest
(
'hex'
)
.
slice
(
0
,
16
)
;
}
function
accountDir
(
token
)
{
return
path
.
join
(
DATA_DIR
,
shortHash
(
token
)
)
;
}
function
ensureDir
(
d
)
{
fs
.
mkdirSync
(
d
,
{
recursive
:
true
}
)
;
}
/** Read the persisted session id for an account, or null (none until the first write). */
function
getSession
(
acct
)
{
try
{
return
fs
.
readFileSync
(
path
.
join
(
acct
,
'session'
)
,
'utf8'
)
||
null
;
}
catch
{
return
null
;
}
}
/** Ensure a session id exists (created at first write, rotated by clear()); returns it. */
function
ensureSession
(
acct
)
{
let
s
=
getSession
(
acct
)
;
if
(
!
s
)
{
s
=
crypto
.
randomBytes
(
8
)
.
toString
(
'hex'
)
;
ensureDir
(
acct
)
;
fs
.
writeFileSync
(
path
.
join
(
acct
,
'session'
)
,
s
,
'utf8'
)
;
}
return
s
;
}
// Resources live under a BASE dir: the account root (user resources) or a collection dir.
/** Read a resource's {ref, content} under `base`, or null. */
function
readResource
(
base
,
type
)
{
try
{
return
JSON
.
parse
(
fs
.
readFileSync
(
path
.
join
(
base
,
'resource'
,
type
+
'.json'
)
,
'utf8'
)
)
;
}
catch
{
return
null
;
}
}
/** Write a resource under `base`, bumping its ref. Returns the new ref (string). */
function
writeResource
(
base
,
type
,
content
)
{
const
prev
=
readResource
(
base
,
type
)
;
const
nextRef
=
String
(
(
prev
?
parseInt
(
prev
.
ref
,
10
)
:
0
)
+
1
)
;
ensureDir
(
path
.
join
(
base
,
'resource'
)
)
;
fs
.
writeFileSync
(
path
.
join
(
base
,
'resource'
,
type
+
'.json'
)
,
JSON
.
stringify
(
{
ref
:
nextRef
,
content
}
)
,
'utf8'
)
;
// keep history so GET /resource/:type/:ref works (a self-host superset of the core contract)
ensureDir
(
path
.
join
(
base
,
'history'
,
type
)
)
;
fs
.
writeFileSync
(
path
.
join
(
base
,
'history'
,
type
,
nextRef
)
,
content
,
'utf8'
)
;
return
nextRef
;
}
function
deleteResource
(
base
,
type
)
{
fs
.
rmSync
(
path
.
join
(
base
,
'resource'
,
type
+
'.json'
)
,
{
force
:
true
}
)
;
fs
.
rmSync
(
path
.
join
(
base
,
'history'
,
type
)
,
{
recursive
:
true
,
force
:
true
}
)
;
}
/** Map of {type: ref} for every resource currently stored under `base`. */
function
latestOf
(
base
)
{
const
out
=
{
}
;
const
rdir
=
path
.
join
(
base
,
'resource'
)
;
if
(
fs
.
existsSync
(
rdir
)
)
{
for
(
const
f
of
fs
.
readdirSync
(
rdir
)
)
{
if
(
!
f
.
endsWith
(
'.json'
)
)
{
continue
;
}
const
t
=
f
.
slice
(
0
,
-
5
)
;
const
r
=
readResource
(
base
,
t
)
;
if
(
r
)
{
out
[
t
]
=
r
.
ref
;
}
}
}
return
out
;
}
function
collectionsDir
(
acct
)
{
return
path
.
join
(
acct
,
'collections'
)
;
}
function
collectionDir
(
acct
,
id
)
{
return
path
.
join
(
collectionsDir
(
acct
)
,
id
)
;
}
function
listCollections
(
acct
)
{
try
{
return
fs
.
readdirSync
(
collectionsDir
(
acct
)
)
.
filter
(
id
=>
fs
.
statSync
(
collectionDir
(
acct
,
id
)
)
.
isDirectory
(
)
)
;
}
catch
{
return
[
]
;
}
}
/** Build the manifest for an account, or null if it has never been written (→ 204). */
function
buildManifest
(
acct
)
{
const
session
=
getSession
(
acct
)
;
if
(
!
session
)
{
return
null
;
}
const
latest
=
latestOf
(
acct
)
;
const
collections
=
{
}
;
for
(
const
id
of
listCollections
(
acct
)
)
{
collections
[
id
]
=
{
latest
:
latestOf
(
collectionDir
(
acct
,
id
)
)
}
;
}
const
ref
=
shortHash
(
session
+
':'
+
JSON
.
stringify
(
latest
)
+
':'
+
JSON
.
stringify
(
collections
)
)
;
return
{
session
,
ref
,
latest
,
collections
}
;
}
/** Build the bulk "download all" payload (IDownloadLatestDataType shape). */
function
buildDownload
(
acct
)
{
const
dump
=
(
base
)
=>
{
const
resources
=
{
}
;
for
(
const
t
of
Object
.
keys
(
latestOf
(
base
)
)
)
{
const
r
=
readResource
(
base
,
t
)
;
if
(
r
)
{
resources
[
t
]
=
[
{
ref
:
r
.
ref
,
content
:
r
.
content
}
]
;
}
}
return
resources
;
}
;
const
collections
=
{
}
;
for
(
const
id
of
listCollections
(
acct
)
)
{
collections
[
id
]
=
{
resources
:
dump
(
collectionDir
(
acct
,
id
)
)
}
;
}
return
{
resources
:
dump
(
acct
)
,
collections
}
;
}
function
send
(
res
,
status
,
body
,
headers
)
{
res
.
writeHead
(
status
,
Object
.
assign
(
{
'Cache-Control'
:
'no-store'
}
,
headers
||
{
}
)
)
;
res
.
end
(
body
)
;
}
function
readBody
(
req
)
{
return
new
Promise
(
(
resolve
)
=>
{
let
data
=
''
;
req
.
on
(
'data'
,
(
c
)
=>
{
data
+=
c
;
}
)
;
req
.
on
(
'end'
,
(
)
=>
resolve
(
data
)
)
;
}
)
;
}
/** GET a resource under `base`: ref='latest' → current (204+ETag '0' if none); else a historical ref. */
function
resourceGet
(
req
,
res
,
base
,
type
,
ref
)
{
if
(
ref
===
'latest'
)
{
const
rec
=
readResource
(
base
,
type
)
;
if
(
!
rec
)
{
return
send
(
res
,
204
,
''
,
{
'ETag'
:
'0'
}
)
;
}
const
inm
=
req
.
headers
[
'if-none-match'
]
;
if
(
inm
&&
inm
===
rec
.
ref
)
{
return
send
(
res
,
304
,
''
,
{
'ETag'
:
rec
.
ref
}
)
;
}
return
send
(
res
,
200
,
rec
.
content
,
{
'Content-Type'
:
'text/plain'
,
'ETag'
:
rec
.
ref
}
)
;
}
try
{
return
send
(
res
,
200
,
fs
.
readFileSync
(
path
.
join
(
base
,
'history'
,
type
,
ref
)
,
'utf8'
)
,
{
'Content-Type'
:
'text/plain'
,
'ETag'
:
ref
}
)
;
}
catch
{
return
send
(
res
,
404
,
'No such ref'
)
;
}
}
/** POST a resource under `base` with If-Match optimistic concurrency ('0' = non-existing ref). */
async
function
resourcePost
(
req
,
res
,
base
,
type
)
{
const
prev
=
readResource
(
base
,
type
)
;
const
cur
=
prev
?
prev
.
ref
:
'0'
;
const
ifMatch
=
req
.
headers
[
'if-match'
]
;
if
(
ifMatch
!==
undefined
&&
ifMatch
!==
'*'
&&
ifMatch
!==
cur
)
{
return
send
(
res
,
412
,
'Precondition Failed'
,
{
'ETag'
:
cur
}
)
;
}
const
content
=
await
readBody
(
req
)
;
return
send
(
res
,
200
,
''
,
{
'ETag'
:
writeResource
(
base
,
type
,
content
)
}
)
;
}
const
server
=
http
.
createServer
(
async
(
req
,
res
)
=>
{
if
(
LOG
)
{
res
.
on
(
'finish'
,
(
)
=>
{
const
u
=
req
.
url
||
''
;
if
(
u
!==
'/'
&&
u
!==
'/health'
)
{
// log everything (incl. misdirected /api/update → 404)
console
.
log
(
'[sync] '
+
(
req
.
method
||
'GET'
)
+
' '
+
u
+
' → '
+
res
.
statusCode
)
;
}
}
)
;
}
const
url
=
(
req
.
url
||
''
)
.
split
(
'?'
)
[
0
]
;
const
method
=
req
.
method
||
'GET'
;
if
(
url
===
'/'
||
url
===
'/health'
)
{
return
send
(
res
,
200
,
'LevelCode reference sync server\n'
,
{
'Content-Type'
:
'text/plain'
}
)
;
}
const
auth
=
req
.
headers
[
'authorization'
]
||
''
;
const
tok
=
/
^
B
e
a
r
e
r
\s
+
(
.
+
)
$
/
i
.
exec
(
String
(
auth
)
)
;
if
(
!
url
.
startsWith
(
'/v1'
)
)
{
return
send
(
res
,
404
,
'Not found'
)
;
}
if
(
!
tok
)
{
return
send
(
res
,
401
,
'Missing bearer token'
)
;
}
const
acct
=
accountDir
(
tok
[
1
]
)
;
// GET /v1/manifest (204 with no body until the first write creates a session)
if
(
url
===
'/v1/manifest'
&&
method
===
'GET'
)
{
const
manifest
=
buildManifest
(
acct
)
;
if
(
!
manifest
)
{
return
send
(
res
,
204
,
''
,
{
'ETag'
:
'0'
}
)
;
}
const
inm
=
req
.
headers
[
'if-none-match'
]
;
if
(
inm
&&
inm
===
manifest
.
ref
)
{
return
send
(
res
,
304
,
''
)
;
}
return
send
(
res
,
200
,
JSON
.
stringify
(
manifest
)
,
{
'Content-Type'
:
'application/json'
,
'ETag'
:
manifest
.
ref
}
)
;
}
// GET /v1/download/latest (bulk snapshot; 204 before any data)
if
(
url
===
'/v1/download/latest'
&&
method
===
'GET'
)
{
if
(
!
getSession
(
acct
)
)
{
return
send
(
res
,
204
,
''
)
;
}
return
send
(
res
,
200
,
JSON
.
stringify
(
buildDownload
(
acct
)
)
,
{
'Content-Type'
:
'application/json'
}
)
;
}
// Collection-scoped resource: /v1/collection/{cid}/resource/{type}[/{ref}]
let
m
=
/
^
\/
v
1
\/
c
o
l
l
e
c
t
i
o
n
\/
(
[
^
/
]
+
)
\/
r
e
s
o
u
r
c
e
\/
(
[
^
/
]
+
)
(?:
\/
(
[
^
/
]
+
)
)
?
$
/
.
exec
(
url
)
;
if
(
m
)
{
const
base
=
collectionDir
(
acct
,
m
[
1
]
)
;
if
(
!
fs
.
existsSync
(
base
)
)
{
return
send
(
res
,
404
,
'No such collection'
)
;
}
if
(
method
===
'GET'
&&
m
[
3
]
)
{
return
resourceGet
(
req
,
res
,
base
,
m
[
2
]
,
m
[
3
]
)
;
}
if
(
method
===
'POST'
&&
!
m
[
3
]
)
{
ensureSession
(
acct
)
;
return
resourcePost
(
req
,
res
,
base
,
m
[
2
]
)
;
}
if
(
method
===
'DELETE'
)
{
deleteResource
(
base
,
m
[
2
]
)
;
return
send
(
res
,
200
,
''
)
;
}
return
send
(
res
,
404
,
'Not found'
)
;
}
// Collections (user-data profiles): list / create / delete
if
(
url
===
'/v1/collection'
&&
method
===
'GET'
)
{
return
send
(
res
,
200
,
JSON
.
stringify
(
listCollections
(
acct
)
.
map
(
id
=>
(
{
id
}
)
)
)
,
{
'Content-Type'
:
'application/json'
}
)
;
}
if
(
url
===
'/v1/collection'
&&
method
===
'POST'
)
{
ensureSession
(
acct
)
;
const
id
=
shortHash
(
crypto
.
randomBytes
(
8
)
.
toString
(
'hex'
)
)
;
ensureDir
(
collectionDir
(
acct
,
id
)
)
;
return
send
(
res
,
200
,
id
,
{
'Content-Type'
:
'text/plain'
}
)
;
}
if
(
url
===
'/v1/collection'
&&
method
===
'DELETE'
)
{
fs
.
rmSync
(
collectionsDir
(
acct
)
,
{
recursive
:
true
,
force
:
true
}
)
;
return
send
(
res
,
200
,
''
)
;
}
m
=
/
^
\/
v
1
\/
c
o
l
l
e
c
t
i
o
n
\/
(
[
^
/
]
+
)
$
/
.
exec
(
url
)
;
if
(
m
&&
method
===
'DELETE'
)
{
fs
.
rmSync
(
collectionDir
(
acct
,
m
[
1
]
)
,
{
recursive
:
true
,
force
:
true
}
)
;
return
send
(
res
,
200
,
''
)
;
}
// DELETE /v1/resource (clear all — the client's clear() / resetRemote()). Rotates the session.
if
(
url
===
'/v1/resource'
&&
method
===
'DELETE'
)
{
fs
.
rmSync
(
path
.
join
(
acct
,
'resource'
)
,
{
recursive
:
true
,
force
:
true
}
)
;
fs
.
rmSync
(
path
.
join
(
acct
,
'history'
)
,
{
recursive
:
true
,
force
:
true
}
)
;
fs
.
rmSync
(
collectionsDir
(
acct
)
,
{
recursive
:
true
,
force
:
true
}
)
;
fs
.
rmSync
(
path
.
join
(
acct
,
'session'
)
,
{
force
:
true
}
)
;
return
send
(
res
,
204
,
''
)
;
}
// Top-level resource: /v1/resource/{type}[/{ref}]
m
=
/
^
\/
v
1
\/
r
e
s
o
u
r
c
e
\/
(
[
^
/
]
+
)
(?:
\/
(
[
^
/
]
+
)
)
?
$
/
.
exec
(
url
)
;
if
(
m
)
{
if
(
method
===
'GET'
&&
m
[
2
]
)
{
return
resourceGet
(
req
,
res
,
acct
,
m
[
1
]
,
m
[
2
]
)
;
}
if
(
method
===
'POST'
&&
!
m
[
2
]
)
{
ensureSession
(
acct
)
;
return
resourcePost
(
req
,
res
,
acct
,
m
[
1
]
)
;
}
if
(
method
===
'DELETE'
)
{
deleteResource
(
acct
,
m
[
1
]
)
;
return
send
(
res
,
200
,
''
)
;
}
}
return
send
(
res
,
404
,
'Not found: '
+
method
+
' '
+
url
)
;
}
)
;
if
(
require
.
main
===
module
)
{
ensureDir
(
DATA_DIR
)
;
server
.
listen
(
PORT
,
(
)
=>
{
// eslint-disable-next-line no-console
console
.
log
(
'LevelCode reference sync server on http://localhost:'
+
PORT
+
' (data: '
+
DATA_DIR
+
')'
)
;
console
.
log
(
'Watch this terminal — every editor sync request prints as a [sync] line.'
)
;
}
)
;
}
module
.
exports
=
{
server
,
buildManifest
,
buildDownload
,
readResource
,
writeResource
,
accountDir
}
;
Back
|
FazBrowse Home
|
New Git URL