@dreamteamer/web 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +202 -112
- package/UPDATING.md +45 -0
- package/bin/dt-web-hook.mjs +21 -0
- package/bin/dt-web.mjs +2 -1
- package/commands/web/show.command.md +6 -0
- package/package.json +10 -7
- package/skills/serving-the-web-ui/SKILL.md +67 -40
- package/src/cli.js +378 -39
- package/src/dispatch.js +16 -0
- package/src/extension.js +34 -8
- package/src/handoff.js +67 -0
- package/src/headers.js +15 -9
- package/src/hook.js +150 -0
- package/src/module-frames.js +3 -2
- package/src/pairing.js +28 -0
- package/src/pane-store.js +116 -0
- package/src/runtime.js +177 -15
- package/src/server.js +497 -170
- package/src/sessions.js +37 -0
- package/src/state.js +143 -0
- package/src/tokens.js +24 -0
package/src/server.js
CHANGED
|
@@ -1,23 +1,49 @@
|
|
|
1
|
-
// dt web's
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// dt web's ONE loopback listener (cr-modules-017 §1). A strict prefix dispatcher (dispatch.js) names what a path is
|
|
2
|
+
// before anything else runs: the shell (/app/, no cookie, no data), sign-in (/sign-in/<code>), the protocol (/api/,
|
|
3
|
+
// the cookie with Fetch-Metadata), the server's own routes (/_dt/), record files (/_files/<key>/, sandboxed) and module
|
|
4
|
+
// frames (/_frames/<key>/, sandbox allow-scripts). Every rule here is written in modules/web/README.md.
|
|
4
5
|
import crypto from 'node:crypto';
|
|
5
6
|
import fs from 'node:fs';
|
|
6
7
|
import http from 'node:http';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import { createHandler } from '@dreamteamer/protocol/handler';
|
|
10
|
+
import { validateSelection } from '@dreamteamer/protocol/pane';
|
|
9
11
|
import { engine } from '@dreamteamer/protocol/host/engine';
|
|
10
12
|
import { envRoots } from '@dreamteamer/protocol/host/folder-images';
|
|
11
|
-
import { changeScope, invalidateMessage } from '@dreamteamer/protocol/host/changes';
|
|
13
|
+
import { changeScope, invalidateMessage, NAV_DOC_FILE } from '@dreamteamer/protocol/host/changes';
|
|
14
|
+
import { hasNavSettings } from '@dreamteamer/protocol/host/nav';
|
|
15
|
+
import { migrateNavSettings } from '@dreamteamer/protocol/nav-doc';
|
|
16
|
+
import { workspaceNavSettings } from '@dreamteamer/protocol/nav-model';
|
|
17
|
+
import { gate } from '@dreamteamer/protocol/gate';
|
|
18
|
+
import { internalCollections, LEVELS, levelRank, lowerLevel, paneScope } from '@dreamteamer/protocol/pane-scope';
|
|
19
|
+
import { matchRoute } from '@dreamteamer/protocol/routes';
|
|
12
20
|
import { appCsp, baseHeaders, FILE_CSP } from './headers.js';
|
|
13
|
-
import {
|
|
21
|
+
import { classify } from './dispatch.js';
|
|
14
22
|
import { moduleFrameRoute, resolveGuest } from './module-frames.js';
|
|
23
|
+
import { createPairing } from './pairing.js';
|
|
24
|
+
import { createSessions } from './sessions.js';
|
|
25
|
+
import { readWorkspaceState, stateDir as defaultStateDir, workspaceKey, writeWorkspaceState } from './state.js';
|
|
26
|
+
import { matchClientToken, mintClientToken } from './tokens.js';
|
|
27
|
+
import { createPaneStore, workspaceName } from './pane-store.js';
|
|
28
|
+
import { CHALLENGE, IDENTITY_PATH, identityProof } from './runtime.js';
|
|
29
|
+
import { handoff, listWorkspaces } from './handoff.js';
|
|
15
30
|
|
|
16
|
-
const SIGN_IN_TTL_MS = 10 * 60_000;
|
|
17
31
|
const DEBOUNCE_MS = 150;
|
|
32
|
+
const MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
33
|
+
export const NAV_MIGRATED_NOTICE = 'nav.yaml written from your nav settings — they are no longer read';
|
|
34
|
+
export const SIGN_IN_REFUSED = 'This sign-in link was used or is older than a minute — ask the chat to show the pane again (dt web open).';
|
|
35
|
+
export const NOT_FROM_THE_PANE = "a request with the pane's cookie must come from the pane";
|
|
36
|
+
export const CLIENT_NOT_PANE = "a client token cannot set the pane's selection or queue — they are the page's";
|
|
37
|
+
export const PAGE_CANNOT_RAISE = (level) => `the page cannot raise the level — ask in the chat (dt web open --${level})`;
|
|
38
|
+
const SIGN_IN_FIRST = 'sign in: ask the chat to show the pane (dt web open)';
|
|
39
|
+
export const HOST_CAPABILITIES = { nav: 'app', commands: false, openFile: 'tab', tabs: false, history: 'hash', moduleFrames: 'url' };
|
|
18
40
|
const random = (n = 24) => crypto.randomBytes(n).toString('base64url');
|
|
19
41
|
const same = (a, b) => typeof a === 'string' && typeof b === 'string' && a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
20
|
-
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.ttf': 'font/ttf', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ico': 'image/x-icon', '.pdf': 'application/pdf', '.txt': 'text/plain; charset=utf-8' };
|
|
42
|
+
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.ttf': 'font/ttf', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ico': 'image/x-icon', '.pdf': 'application/pdf', '.txt': 'text/plain; charset=utf-8', '.map': 'application/json' };
|
|
43
|
+
/** A record file's bytes are data, never code or style the browser would apply. */
|
|
44
|
+
const NEVER_ACTIVE = new Set(['.js', '.mjs', '.cjs', '.css']);
|
|
45
|
+
const WRITES = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
46
|
+
const JSON_TYPE = /^application\/json\s*(;|$)/i;
|
|
21
47
|
|
|
22
48
|
/** `file` under `root` after resolving every link, or null — `..`, absolute paths and links out are refused. */
|
|
23
49
|
export function contained(root, rel) {
|
|
@@ -28,24 +54,71 @@ export function contained(root, rel) {
|
|
|
28
54
|
}
|
|
29
55
|
|
|
30
56
|
const hostNames = (port) => new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
|
|
57
|
+
/** The methods each prefix answers, for the Allow header of a 405 to OPTIONS. */
|
|
58
|
+
const ALLOW = { root: 'GET, HEAD', shell: 'GET, HEAD', 'sign-in': 'GET', api: 'GET, HEAD, POST, PUT, PATCH, DELETE', dt: 'GET, POST', files: 'GET, HEAD', frames: 'GET, HEAD' };
|
|
59
|
+
class HttpError extends Error { constructor(status, message) { super(message); this.status = status; } }
|
|
60
|
+
const decode = (s) => { try { return decodeURIComponent(s); } catch { throw new HttpError(400, 'a malformed percent-escape in the path'); } };
|
|
31
61
|
|
|
32
62
|
/**
|
|
33
|
-
* Start dt web for the workspace at `root
|
|
34
|
-
*
|
|
63
|
+
* Start dt web for the workspace at `root` on one listener. `appDir` holds the built app (index.html + assets/) —
|
|
64
|
+
* @dreamteamer/ui's dist/app. `stateDir` is the per-user state dir (state.js); `pane` the PaneStore the protocol's
|
|
65
|
+
* pane routes answer from (absent: the handler's own memory store).
|
|
66
|
+
* Resolves `{ url, port, controlToken, instance, filesKey, pair({ route?, cap? }) → url, broadcast(msg), clients() → number, close(why?), closed, movedFrom? }` —
|
|
67
|
+
* `instance` the per-start id the runtime file keeps and GET /_dt/identity proves, `clients()` the pages connected with
|
|
68
|
+
* the cookie (a client token's stream is not one), `movedFrom` the stored port it could not take.
|
|
35
69
|
*/
|
|
36
|
-
export async function startWeb({ root, appDir, port = 0, idleMinutes =
|
|
70
|
+
export async function startWeb({ root, appDir, port = 0, idleMinutes = 60, ownerPid, onStop, now = Date.now, stateDir = defaultStateDir(), moduleGuest, pane } = {}) {
|
|
37
71
|
if (!fs.existsSync(path.join(appDir, 'index.html'))) {
|
|
38
72
|
throw new Error(`the app is missing: ${path.join(appDir, 'index.html')} — @dreamteamer/ui is installed without its build (see the README)`);
|
|
39
73
|
}
|
|
40
|
-
const
|
|
41
|
-
const
|
|
74
|
+
const state = readWorkspaceState(root, stateDir);
|
|
75
|
+
const save = () => writeWorkspaceState(root, state, stateDir);
|
|
76
|
+
/** The client tokens as `next`: the state file first, then memory — a write that throws changes neither. */
|
|
77
|
+
const saveTokens = (next) => {
|
|
78
|
+
writeWorkspaceState(root, { ...state, clientTokens: next }, stateDir);
|
|
79
|
+
state.clientTokens = next;
|
|
80
|
+
};
|
|
81
|
+
const sessions = createSessions({ now });
|
|
82
|
+
const pairing = createPairing({ now });
|
|
83
|
+
const controlToken = random(32);
|
|
84
|
+
const instance = random(32);
|
|
42
85
|
const filesKey = random(18);
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
86
|
+
let handler = null;
|
|
87
|
+
const store = pane ?? createPaneStore({
|
|
88
|
+
root, state, save, filesKey, now,
|
|
89
|
+
workspace: { id: workspaceKey(root), name: workspaceName(root) },
|
|
90
|
+
fileBases: () => fileBases(),
|
|
91
|
+
varRoots: () => varRoots(),
|
|
92
|
+
onChange: (type) => broadcast({ type }),
|
|
93
|
+
});
|
|
94
|
+
handler = await createHandler({ root, pane: store });
|
|
95
|
+
// cr-dt-008: the old nav settings are written into nav.yaml on the first GET /nav of this server's life, once; a
|
|
96
|
+
// failure is logged and the request still answers (the legacy tree, from the settings)
|
|
97
|
+
let navMigrationTried = false;
|
|
98
|
+
const migrateNavOnce = () => {
|
|
99
|
+
if (navMigrationTried) return false;
|
|
100
|
+
navMigrationTried = true;
|
|
101
|
+
try {
|
|
102
|
+
if (fs.existsSync(path.join(root, NAV_DOC_FILE))) return false;
|
|
103
|
+
const settings = workspaceNavSettings(root);
|
|
104
|
+
return hasNavSettings(settings) && migrateNavSettings(root, settings).written;
|
|
105
|
+
} catch (e) {
|
|
106
|
+
console.error(`dt web: your nav settings were not written into nav.yaml — ${String(e?.message ?? e).split('\n')[0]}`);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// the event streams: the PAGES (the cookie) are what `dt web show` delivers to, what the hook calls connected and
|
|
112
|
+
// what keeps the server from its idle stop; a client token's stream hears the same messages and counts for none of it
|
|
113
|
+
const pageStreams = new Set();
|
|
114
|
+
/** @type {Map<import('node:http').ServerResponse, object>} each client token's stream → its token record */
|
|
115
|
+
const clientStreams = new Map();
|
|
116
|
+
/** End the streams of a client token that is no longer in the state (revoked, or minted again under its name). */
|
|
117
|
+
const endRevokedStreams = () => { for (const [res, rec] of clientStreams) if (!state.clientTokens.includes(rec)) res.end(); };
|
|
118
|
+
let listenPort;
|
|
119
|
+
const cookieName = () => `dt_pane_${listenPort}`;
|
|
47
120
|
|
|
48
|
-
// ----
|
|
121
|
+
// ---- record files and module frames --------------------------------------------------------
|
|
49
122
|
const varRoots = () => { try { const e = engine(); return envRoots(e.declaredVars ?? [], e.renderEnv); } catch { return []; } };
|
|
50
123
|
const fileBases = () => {
|
|
51
124
|
const out = [];
|
|
@@ -55,38 +128,21 @@ export async function startWeb({ root, appDir, port = 0, idleMinutes = 30, owner
|
|
|
55
128
|
} catch { /* no binary collections */ }
|
|
56
129
|
return out;
|
|
57
130
|
};
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
try {
|
|
63
|
-
serveFile(req, res);
|
|
64
|
-
} catch {
|
|
65
|
-
if (res.headersSent) return void res.destroy();
|
|
66
|
-
send(res, 400, 'bad request');
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
const serveFile = (req, res) => {
|
|
70
|
-
baseHeaders(res);
|
|
131
|
+
// cr-modules-008: module apps run in frames; `moduleGuest: null` forces the missing-guest path in tests
|
|
132
|
+
const frameRoute = moduleFrameRoute({ root, appPort: () => listenPort, guest: moduleGuest === undefined ? resolveGuest(path.dirname(new URL(import.meta.url).pathname)) : moduleGuest });
|
|
133
|
+
|
|
134
|
+
const serveFile = (req, res, url, rest) => {
|
|
71
135
|
res.setHeader('Content-Security-Policy', FILE_CSP);
|
|
72
|
-
if (!hostNames(filesPort).has(String(req.headers.host ?? '').toLowerCase())) return send(res, 403, 'not this server');
|
|
73
|
-
if (req.headers['sec-fetch-site'] === 'cross-site') return send(res, 403, 'cross-site');
|
|
74
136
|
if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'read only');
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const kind = parts[1];
|
|
79
|
-
const rest = parts.slice(2).map((p) => { try { return decodeURIComponent(p); } catch { return '\0'; } });
|
|
80
|
-
if (kind === '_dt' && rest[0] === 'frame') {
|
|
81
|
-
for (const r of frameRoutes) { const out = r(rest.slice(1), req); if (out) return out.status ? send(res, out.status, out.body) : sendFile(res, out.body, out.type ?? 'text/html; charset=utf-8', out.headers); }
|
|
82
|
-
return send(res, 404, 'no frame');
|
|
83
|
-
}
|
|
137
|
+
const [key, kind, ...tail] = rest;
|
|
138
|
+
if (!same(key, filesKey)) return send(res, 404, 'not found');
|
|
139
|
+
const parts = tail.map(decode);
|
|
84
140
|
let file = null;
|
|
85
141
|
if (kind === 'v') {
|
|
86
|
-
const r = varRoots().find((x) => x.name ===
|
|
87
|
-
if (r) file = contained(r.path,
|
|
142
|
+
const r = varRoots().find((x) => x.name === parts[0]);
|
|
143
|
+
if (r) file = contained(r.path, parts.slice(1).join('/'));
|
|
88
144
|
} else if (kind === 'w') {
|
|
89
|
-
const rel =
|
|
145
|
+
const rel = parts.join('/');
|
|
90
146
|
const allowed = rel.endsWith('.html') && !rel.split('/').some((s) => s.startsWith('.') || s === 'node_modules')
|
|
91
147
|
|| fileBases().some((b) => rel.startsWith(b.path.replace(/\\/g, '/') + '/'));
|
|
92
148
|
if (allowed) file = contained(root, rel);
|
|
@@ -95,140 +151,388 @@ export async function startWeb({ root, appDir, port = 0, idleMinutes = 30, owner
|
|
|
95
151
|
const ext = path.extname(file).toLowerCase();
|
|
96
152
|
const headers = {};
|
|
97
153
|
if ((ext === '.svg' || ext === '.html') && url.searchParams.get('frame') !== '1') headers['Content-Disposition'] = 'attachment';
|
|
98
|
-
|
|
154
|
+
const type = NEVER_ACTIVE.has(ext) ? 'application/octet-stream' : (MIME[ext] ?? 'application/octet-stream');
|
|
155
|
+
return sendFile(res, fs.readFileSync(file), type, headers, req.method === 'HEAD');
|
|
156
|
+
};
|
|
157
|
+
const serveFrame = (req, res, rest) => {
|
|
158
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'read only');
|
|
159
|
+
const [key, ...tail] = rest;
|
|
160
|
+
if (!same(key, filesKey)) return send(res, 404, 'not found');
|
|
161
|
+
const out = frameRoute(tail.map(decode), req);
|
|
162
|
+
if (!out) return send(res, 404, 'no frame');
|
|
163
|
+
if (out.status) return send(res, out.status, out.body);
|
|
164
|
+
return sendFile(res, out.body, out.type ?? 'text/html; charset=utf-8', out.headers, req.method === 'HEAD');
|
|
99
165
|
};
|
|
100
166
|
|
|
101
|
-
|
|
102
|
-
// ---- the app origin ----------------------------------------------------------------------
|
|
167
|
+
// ---- the shell -----------------------------------------------------------------------------
|
|
103
168
|
const appHtml = fs.readFileSync(path.join(appDir, 'index.html'), 'utf8');
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const filesOrigin = (pageHost = '') => `http://${/^localhost(:|$)/i.test(pageHost) ? 'localhost' : '127.0.0.1'}:${filesPort}`;
|
|
107
|
-
const boot = (nonce, pageHost) => {
|
|
108
|
-
const base = `${filesOrigin(pageHost)}/${filesKey}`;
|
|
109
|
-
const globals = {
|
|
110
|
-
__DT_HOST__: { nav: 'app', commands: false, openFile: 'tab', tabs: false, history: 'hash', moduleFrames: 'url' },
|
|
111
|
-
...((g) => ({ __DT_MODULE_GRANTS__: g.grant, __DT_MODULE_GRANT_PROBLEM__: g.problem }))(readModuleGrantResult(root)),
|
|
112
|
-
__DT_FILE_BASES__: fileBases().map((b) => ({ ...b, uri: `${base}/w/${b.path}` })),
|
|
113
|
-
__DT_VAR_ROOTS__: varRoots().map((r) => ({ ...r, uri: `${base}/v/${encodeURIComponent(r.name)}` })),
|
|
114
|
-
__DT_FILES__: base,
|
|
115
|
-
};
|
|
116
|
-
const json = JSON.stringify(globals).replace(/</g, '\\u003c');
|
|
169
|
+
const boot = (nonce) => {
|
|
170
|
+
const json = JSON.stringify({ __DT_HOST__: HOST_CAPABILITIES }).replace(/</g, '\\u003c');
|
|
117
171
|
return `<script nonce="${nonce}">Object.assign(window, ${json});</script>`;
|
|
118
172
|
};
|
|
173
|
+
const serveShell = (req, res, url, host) => {
|
|
174
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'the app is read only');
|
|
175
|
+
const rel = url.pathname.slice('/app/'.length);
|
|
176
|
+
if (rel === '' || rel === 'index.html') {
|
|
177
|
+
const nonce = random(16);
|
|
178
|
+
res.setHeader('Content-Security-Policy', appCsp(nonce, host));
|
|
179
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
180
|
+
return sendFile(res, appHtml.replace(/<head>/i, `<head>${boot(nonce)}`), 'text/html; charset=utf-8', {}, req.method === 'HEAD');
|
|
181
|
+
}
|
|
182
|
+
const f = contained(appDir, decode(rel));
|
|
183
|
+
if (!f || !fs.statSync(f).isFile()) return send(res, 404, 'not found');
|
|
184
|
+
return sendFile(res, fs.readFileSync(f), MIME[path.extname(f).toLowerCase()] ?? 'application/octet-stream', {}, req.method === 'HEAD');
|
|
185
|
+
};
|
|
119
186
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (
|
|
123
|
-
const
|
|
124
|
-
|
|
187
|
+
// ---- sign-in -------------------------------------------------------------------------------
|
|
188
|
+
const signIn = (req, res, url) => {
|
|
189
|
+
if (req.method !== 'GET') return send(res, 405, 'sign in with GET');
|
|
190
|
+
const taken = pairing.take(url.pathname.slice('/sign-in/'.length));
|
|
191
|
+
if (!taken) return page(res, 401, SIGN_IN_REFUSED);
|
|
192
|
+
const sid = sessions.mint(taken.cap ?? null);
|
|
193
|
+
// a browser-session cookie: no Max-Age, since the session itself lives only as long as this process. Cookies ignore
|
|
194
|
+
// the port, so every other server on 127.0.0.1 is sent this one too — Path keeps that to the two prefixes that read
|
|
195
|
+
// it, never a page, an asset or the root (README, "does not protect against")
|
|
196
|
+
res.setHeader('Set-Cookie', ['/api/', '/_dt/'].map((p) => `${cookieName()}=${sid}; HttpOnly; SameSite=Strict; Path=${p}`));
|
|
197
|
+
res.writeHead(302, { Location: `/app/#${taken.route}` });
|
|
198
|
+
return res.end();
|
|
125
199
|
};
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
200
|
+
const pairUrl = ({ route = '/', cap = null } = {}) => `http://127.0.0.1:${listenPort}/sign-in/${pairing.mint({ route, cap })}`;
|
|
201
|
+
|
|
202
|
+
// ---- who is asking -------------------------------------------------------------------------
|
|
203
|
+
const bearerOf = (req) => /^Bearer (.+)$/.exec(req.headers.authorization ?? '')?.[1] ?? null;
|
|
204
|
+
const cookieOf = (req) => {
|
|
205
|
+
const name = cookieName();
|
|
206
|
+
for (const part of String(req.headers.cookie ?? '').split(';')) {
|
|
207
|
+
const i = part.indexOf('=');
|
|
208
|
+
if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
};
|
|
212
|
+
/** A cookie counts only from the pane's own page: Sec-Fetch-Site same-origin, or an Origin equal to this request's. */
|
|
213
|
+
const fromThePane = (req) => {
|
|
214
|
+
const site = req.headers['sec-fetch-site'];
|
|
139
215
|
const origin = req.headers.origin;
|
|
140
|
-
if (origin !== undefined
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
216
|
+
if (origin !== undefined) return origin === `http://${String(req.headers.host ?? '').toLowerCase()}` && (site === undefined || site === 'same-origin');
|
|
217
|
+
return site === 'same-origin';
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const readBody = async (req) => {
|
|
221
|
+
let size = 0;
|
|
222
|
+
const chunks = [];
|
|
223
|
+
for await (const chunk of req) {
|
|
224
|
+
size += chunk.length;
|
|
225
|
+
if (size > MAX_BODY_BYTES) throw new HttpError(413, 'the body is too large');
|
|
226
|
+
chunks.push(chunk);
|
|
146
227
|
}
|
|
228
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
229
|
+
};
|
|
230
|
+
/** A write's body: JSON only, by Content-Type — 415 for any other type, 400 for JSON that does not parse. */
|
|
231
|
+
const jsonBody = async (req) => {
|
|
232
|
+
const raw = await readBody(req);
|
|
233
|
+
if (!WRITES.has(req.method) || !raw.length) return undefined;
|
|
234
|
+
if (!JSON_TYPE.test(String(req.headers['content-type'] ?? ''))) throw new HttpError(415, `${req.method} takes Content-Type: application/json`);
|
|
235
|
+
try { return JSON.parse(raw); } catch { throw new HttpError(400, 'the body is not JSON'); }
|
|
236
|
+
};
|
|
147
237
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
238
|
+
// ---- levels ---------------------------------------------------------------------------------
|
|
239
|
+
// the collections whose record WRITES need admin (reading 1), recomputed when the descriptors change
|
|
240
|
+
let internalFor = null;
|
|
241
|
+
let internal = null;
|
|
242
|
+
const internalNow = () => {
|
|
243
|
+
const d = handler.api.descriptors();
|
|
244
|
+
if (d !== internalFor) {
|
|
245
|
+
let e = {};
|
|
246
|
+
try { e = engine().descriptor; } catch { /* no engine: system collections only */ }
|
|
247
|
+
internal = internalCollections(d.values(), e);
|
|
248
|
+
internalFor = d;
|
|
158
249
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
250
|
+
return internal;
|
|
251
|
+
};
|
|
252
|
+
/** The level a request is answered at: the stored level lowered by the session's cap, or the client token's own. */
|
|
253
|
+
const levelOf = (who) => (who.kind === 'client' ? who.record.level : lowerLevel(state.level, who.cap ?? 'admin'));
|
|
254
|
+
const setLevel = (level) => {
|
|
255
|
+
const was = state.level;
|
|
256
|
+
if (levelRank(level) > levelRank(was)) state.raisedAt = new Date(now()).toISOString();
|
|
257
|
+
else if (levelRank(level) < levelRank(was)) state.raisedAt = null;
|
|
258
|
+
state.level = level;
|
|
259
|
+
save();
|
|
260
|
+
broadcast({ type: 'dt:host' });
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// ---- /api/ and /_dt/ -----------------------------------------------------------------------
|
|
264
|
+
/** Who asks /api/: a client token (bearer), or the cookie from the pane's own page. The control token is not one. */
|
|
265
|
+
const apiCaller = (req) => {
|
|
266
|
+
const bearer = bearerOf(req);
|
|
267
|
+
if (bearer !== null) {
|
|
268
|
+
const record = matchClientToken(state.clientTokens, bearer);
|
|
269
|
+
if (!record) return { refuse: [401, same(bearer, controlToken) ? 'the control token opens /_dt/ only — /api/ takes the cookie or a client token (dt web token)' : 'not a client token of this server'] };
|
|
270
|
+
return { kind: 'client', record };
|
|
162
271
|
}
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
272
|
+
const sid = cookieOf(req);
|
|
273
|
+
const session = sid ? sessions.check(sid) : null;
|
|
274
|
+
if (!session) return { refuse: [401, SIGN_IN_FIRST] };
|
|
275
|
+
if (!fromThePane(req)) return { refuse: [403, NOT_FROM_THE_PANE] };
|
|
276
|
+
return { kind: 'cookie', cap: session.cap, sid };
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const serveApi = async (req, res, url) => {
|
|
280
|
+
const who = apiCaller(req);
|
|
281
|
+
if (who.refuse) return json(res, who.refuse[0], { error: who.refuse[1] });
|
|
282
|
+
if (who.kind === 'client') idle.reset();
|
|
283
|
+
if (req.method === 'GET' && url.pathname === '/api/events') return openStream(req, res, who);
|
|
284
|
+
const body = await jsonBody(req);
|
|
285
|
+
const apiPath = req.url.slice(4);
|
|
286
|
+
const hit = matchRoute(req.method, apiPath);
|
|
287
|
+
if (who.kind === 'client' && (hit?.route.id === 'selection-put' || hit?.route.id === 'queue-post')) return json(res, 403, { error: CLIENT_NOT_PANE });
|
|
288
|
+
const level = levelOf(who);
|
|
289
|
+
const decision = gate(paneScope({ level, internal: internalNow(), label: who.kind === 'client' ? 'this token' : 'this pane' }), handler.api.descriptors(), req.method, apiPath, url.searchParams, body);
|
|
290
|
+
// no route: the handler answers its own 404, with the encoding hint
|
|
291
|
+
if (!decision.ok && decision.status !== 404) return json(res, decision.status, { error: decision.error });
|
|
292
|
+
if (hit?.route.id === 'host') return json(res, 200, await store.host({ level }));
|
|
293
|
+
const log = console.log;
|
|
294
|
+
console.log = () => {};
|
|
295
|
+
try {
|
|
296
|
+
// the migration writes nav.yaml, so it waits for a caller at Edit or above; below, the handler draws the old
|
|
297
|
+
// settings' tree and nothing is written
|
|
298
|
+
const migrated = req.method === 'GET' && url.pathname === '/api/nav' && levelRank(level) >= levelRank('edit') && migrateNavOnce();
|
|
299
|
+
const out = await handler.handle(req.method, apiPath, body);
|
|
300
|
+
if (migrated && out.status === 200 && Array.isArray(out.body) && out.body[0]?.kind === 'workspace') out.body[0] = { ...out.body[0], notice: NAV_MIGRATED_NOTICE };
|
|
301
|
+
return json(res, out.status, out.body ?? null);
|
|
302
|
+
} catch (e) {
|
|
303
|
+
if (e instanceof URIError) throw new HttpError(400, 'a malformed percent-escape in the path');
|
|
304
|
+
return json(res, 500, { error: String(e?.message ?? e) });
|
|
305
|
+
} finally { console.log = log; }
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// ---- the push's targets --------------------------------------------------------------------
|
|
309
|
+
const reader = { descriptors: () => handler.api.descriptors(), handle: (m, p) => handler.handle(m, p) };
|
|
310
|
+
const parseRef = (ref) => { try { return engine().parseRef(ref); } catch { return null; } };
|
|
311
|
+
/** `dt web show`'s target → the app route: a collection, then a record ref, then a ui-view id; null for none. */
|
|
312
|
+
const resolveTarget = async (target) => {
|
|
313
|
+
if (typeof target !== 'string' || !target || target.length > 500) return null;
|
|
314
|
+
if (handler.api.descriptors().has(target)) return `/content/${target}`;
|
|
315
|
+
const ref = parseRef(target);
|
|
316
|
+
if (ref?.collection && ref.id && handler.api.descriptors().has(ref.collection)) {
|
|
317
|
+
const r = await handler.handle('GET', `/collections/${encodeURIComponent(ref.collection)}/records/${ref.id.split('/').map(encodeURIComponent).join('/')}`);
|
|
318
|
+
if (r.status === 200) return `/content/${ref.collection}/${ref.id}`;
|
|
167
319
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
if (
|
|
320
|
+
if (!target.includes('/')) {
|
|
321
|
+
const v = await handler.handle('GET', `/collections/ui-views/records/${encodeURIComponent(target)}`);
|
|
322
|
+
const route = v.status === 200 ? v.body?.fields?.route : null;
|
|
323
|
+
if (typeof route === 'string' && route.startsWith('/')) return route;
|
|
172
324
|
}
|
|
325
|
+
return null;
|
|
326
|
+
};
|
|
327
|
+
/** `dt web select`'s refs: existing records, at most 50, all of one collection (as PUT /selection checks them). */
|
|
328
|
+
const checkRefs = async (refs) => {
|
|
329
|
+
const r = await validateSelection(reader, { view: { other: true }, refs });
|
|
330
|
+
if (!r.ok) throw new HttpError(400, r.error);
|
|
331
|
+
if (new Set(r.value.refs.map((x) => parseRef(x)?.collection)).size > 1) throw new HttpError(400, 'the refs of a selection are all of one collection');
|
|
332
|
+
return r.value.refs;
|
|
333
|
+
};
|
|
173
334
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
335
|
+
const levelIn = (body) => {
|
|
336
|
+
if (!LEVELS.includes(body?.level)) throw new HttpError(400, `a level is ${LEVELS.join(', ')}`);
|
|
337
|
+
return body.level;
|
|
338
|
+
};
|
|
339
|
+
/** /_dt/ under the control token: the verbs' routes. */
|
|
340
|
+
const controlRoutes = {
|
|
341
|
+
'POST /_dt/pair': async (body) => {
|
|
342
|
+
let route = typeof body?.route === 'string' && body.route.startsWith('/') ? body.route : '/';
|
|
343
|
+
// `dt web open <target>`: the same resolver as the push
|
|
344
|
+
if (body?.target !== undefined) {
|
|
345
|
+
route = await resolveTarget(body.target);
|
|
346
|
+
if (!route) return [400, { error: `no record, collection or ui-view ${body.target}` }];
|
|
347
|
+
}
|
|
348
|
+
const cap = LEVELS.includes(body?.cap) ? body.cap : null;
|
|
349
|
+
return [200, { url: pairUrl({ route, cap }) }];
|
|
350
|
+
},
|
|
351
|
+
'POST /_dt/token': (body) => {
|
|
352
|
+
let minted;
|
|
353
|
+
try { minted = mintClientToken({ name: body?.name, level: body?.level ?? 'read', now }); } catch (e) { throw new HttpError(400, e.message); }
|
|
354
|
+
// written first, then swapped in memory: a refused write (a read-only state dir) leaves the earlier token of
|
|
355
|
+
// that name working, here and in the file
|
|
356
|
+
saveTokens([...state.clientTokens.filter((t) => t.name !== minted.record.name), minted.record]);
|
|
357
|
+
endRevokedStreams();
|
|
358
|
+
return [200, { token: minted.token, api: `http://127.0.0.1:${listenPort}/api` }];
|
|
359
|
+
},
|
|
360
|
+
'POST /_dt/revoke': (body) => {
|
|
361
|
+
if (body?.token !== undefined) {
|
|
362
|
+
const kept = state.clientTokens.filter((t) => t.name !== body.token);
|
|
363
|
+
const n = state.clientTokens.length - kept.length;
|
|
364
|
+
// nothing to revoke, nothing to write; otherwise written first, then swapped (as a token is)
|
|
365
|
+
if (n) { saveTokens(kept); endRevokedStreams(); }
|
|
366
|
+
return [200, { revoked: n }];
|
|
367
|
+
}
|
|
368
|
+
const n = sessions.revokeAll();
|
|
369
|
+
for (const c of pageStreams) c.end();
|
|
370
|
+
return [200, { revoked: n }];
|
|
371
|
+
},
|
|
372
|
+
'POST /_dt/level': (body) => { setLevel(levelIn(body)); return [200, { level: state.level, raisedAt: state.raisedAt ?? null }]; },
|
|
373
|
+
'POST /_dt/push': async (body) => {
|
|
374
|
+
if (body?.type === 'navigate') {
|
|
375
|
+
const route = await resolveTarget(body.target);
|
|
376
|
+
if (!route) return [400, { error: `no record, collection or ui-view ${body.target}` }];
|
|
377
|
+
broadcast({ type: 'dt:navigate', route });
|
|
378
|
+
return [200, { route, delivered: pageStreams.size }];
|
|
379
|
+
}
|
|
380
|
+
if (body?.type === 'select') {
|
|
381
|
+
const refs = body.clear === true ? [] : await checkRefs(body.refs);
|
|
382
|
+
broadcast({ type: 'dt:select', refs });
|
|
383
|
+
return [200, { refs, delivered: pageStreams.size }];
|
|
384
|
+
}
|
|
385
|
+
return [400, { error: 'a push is { type: "navigate", target } or { type: "select", refs | clear: true }' }];
|
|
386
|
+
},
|
|
387
|
+
'GET /_dt/pane': (_body, _who, url) => [200, store.snapshot({ hook: url.searchParams.get('hook') === '1', connected: pageStreams.size > 0 })],
|
|
388
|
+
'POST /_dt/pane/delivered': (body) => [200, { delivered: store.delivered(body?.ids) }],
|
|
389
|
+
};
|
|
390
|
+
/** /_dt/ under the cookie: the page's own routes. */
|
|
391
|
+
const pageRoutes = {
|
|
392
|
+
'POST /_dt/level': (body, who) => {
|
|
393
|
+
const level = levelIn(body);
|
|
394
|
+
if (levelRank(level) > levelRank(levelOf(who))) return [403, { error: PAGE_CANNOT_RAISE(level) }];
|
|
395
|
+
if (level !== state.level) setLevel(level);
|
|
396
|
+
return [200, { level }];
|
|
397
|
+
},
|
|
398
|
+
'GET /_dt/workspaces': async () => [200, await listWorkspaces({ selfId: workspaceKey(root), stateDir })],
|
|
399
|
+
'POST /_dt/handoff': async (body, who) => {
|
|
400
|
+
const r = await handoff({ id: body?.id, pageLevel: levelOf(who), selfId: workspaceKey(root), stateDir, now, moved: (to) => store.moved?.(to) });
|
|
401
|
+
return [r.status, r.body];
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const serveDt = async (req, res, url) => {
|
|
406
|
+
const key = `${req.method} ${url.pathname}`;
|
|
407
|
+
// anyone may ask the server to prove it is the one its runtime file names: a fresh challenge in, an HMAC under the
|
|
408
|
+
// per-start instance out — no token, no data, and nothing that names the instance (runtime.js `proves`)
|
|
409
|
+
if (url.pathname === IDENTITY_PATH) {
|
|
410
|
+
if (req.method !== 'GET') return json(res, 405, { error: `${IDENTITY_PATH} takes GET` });
|
|
411
|
+
const challenge = url.searchParams.get('challenge');
|
|
412
|
+
if (typeof challenge !== 'string' || !CHALLENGE.test(challenge)) return json(res, 400, { error: 'a challenge is 16 to 128 base64url characters' });
|
|
177
413
|
res.setHeader('Cache-Control', 'no-store');
|
|
178
|
-
return
|
|
179
|
-
}
|
|
180
|
-
if (req.method === 'GET' && url.pathname.startsWith('/assets/')) {
|
|
181
|
-
const f = contained(path.join(appDir, 'assets'), decodeURIComponent(url.pathname.slice(8)));
|
|
182
|
-
if (!f) return send(res, 404, 'not found');
|
|
183
|
-
return sendFile(res, fs.readFileSync(f), MIME[path.extname(f).toLowerCase()] ?? 'application/octet-stream');
|
|
414
|
+
return json(res, 200, { proof: identityProof(instance, challenge) });
|
|
184
415
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
416
|
+
const bearer = bearerOf(req);
|
|
417
|
+
if (bearer !== null) {
|
|
418
|
+
if (!same(bearer, controlToken)) {
|
|
419
|
+
const client = matchClientToken(state.clientTokens, bearer);
|
|
420
|
+
return json(res, 401, { error: client ? 'a client token opens /api/ only' : 'not this server\'s control token' });
|
|
421
|
+
}
|
|
189
422
|
idle.reset();
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
try { body = JSON.parse(raw); } catch { return json(res, 415, { error: `${req.method} ${url.pathname} takes a JSON body` }); }
|
|
423
|
+
const body = await jsonBody(req);
|
|
424
|
+
const fn = controlRoutes[key];
|
|
425
|
+
if (!fn) return json(res, 404, { error: `no route ${key}` });
|
|
426
|
+
let answer;
|
|
427
|
+
// a state write refused by the dir's mode answers its sentence, with the code the verbs refuse on as open does
|
|
428
|
+
try { answer = await fn(body, { kind: 'control' }, url); } catch (e) {
|
|
429
|
+
if (e?.code === 'EDTWEBDIR') return json(res, 500, { error: e.message, code: e.code });
|
|
430
|
+
throw e;
|
|
199
431
|
}
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
432
|
+
const [status, out] = answer;
|
|
433
|
+
return json(res, status, out);
|
|
434
|
+
}
|
|
435
|
+
const sid = cookieOf(req);
|
|
436
|
+
if (sid !== null) {
|
|
437
|
+
if (!fromThePane(req)) return json(res, 403, { error: NOT_FROM_THE_PANE });
|
|
438
|
+
const session = sessions.check(sid);
|
|
439
|
+
if (!session) return json(res, 401, { error: SIGN_IN_FIRST });
|
|
440
|
+
const body = await jsonBody(req);
|
|
441
|
+
const fn = pageRoutes[key];
|
|
442
|
+
if (!fn) return json(res, 401, { error: `${key} takes the control token` });
|
|
443
|
+
const [status, out] = await fn(body, { kind: 'cookie', cap: session.cap, sid }, url);
|
|
444
|
+
return json(res, status, out);
|
|
208
445
|
}
|
|
209
|
-
return
|
|
446
|
+
return json(res, 401, { error: `${url.pathname} takes the control token` });
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// ---- the event stream ----------------------------------------------------------------------
|
|
450
|
+
const openStream = (req, res, who) => {
|
|
451
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
|
|
452
|
+
res.write(': open\n\n');
|
|
453
|
+
if (who.kind === 'client') clientStreams.set(res, who.record);
|
|
454
|
+
else pageStreams.add(res);
|
|
455
|
+
idle.reset();
|
|
456
|
+
req.on('close', () => { pageStreams.delete(res); clientStreams.delete(res); idle.reset(); });
|
|
457
|
+
};
|
|
458
|
+
const broadcast = (msg) => {
|
|
459
|
+
const data = `data: ${JSON.stringify(msg)}\n\n`;
|
|
460
|
+
for (const c of pageStreams) c.write(data);
|
|
461
|
+
for (const c of clientStreams.keys()) c.write(data);
|
|
210
462
|
};
|
|
211
463
|
|
|
464
|
+
// ---- one request ---------------------------------------------------------------------------
|
|
465
|
+
const handle = async (req, res) => {
|
|
466
|
+
baseHeaders(res);
|
|
467
|
+
const host = String(req.headers.host ?? '').toLowerCase();
|
|
468
|
+
if (!hostNames(listenPort).has(host)) return send(res, 403, `Host "${req.headers.host ?? ''}" is not this server — use 127.0.0.1:${listenPort}`);
|
|
469
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') return send(res, 403, 'a request from another site is refused');
|
|
470
|
+
const origin = req.headers.origin;
|
|
471
|
+
if (origin !== undefined && origin !== 'null' && !(origin.startsWith('http://') && hostNames(listenPort).has(origin.slice(7).toLowerCase()))) return send(res, 403, `Origin "${origin}" may not call this server`);
|
|
472
|
+
// CSP has no IPv6 host-source, so module frames cannot name [::1] as an ancestor: the page moves to 127.0.0.1
|
|
473
|
+
if (host.startsWith('[::1]')) {
|
|
474
|
+
res.writeHead(302, { Location: `http://127.0.0.1:${listenPort}${req.url}` });
|
|
475
|
+
return res.end();
|
|
476
|
+
}
|
|
477
|
+
const url = new URL(req.url, 'http://x');
|
|
478
|
+
const rawPath = String(req.url).split('?')[0];
|
|
479
|
+
const kind = classify(rawPath);
|
|
480
|
+
// a sandboxed document (a record file, a module frame) has an opaque origin: it reaches nothing but bytes
|
|
481
|
+
if (origin === 'null' && kind !== 'files' && kind !== 'frames') return kind === 'api' || kind === 'dt' ? json(res, 403, { error: NOT_FROM_THE_PANE }) : send(res, 403, 'a request from a sandboxed document is refused');
|
|
482
|
+
// no cross-origin request is ever granted, so there is no preflight to answer: OPTIONS is refused as a method on
|
|
483
|
+
// every prefix (never a redirect, a 404 that says the route is missing, or a 401 that asks for a token)
|
|
484
|
+
if (req.method === 'OPTIONS' && ALLOW[kind]) {
|
|
485
|
+
res.setHeader('Allow', ALLOW[kind]);
|
|
486
|
+
return kind === 'api' || kind === 'dt' ? json(res, 405, { error: `OPTIONS is not answered here — ${ALLOW[kind]}` }) : send(res, 405, `OPTIONS is not answered here — ${ALLOW[kind]}`);
|
|
487
|
+
}
|
|
488
|
+
switch (kind) {
|
|
489
|
+
case 'root':
|
|
490
|
+
res.writeHead(302, { Location: '/app/' });
|
|
491
|
+
return res.end();
|
|
492
|
+
case 'shell': return serveShell(req, res, url, host);
|
|
493
|
+
case 'sign-in': return signIn(req, res, url);
|
|
494
|
+
case 'api': return serveApi(req, res, url);
|
|
495
|
+
case 'dt': return serveDt(req, res, url);
|
|
496
|
+
case 'files': return serveFile(req, res, url, rawPath.slice('/_files/'.length).split('/'));
|
|
497
|
+
case 'frames': return serveFrame(req, res, rawPath.slice('/_frames/'.length).split('/'));
|
|
498
|
+
default: return send(res, 404, 'not found');
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
// a request that throws — a malformed percent-escape, a client gone mid-body — answers or is dropped, never takes
|
|
502
|
+
// the server down with it
|
|
503
|
+
const server = http.createServer((req, res) => {
|
|
504
|
+
handle(req, res).catch((err) => {
|
|
505
|
+
// a request whose body was read to its end is `destroyed` by then; only a gone socket is beyond answering
|
|
506
|
+
if (res.headersSent || res.destroyed || req.socket?.destroyed) return void res.destroy();
|
|
507
|
+
const status = err instanceof HttpError ? err.status : err instanceof URIError ? 400 : 500;
|
|
508
|
+
const message = err instanceof HttpError ? err.message : err instanceof URIError ? 'a malformed percent-escape in the path' : 'the request failed';
|
|
509
|
+
const kind = classify(String(req.url ?? '').split('?')[0]);
|
|
510
|
+
return kind === 'api' || kind === 'dt' ? json(res, status, { error: message }) : send(res, status, message);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
212
513
|
|
|
213
514
|
// ---- live updates ------------------------------------------------------------------------
|
|
214
515
|
let pending = null;
|
|
215
516
|
let timer = null;
|
|
216
|
-
const broadcast = (scope) => {
|
|
217
|
-
const data = `data: ${JSON.stringify(invalidateMessage(scope))}\n\n`;
|
|
218
|
-
for (const c of clients) c.write(data);
|
|
219
|
-
};
|
|
220
517
|
const changed = (file) => {
|
|
221
518
|
const scope = changeScope(root, file);
|
|
222
519
|
pending = pending === 'schema' || scope === 'schema' ? 'schema' : 'data';
|
|
223
520
|
clearTimeout(timer);
|
|
224
|
-
timer = setTimeout(() => { const s = pending; pending = null; broadcast(s); }, DEBOUNCE_MS);
|
|
521
|
+
timer = setTimeout(() => { const s = pending; pending = null; broadcast(invalidateMessage(s)); }, DEBOUNCE_MS);
|
|
225
522
|
};
|
|
226
523
|
const watchers = [];
|
|
524
|
+
// A watcher reports trouble as an 'error' event, and an EventEmitter with no 'error' listener throws it — out of the
|
|
525
|
+
// server, uncaught. On Linux a recursive watch is Node's own walk (readdir of each new folder), so a folder that a
|
|
526
|
+
// compile creates and removes again (.dreamteamer/skills) fails that walk with ENOENT. That is a change, not a fault:
|
|
527
|
+
// it is sent as one, and the watch goes on.
|
|
528
|
+
const watched = (w, dir) => { w.on('error', (e) => { if (!stopped) changed(typeof e?.path === 'string' ? e.path : dir); }); watchers.push(w); };
|
|
227
529
|
const watchDir = (dir) => {
|
|
228
530
|
if (!fs.existsSync(dir)) return;
|
|
229
|
-
try {
|
|
531
|
+
try { watched(fs.watch(dir, { recursive: true }, (_e, f) => f && changed(path.join(dir, String(f)))), dir); } catch { /* unwatchable */ }
|
|
230
532
|
};
|
|
231
533
|
for (const d of ['data', 'state', '.dreamteamer']) watchDir(path.join(root, d));
|
|
534
|
+
// nav.yaml at the root, by name: a save renames a temp file over it, which only a watch on the folder sees
|
|
535
|
+
try { watched(fs.watch(root, (_e, f) => f && String(f) === NAV_DOC_FILE && changed(path.join(root, NAV_DOC_FILE))), path.join(root, NAV_DOC_FILE)); } catch { /* unwatchable */ }
|
|
232
536
|
try { for (const l of fs.readdirSync(path.join(root, 'git_modules'))) watchDir(path.join(root, 'git_modules', l, 'data')); } catch { /* no layers */ }
|
|
233
537
|
|
|
234
538
|
// ---- lifetime ----------------------------------------------------------------------------
|
|
@@ -242,9 +546,11 @@ export async function startWeb({ root, appDir, port = 0, idleMinutes = 30, owner
|
|
|
242
546
|
stopped = true;
|
|
243
547
|
clearTimeout(timer); idle.clear(); clearInterval(ownerTimer);
|
|
244
548
|
for (const w of watchers) w.close();
|
|
245
|
-
for (const c of
|
|
246
|
-
|
|
247
|
-
|
|
549
|
+
for (const c of pageStreams) c.end();
|
|
550
|
+
for (const c of clientStreams.keys()) c.end();
|
|
551
|
+
try { store.flush?.(); } catch { /* the state dir went away: the next start reads what was written */ }
|
|
552
|
+
const done = new Promise((r) => server.close(r));
|
|
553
|
+
server.closeAllConnections?.();
|
|
248
554
|
await done;
|
|
249
555
|
onStop?.(why);
|
|
250
556
|
resolveClosed(why);
|
|
@@ -255,36 +561,53 @@ export async function startWeb({ root, appDir, port = 0, idleMinutes = 30, owner
|
|
|
255
561
|
clear() { clearTimeout(this.t); this.t = null; },
|
|
256
562
|
reset() {
|
|
257
563
|
this.clear();
|
|
258
|
-
if (idleMinutes > 0 &&
|
|
564
|
+
if (idleMinutes > 0 && pageStreams.size === 0) this.t = setTimeout(() => close('idle'), idleMinutes * 60_000);
|
|
259
565
|
},
|
|
260
566
|
};
|
|
261
567
|
const ownerTimer = ownerPid ? setInterval(() => { try { process.kill(ownerPid, 0); } catch (e) { if (e.code !== 'EPERM') close('owner gone'); } }, 5_000) : null;
|
|
262
568
|
ownerTimer?.unref?.();
|
|
263
569
|
|
|
264
|
-
|
|
265
|
-
|
|
570
|
+
// the port: the one asked for, else the stored one, else any. A stored port another process took moves the server and
|
|
571
|
+
// the caller says where from (no session outlives a process, so the old port's cookie names nothing here); a port the
|
|
572
|
+
// person asked for is theirs, and taken it is an error. The port is stored at once.
|
|
573
|
+
const listenOn = (p) => new Promise((resolve, reject) => {
|
|
574
|
+
const fail = (e) => reject(e);
|
|
575
|
+
server.once('error', fail);
|
|
576
|
+
server.listen(p, '127.0.0.1', () => { server.off('error', fail); resolve(server.address().port); });
|
|
577
|
+
});
|
|
578
|
+
const stored = port ? null : state.port;
|
|
579
|
+
let movedFrom;
|
|
580
|
+
try {
|
|
581
|
+
try {
|
|
582
|
+
listenPort = await listenOn(port || stored || 0);
|
|
583
|
+
} catch (e) {
|
|
584
|
+
if (e?.code !== 'EADDRINUSE' || !stored) throw e;
|
|
585
|
+
movedFrom = stored;
|
|
586
|
+
listenPort = await listenOn(0);
|
|
587
|
+
}
|
|
588
|
+
} catch (e) {
|
|
589
|
+
clearTimeout(timer); clearInterval(ownerTimer);
|
|
590
|
+
for (const w of watchers) w.close();
|
|
591
|
+
throw e;
|
|
592
|
+
}
|
|
593
|
+
if (state.port !== listenPort) { state.port = listenPort; save(); }
|
|
266
594
|
// [::1] answers only to send the page to 127.0.0.1: CSP cannot name an IPv6 ancestor for module frames (cr-modules-008 t13)
|
|
267
|
-
v6 = http.createServer((req, res) => { baseHeaders(res); res.writeHead(302, { Location: `http://127.0.0.1:${
|
|
595
|
+
v6 = http.createServer((req, res) => { baseHeaders(res); res.writeHead(302, { Location: `http://127.0.0.1:${listenPort}${req.url ?? '/'}` }); res.end(); });
|
|
268
596
|
v6.on('error', () => { /* no IPv6 loopback here: nothing to redirect */ });
|
|
269
|
-
v6.listen(
|
|
270
|
-
filesPort = await listen(files, 0);
|
|
597
|
+
v6.listen(listenPort, '::1');
|
|
271
598
|
idle.reset();
|
|
272
|
-
const signInUrl = () => {
|
|
273
|
-
const code = random(24);
|
|
274
|
-
signIns.set(code, Date.now() + signInTtlMs);
|
|
275
|
-
return `http://127.0.0.1:${appPort}/sign-in/${code}`;
|
|
276
|
-
};
|
|
277
599
|
return {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
600
|
+
url: `http://127.0.0.1:${listenPort}`,
|
|
601
|
+
port: listenPort,
|
|
602
|
+
controlToken,
|
|
603
|
+
instance,
|
|
604
|
+
filesKey,
|
|
605
|
+
pair: pairUrl,
|
|
606
|
+
broadcast,
|
|
607
|
+
clients: () => pageStreams.size,
|
|
286
608
|
close,
|
|
287
609
|
closed,
|
|
610
|
+
...(movedFrom !== undefined ? { movedFrom } : {}),
|
|
288
611
|
};
|
|
289
612
|
}
|
|
290
613
|
|
|
@@ -293,14 +616,18 @@ function send(res, status, text) {
|
|
|
293
616
|
res.end(text);
|
|
294
617
|
}
|
|
295
618
|
function json(res, status, body) {
|
|
619
|
+
// serialised before the head is written: a body that cannot be (a cycle) is a 500 that says so, never a dropped
|
|
620
|
+
// connection
|
|
621
|
+
let text;
|
|
622
|
+
try { text = JSON.stringify(body); } catch (e) { status = 500; text = JSON.stringify({ error: `the answer could not be sent as JSON: ${e?.message ?? e}` }); }
|
|
296
623
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
297
|
-
res.end(
|
|
624
|
+
res.end(text);
|
|
298
625
|
}
|
|
299
|
-
function page(res, status,
|
|
626
|
+
function page(res, status, text) {
|
|
300
627
|
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': "default-src 'none'" });
|
|
301
|
-
res.end(`<!doctype html><meta charset="utf-8"><title>dreamteamer</title><p style="font:14px system-ui;margin:2em">${
|
|
628
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>dreamteamer</title><p style="font:14px system-ui;margin:2em">${text.replace(/&/g, '&').replace(/</g, '<')}</p>`);
|
|
302
629
|
}
|
|
303
|
-
function sendFile(res, body, type, headers = {}) {
|
|
304
|
-
res.writeHead(200, { 'Content-Type': type, ...headers });
|
|
305
|
-
res.end(body);
|
|
630
|
+
function sendFile(res, body, type, headers = {}, headOnly = false) {
|
|
631
|
+
res.writeHead(200, { 'Content-Type': type, 'Content-Length': Buffer.byteLength(body), ...headers });
|
|
632
|
+
res.end(headOnly ? undefined : body);
|
|
306
633
|
}
|