geneseed 3.1.3 → 3.2.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/CHANGELOG.md +70 -0
- package/README.md +2 -2
- package/SHIPPED.md +3 -3
- package/bin/build-driver.mjs +127 -79
- package/bin/geneseed-cli.mjs +42 -84
- package/bin/geneseed-hook.mjs +5 -2
- package/harness.config.json +1 -1
- package/js/README.md +1 -10
- package/js/build/emit-claude.mjs +4 -5
- package/js/build/emit-common.mjs +63 -114
- package/js/build/emit-opencode.mjs +74 -66
- package/js/build/generate.mjs +4 -6
- package/js/build/render.mjs +13 -13
- package/js/build/source.mjs +7 -6
- package/js/build/stubs.mjs +25 -33
- package/js/build/themes.mjs +1 -1
- package/js/build/version.mjs +1 -3
- package/js/hosts/hooks.mjs +3 -3
- package/js/hosts/hosts.mjs +2 -4
- package/js/hosts/installs.mjs +35 -28
- package/js/hosts/link.mjs +1 -1
- package/js/hosts/native.mjs +37 -19
- package/js/hosts/opencode.mjs +39 -28
- package/js/hosts/settings.mjs +33 -110
- package/js/inspect/checks-authoring.mjs +5 -4
- package/js/inspect/checks-build.mjs +1 -1
- package/js/inspect/diff.mjs +1 -3
- package/js/inspect/doctor.mjs +59 -44
- package/js/inspect/excludes.mjs +4 -10
- package/js/inspect/inventory.mjs +37 -55
- package/js/inspect/registry.mjs +4 -7
- package/js/inspect/scan.mjs +8 -15
- package/js/inspect/status.mjs +1 -1
- package/js/lib/fs.mjs +34 -0
- package/js/lib/json.mjs +28 -1
- package/js/lib/paths.mjs +0 -10
- package/js/lib/udiff.mjs +11 -16
- package/js/maintain/memory.mjs +2 -4
- package/js/maintain/migrate.mjs +3 -3
- package/js/maintain/uninstall.mjs +90 -78
- package/js/maintain/update.mjs +2 -2
- package/js/ui/anim.mjs +1 -1
- package/js/ui/menu.mjs +1 -1
- package/js/ui/tui.mjs +3 -20
- package/js/web/actions.mjs +230 -217
- package/js/web/activity.mjs +71 -89
- package/js/web/api.mjs +133 -224
- package/js/web/daemon.mjs +92 -110
- package/js/web/docs.mjs +66 -111
- package/js/web/graph.mjs +0 -0
- package/js/web/handler.mjs +78 -97
- package/js/web/jobs.mjs +124 -165
- package/js/web/routes.mjs +73 -152
- package/js/web/server.mjs +47 -82
- package/package.json +1 -1
- package/src/agents/explorer.md +2 -1
- package/src/doctrines/process.md +4 -2
- package/src/laws/universal.md +55 -43
- package/src/skills/clarify.md +1 -1
- package/src/skills/forge-mcp.md +2 -1
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/ingest.md +2 -1
- package/src/skills/parallel-agents.md +1 -1
- package/src/skills/ponytail.md +2 -2
- package/src/skills/research.md +4 -2
- package/themes/_TEMPLATE.json +2 -0
- package/themes/biker.json +2 -0
- package/themes/commentator.json +2 -0
- package/themes/cyberpunk.json +2 -0
- package/themes/gamer.json +2 -0
- package/themes/imperial.json +2 -0
- package/themes/joker.json +2 -0
- package/themes/marvin.json +2 -0
- package/themes/mean.json +2 -0
- package/themes/military.json +2 -0
- package/themes/neutral.json +2 -0
- package/themes/pirate.json +2 -0
- package/themes/sports.json +2 -0
- package/themes/verstappen.json +2 -0
- package/themes/wizard.json +2 -0
- package/web/dist/assets/{Activity-B49fE_ug.js → Activity-BTCG2gMl.js} +1 -1
- package/web/dist/assets/{ActivityDetail-abt7hzD2.js → ActivityDetail-julsmPDB.js} +1 -1
- package/web/dist/assets/CatalogRow-DqS4e5XM.js +1 -0
- package/web/dist/assets/Diff-CIrGbiqm.js +5 -0
- package/web/dist/assets/Doctor-CpI53oHY.js +1 -0
- package/web/dist/assets/{Graph-DvYMtlvl.js → Graph-CS4tA7YS.js} +1 -1
- package/web/dist/assets/Harness-vRYs11kX.js +1 -0
- package/web/dist/assets/Laws-BjbHJrJt.js +3 -0
- package/web/dist/assets/Library-CjC7BAWN.js +1 -0
- package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-kgpg9EQM.js} +15 -15
- package/web/dist/assets/Profile-CHr-PPf6.js +1 -0
- package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-CsC6hfqk.js} +1 -1
- package/web/dist/assets/Skills-BvatJGfe.js +1 -0
- package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-gRrak0nO.js} +1 -1
- package/web/dist/assets/{activity-Cc0n2jwx.js → activity-B0-FXZCj.js} +1 -1
- package/web/dist/assets/index-BnRWQbZb.js +12 -0
- package/web/dist/assets/index-Bx3I7cRW.js +2 -0
- package/web/dist/assets/index-D0f9tJlA.css +1 -0
- package/web/dist/assets/index-DJOhOwwR.js +1 -0
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +42 -71
- package/web/dist/assets/Diff-D3_bVkih.js +0 -5
- package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
- package/web/dist/assets/Harness-BIWei7N5.js +0 -1
- package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
- package/web/dist/assets/Library-MjbOkHIW.js +0 -1
- package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
- package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
- package/web/dist/assets/Skills-DArA6IHS.js +0 -1
- package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
- package/web/dist/assets/index-DC3-GlCY.js +0 -1
- package/web/dist/assets/index-DP035wPa.js +0 -12
- package/web/dist/assets/index-dHi-7M-F.js +0 -2
package/js/web/handler.mjs
CHANGED
|
@@ -15,21 +15,25 @@ import { doctrinesForBuild } from '../hosts/installs.mjs';
|
|
|
15
15
|
import { isTruthy, jsonDumpsCompact } from '../lib/json.mjs';
|
|
16
16
|
import { parseIntStrict, percentDecode } from '../lib/text.mjs';
|
|
17
17
|
import { preflight } from '../maintain/update.mjs';
|
|
18
|
-
import { apiDeployCmd, apiInstallCmd, apiRestore, buildOverride, mcpTargetPaths } from './actions.mjs';
|
|
18
|
+
import { apiDeployCmd, apiInstallCmd, apiPickFolder, apiRestore, buildOverride, mcpTargetPaths } from './actions.mjs';
|
|
19
19
|
import { NotFound, PREFIX_ROUTES, STATE_ROUTES } from './api.mjs';
|
|
20
20
|
import { openUrl, requestRestart } from './daemon.mjs';
|
|
21
21
|
import { apiDocs, apiDocsPage } from './docs.mjs';
|
|
22
22
|
import { NOT_PORTED_ACTIONS, actionCommands } from './jobs.mjs';
|
|
23
23
|
import { POST_ROUTES, notPorted, notPortedPost, readJsonBody } from './routes.mjs';
|
|
24
|
-
import {
|
|
24
|
+
import { isFile } from '../lib/fs.mjs';
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
25
26
|
import { basename, dirname, extname, join, sep } from 'node:path';
|
|
26
27
|
import { gzipSync } from 'node:zlib';
|
|
27
28
|
|
|
29
|
+
// `isFile` is owned by `js/lib/fs.mjs` now (single owner). Re-exported here so
|
|
30
|
+
// `js/web/server.mjs`'s existing `import { isFile, ... } from './handler.mjs'` keeps working.
|
|
31
|
+
export { isFile };
|
|
32
|
+
|
|
28
33
|
/**
|
|
29
|
-
* DNS-rebinding guard.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* `[::1]` prefix test above it is for.
|
|
34
|
+
* DNS-rebinding guard. `h.split(":", 1)[0]` keeps everything before the FIRST colon, so
|
|
35
|
+
* `localhost:4747` passes and a bare IPv6 literal would not — which is what the `[::1]`
|
|
36
|
+
* prefix check above it is for.
|
|
33
37
|
*/
|
|
34
38
|
export function localHost(host) {
|
|
35
39
|
const h = (host || '').trim().toLowerCase();
|
|
@@ -52,11 +56,6 @@ const CTYPES = {
|
|
|
52
56
|
|
|
53
57
|
// ---- the request handler ---------------------------------------------------
|
|
54
58
|
|
|
55
|
-
/**
|
|
56
|
-
* `make_handler(state, jm, token, dist, holder)` — `jm` is the SECOND argument here as it is
|
|
57
|
-
* there. P6a shipped this without it because there was no `JobManager` to pass; every caller
|
|
58
|
-
* moved in the same commit that added one.
|
|
59
|
-
*/
|
|
60
59
|
export function makeHandler(state, jm, token, dist, holder = null) {
|
|
61
60
|
const staticCache = new Map();
|
|
62
61
|
|
|
@@ -85,8 +84,8 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
85
84
|
const rel = (path === '/' || path === '') ? 'index.html' : path.replace(/^\/+/, '');
|
|
86
85
|
const index = resolvePath(join(dist, 'index.html'));
|
|
87
86
|
let fp = resolvePath(join(dist, rel));
|
|
88
|
-
// `dist
|
|
89
|
-
//
|
|
87
|
+
// STRICTLY under `dist`: `fp === dist` itself must also take the fallback below, which
|
|
88
|
+
// is why this checks `fp !== dist` and not just the prefix.
|
|
90
89
|
if (!(fp.startsWith(dist + sep) && fp !== dist) && fp !== index) fp = join(dist, 'index.html');
|
|
91
90
|
if (!isFile(fp)) fp = join(dist, 'index.html');
|
|
92
91
|
if (!isFile(fp)) {
|
|
@@ -110,8 +109,6 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
110
109
|
}
|
|
111
110
|
|
|
112
111
|
function doGet(req, res, path, ae) {
|
|
113
|
-
// The reference's own table first, then the routes that parse the path — and `ping`
|
|
114
|
-
// sits between them there too, so the order here is its order.
|
|
115
112
|
const route = STATE_ROUTES[path];
|
|
116
113
|
if (route !== undefined) return sendJson(res, route(state), 200, ae);
|
|
117
114
|
if (path === '/api/ping') return sendJson(res, { ok: true, theme: state.theme }, 200, ae);
|
|
@@ -126,16 +123,16 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
126
123
|
try {
|
|
127
124
|
return sendJson(res, apiDocsPage(state, pid, harnessParam(req.url)), 200, ae);
|
|
128
125
|
} catch (e) {
|
|
129
|
-
//
|
|
130
|
-
// `NotFound` at the bottom of `apiDocsPage` and claiming the page is missing.
|
|
126
|
+
// An unimplemented doc kind says so explicitly (501), rather than falling through
|
|
127
|
+
// to the `NotFound` at the bottom of `apiDocsPage` and claiming the page is missing.
|
|
131
128
|
if (e && e.notPorted) return sendJson(res, { error: e.message }, 501, ae);
|
|
132
129
|
throw e;
|
|
133
130
|
}
|
|
134
131
|
}
|
|
135
132
|
if (path === '/api/jobs') return sendJson(res, { jobs: jm.recent() }, 200, ae);
|
|
136
133
|
if (path.startsWith('/api/jobs/')) {
|
|
137
|
-
//
|
|
138
|
-
//
|
|
134
|
+
// The LAST segment: `/api/jobs/a/b` looks up `b` and misses (no such job) rather than
|
|
135
|
+
// 404ing on the URL shape.
|
|
139
136
|
const j = jm.get(path.slice(path.lastIndexOf('/') + 1));
|
|
140
137
|
return j ? sendJson(res, j, 200, ae)
|
|
141
138
|
: sendJson(res, { error: 'no such job' }, 404, ae);
|
|
@@ -154,12 +151,10 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
154
151
|
return sendJson(res, { error: 'forbidden' }, 403, ae);
|
|
155
152
|
}
|
|
156
153
|
if (path === '/api/shutdown') {
|
|
157
|
-
// The one POST route the SHELL owns. It must answer BEFORE it stops,
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
// waits for idle keep-alive sockets, and the client that just sent this request is
|
|
162
|
-
// holding one open. The reference drops them by exiting.
|
|
154
|
+
// The one POST route the SHELL owns. It must answer BEFORE it stops, so closing is
|
|
155
|
+
// deferred to `res.on('finish', ...)` so the response flushes first.
|
|
156
|
+
// `closeAllConnections` is the other half: `close()` alone waits for idle keep-alive
|
|
157
|
+
// sockets, and the client that just sent this request is holding one open.
|
|
163
158
|
res.on('finish', () => {
|
|
164
159
|
holder.srv.close();
|
|
165
160
|
holder.srv.closeAllConnections();
|
|
@@ -169,19 +164,15 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
169
164
|
if (path === '/api/restart') {
|
|
170
165
|
// THE ONE ROUTE IN THIS FILE NO TEST MAY REACH, and it is declared rather than probed.
|
|
171
166
|
// `requestRestart` spawns a DETACHED `web restart`, which stops whatever daemon the
|
|
172
|
-
// record names and starts a fresh one that outlives the caller.
|
|
173
|
-
// a
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
// where the replacement would bind 4747 and serve the checkout forever. That is the
|
|
177
|
-
// same reasoning P6g used to ban a job cell that starts real work, one step further:
|
|
178
|
-
// there the child was merely orphaned, here it is a second daemon.
|
|
167
|
+
// record names and starts a fresh one that outlives the caller. Actually driving this
|
|
168
|
+
// route from a test would either stop the test's own server or, worse, orphan a real
|
|
169
|
+
// daemon that binds 4747 and serves the checkout forever in the developer's own
|
|
170
|
+
// environment.
|
|
179
171
|
//
|
|
180
|
-
// So the gate
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// beats a silently missing probe.
|
|
172
|
+
// So the gate asserts this dispatches to `requestRestart(state.theme)` directly rather
|
|
173
|
+
// than by hitting the route through a live request — a declaration is not normally
|
|
174
|
+
// enough to stand in for a dispatcher, but here probing the dispatcher costs more than
|
|
175
|
+
// the assurance is worth.
|
|
185
176
|
requestRestart(state.theme);
|
|
186
177
|
return sendJson(res, { restarting: true }, 200, ae);
|
|
187
178
|
}
|
|
@@ -213,6 +204,16 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
213
204
|
openUrl(dir);
|
|
214
205
|
return sendJson(res, { ok: true, dir }, 200, ae);
|
|
215
206
|
}
|
|
207
|
+
if (path === '/api/pick-folder') {
|
|
208
|
+
// INLINE, NOT `POST_ROUTES`: that table's `fn(state, body) -> object` shape answers the
|
|
209
|
+
// instant `fn` returns, and this endpoint cannot — it spawns a native dialog that a human
|
|
210
|
+
// may sit in front of for a while. `doPost` returns to its caller immediately, unresolved;
|
|
211
|
+
// `apiPickFolder`'s `done` calls `sendJson` whenever the child actually exits, which is
|
|
212
|
+
// what lets the daemon keep answering every OTHER request while the dialog is open — see
|
|
213
|
+
// `apiPickFolder` in `js/web/actions.mjs` for the async-spawn reasoning.
|
|
214
|
+
apiPickFolder((result) => sendJson(res, result, 200, ae));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
216
217
|
const route = POST_ROUTES.get(path);
|
|
217
218
|
if (route !== undefined) {
|
|
218
219
|
const [fn, okIs409] = route;
|
|
@@ -220,8 +221,8 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
220
221
|
return sendJson(res, obj, (okIs409 && !isTruthy(obj.ok)) ? 409 : 200, ae);
|
|
221
222
|
}
|
|
222
223
|
if (path.startsWith('/api/jobs/') && path.endsWith('/cancel')) {
|
|
223
|
-
//
|
|
224
|
-
//
|
|
224
|
+
// Positional: `/api/jobs/<id>/cancel` takes `<id>` and a deeper path takes its third
|
|
225
|
+
// segment regardless.
|
|
225
226
|
const jid = path.split('/')[3];
|
|
226
227
|
return jm.cancel(jid) ? sendJson(res, { cancelled: jid }, 200, ae)
|
|
227
228
|
: sendJson(res, { error: 'no running job by that id' }, 404, ae);
|
|
@@ -236,22 +237,21 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
236
237
|
}
|
|
237
238
|
|
|
238
239
|
/**
|
|
239
|
-
* `/api/actions/<x>`
|
|
240
|
+
* `/api/actions/<x>` dispatch order, and the order is deliberate rather than incidental.
|
|
240
241
|
*
|
|
241
242
|
* `restore` is answered BEFORE anything else because it is synchronous and returns a result
|
|
242
243
|
* rather than a job id. `install` and `deploy` resolve their argv from the body, and their
|
|
243
244
|
* refusal statuses DIFFER — 409 for install (its target came from an allowlist, so a refusal
|
|
244
245
|
* means the world changed under the client) and 400 for deploy (its path came from the body,
|
|
245
|
-
* so a refusal means the body was wrong).
|
|
246
|
-
* unified them would be wrong on one of the two and no cell would say which.
|
|
246
|
+
* so a refusal means the body was wrong). Unifying the two would be wrong for one of them.
|
|
247
247
|
*
|
|
248
|
-
* `update` COMES NEXT AND IS INLINE
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* the restart happens HERE, after the job is saved as finished, and only when
|
|
253
|
-
* failed update changed nothing worth reloading and bouncing would disconnect
|
|
254
|
-
* reason.
|
|
248
|
+
* `update` COMES NEXT AND IS INLINE: it is gated on a LOCAL-ONLY preflight before a job is
|
|
249
|
+
* ever started, so a dirty tree or a zip-download install gets a friendly 422 the UI renders
|
|
250
|
+
* as an info popup instead of a job that spawns and then fails. Its finish handler is its own
|
|
251
|
+
* too — the child skips its own daemon bounce (`GENESEED_WEB_JOB`, set by `_run` in
|
|
252
|
+
* `jobs.mjs`), so the restart happens HERE, after the job is saved as finished, and only when
|
|
253
|
+
* it succeeded: a failed update changed nothing worth reloading and bouncing would disconnect
|
|
254
|
+
* the PWA for no reason.
|
|
255
255
|
*
|
|
256
256
|
* THEN the unported rows, and only then the table. Order matters: `link`/`unlink` are REAL
|
|
257
257
|
* actions whose verb has not crossed, and letting them fall through to `actionCommands` would
|
|
@@ -309,7 +309,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
309
309
|
/**
|
|
310
310
|
* The 202/409 pair every job route ends in. `state.refresh()` on finish is not optional: a
|
|
311
311
|
* Build may re-theme the install, and the re-detect must read the new `.geneseed-emit` or
|
|
312
|
-
* the console keeps serving the old mode
|
|
312
|
+
* the console keeps serving the old mode.
|
|
313
313
|
*/
|
|
314
314
|
function startJob(res, action, cmds, ae) {
|
|
315
315
|
const jid = jm.start(action, cmds, () => state.refresh());
|
|
@@ -328,10 +328,9 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
328
328
|
try {
|
|
329
329
|
return doGet(req, res, path, ae);
|
|
330
330
|
} catch (e) {
|
|
331
|
-
// The `NotFound` → 404 convention
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
// the shell decides the status. Anything else is still the outer 500.
|
|
331
|
+
// The `NotFound` → 404 convention wraps the ROUTE DISPATCH as a whole, not each
|
|
332
|
+
// handler individually: an endpoint several frames down raises and this decides
|
|
333
|
+
// the status. Anything else still falls to the outer 500.
|
|
335
334
|
if (e instanceof NotFound) {
|
|
336
335
|
return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
|
|
337
336
|
}
|
|
@@ -339,30 +338,20 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
339
338
|
}
|
|
340
339
|
}
|
|
341
340
|
if (req.method === 'POST') {
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
// two mutations — reading zero bytes, then neither reading nor resuming the
|
|
348
|
-
// stream — both survived the keep-alive cell, which the reference needs the
|
|
349
|
-
// drain to pass. Node's HTTP parser owns the message boundary, so unread body
|
|
350
|
-
// bytes are discarded when the response ends instead of being re-parsed. It
|
|
351
|
-
// stays because `_read_json_body` needs the bytes from P6f on, and because a
|
|
352
|
-
// shell whose two implementations diverged only under load would be the worst
|
|
353
|
-
// kind of divergence to carry. UNREACHABLE is not the word for it: the branch
|
|
354
|
-
// runs on every POST, it simply cannot be observed to matter on this runtime.
|
|
341
|
+
// The body is drained before routing regardless of what the route needs it for:
|
|
342
|
+
// `readJsonBody` needs the bytes for a real POST body, and Node's own HTTP parser
|
|
343
|
+
// owns the message boundary anyway — unread bytes are discarded when the response
|
|
344
|
+
// ends rather than mis-parsed as the next request line, so this is where the bytes
|
|
345
|
+
// have to come from either way.
|
|
355
346
|
const length = parseIntStrict(String(req.headers['content-length'] ?? '')) ?? 0;
|
|
356
347
|
return readBody(req, length, (buf) => {
|
|
357
348
|
req._body = buf;
|
|
358
349
|
try {
|
|
359
350
|
return doPost(req, res, path, ae, buf);
|
|
360
351
|
} catch (e) {
|
|
361
|
-
// `
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
// unknown target, and the reference answers 404. The 500 arm below it is what
|
|
365
|
-
// carries `_rule_fields`' ValueError message, which is a 500 there too.
|
|
352
|
+
// `apiMcpToggle`, `apiSelectView`, `apiMemoryDelete`, `apiRulesMutate` and
|
|
353
|
+
// `apiRulesPromote` all raise `NotFound` for an unknown target, mapped to 404
|
|
354
|
+
// here; anything else falls to the 500 arm below, carrying its own message.
|
|
366
355
|
if (e instanceof NotFound) {
|
|
367
356
|
return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
|
|
368
357
|
}
|
|
@@ -381,10 +370,19 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
381
370
|
* `Handler._harness` — the `?harness=` query param, `null` when absent.
|
|
382
371
|
*
|
|
383
372
|
* `urllib.parse.parse_qs` with its defaults, reproduced only as far as this one caller
|
|
384
|
-
* needs and no further:
|
|
385
|
-
*
|
|
386
|
-
* the
|
|
387
|
-
* because `parse_qs` returns a list and the reference takes `[0]
|
|
373
|
+
* needs and no further: values unquoted with `+` meaning a space, and a BLANK value dropped
|
|
374
|
+
* (`keep_blank_values=False`), so `?harness=` resolves to the installed default rather than
|
|
375
|
+
* to the empty string — `'' || null` is what drops it below. The first occurrence wins,
|
|
376
|
+
* because `parse_qs` returns a list and the reference takes `[0]`, and so does
|
|
377
|
+
* `URLSearchParams.get`.
|
|
378
|
+
*
|
|
379
|
+
* `new URL(url, 'http://x')` rather than a hand-split on `&`/`=`: the base is a throwaway,
|
|
380
|
+
* needed only because `URL` refuses a bare path-plus-query with no scheme. One measured
|
|
381
|
+
* difference from the hand-split version this replaced — found by diffing both against a
|
|
382
|
+
* battery of query strings before this landed — and it is a FIX, not a regression: a literal
|
|
383
|
+
* `#` in the query is a URL FRAGMENT, which `URLSearchParams` correctly excludes and the old
|
|
384
|
+
* split-on-`&` did not, because a fragment is a client-side-only construct an HTTP request
|
|
385
|
+
* line can never actually carry — no `req.url` this handler ever sees can contain one.
|
|
388
386
|
*
|
|
389
387
|
* THE BLANK-VALUE DROP IS INDISTINGUISHABLE HERE, measured rather than assumed: a
|
|
390
388
|
* mutation removing it survived, because `normHarness('')` and `normHarness(null)` both
|
|
@@ -396,16 +394,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
396
394
|
* through the one consumer it has.
|
|
397
395
|
*/
|
|
398
396
|
function harnessParam(url) {
|
|
399
|
-
|
|
400
|
-
if (at < 0) return null;
|
|
401
|
-
for (const pair of url.slice(at + 1).split('&')) {
|
|
402
|
-
if (!pair) continue;
|
|
403
|
-
const eq = pair.indexOf('=');
|
|
404
|
-
const k = percentDecode((eq < 0 ? pair : pair.slice(0, eq)).replace(/\+/g, ' '));
|
|
405
|
-
const v = eq < 0 ? '' : percentDecode(pair.slice(eq + 1).replace(/\+/g, ' '));
|
|
406
|
-
if (k === 'harness' && v !== '') return v;
|
|
407
|
-
}
|
|
408
|
-
return null;
|
|
397
|
+
return new URL(url || '', 'http://x').searchParams.get('harness') || null;
|
|
409
398
|
}
|
|
410
399
|
|
|
411
400
|
function readBody(req, length, done) {
|
|
@@ -416,8 +405,8 @@ function readBody(req, length, done) {
|
|
|
416
405
|
const chunks = [];
|
|
417
406
|
let seen = 0;
|
|
418
407
|
req.on('data', (c) => {
|
|
419
|
-
// Exactly `Content-Length` bytes
|
|
420
|
-
//
|
|
408
|
+
// Exactly `Content-Length` bytes: any more belongs to the next request and is the HTTP
|
|
409
|
+
// parser's concern, not this handler's.
|
|
421
410
|
if (seen >= length) return;
|
|
422
411
|
chunks.push(c.subarray(0, length - seen));
|
|
423
412
|
seen += c.length;
|
|
@@ -425,15 +414,7 @@ function readBody(req, length, done) {
|
|
|
425
414
|
req.on('end', () => done(Buffer.concat(chunks)));
|
|
426
415
|
}
|
|
427
416
|
|
|
428
|
-
|
|
429
|
-
try {
|
|
430
|
-
return statSync(p).isFile();
|
|
431
|
-
} catch {
|
|
432
|
-
return false;
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/** `bytes.replace(old, new, 1)` — the first occurrence only. */
|
|
417
|
+
/** Replaces the first occurrence only. */
|
|
437
418
|
function replaceOnce(buf, find, repl) {
|
|
438
419
|
const at = buf.indexOf(find);
|
|
439
420
|
if (at < 0) return buf;
|