@a-company/gnomon 0.0.0-stage → 8.0.2
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 +126 -2
- package/dist/agent.d.ts +28 -0
- package/dist/agent.js +45708 -0
- package/dist/build-id.json +1 -0
- package/dist/cli/gnomon.js +53946 -0
- package/dist/cli/gnomond.js +47744 -0
- package/dist/gnomon-local/cli.js +897 -0
- package/dist/index.d.ts +3477 -0
- package/dist/index.js +13698 -0
- package/dist/loops.d.ts +440 -0
- package/dist/loops.js +4706 -0
- package/dist/signer-CQ3Bpe7G.d.ts +158 -0
- package/dist/transport/gnomon-web.js +688 -0
- package/gnomon-mods/.purpose +143 -0
- package/gnomon-mods/constellation/README.md +27 -0
- package/gnomon-mods/constellation/fixtures/map-the-idea.golden.json +19 -0
- package/gnomon-mods/constellation/fixtures/map-the-idea.json +3 -0
- package/gnomon-mods/constellation/fixtures/summarize-the-session.golden.json +20 -0
- package/gnomon-mods/constellation/fixtures/summarize-the-session.json +3 -0
- package/gnomon-mods/constellation/fixtures/two-blocks.golden.json +4 -0
- package/gnomon-mods/constellation/fixtures/two-blocks.json +3 -0
- package/gnomon-mods/constellation/gnomon-manifest.json +68 -0
- package/gnomon-mods/constellation/skills/constellation-desk/SKILL.md +35 -0
- package/gnomon-mods/ledger/README.md +10 -0
- package/gnomon-mods/ledger/fixtures/breaker.golden.json +28 -0
- package/gnomon-mods/ledger/fixtures/breaker.json +80 -0
- package/gnomon-mods/ledger/fixtures/compacted.golden.json +42 -0
- package/gnomon-mods/ledger/fixtures/compacted.json +103 -0
- package/gnomon-mods/ledger/fixtures/over-budget.golden.json +24 -0
- package/gnomon-mods/ledger/fixtures/over-budget.json +86 -0
- package/gnomon-mods/ledger/fixtures/strip-reduced.golden.json +23 -0
- package/gnomon-mods/ledger/fixtures/strip-reduced.json +76 -0
- package/gnomon-mods/ledger/fixtures/strip.golden.json +18 -0
- package/gnomon-mods/ledger/fixtures/strip.json +68 -0
- package/gnomon-mods/ledger/gnomon-manifest.json +16 -0
- package/gnomon-mods/ledger/index.mjs +77 -0
- package/gnomon-mods/ledger/model.mjs +237 -0
- package/gnomon-mods/neurons/README.md +31 -0
- package/gnomon-mods/neurons/fixtures/capture-a-decision.golden.json +20 -0
- package/gnomon-mods/neurons/fixtures/capture-a-decision.json +3 -0
- package/gnomon-mods/neurons/fixtures/capture-a-learning.golden.json +19 -0
- package/gnomon-mods/neurons/fixtures/capture-a-learning.json +3 -0
- package/gnomon-mods/neurons/fixtures/search-the-brain.golden.json +19 -0
- package/gnomon-mods/neurons/fixtures/search-the-brain.json +3 -0
- package/gnomon-mods/neurons/fixtures/undeclared-kind.golden.json +9 -0
- package/gnomon-mods/neurons/fixtures/undeclared-kind.json +3 -0
- package/gnomon-mods/neurons/gnomon-manifest.json +87 -0
- package/gnomon-mods/neurons/skills/neurons-desk/SKILL.md +44 -0
- package/gnomon-mods/paradigm-context/README.md +9 -0
- package/gnomon-mods/paradigm-context/fixtures/empty.golden.json +28 -0
- package/gnomon-mods/paradigm-context/fixtures/empty.json +48 -0
- package/gnomon-mods/paradigm-context/fixtures/full-dark.golden.json +497 -0
- package/gnomon-mods/paradigm-context/fixtures/full-dark.json +134 -0
- package/gnomon-mods/paradigm-context/fixtures/full.golden.json +497 -0
- package/gnomon-mods/paradigm-context/fixtures/full.json +134 -0
- package/gnomon-mods/paradigm-context/fixtures/stale.golden.json +205 -0
- package/gnomon-mods/paradigm-context/fixtures/stale.json +69 -0
- package/gnomon-mods/paradigm-context/fixtures/unfocused.golden.json +28 -0
- package/gnomon-mods/paradigm-context/fixtures/unfocused.json +14 -0
- package/gnomon-mods/paradigm-context/gnomon-manifest.json +15 -0
- package/gnomon-mods/paradigm-context/index.mjs +64 -0
- package/gnomon-mods/paradigm-context/model.mjs +137 -0
- package/gnomon-mods/workshop/README.md +12 -0
- package/gnomon-mods/workshop/fixtures/strip-reduced.golden.json +56 -0
- package/gnomon-mods/workshop/fixtures/strip-reduced.json +16 -0
- package/gnomon-mods/workshop/fixtures/strip.golden.json +56 -0
- package/gnomon-mods/workshop/fixtures/strip.json +16 -0
- package/gnomon-mods/workshop/gnomon-manifest.json +14 -0
- package/gnomon-mods/workshop/index.mjs +73 -0
- package/gnomon-mods/workshop/model.mjs +152 -0
- package/package.json +80 -4
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/transport/gnomon-web.ts
|
|
4
|
+
import { Socket } from "node:net";
|
|
5
|
+
|
|
6
|
+
// src/kernel/web/protocol.ts
|
|
7
|
+
var WEB_CONN_MESSAGE = "conn";
|
|
8
|
+
var WS_MAX_FRAME_BYTES = 4 * 1024 * 1024;
|
|
9
|
+
var CHANNEL_MAX_RECORD_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
var HTTP_MAX_BODY_BYTES = 64 * 1024;
|
|
11
|
+
var WS_PRE_AUTH_MAX_FRAME_BYTES = 16 * 1024;
|
|
12
|
+
var WS_MAX_SOCKET_BUFFER = 8 * 1024 * 1024;
|
|
13
|
+
var WS_WRITE_HIGH_WATER = 1024 * 1024;
|
|
14
|
+
var WS_WRITE_LOW_WATER = 256 * 1024;
|
|
15
|
+
function encodeRecord(r) {
|
|
16
|
+
return `${JSON.stringify(r)}
|
|
17
|
+
`;
|
|
18
|
+
}
|
|
19
|
+
function flattenHeaders(h) {
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const [k, v] of Object.entries(h)) {
|
|
22
|
+
if (v === void 0) continue;
|
|
23
|
+
out[k.toLowerCase()] = Array.isArray(v) ? v.join(", ") : v;
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
var MAX_HEADER_VALUE = 16 * 1024;
|
|
28
|
+
var MAX_PATH = 8 * 1024;
|
|
29
|
+
|
|
30
|
+
// src/version.ts
|
|
31
|
+
var GNOMON_VERSION = "8.0.2";
|
|
32
|
+
|
|
33
|
+
// src/transport/web-child.ts
|
|
34
|
+
import * as http from "node:http";
|
|
35
|
+
|
|
36
|
+
// src/kernel/socket/framing.ts
|
|
37
|
+
var MAX_FRAME_BYTES = 4 * 1024 * 1024;
|
|
38
|
+
var NL = 10;
|
|
39
|
+
var CR = 13;
|
|
40
|
+
var utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false });
|
|
41
|
+
function decodeLine(line) {
|
|
42
|
+
let end = line.length;
|
|
43
|
+
if (end > 0 && line[end - 1] === CR) end--;
|
|
44
|
+
const body = line.subarray(0, end);
|
|
45
|
+
let text;
|
|
46
|
+
try {
|
|
47
|
+
text = utf8.decode(body);
|
|
48
|
+
} catch {
|
|
49
|
+
return { kind: "malformed", reason: "utf8" };
|
|
50
|
+
}
|
|
51
|
+
if (text.trim() === "") return null;
|
|
52
|
+
let value;
|
|
53
|
+
try {
|
|
54
|
+
value = JSON.parse(text);
|
|
55
|
+
} catch {
|
|
56
|
+
return { kind: "malformed", reason: "json" };
|
|
57
|
+
}
|
|
58
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
59
|
+
return { kind: "malformed", reason: "not-object" };
|
|
60
|
+
}
|
|
61
|
+
return { kind: "frame", value };
|
|
62
|
+
}
|
|
63
|
+
var NdjsonDecoder = class {
|
|
64
|
+
maxFrameBytes;
|
|
65
|
+
pending = [];
|
|
66
|
+
pendingLen = 0;
|
|
67
|
+
discarding = false;
|
|
68
|
+
discarded = 0;
|
|
69
|
+
constructor(maxFrameBytes = MAX_FRAME_BYTES) {
|
|
70
|
+
if (!Number.isInteger(maxFrameBytes) || maxFrameBytes < 1) throw new RangeError("maxFrameBytes must be a positive integer");
|
|
71
|
+
this.maxFrameBytes = maxFrameBytes;
|
|
72
|
+
}
|
|
73
|
+
/** Change the inbound limit (e.g. 16 KiB pre-auth, raised after auth). Applies to the next bytes. */
|
|
74
|
+
setMaxFrameBytes(n) {
|
|
75
|
+
if (!Number.isInteger(n) || n < 1) throw new RangeError("maxFrameBytes must be a positive integer");
|
|
76
|
+
this.maxFrameBytes = n;
|
|
77
|
+
}
|
|
78
|
+
/** Bytes buffered toward the current (incomplete) frame; never exceeds maxFrameBytes. */
|
|
79
|
+
get pendingBytes() {
|
|
80
|
+
return this.pendingLen;
|
|
81
|
+
}
|
|
82
|
+
push(chunk) {
|
|
83
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
84
|
+
const out = [];
|
|
85
|
+
let offset = 0;
|
|
86
|
+
while (offset <= buf.length) {
|
|
87
|
+
const idx = buf.indexOf(NL, offset);
|
|
88
|
+
if (idx === -1) {
|
|
89
|
+
const rest = buf.subarray(offset);
|
|
90
|
+
if (rest.length === 0) break;
|
|
91
|
+
if (this.discarding) {
|
|
92
|
+
this.discarded += rest.length;
|
|
93
|
+
} else if (this.pendingLen + rest.length > this.maxFrameBytes) {
|
|
94
|
+
this.discarding = true;
|
|
95
|
+
this.discarded = this.pendingLen + rest.length;
|
|
96
|
+
this.pending = [];
|
|
97
|
+
this.pendingLen = 0;
|
|
98
|
+
} else {
|
|
99
|
+
this.pending.push(Buffer.from(rest));
|
|
100
|
+
this.pendingLen += rest.length;
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
const seg = buf.subarray(offset, idx);
|
|
105
|
+
offset = idx + 1;
|
|
106
|
+
if (this.discarding) {
|
|
107
|
+
out.push({ kind: "oversize", bytes: this.discarded + seg.length });
|
|
108
|
+
this.discarding = false;
|
|
109
|
+
this.discarded = 0;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const lineLen = this.pendingLen + seg.length;
|
|
113
|
+
if (lineLen > this.maxFrameBytes) {
|
|
114
|
+
this.pending = [];
|
|
115
|
+
this.pendingLen = 0;
|
|
116
|
+
out.push({ kind: "oversize", bytes: lineLen });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const line = this.pendingLen === 0 ? seg : Buffer.concat([...this.pending, seg], lineLen);
|
|
120
|
+
this.pending = [];
|
|
121
|
+
this.pendingLen = 0;
|
|
122
|
+
const item = decodeLine(line);
|
|
123
|
+
if (item) out.push(item);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
/** The stream ended: report an unterminated partial frame, if any. */
|
|
128
|
+
end() {
|
|
129
|
+
const bytes = this.discarding ? this.discarded : this.pendingLen;
|
|
130
|
+
const had = this.discarding || this.pendingLen > 0;
|
|
131
|
+
this.pending = [];
|
|
132
|
+
this.pendingLen = 0;
|
|
133
|
+
this.discarding = false;
|
|
134
|
+
this.discarded = 0;
|
|
135
|
+
return had ? [{ kind: "truncated", bytes }] : [];
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// src/kernel/web/bundle.ts
|
|
140
|
+
import { createHash } from "node:crypto";
|
|
141
|
+
import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
142
|
+
import path from "node:path";
|
|
143
|
+
var BUNDLE_TREE_DOMAIN = "gnomon-bundle-tree:v1";
|
|
144
|
+
var MAX_BUNDLE_FILES = 1e4;
|
|
145
|
+
var MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
|
|
146
|
+
var SAFE_PATH = /^[A-Za-z0-9._@+-]+(\/[A-Za-z0-9._@+-]+)*$/;
|
|
147
|
+
var BundleError = class extends Error {
|
|
148
|
+
constructor(why) {
|
|
149
|
+
super(`gnomon-web bundle: ${why}`);
|
|
150
|
+
this.why = why;
|
|
151
|
+
this.name = "BundleError";
|
|
152
|
+
}
|
|
153
|
+
why;
|
|
154
|
+
};
|
|
155
|
+
function treeHashOf(files) {
|
|
156
|
+
const names = [...files.keys()].sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)));
|
|
157
|
+
const h = createHash("sha256").update(`${BUNDLE_TREE_DOMAIN}
|
|
158
|
+
`);
|
|
159
|
+
for (const n of names) {
|
|
160
|
+
if (!SAFE_PATH.test(n) || n.split("/").some((s) => s === "." || s === "..")) throw new BundleError(`unsafe path ${JSON.stringify(n)}`);
|
|
161
|
+
h.update(`${n}\0${createHash("sha256").update(files.get(n)).digest("hex")}
|
|
162
|
+
`);
|
|
163
|
+
}
|
|
164
|
+
return `sha256:${h.digest("hex")}`;
|
|
165
|
+
}
|
|
166
|
+
function hashBundleDir(dir) {
|
|
167
|
+
const files = /* @__PURE__ */ new Map();
|
|
168
|
+
let total = 0;
|
|
169
|
+
const visit = (abs, rel) => {
|
|
170
|
+
for (const name of readdirSync(abs).sort()) {
|
|
171
|
+
const full = path.join(abs, name);
|
|
172
|
+
const r = rel ? `${rel}/${name}` : name;
|
|
173
|
+
const st = lstatSync(full);
|
|
174
|
+
if (st.isSymbolicLink()) throw new BundleError(`symlink in bundle: ${r}`);
|
|
175
|
+
if (st.isDirectory()) visit(full, r);
|
|
176
|
+
else if (st.isFile()) {
|
|
177
|
+
if (files.size >= MAX_BUNDLE_FILES) throw new BundleError(`more than ${MAX_BUNDLE_FILES} files`);
|
|
178
|
+
const bytes = readFileSync(full);
|
|
179
|
+
total += bytes.length;
|
|
180
|
+
if (total > MAX_BUNDLE_BYTES) throw new BundleError(`more than ${MAX_BUNDLE_BYTES} bytes`);
|
|
181
|
+
files.set(r, bytes);
|
|
182
|
+
} else throw new BundleError(`not a regular file: ${r}`);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
const root = lstatSync(dir);
|
|
186
|
+
if (!root.isDirectory()) throw new BundleError("bundle root is not a directory");
|
|
187
|
+
visit(dir, "");
|
|
188
|
+
if (!files.has("index.html")) throw new BundleError("bundle has no index.html");
|
|
189
|
+
return { hash: treeHashOf(files), files };
|
|
190
|
+
}
|
|
191
|
+
var PLACEHOLDER_FILES = Object.freeze({
|
|
192
|
+
"index.html": '<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<title>Gnomon</title>\n<script src="/app.js" defer></script>\n</head>\n<body>\n<main id="gnomon"><p>Gnomon: the GUI arrives with M1.10. This is the pinned placeholder bundle.</p></main>\n</body>\n</html>\n',
|
|
193
|
+
// §11.2 rule 2: the fragment is cleared before any other script runs (the real read-and-clear is the M1.10 SPA's).
|
|
194
|
+
// /diag and /blob/<sha> are POST with the GUI token in X-Gnomon-Token and referrerPolicy 'same-origin' (under the
|
|
195
|
+
// page's no-referrer policy a POST would carry Origin: null and fail the rule-3 origin lock).
|
|
196
|
+
"app.js": "'use strict';\nif (location.hash) history.replaceState(null, '', location.pathname + location.search);\nglobalThis.gnomonPost = (path, token, body) => fetch(path, { method: 'POST', referrerPolicy: 'same-origin', headers: { 'X-Gnomon-Token': token }, body });\n"
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// src/transport/ws-codec.ts
|
|
200
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
201
|
+
var WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
202
|
+
var OP = Object.freeze({ CONT: 0, TEXT: 1, BINARY: 2, CLOSE: 8, PING: 9, PONG: 10 });
|
|
203
|
+
var CLOSE_CODES = Object.freeze({
|
|
204
|
+
NORMAL: 1e3,
|
|
205
|
+
GOING_AWAY: 1001,
|
|
206
|
+
PROTOCOL: 1002,
|
|
207
|
+
UNSUPPORTED_DATA: 1003,
|
|
208
|
+
NO_STATUS: 1005,
|
|
209
|
+
ABNORMAL: 1006,
|
|
210
|
+
INVALID_PAYLOAD: 1007,
|
|
211
|
+
POLICY: 1008,
|
|
212
|
+
TOO_BIG: 1009,
|
|
213
|
+
TRY_AGAIN_LATER: 1013
|
|
214
|
+
});
|
|
215
|
+
function validCloseCode(c) {
|
|
216
|
+
return c >= 1e3 && c <= 1003 || c >= 1007 && c <= 1014 || c >= 3e3 && c <= 4999;
|
|
217
|
+
}
|
|
218
|
+
function acceptKey(key) {
|
|
219
|
+
return createHash2("sha1").update(key + WS_GUID).digest("base64");
|
|
220
|
+
}
|
|
221
|
+
var utf82 = new TextDecoder("utf-8", { fatal: true });
|
|
222
|
+
function isUtf8(b) {
|
|
223
|
+
try {
|
|
224
|
+
utf82.decode(b);
|
|
225
|
+
return true;
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
var FrameDecoder = class {
|
|
231
|
+
#chunks = [];
|
|
232
|
+
#len = 0;
|
|
233
|
+
#head = null;
|
|
234
|
+
#done = false;
|
|
235
|
+
#max;
|
|
236
|
+
constructor(maxPayload) {
|
|
237
|
+
this.#max = maxPayload;
|
|
238
|
+
}
|
|
239
|
+
get done() {
|
|
240
|
+
return this.#done;
|
|
241
|
+
}
|
|
242
|
+
get maxPayload() {
|
|
243
|
+
return this.#max;
|
|
244
|
+
}
|
|
245
|
+
/** Change the payload cap for the frames that follow (16 KiB pre-auth, raised on authentication). */
|
|
246
|
+
setMaxPayload(n) {
|
|
247
|
+
this.#max = n;
|
|
248
|
+
}
|
|
249
|
+
#peek(n) {
|
|
250
|
+
if (this.#chunks[0].length >= n) return this.#chunks[0];
|
|
251
|
+
const parts = [];
|
|
252
|
+
let got = 0;
|
|
253
|
+
for (const c of this.#chunks) {
|
|
254
|
+
parts.push(c);
|
|
255
|
+
got += c.length;
|
|
256
|
+
if (got >= n) break;
|
|
257
|
+
}
|
|
258
|
+
return Buffer.concat(parts, got);
|
|
259
|
+
}
|
|
260
|
+
#take(n) {
|
|
261
|
+
const parts = [];
|
|
262
|
+
let got = 0;
|
|
263
|
+
while (got < n) {
|
|
264
|
+
const c = this.#chunks[0];
|
|
265
|
+
const want = n - got;
|
|
266
|
+
if (c.length <= want) {
|
|
267
|
+
parts.push(c);
|
|
268
|
+
got += c.length;
|
|
269
|
+
this.#chunks.shift();
|
|
270
|
+
} else {
|
|
271
|
+
parts.push(c.subarray(0, want));
|
|
272
|
+
this.#chunks[0] = c.subarray(want);
|
|
273
|
+
got += want;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
this.#len -= n;
|
|
277
|
+
return parts.length === 1 ? parts[0] : Buffer.concat(parts, n);
|
|
278
|
+
}
|
|
279
|
+
/** Parse (and validate) the next frame header; null = need more bytes; a fail event = refused. */
|
|
280
|
+
#parseHead() {
|
|
281
|
+
const b = this.#peek(Math.min(this.#len, 14));
|
|
282
|
+
if (b.length < 2) return null;
|
|
283
|
+
const fin = (b[0] & 128) !== 0;
|
|
284
|
+
const rsv = b[0] & 112;
|
|
285
|
+
const op = b[0] & 15;
|
|
286
|
+
const masked = (b[1] & 128) !== 0;
|
|
287
|
+
const len7 = b[1] & 127;
|
|
288
|
+
const fail = (code, why) => ({ kind: "fail", code, why });
|
|
289
|
+
if (rsv !== 0) return fail(CLOSE_CODES.PROTOCOL, "rsv-bits");
|
|
290
|
+
const control = op >= 8;
|
|
291
|
+
if (![OP.CONT, OP.TEXT, OP.BINARY, OP.CLOSE, OP.PING, OP.PONG].includes(op)) return fail(CLOSE_CODES.PROTOCOL, "reserved-opcode");
|
|
292
|
+
if (control && (!fin || len7 > 125)) return fail(CLOSE_CODES.PROTOCOL, "bad-control-frame");
|
|
293
|
+
if (op === OP.BINARY) return fail(CLOSE_CODES.UNSUPPORTED_DATA, "binary-frame");
|
|
294
|
+
if (op === OP.CONT || op === OP.TEXT && !fin) return fail(CLOSE_CODES.TOO_BIG, "fragmented");
|
|
295
|
+
if (!masked) return fail(CLOSE_CODES.PROTOCOL, "unmasked");
|
|
296
|
+
let off = 2;
|
|
297
|
+
let len = len7;
|
|
298
|
+
if (len7 === 126) {
|
|
299
|
+
if (b.length < 4) return null;
|
|
300
|
+
len = b.readUInt16BE(2);
|
|
301
|
+
off = 4;
|
|
302
|
+
} else if (len7 === 127) {
|
|
303
|
+
if (b.length < 10) return null;
|
|
304
|
+
const hi = b.readUInt32BE(2);
|
|
305
|
+
if (hi & 2147483648) return fail(CLOSE_CODES.PROTOCOL, "length-msb");
|
|
306
|
+
if (hi !== 0) return fail(CLOSE_CODES.TOO_BIG, "oversize");
|
|
307
|
+
len = b.readUInt32BE(6);
|
|
308
|
+
off = 10;
|
|
309
|
+
}
|
|
310
|
+
if (len > this.#max) return fail(CLOSE_CODES.TOO_BIG, "oversize");
|
|
311
|
+
return { op, off: off + 4, len };
|
|
312
|
+
}
|
|
313
|
+
push(chunk) {
|
|
314
|
+
if (this.#done) return [];
|
|
315
|
+
if (chunk.length) {
|
|
316
|
+
this.#chunks.push(Buffer.from(chunk));
|
|
317
|
+
this.#len += chunk.length;
|
|
318
|
+
}
|
|
319
|
+
const out = [];
|
|
320
|
+
const fail = (code, why) => {
|
|
321
|
+
this.#done = true;
|
|
322
|
+
this.#chunks = [];
|
|
323
|
+
this.#len = 0;
|
|
324
|
+
out.push({ kind: "fail", code, why });
|
|
325
|
+
return out;
|
|
326
|
+
};
|
|
327
|
+
for (; ; ) {
|
|
328
|
+
if (!this.#head) {
|
|
329
|
+
if (this.#len < 2) return out;
|
|
330
|
+
const h = this.#parseHead();
|
|
331
|
+
if (h === null) return out;
|
|
332
|
+
if ("kind" in h) {
|
|
333
|
+
const f = h;
|
|
334
|
+
return fail(f.code, f.why);
|
|
335
|
+
}
|
|
336
|
+
this.#head = h;
|
|
337
|
+
}
|
|
338
|
+
const { op, off, len } = this.#head;
|
|
339
|
+
if (this.#len < off + len) return out;
|
|
340
|
+
const frame = this.#take(off + len);
|
|
341
|
+
this.#head = null;
|
|
342
|
+
const mask = frame.subarray(off - 4, off);
|
|
343
|
+
const payload = Buffer.from(frame.subarray(off, off + len));
|
|
344
|
+
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
|
|
345
|
+
if (op === OP.TEXT) {
|
|
346
|
+
if (!isUtf8(payload)) return fail(CLOSE_CODES.INVALID_PAYLOAD, "invalid-utf8");
|
|
347
|
+
out.push({ kind: "text", data: payload });
|
|
348
|
+
} else if (op === OP.PING) out.push({ kind: "ping", data: payload });
|
|
349
|
+
else if (op === OP.PONG) out.push({ kind: "pong", data: payload });
|
|
350
|
+
else {
|
|
351
|
+
if (payload.length === 1) return fail(CLOSE_CODES.PROTOCOL, "close-payload");
|
|
352
|
+
if (payload.length === 0) {
|
|
353
|
+
this.#done = true;
|
|
354
|
+
out.push({ kind: "close", code: null, reason: Buffer.alloc(0) });
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
const code = payload.readUInt16BE(0);
|
|
358
|
+
if (!validCloseCode(code)) return fail(CLOSE_CODES.PROTOCOL, "close-code");
|
|
359
|
+
const reason = payload.subarray(2);
|
|
360
|
+
if (!isUtf8(reason)) return fail(CLOSE_CODES.INVALID_PAYLOAD, "close-reason-utf8");
|
|
361
|
+
this.#done = true;
|
|
362
|
+
out.push({ kind: "close", code, reason });
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
function encodeWsFrame(op, payload = Buffer.alloc(0)) {
|
|
369
|
+
const n = payload.length;
|
|
370
|
+
let head;
|
|
371
|
+
if (n < 126) {
|
|
372
|
+
head = Buffer.from([128 | op, n]);
|
|
373
|
+
} else if (n < 65536) {
|
|
374
|
+
head = Buffer.alloc(4);
|
|
375
|
+
head[0] = 128 | op;
|
|
376
|
+
head[1] = 126;
|
|
377
|
+
head.writeUInt16BE(n, 2);
|
|
378
|
+
} else {
|
|
379
|
+
head = Buffer.alloc(10);
|
|
380
|
+
head[0] = 128 | op;
|
|
381
|
+
head[1] = 127;
|
|
382
|
+
head.writeUInt32BE(Math.floor(n / 2 ** 32), 2);
|
|
383
|
+
head.writeUInt32BE(n >>> 0, 6);
|
|
384
|
+
}
|
|
385
|
+
return Buffer.concat([head, payload]);
|
|
386
|
+
}
|
|
387
|
+
function closeFrame(code) {
|
|
388
|
+
if (code === null) return encodeWsFrame(OP.CLOSE);
|
|
389
|
+
const p = Buffer.alloc(2);
|
|
390
|
+
p.writeUInt16BE(code, 0);
|
|
391
|
+
return encodeWsFrame(OP.CLOSE, p);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// src/transport/web-child.ts
|
|
395
|
+
var KERNEL_ANSWER_TIMEOUT_MS = 1e4;
|
|
396
|
+
var HEADERS_TIMEOUT_MS = 1e4;
|
|
397
|
+
var REQUEST_TIMEOUT_MS = 3e4;
|
|
398
|
+
var STATUS_TEXT = { 400: "Bad Request", 403: "Forbidden", 404: "Not Found", 421: "Misdirected Request", 426: "Upgrade Required", 503: "Service Unavailable" };
|
|
399
|
+
function runWebChild(io) {
|
|
400
|
+
const decoder = new NdjsonDecoder(CHANNEL_MAX_RECORD_BYTES);
|
|
401
|
+
let config = null;
|
|
402
|
+
let bundle = null;
|
|
403
|
+
let server = null;
|
|
404
|
+
let nextRid = 1;
|
|
405
|
+
const pendingHttp = /* @__PURE__ */ new Map();
|
|
406
|
+
const pendingUpgrade = /* @__PURE__ */ new Map();
|
|
407
|
+
const ws = /* @__PURE__ */ new Map();
|
|
408
|
+
let ended = false;
|
|
409
|
+
const emit = (r) => {
|
|
410
|
+
if (!ended) io.output.write(encodeRecord(r));
|
|
411
|
+
};
|
|
412
|
+
const finish = (code) => {
|
|
413
|
+
if (ended) return;
|
|
414
|
+
for (const c of ws.values()) c.socket.destroy();
|
|
415
|
+
for (const p of pendingUpgrade.values()) p.socket.destroy();
|
|
416
|
+
server?.close();
|
|
417
|
+
ended = true;
|
|
418
|
+
io.exit(code);
|
|
419
|
+
};
|
|
420
|
+
const fatal = (why) => {
|
|
421
|
+
emit({ t: "fatal", why });
|
|
422
|
+
finish(1);
|
|
423
|
+
};
|
|
424
|
+
const reportClosed = (rid, code, why) => {
|
|
425
|
+
const c = ws.get(rid);
|
|
426
|
+
if (!c || c.reported) return;
|
|
427
|
+
c.reported = true;
|
|
428
|
+
if (c.timer) clearInterval(c.timer);
|
|
429
|
+
ws.delete(rid);
|
|
430
|
+
emit({ t: "closed", rid, code, why });
|
|
431
|
+
};
|
|
432
|
+
const closeWs = (rid, code, why) => {
|
|
433
|
+
const c = ws.get(rid);
|
|
434
|
+
if (!c) return;
|
|
435
|
+
if (!c.closing) {
|
|
436
|
+
c.closing = true;
|
|
437
|
+
c.socket.write(closeFrame(code));
|
|
438
|
+
}
|
|
439
|
+
c.socket.end();
|
|
440
|
+
setTimeout(() => c.socket.destroy(), 1e3).unref();
|
|
441
|
+
reportClosed(rid, code, why);
|
|
442
|
+
};
|
|
443
|
+
const rawRefuse = (socket, status, headers) => {
|
|
444
|
+
const lines = [`HTTP/1.1 ${status} ${STATUS_TEXT[status] ?? "Error"}`, "Content-Length: 0", "Connection: close"];
|
|
445
|
+
for (const [k, v] of Object.entries({ ...config.defaultHeaders, ...headers })) lines.push(`${k}: ${v}`);
|
|
446
|
+
socket.end(`${lines.join("\r\n")}\r
|
|
447
|
+
\r
|
|
448
|
+
`);
|
|
449
|
+
};
|
|
450
|
+
const onRequest = (req, res) => {
|
|
451
|
+
const rid = nextRid++;
|
|
452
|
+
const max = config.maxBodyBytes;
|
|
453
|
+
const chunks = [];
|
|
454
|
+
let size = 0;
|
|
455
|
+
let over = false;
|
|
456
|
+
req.on("data", (c) => {
|
|
457
|
+
if (over) return;
|
|
458
|
+
size += c.length;
|
|
459
|
+
if (size > max) {
|
|
460
|
+
over = true;
|
|
461
|
+
chunks.length = 0;
|
|
462
|
+
} else chunks.push(c);
|
|
463
|
+
});
|
|
464
|
+
req.on("end", () => {
|
|
465
|
+
const timer = setTimeout(() => {
|
|
466
|
+
pendingHttp.delete(rid);
|
|
467
|
+
res.writeHead(503, { ...config.defaultHeaders, "content-length": "0" }).end();
|
|
468
|
+
}, config.answerTimeoutMs ?? KERNEL_ANSWER_TIMEOUT_MS);
|
|
469
|
+
timer.unref();
|
|
470
|
+
pendingHttp.set(rid, { res, method: req.method ?? "GET", timer });
|
|
471
|
+
const body = chunks.length ? Buffer.concat(chunks).toString("base64") : void 0;
|
|
472
|
+
emit({ t: "http", rid, method: req.method ?? "", path: req.url ?? "", headers: flattenHeaders(req.headers), ...body ? { body } : {}, ...over ? { bodyOver: true } : {} });
|
|
473
|
+
});
|
|
474
|
+
req.on("error", () => res.destroy());
|
|
475
|
+
};
|
|
476
|
+
const onUpgrade = (req, socket, head) => {
|
|
477
|
+
const rid = nextRid++;
|
|
478
|
+
socket.on("error", () => socket.destroy());
|
|
479
|
+
const timer = setTimeout(() => {
|
|
480
|
+
pendingUpgrade.delete(rid);
|
|
481
|
+
rawRefuse(socket, 503, {});
|
|
482
|
+
}, config.answerTimeoutMs ?? KERNEL_ANSWER_TIMEOUT_MS);
|
|
483
|
+
timer.unref();
|
|
484
|
+
pendingUpgrade.set(rid, { socket, head: Buffer.from(head), key: String(req.headers["sec-websocket-key"] ?? ""), timer });
|
|
485
|
+
socket.on("close", () => {
|
|
486
|
+
const p = pendingUpgrade.get(rid);
|
|
487
|
+
if (p) {
|
|
488
|
+
clearTimeout(p.timer);
|
|
489
|
+
pendingUpgrade.delete(rid);
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
emit({ t: "upgrade", rid, method: req.method ?? "", path: req.url ?? "", headers: flattenHeaders(req.headers) });
|
|
493
|
+
};
|
|
494
|
+
const accept = (rid) => {
|
|
495
|
+
const p = pendingUpgrade.get(rid);
|
|
496
|
+
if (!p) return;
|
|
497
|
+
clearTimeout(p.timer);
|
|
498
|
+
pendingUpgrade.delete(rid);
|
|
499
|
+
const { socket } = p;
|
|
500
|
+
socket.write(`HTTP/1.1 101 Switching Protocols\r
|
|
501
|
+
Upgrade: websocket\r
|
|
502
|
+
Connection: Upgrade\r
|
|
503
|
+
Sec-WebSocket-Accept: ${acceptKey(p.key)}\r
|
|
504
|
+
\r
|
|
505
|
+
`);
|
|
506
|
+
const c = { socket, pressured: false, decoder: new FrameDecoder(config.preAuthMaxFrameBytes), missed: 0, timer: null, reported: false, closing: false };
|
|
507
|
+
const low = Math.max(Math.min(config.writeLowWater ?? config.writeHighWater, config.writeHighWater), socket.writableHighWaterMark ?? 0);
|
|
508
|
+
socket.on("drain", () => {
|
|
509
|
+
if (c.pressured && (socket.writableLength ?? 0) <= low) {
|
|
510
|
+
c.pressured = false;
|
|
511
|
+
emit({ t: "drain", rid });
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
ws.set(rid, c);
|
|
515
|
+
c.timer = setInterval(() => {
|
|
516
|
+
if (c.missed >= config.missedPongs) return closeWs(rid, CLOSE_CODES.GOING_AWAY, "missed-pongs");
|
|
517
|
+
c.missed++;
|
|
518
|
+
socket.write(encodeWsFrame(OP.PING));
|
|
519
|
+
}, config.pingMs);
|
|
520
|
+
c.timer.unref();
|
|
521
|
+
const onData = (chunk) => {
|
|
522
|
+
for (const e of c.decoder.push(chunk)) {
|
|
523
|
+
if (e.kind === "text") emit({ t: "frame", rid, data: e.data.toString("base64") });
|
|
524
|
+
else if (e.kind === "ping") socket.write(encodeWsFrame(OP.PONG, e.data));
|
|
525
|
+
else if (e.kind === "pong") c.missed = 0;
|
|
526
|
+
else if (e.kind === "close") {
|
|
527
|
+
if (!c.closing) {
|
|
528
|
+
c.closing = true;
|
|
529
|
+
socket.write(closeFrame(e.code));
|
|
530
|
+
}
|
|
531
|
+
socket.end();
|
|
532
|
+
reportClosed(rid, e.code ?? CLOSE_CODES.NO_STATUS, "peer-close");
|
|
533
|
+
} else closeWs(rid, e.code, e.why);
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
socket.on("data", onData);
|
|
537
|
+
socket.on("close", () => reportClosed(rid, CLOSE_CODES.ABNORMAL, "socket-closed"));
|
|
538
|
+
socket.on("end", () => {
|
|
539
|
+
socket.destroy();
|
|
540
|
+
reportClosed(rid, CLOSE_CODES.ABNORMAL, "peer-ended");
|
|
541
|
+
});
|
|
542
|
+
if (p.head.length) onData(p.head);
|
|
543
|
+
};
|
|
544
|
+
const onKernel = (r) => {
|
|
545
|
+
switch (r.t) {
|
|
546
|
+
case "config":
|
|
547
|
+
return;
|
|
548
|
+
// only the first record configures
|
|
549
|
+
case "respond": {
|
|
550
|
+
const h = pendingHttp.get(r.rid);
|
|
551
|
+
if (h) {
|
|
552
|
+
clearTimeout(h.timer);
|
|
553
|
+
pendingHttp.delete(r.rid);
|
|
554
|
+
const body = r.body ? Buffer.from(r.body, "base64") : Buffer.alloc(0);
|
|
555
|
+
h.res.writeHead(r.status, { ...r.headers, "content-length": String(body.length) });
|
|
556
|
+
h.res.end(h.method === "HEAD" ? void 0 : body);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const u = pendingUpgrade.get(r.rid);
|
|
560
|
+
if (u) {
|
|
561
|
+
clearTimeout(u.timer);
|
|
562
|
+
pendingUpgrade.delete(r.rid);
|
|
563
|
+
rawRefuse(u.socket, r.status, r.headers);
|
|
564
|
+
}
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
case "static": {
|
|
568
|
+
const h = pendingHttp.get(r.rid);
|
|
569
|
+
if (!h) return;
|
|
570
|
+
clearTimeout(h.timer);
|
|
571
|
+
pendingHttp.delete(r.rid);
|
|
572
|
+
const bytes = bundle.files.get(r.file);
|
|
573
|
+
if (!bytes) {
|
|
574
|
+
h.res.writeHead(404, { ...config.defaultHeaders, ...r.headers, "content-length": "0" }).end();
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
h.res.writeHead(200, { ...r.headers, "content-length": String(bytes.length) });
|
|
578
|
+
h.res.end(h.method === "HEAD" ? void 0 : bytes);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
case "accept":
|
|
582
|
+
return accept(r.rid);
|
|
583
|
+
case "send": {
|
|
584
|
+
const c = ws.get(r.rid);
|
|
585
|
+
if (!c || c.closing) return;
|
|
586
|
+
c.socket.write(encodeWsFrame(OP.TEXT, Buffer.from(r.data, "base64")));
|
|
587
|
+
const queued = c.socket.writableLength ?? 0;
|
|
588
|
+
if (queued > config.maxSocketBuffer) return closeWs(r.rid, CLOSE_CODES.POLICY, "backpressure");
|
|
589
|
+
if (!c.pressured && queued > config.writeHighWater) {
|
|
590
|
+
c.pressured = true;
|
|
591
|
+
emit({ t: "pressure", rid: r.rid, bytes: queued });
|
|
592
|
+
}
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
case "limit": {
|
|
596
|
+
const c = ws.get(r.rid);
|
|
597
|
+
if (c) c.decoder.setMaxPayload(Math.min(r.maxFrameBytes, config.maxFrameBytes));
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
case "close": {
|
|
601
|
+
if (ws.has(r.rid)) return closeWs(r.rid, r.code, "kernel-close");
|
|
602
|
+
const u = pendingUpgrade.get(r.rid);
|
|
603
|
+
if (u) {
|
|
604
|
+
clearTimeout(u.timer);
|
|
605
|
+
pendingUpgrade.delete(r.rid);
|
|
606
|
+
u.socket.destroy();
|
|
607
|
+
}
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
case "shutdown":
|
|
611
|
+
return finish(0);
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
const start = (cfg) => {
|
|
615
|
+
config = cfg;
|
|
616
|
+
try {
|
|
617
|
+
bundle = hashBundleDir(cfg.bundleDir);
|
|
618
|
+
} catch (err) {
|
|
619
|
+
return fatal(`bundle: ${err instanceof Error ? err.message : String(err)}`);
|
|
620
|
+
}
|
|
621
|
+
const s = http.createServer({ maxHeaderSize: 16 * 1024 }, onRequest);
|
|
622
|
+
server = s;
|
|
623
|
+
s.headersTimeout = cfg.headersTimeoutMs ?? HEADERS_TIMEOUT_MS;
|
|
624
|
+
s.requestTimeout = cfg.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
625
|
+
s.on("request", (req, res) => {
|
|
626
|
+
const sock = req.socket;
|
|
627
|
+
arm(sock, s.requestTimeout);
|
|
628
|
+
req.on("end", () => disarm(sock));
|
|
629
|
+
res.on("finish", () => {
|
|
630
|
+
if (!sock.destroyed) arm(sock, s.headersTimeout);
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
s.on("upgrade", (_req, socket) => disarm(socket));
|
|
634
|
+
s.on("upgrade", onUpgrade);
|
|
635
|
+
s.on("clientError", (_err, socket) => socket.destroy());
|
|
636
|
+
emit({ t: "ready", bundleHash: bundle.hash, files: bundle.files.size });
|
|
637
|
+
};
|
|
638
|
+
const deadlines = /* @__PURE__ */ new WeakMap();
|
|
639
|
+
const disarm = (socket) => {
|
|
640
|
+
const t = deadlines.get(socket);
|
|
641
|
+
if (t) clearTimeout(t);
|
|
642
|
+
deadlines.delete(socket);
|
|
643
|
+
};
|
|
644
|
+
const arm = (socket, ms) => {
|
|
645
|
+
disarm(socket);
|
|
646
|
+
const t = setTimeout(() => {
|
|
647
|
+
deadlines.delete(socket);
|
|
648
|
+
socket.destroy();
|
|
649
|
+
}, ms);
|
|
650
|
+
t.unref();
|
|
651
|
+
deadlines.set(socket, t);
|
|
652
|
+
};
|
|
653
|
+
io.onSocket((socket) => {
|
|
654
|
+
if (ended || server === null) return void socket.destroy();
|
|
655
|
+
arm(socket, server.headersTimeout);
|
|
656
|
+
socket.once("close", () => disarm(socket));
|
|
657
|
+
server.emit("connection", socket);
|
|
658
|
+
});
|
|
659
|
+
io.input.on("data", (chunk) => {
|
|
660
|
+
for (const item of decoder.push(chunk)) {
|
|
661
|
+
if (item.kind !== "frame") return fatal(`channel: ${item.kind}`);
|
|
662
|
+
const r = item.value;
|
|
663
|
+
if (config === null) {
|
|
664
|
+
if (r.t !== "config") return fatal("channel: the first record must be config");
|
|
665
|
+
start(r);
|
|
666
|
+
} else onKernel(r);
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
io.input.on("end", () => finish(0));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// src/transport/gnomon-web.ts
|
|
673
|
+
if (process.argv.includes("--version")) {
|
|
674
|
+
process.stdout.write(`gnomon-web ${GNOMON_VERSION}
|
|
675
|
+
`);
|
|
676
|
+
} else {
|
|
677
|
+
runWebChild({
|
|
678
|
+
input: process.stdin,
|
|
679
|
+
output: process.stdout,
|
|
680
|
+
exit: (code) => process.exit(code),
|
|
681
|
+
// The IPC channel carries only the connected sockets the kernel accepted (§15.5); anything else is dropped.
|
|
682
|
+
onSocket: (accept) => void process.on("message", (m, handle) => {
|
|
683
|
+
if (m === WEB_CONN_MESSAGE && handle instanceof Socket) accept(handle);
|
|
684
|
+
else if (handle && typeof handle.destroy === "function") handle.destroy();
|
|
685
|
+
else if (handle && typeof handle.close === "function") handle.close();
|
|
686
|
+
})
|
|
687
|
+
});
|
|
688
|
+
}
|