/*--------------------------------------------------------------------------------------------- * 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 = /^Bearer\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 = /^\/v1\/collection\/([^/]+)\/resource\/([^/]+)(?:\/([^/]+))?$/.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 = /^\/v1\/collection\/([^/]+)$/.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 = /^\/v1\/resource\/([^/]+)(?:\/([^/]+))?$/.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 };