@ekrooh/bare 0.5.0 → 0.6.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 +15 -5
- package/dist/index.mjs +1 -1
- package/dist/{messages-BbpF3wNH.mjs → messages-CevD8qkV.mjs} +7 -5
- package/dist/{plugins-DZu_NWVp.mjs → plugins-Ddp2AgOe.mjs} +6 -3
- package/dist/plugins-discovery-events.mjs +1 -1
- package/dist/plugins-health-events.mjs +1 -1
- package/dist/plugins-logs-events.mjs +1 -1
- package/dist/plugins-media-events.mjs +1 -1
- package/dist/plugins-permissions-events.mjs +1 -1
- package/dist/plugins.d.mts +1 -1
- package/dist/plugins.mjs +1 -1
- package/dist/runtime.d.mts +42 -33
- package/dist/runtime.mjs +871 -596
- package/dist/{static-file-server-C8pIX1JD.d.mts → static-file-server-COxd-PFR.d.mts} +41 -36
- package/dist/transports.d.mts +3 -5
- package/dist/transports.mjs +2 -3
- package/package.json +1 -2
package/dist/runtime.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t as createDefaultPlugins } from "./plugins-
|
|
1
|
+
import { _ as MAX_FRAME_BYTES, c as createRequestId, g as ErrorCode, n as createBareRuntimeContext, v as MAX_HEADER_BYTES, y as MessageType } from "./messages-CevD8qkV.mjs";
|
|
2
|
+
import { t as createDefaultPlugins } from "./plugins-Ddp2AgOe.mjs";
|
|
3
3
|
import { t as createLogRingBuffer } from "./store-DTg1jN5E.mjs";
|
|
4
4
|
import { TextDecoder, TextEncoder } from "bare-encoding";
|
|
5
5
|
import fs from "bare-fs";
|
|
@@ -9,15 +9,176 @@ import http from "bare-http1";
|
|
|
9
9
|
import ws from "bare-ws";
|
|
10
10
|
// core/lib/get-ipc.ts
|
|
11
11
|
/**
|
|
12
|
-
* Returns the active IPC channel.
|
|
13
|
-
* In BareKit (Android), it uses BareKit.IPC.
|
|
12
|
+
* Returns the active IPC channel, or null when there is no host.
|
|
13
|
+
* In BareKit (Android, iOS), it uses BareKit.IPC.
|
|
14
14
|
* In Bare (Sidecar), it uses Bare.IPC.
|
|
15
15
|
*/
|
|
16
16
|
function getIPC() {
|
|
17
17
|
const IPC = typeof BareKit !== "undefined" ? BareKit.IPC : typeof Bare !== "undefined" ? Bare.IPC : void 0;
|
|
18
18
|
if (IPC && typeof IPC.resume === "function") IPC.resume();
|
|
19
|
-
return IPC;
|
|
19
|
+
return IPC ?? null;
|
|
20
20
|
}
|
|
21
|
+
// core/messages/bytes.ts
|
|
22
|
+
/** Normalizes a binary stream chunk to a `Uint8Array` view without copying.
|
|
23
|
+
* A `Buffer` is already a `Uint8Array`. Anything else (a text WebSocket
|
|
24
|
+
* message, a stray object) is not a protocol frame and throws. */
|
|
25
|
+
function toBytes(data) {
|
|
26
|
+
if (data instanceof Uint8Array) return data;
|
|
27
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
28
|
+
throw new TypeError(`expected binary data, got ${data === null ? "null" : typeof data}`);
|
|
29
|
+
}
|
|
30
|
+
/** The chunk form Bare's IPC stream writes: a `Buffer` view over the same
|
|
31
|
+
* memory where `Buffer` exists (on Bare `encode` already returns one), the
|
|
32
|
+
* bytes themselves elsewhere. */
|
|
33
|
+
function toIpcChunk(bytes) {
|
|
34
|
+
if (typeof Buffer === "undefined" || Buffer.isBuffer(bytes)) return bytes;
|
|
35
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
36
|
+
}
|
|
37
|
+
// core/messages/backpressure.ts
|
|
38
|
+
/** Honours `write() === false`: once the target signals pressure, frames queue
|
|
39
|
+
* (bounded) until it drains instead of piling up inside the stream. */
|
|
40
|
+
function createBoundedWriter(target, options) {
|
|
41
|
+
const hardMax = options.hardMaxQueuedBytes ?? options.maxQueuedBytes * 8;
|
|
42
|
+
let queue = [];
|
|
43
|
+
let head = 0;
|
|
44
|
+
let queued = 0;
|
|
45
|
+
let blocked = false;
|
|
46
|
+
let dead = false;
|
|
47
|
+
let dropped = 0;
|
|
48
|
+
function flush() {
|
|
49
|
+
while (!blocked && head < queue.length) {
|
|
50
|
+
const next = queue[head++];
|
|
51
|
+
queued -= next.byteLength;
|
|
52
|
+
if (!target.write(next)) blocked = true;
|
|
53
|
+
}
|
|
54
|
+
if (head === queue.length) {
|
|
55
|
+
queue = [];
|
|
56
|
+
head = 0;
|
|
57
|
+
} else if (head > 1024) {
|
|
58
|
+
queue = queue.slice(head);
|
|
59
|
+
head = 0;
|
|
60
|
+
}
|
|
61
|
+
if (!blocked && dropped > 0) {
|
|
62
|
+
console.warn(`[loopback] dropped ${dropped} push frame(s) while the client was not reading`);
|
|
63
|
+
dropped = 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
target.on("drain", () => {
|
|
67
|
+
blocked = false;
|
|
68
|
+
flush();
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
write(frame, writeOptions) {
|
|
72
|
+
if (dead) return false;
|
|
73
|
+
if (!blocked && head === queue.length) {
|
|
74
|
+
if (!target.write(frame)) blocked = true;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
const size = frame.byteLength;
|
|
78
|
+
if (writeOptions?.droppable) {
|
|
79
|
+
if (queued + size > options.maxQueuedBytes) {
|
|
80
|
+
if (dropped === 0) console.warn("[loopback] client is not reading; dropping push frames (newest first)");
|
|
81
|
+
dropped++;
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
} else if (queued > 0 && queued + size > hardMax) {
|
|
85
|
+
dead = true;
|
|
86
|
+
queue = [];
|
|
87
|
+
head = 0;
|
|
88
|
+
queued = 0;
|
|
89
|
+
options.onOverflow();
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
queue.push(frame);
|
|
93
|
+
queued += size;
|
|
94
|
+
return true;
|
|
95
|
+
},
|
|
96
|
+
clear() {
|
|
97
|
+
queue = [];
|
|
98
|
+
head = 0;
|
|
99
|
+
queued = 0;
|
|
100
|
+
},
|
|
101
|
+
get queuedBytes() {
|
|
102
|
+
return queued;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** Pauses an inbound stream while too much routed work is pending, so a fast
|
|
107
|
+
* sender cannot queue work faster than it completes. */
|
|
108
|
+
function createInboundGate(options) {
|
|
109
|
+
let pending = 0;
|
|
110
|
+
let paused = false;
|
|
111
|
+
function done() {
|
|
112
|
+
pending--;
|
|
113
|
+
if (paused && pending <= options.low) {
|
|
114
|
+
paused = false;
|
|
115
|
+
options.resume();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
/** Counts `work` as pending until it settles. */
|
|
120
|
+
track(work) {
|
|
121
|
+
pending++;
|
|
122
|
+
if (!paused && pending >= options.high) {
|
|
123
|
+
paused = true;
|
|
124
|
+
options.pause();
|
|
125
|
+
}
|
|
126
|
+
work.then(done, done);
|
|
127
|
+
},
|
|
128
|
+
get pending() {
|
|
129
|
+
return pending;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
// core/messages/channel-health.ts
|
|
134
|
+
/**
|
|
135
|
+
* Per-channel decode/framing health tracker for the host↔worklet IPC pipe.
|
|
136
|
+
*
|
|
137
|
+
* The IPC pipe is a raw byte stream with no reconnect seam. A single corrupted
|
|
138
|
+
* byte permanently desyncs the frame boundaries, so every subsequent frame
|
|
139
|
+
* fails to decode. Unconditionally clearing the decoder after each failure
|
|
140
|
+
* keeps parsing garbage forever — the channel is silently dead for the rest of
|
|
141
|
+
* the session, and all host-delegated invokes time out until a restart.
|
|
142
|
+
*
|
|
143
|
+
* Instead we count consecutive decode/framing failures and treat a sustained run
|
|
144
|
+
* as a fatal, unrecoverable desync. The caller then stops parsing rather than
|
|
145
|
+
* spinning on garbage. A single isolated bad chunk is tolerated (the counter
|
|
146
|
+
* resets on any successful frame batch), so transient corruption does not kill a
|
|
147
|
+
* healthy channel.
|
|
148
|
+
*/
|
|
149
|
+
var ChannelHealth = class {
|
|
150
|
+
maxConsecutiveFailures;
|
|
151
|
+
consecutiveFailures = 0;
|
|
152
|
+
fatal = false;
|
|
153
|
+
constructor(maxConsecutiveFailures = 5) {
|
|
154
|
+
this.maxConsecutiveFailures = maxConsecutiveFailures;
|
|
155
|
+
}
|
|
156
|
+
/** Record a successful frame batch; resets the consecutive-failure counter. */
|
|
157
|
+
noteSuccess() {
|
|
158
|
+
this.consecutiveFailures = 0;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Record a decode/framing failure. Returns `true` once the run of
|
|
162
|
+
* consecutive failures reaches {@link maxConsecutiveFailures}, signalling the
|
|
163
|
+
* caller that the channel is fatally desynced and parsing should stop.
|
|
164
|
+
*/
|
|
165
|
+
noteFailure() {
|
|
166
|
+
this.consecutiveFailures++;
|
|
167
|
+
if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
|
|
168
|
+
this.fatal = true;
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
/** Whether a fatal, unrecoverable desync has been reached. */
|
|
174
|
+
get isFatal() {
|
|
175
|
+
return this.fatal;
|
|
176
|
+
}
|
|
177
|
+
/** Number of consecutive failures since the last successful frame batch. */
|
|
178
|
+
get failures() {
|
|
179
|
+
return this.consecutiveFailures;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
21
182
|
// core/messages/encode-response.ts
|
|
22
183
|
/** Encodes a routed response. A response that cannot be encoded (a result over
|
|
23
184
|
* the 64 KB header limit, or one that does not serialize) is replaced by an
|
|
@@ -146,61 +307,76 @@ function createFrameDecoder(protocol) {
|
|
|
146
307
|
}
|
|
147
308
|
};
|
|
148
309
|
}
|
|
149
|
-
// core/messages/channel
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
constructor(maxConsecutiveFailures = 5) {
|
|
170
|
-
this.maxConsecutiveFailures = maxConsecutiveFailures;
|
|
171
|
-
}
|
|
172
|
-
/** Record a successful frame batch; resets the consecutive-failure counter. */
|
|
173
|
-
noteSuccess() {
|
|
174
|
-
this.consecutiveFailures = 0;
|
|
175
|
-
}
|
|
176
|
-
/**
|
|
177
|
-
* Record a decode/framing failure. Returns `true` once the run of
|
|
178
|
-
* consecutive failures reaches {@link maxConsecutiveFailures}, signalling the
|
|
179
|
-
* caller that the channel is fatally desynced and parsing should stop.
|
|
180
|
-
*/
|
|
181
|
-
noteFailure() {
|
|
182
|
-
this.consecutiveFailures++;
|
|
183
|
-
if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
|
|
184
|
-
this.fatal = true;
|
|
185
|
-
return true;
|
|
310
|
+
// core/messages/frame-channel.ts
|
|
311
|
+
function createFrameChannel(options) {
|
|
312
|
+
const { name, protocol, router, onMalformed } = options;
|
|
313
|
+
const decoder = createFrameDecoder(protocol);
|
|
314
|
+
const health = onMalformed.kind === "tolerate" ? new ChannelHealth(onMalformed.maxConsecutiveFailures) : null;
|
|
315
|
+
const gate = options.inbound ? createInboundGate({
|
|
316
|
+
high: options.inbound.maxPending,
|
|
317
|
+
low: Math.floor(options.inbound.maxPending / 2),
|
|
318
|
+
pause: options.inbound.pause,
|
|
319
|
+
resume: options.inbound.resume
|
|
320
|
+
}) : null;
|
|
321
|
+
let stopped = false;
|
|
322
|
+
/** Handles a malformed chunk per the channel's policy. */
|
|
323
|
+
function malformed(reason) {
|
|
324
|
+
console.error(`${name} malformed frame dropped: ${reason}`);
|
|
325
|
+
if (health === null) {
|
|
326
|
+
stopped = true;
|
|
327
|
+
decoder.clear();
|
|
328
|
+
if (onMalformed.kind === "close") onMalformed.close();
|
|
329
|
+
return;
|
|
186
330
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
331
|
+
if (health.noteFailure()) {
|
|
332
|
+
stopped = true;
|
|
333
|
+
console.error(`${name} channel permanently desynced after repeated malformed frames; no longer parsing it`);
|
|
334
|
+
}
|
|
335
|
+
decoder.clear();
|
|
192
336
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
337
|
+
function route(message) {
|
|
338
|
+
const work = router.route(message.header, message.payload).then((response) => {
|
|
339
|
+
if (response) options.write(encodeResponse(protocol, response));
|
|
340
|
+
}).catch((err) => {
|
|
341
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
342
|
+
console.error(`${name} routing failed: ${reason}`);
|
|
343
|
+
});
|
|
344
|
+
gate?.track(work);
|
|
196
345
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
346
|
+
return {
|
|
347
|
+
push(chunk) {
|
|
348
|
+
if (stopped) return;
|
|
349
|
+
let bytes;
|
|
350
|
+
try {
|
|
351
|
+
bytes = toBytes(chunk);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
354
|
+
if (health === null) {
|
|
355
|
+
malformed(reason);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
console.error(`${name} non-binary chunk dropped: ${reason}`);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (bytes.byteLength === 0) return;
|
|
362
|
+
const messages = decoder.push(bytes);
|
|
363
|
+
if (decoder.error) {
|
|
364
|
+
malformed(decoder.error.message);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
health?.noteSuccess();
|
|
368
|
+
for (const message of messages) {
|
|
369
|
+
if (options.intercept?.(message)) continue;
|
|
370
|
+
route(message);
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
close() {
|
|
374
|
+
stopped = true;
|
|
375
|
+
decoder.clear();
|
|
376
|
+
}
|
|
377
|
+
};
|
|
203
378
|
}
|
|
379
|
+
// core/messages/host-ipc.ts
|
|
204
380
|
/** Hard ceiling on concurrently pending host calls. Above this, the oldest call
|
|
205
381
|
* is dropped (its timer cleared and promise rejected) so a flood of invokes
|
|
206
382
|
* cannot grow the pending map without bound. */
|
|
@@ -208,10 +384,6 @@ const MAX_CONCURRENT_HOST_CALLS = 1024;
|
|
|
208
384
|
/** Sane default for a host invoke: five-minute waits let a single stalled host
|
|
209
385
|
* call pin a slot indefinitely, so we default to 30s instead. */
|
|
210
386
|
const DEFAULT_HOST_INVOKE_TIMEOUT_MS = 3e4;
|
|
211
|
-
function writeEncoded(ipc, bytes) {
|
|
212
|
-
if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") ipc.write(Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes));
|
|
213
|
-
else ipc.write(bytes);
|
|
214
|
-
}
|
|
215
387
|
function createHostIpcBridge(config) {
|
|
216
388
|
const { ipc, protocol } = config;
|
|
217
389
|
const pending = /* @__PURE__ */ new Map();
|
|
@@ -277,7 +449,7 @@ function createHostIpcBridge(config) {
|
|
|
277
449
|
tryConsumeDownstreamFromHost(raw) {
|
|
278
450
|
let msg;
|
|
279
451
|
try {
|
|
280
|
-
msg = protocol.decode(
|
|
452
|
+
msg = protocol.decode(toBytes(raw));
|
|
281
453
|
} catch (err) {
|
|
282
454
|
const message = err instanceof Error ? err.message : String(err);
|
|
283
455
|
console.debug(`[ipc] dropped undecodable host frame: ${message}`);
|
|
@@ -292,12 +464,12 @@ function createHostIpcBridge(config) {
|
|
|
292
464
|
type: "HOST_CAPABILITIES_QUERY",
|
|
293
465
|
requestId
|
|
294
466
|
};
|
|
295
|
-
|
|
467
|
+
ipc.write(toIpcChunk(protocol.encode(MessageType.ENVELOPE, header, null)));
|
|
296
468
|
return (await promise).capabilities ?? [];
|
|
297
469
|
},
|
|
298
470
|
async invokeOnHost(header, payload, timeoutMs = DEFAULT_HOST_INVOKE_TIMEOUT_MS) {
|
|
299
|
-
|
|
300
|
-
|
|
471
|
+
if (!header.requestId) return null;
|
|
472
|
+
const requestId = createRequestId();
|
|
301
473
|
const promise = waitFor(requestId, "HOST_INVOKE_RESPONSE", timeoutMs);
|
|
302
474
|
const hostHeader = {
|
|
303
475
|
type: "HOST_INVOKE_REQUEST",
|
|
@@ -306,13 +478,13 @@ function createHostIpcBridge(config) {
|
|
|
306
478
|
event: header.event,
|
|
307
479
|
args: header.args
|
|
308
480
|
};
|
|
309
|
-
|
|
481
|
+
ipc.write(toIpcChunk(protocol.encode(MessageType.ENVELOPE, hostHeader, payload)));
|
|
310
482
|
const hostResp = await promise;
|
|
311
483
|
return {
|
|
312
484
|
type: "INVOKE_RESPONSE",
|
|
313
485
|
pluginId: hostResp.pluginId,
|
|
314
486
|
event: hostResp.event,
|
|
315
|
-
requestId:
|
|
487
|
+
requestId: header.requestId,
|
|
316
488
|
result: hostResp.result,
|
|
317
489
|
error: hostResp.error
|
|
318
490
|
};
|
|
@@ -387,99 +559,267 @@ function installConsoleCapture(store, source = "backend", options = {}) {
|
|
|
387
559
|
}
|
|
388
560
|
};
|
|
389
561
|
}
|
|
390
|
-
// core/
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
if (
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
562
|
+
// core/server/loopback-gate.ts
|
|
563
|
+
const COOKIE_PREFIX = "bare_session";
|
|
564
|
+
const ORIGIN_PATTERN = /^https?:\/\/[^/?#\s]+$/;
|
|
565
|
+
/** Reads the token from the `X-Bare-Token` request header (parser-lowercased).
|
|
566
|
+
* `fetch`-based non-browser clients that cannot carry cookies use this. It
|
|
567
|
+
* travels the cleartext loopback like the cookie, and is only trusted from an
|
|
568
|
+
* allowed origin. */
|
|
569
|
+
function tokenFromHeaders(headers) {
|
|
570
|
+
const value = headers["x-bare-token"];
|
|
571
|
+
return typeof value === "string" ? value : null;
|
|
572
|
+
}
|
|
573
|
+
/** The session cookie name for a server bound on `port`. Cookies are scoped to
|
|
574
|
+
* the host, not the port, so two loopback servers sharing a browser cookie
|
|
575
|
+
* store (two apps' WebViews, or two desktop backends) would overwrite each
|
|
576
|
+
* other's session under one fixed name. */
|
|
577
|
+
function sessionCookieName(port) {
|
|
578
|
+
return `${COOKIE_PREFIX}_${port}`;
|
|
579
|
+
}
|
|
580
|
+
/** Reads the `name` cookie's value from the `Cookie` request header. */
|
|
581
|
+
function cookieSession(headers, name) {
|
|
582
|
+
const cookie = headers["cookie"];
|
|
583
|
+
if (typeof cookie !== "string") return null;
|
|
584
|
+
for (const part of cookie.split(";")) {
|
|
585
|
+
const eq = part.indexOf("=");
|
|
586
|
+
if (eq === -1) continue;
|
|
587
|
+
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
|
|
588
|
+
}
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
/** Derives the cookie value from the session token so the token itself is not
|
|
592
|
+
* echoed into a request header a local attacker could read back. */
|
|
593
|
+
function sessionNonce(token) {
|
|
594
|
+
return crypto.createHash("blake2b-256").update(token).digest("hex");
|
|
595
|
+
}
|
|
596
|
+
/** Constant-time string comparison. bare-crypto's `timingSafeEqual` requires
|
|
597
|
+
* equal-length inputs (it throws otherwise), so guard on byte length first. */
|
|
598
|
+
function secretEquals(a, b) {
|
|
599
|
+
const ab = Buffer.from(a);
|
|
600
|
+
const bb = Buffer.from(b);
|
|
601
|
+
if (ab.length !== bb.length) return false;
|
|
602
|
+
return crypto.timingSafeEqual(ab, bb);
|
|
603
|
+
}
|
|
604
|
+
/** URL-safe random secret for the session token / bootstrap nonce. */
|
|
605
|
+
function randomSecret() {
|
|
606
|
+
return crypto.randomBytes(32).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
607
|
+
}
|
|
608
|
+
/** `host:port` forms that name the loopback interface on `port`. `localhost`
|
|
609
|
+
* cannot be pointed elsewhere by a remote DNS answer, so accepting it does not
|
|
610
|
+
* reopen DNS rebinding. */
|
|
611
|
+
function loopbackHosts(port) {
|
|
612
|
+
return [
|
|
613
|
+
`127.0.0.1:${port}`,
|
|
614
|
+
`localhost:${port}`,
|
|
615
|
+
`[::1]:${port}`
|
|
616
|
+
];
|
|
617
|
+
}
|
|
618
|
+
function createLoopbackGate(options) {
|
|
619
|
+
const { auth } = options;
|
|
620
|
+
for (const origin of options.allowedOrigins ?? []) if (!ORIGIN_PATTERN.test(origin)) throw new Error(`allowedOrigins entry is not a bare origin (scheme://host[:port]): '${origin}'`);
|
|
621
|
+
const token = options.token ?? randomSecret();
|
|
622
|
+
const cookieValue = sessionNonce(token);
|
|
623
|
+
let bootstrapNonce = randomSecret();
|
|
624
|
+
/** The most recently spent bootstrap nonce, kept only to answer replays of
|
|
625
|
+
* it with 409 (valid once but consumed) instead of 401 (bad credential). */
|
|
626
|
+
let spentBootstrapNonce = null;
|
|
627
|
+
let allowedHosts = /* @__PURE__ */ new Set();
|
|
628
|
+
let allowedOrigins = /* @__PURE__ */ new Set();
|
|
629
|
+
let cookieName = COOKIE_PREFIX;
|
|
630
|
+
function hasCredential(headers) {
|
|
631
|
+
if (!auth) return true;
|
|
632
|
+
if (secretEquals(tokenFromHeaders(headers) ?? "", token)) return true;
|
|
633
|
+
return secretEquals(cookieSession(headers, cookieName) ?? "", cookieValue);
|
|
634
|
+
}
|
|
635
|
+
/** The DNS-rebinding and cross-origin rule. `Host` must name the bound
|
|
636
|
+
* server (a rebinding page sends its own hostname). An `Origin`, when sent,
|
|
637
|
+
* must be the bound origin, a loopback alias of it, or an allowed origin.
|
|
638
|
+
* A top-level navigation (the first WebView load) sends no `Origin`. */
|
|
639
|
+
function checkOrigin(headers, requireOrigin) {
|
|
640
|
+
if (allowedHosts.size === 0) return {
|
|
641
|
+
ok: false,
|
|
642
|
+
status: 403,
|
|
643
|
+
reason: "server not bound yet"
|
|
644
|
+
};
|
|
645
|
+
const host = headers["host"];
|
|
646
|
+
if (typeof host !== "string" || !allowedHosts.has(host)) return {
|
|
647
|
+
ok: false,
|
|
648
|
+
status: 403,
|
|
649
|
+
reason: `host mismatch (got ${typeof host === "string" ? `'${host}'` : "no Host header"})`
|
|
650
|
+
};
|
|
651
|
+
const origin = headers["origin"];
|
|
652
|
+
if (typeof origin !== "string" || origin.length === 0) return requireOrigin ? {
|
|
653
|
+
ok: false,
|
|
654
|
+
status: 403,
|
|
655
|
+
reason: "no Origin header"
|
|
656
|
+
} : { ok: true };
|
|
657
|
+
if (!allowedOrigins.has(origin)) return {
|
|
658
|
+
ok: false,
|
|
659
|
+
status: 403,
|
|
660
|
+
reason: `origin mismatch (got '${origin}')`
|
|
661
|
+
};
|
|
662
|
+
return { ok: true };
|
|
418
663
|
}
|
|
419
|
-
target.on("drain", () => {
|
|
420
|
-
blocked = false;
|
|
421
|
-
flush();
|
|
422
|
-
});
|
|
423
664
|
return {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
dropped++;
|
|
435
|
-
return false;
|
|
436
|
-
}
|
|
437
|
-
} else if (queued > 0 && queued + size > hardMax) {
|
|
438
|
-
dead = true;
|
|
439
|
-
queue = [];
|
|
440
|
-
head = 0;
|
|
441
|
-
queued = 0;
|
|
442
|
-
options.onOverflow();
|
|
443
|
-
return false;
|
|
444
|
-
}
|
|
445
|
-
queue.push(frame);
|
|
446
|
-
queued += size;
|
|
447
|
-
return true;
|
|
665
|
+
bind(origin) {
|
|
666
|
+
const hostPort = origin.slice(origin.indexOf("://") + 3);
|
|
667
|
+
const port = hostPort.slice(hostPort.lastIndexOf(":") + 1);
|
|
668
|
+
allowedHosts = /* @__PURE__ */ new Set([hostPort, ...loopbackHosts(port)]);
|
|
669
|
+
cookieName = sessionCookieName(port);
|
|
670
|
+
allowedOrigins = /* @__PURE__ */ new Set([
|
|
671
|
+
origin,
|
|
672
|
+
...loopbackHosts(port).map((h) => `http://${h}`),
|
|
673
|
+
...options.allowedOrigins ?? []
|
|
674
|
+
]);
|
|
448
675
|
},
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
676
|
+
token: () => token,
|
|
677
|
+
bootstrap: () => bootstrapNonce ?? "",
|
|
678
|
+
admitRequest(headers, resource) {
|
|
679
|
+
const origin = checkOrigin(headers, false);
|
|
680
|
+
if (!origin.ok) return origin;
|
|
681
|
+
if (resource === "gated" && !hasCredential(headers)) return {
|
|
682
|
+
ok: false,
|
|
683
|
+
status: 401,
|
|
684
|
+
reason: "unauthorized"
|
|
685
|
+
};
|
|
686
|
+
return { ok: true };
|
|
453
687
|
},
|
|
454
|
-
|
|
455
|
-
|
|
688
|
+
admitUpgrade(headers) {
|
|
689
|
+
const origin = checkOrigin(headers, auth);
|
|
690
|
+
if (!origin.ok) return origin;
|
|
691
|
+
if (!hasCredential(headers)) return {
|
|
692
|
+
ok: false,
|
|
693
|
+
status: 401,
|
|
694
|
+
reason: `unauthorized (${headers["cookie"] ? "cookie present but invalid" : `no ${cookieName} cookie`})`
|
|
695
|
+
};
|
|
696
|
+
return { ok: true };
|
|
697
|
+
},
|
|
698
|
+
login(body, headers) {
|
|
699
|
+
if (!auth) return {
|
|
700
|
+
ok: true,
|
|
701
|
+
cookie: null
|
|
702
|
+
};
|
|
703
|
+
const credential = body.trim();
|
|
704
|
+
const byBootstrap = bootstrapNonce !== null && secretEquals(credential, bootstrapNonce);
|
|
705
|
+
const bySession = hasCredential(headers);
|
|
706
|
+
if (!byBootstrap && !bySession) return {
|
|
707
|
+
ok: false,
|
|
708
|
+
status: spentBootstrapNonce !== null && secretEquals(credential, spentBootstrapNonce) ? 409 : 401
|
|
709
|
+
};
|
|
710
|
+
if (byBootstrap) {
|
|
711
|
+
spentBootstrapNonce = bootstrapNonce;
|
|
712
|
+
bootstrapNonce = null;
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
ok: true,
|
|
716
|
+
cookie: `${cookieName}=${cookieValue}; HttpOnly; SameSite=Strict; Path=/`
|
|
717
|
+
};
|
|
718
|
+
},
|
|
719
|
+
socketSource(headers) {
|
|
720
|
+
const host = headers["host"];
|
|
721
|
+
return `ws://${typeof host === "string" ? host : ""}`;
|
|
456
722
|
}
|
|
457
723
|
};
|
|
458
724
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
let
|
|
463
|
-
let
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
725
|
+
// core/server/protocol-socket.ts
|
|
726
|
+
function createProtocolSocketSlot(options) {
|
|
727
|
+
const handlers = [];
|
|
728
|
+
let active = null;
|
|
729
|
+
let activeSince = 0;
|
|
730
|
+
/** The upgrade waiting out the replacement interval. At most one waits: a
|
|
731
|
+
* newer one supersedes it, since it would have evicted it on arrival. */
|
|
732
|
+
let waiting = null;
|
|
733
|
+
function accept(req, socket, head, reject) {
|
|
734
|
+
const wait = active ? activeSince + options.replaceMinIntervalMs - Date.now() : 0;
|
|
735
|
+
if (wait > 0) {
|
|
736
|
+
if (waiting) {
|
|
737
|
+
clearTimeout(waiting.timer);
|
|
738
|
+
waiting.reject("superseded by a newer upgrade", 429);
|
|
739
|
+
}
|
|
740
|
+
waiting = {
|
|
741
|
+
socket,
|
|
742
|
+
reject,
|
|
743
|
+
timer: setTimeout(() => {
|
|
744
|
+
waiting = null;
|
|
745
|
+
accept(req, socket, head, reject);
|
|
746
|
+
}, wait)
|
|
747
|
+
};
|
|
748
|
+
socket.on("close", () => {
|
|
749
|
+
if (waiting?.socket !== socket) return;
|
|
750
|
+
clearTimeout(waiting.timer);
|
|
751
|
+
waiting = null;
|
|
752
|
+
});
|
|
753
|
+
socket.on("error", () => socket.destroy());
|
|
754
|
+
return;
|
|
469
755
|
}
|
|
756
|
+
ws.Server.handshake(req, socket, head, (err) => {
|
|
757
|
+
if (err) {
|
|
758
|
+
reject(`handshake failed (${err.message})`);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const raw = new ws.Socket({
|
|
762
|
+
socket,
|
|
763
|
+
isServer: true,
|
|
764
|
+
idleTimeout: options.deadPeerTimeoutMs,
|
|
765
|
+
maxPayload: MAX_FRAME_BYTES
|
|
766
|
+
});
|
|
767
|
+
const writer = createBoundedWriter(raw, {
|
|
768
|
+
maxQueuedBytes: options.maxQueuedBytes,
|
|
769
|
+
onOverflow: () => {
|
|
770
|
+
console.error("[loopback] client is not reading and responses are backing up; closing the protocol socket");
|
|
771
|
+
raw.destroy();
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
const client = {
|
|
775
|
+
on: (event, listener) => raw.on(event, listener),
|
|
776
|
+
write: (data) => writer.write(data),
|
|
777
|
+
destroy: () => raw.destroy(),
|
|
778
|
+
pause: () => raw.pause(),
|
|
779
|
+
resume: () => raw.resume()
|
|
780
|
+
};
|
|
781
|
+
const previous = active;
|
|
782
|
+
active = {
|
|
783
|
+
client,
|
|
784
|
+
writer
|
|
785
|
+
};
|
|
786
|
+
activeSince = Date.now();
|
|
787
|
+
if (previous) {
|
|
788
|
+
console.warn("[loopback] replaced the existing protocol socket");
|
|
789
|
+
previous.client.destroy();
|
|
790
|
+
}
|
|
791
|
+
raw.on("close", () => {
|
|
792
|
+
writer.clear();
|
|
793
|
+
if (active?.client === client) active = null;
|
|
794
|
+
});
|
|
795
|
+
raw.on("error", (err) => {
|
|
796
|
+
console.warn(`[loopback] protocol socket error: ${err.message}`);
|
|
797
|
+
raw.destroy();
|
|
798
|
+
});
|
|
799
|
+
socket.setTimeout(options.idleTimeoutMs, () => raw.destroy());
|
|
800
|
+
for (const handler of handlers) try {
|
|
801
|
+
handler(client, req);
|
|
802
|
+
} catch (e) {
|
|
803
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
804
|
+
console.error("Error in loopback WS connection handler:", message);
|
|
805
|
+
}
|
|
806
|
+
});
|
|
470
807
|
}
|
|
471
808
|
return {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
if (!paused && pending >= options.high) {
|
|
476
|
-
paused = true;
|
|
477
|
-
options.pause();
|
|
478
|
-
}
|
|
479
|
-
work.then(done, done);
|
|
809
|
+
accept,
|
|
810
|
+
onConnection(handler) {
|
|
811
|
+
handlers.push(handler);
|
|
480
812
|
},
|
|
481
|
-
|
|
482
|
-
return
|
|
813
|
+
push(frame) {
|
|
814
|
+
return active ? active.writer.write(frame, { droppable: true }) : false;
|
|
815
|
+
},
|
|
816
|
+
close() {
|
|
817
|
+
active?.client.destroy();
|
|
818
|
+
if (waiting) {
|
|
819
|
+
clearTimeout(waiting.timer);
|
|
820
|
+
waiting.socket.destroy();
|
|
821
|
+
waiting = null;
|
|
822
|
+
}
|
|
483
823
|
}
|
|
484
824
|
};
|
|
485
825
|
}
|
|
@@ -527,40 +867,6 @@ function etagMatches(header, etag) {
|
|
|
527
867
|
if (typeof header !== "string") return false;
|
|
528
868
|
return header.split(",").some((candidate) => candidate.trim() === etag || candidate.trim() === "*");
|
|
529
869
|
}
|
|
530
|
-
/** Reads the token from the `X-Bare-Token` request header (parser-lowercased).
|
|
531
|
-
* `fetch`-based non-browser clients that cannot carry cookies use this.
|
|
532
|
-
* Security note: like the `bare_session` cookie, this credential travels the
|
|
533
|
-
* cleartext 127.0.0.1 loopback — it is only trusted from the bound origin
|
|
534
|
-
* (enforced by `originAllowed`), and is an escape hatch for same-loopback
|
|
535
|
-
* non-browser clients; it is accepted over TLS-agnostic plain HTTP by design. */
|
|
536
|
-
function tokenFromHeaders(headers) {
|
|
537
|
-
const value = headers["x-bare-token"];
|
|
538
|
-
return typeof value === "string" ? value : null;
|
|
539
|
-
}
|
|
540
|
-
/** Reads the `bare_session` value from the `Cookie` request header. */
|
|
541
|
-
function cookieSession(headers) {
|
|
542
|
-
const cookie = headers["cookie"];
|
|
543
|
-
if (typeof cookie !== "string") return null;
|
|
544
|
-
for (const part of cookie.split(";")) {
|
|
545
|
-
const eq = part.indexOf("=");
|
|
546
|
-
if (eq === -1) continue;
|
|
547
|
-
if (part.slice(0, eq).trim() === "bare_session") return part.slice(eq + 1).trim();
|
|
548
|
-
}
|
|
549
|
-
return null;
|
|
550
|
-
}
|
|
551
|
-
/** Derives the cookie value from the session token so the token itself is not
|
|
552
|
-
* echoed into a request header a local attacker could read back. */
|
|
553
|
-
function sessionNonce(token) {
|
|
554
|
-
return crypto.createHash("blake2b-256").update(token).digest("hex");
|
|
555
|
-
}
|
|
556
|
-
/** Constant-time string comparison. bare-crypto's `timingSafeEqual` requires
|
|
557
|
-
* equal-length inputs (it throws otherwise), so guard on byte length first. */
|
|
558
|
-
function secretEquals(a, b) {
|
|
559
|
-
const ab = Buffer.from(a);
|
|
560
|
-
const bb = Buffer.from(b);
|
|
561
|
-
if (ab.length !== bb.length) return false;
|
|
562
|
-
return crypto.timingSafeEqual(ab, bb);
|
|
563
|
-
}
|
|
564
870
|
/** Parses a `Range: bytes=<start>-<end>` header. Returns `null` when absent or
|
|
565
871
|
* unsatisfiable (caller falls back to a 200 full response). */
|
|
566
872
|
function parseRange(value, size) {
|
|
@@ -614,195 +920,208 @@ function collectRequestBody(req, maxBytes = Infinity) {
|
|
|
614
920
|
});
|
|
615
921
|
});
|
|
616
922
|
}
|
|
923
|
+
/** Splits a request target into its decoded path segments, or null when the
|
|
924
|
+
* path cannot name a file inside a mount: a malformed escape, or a segment
|
|
925
|
+
* that is `.`/`..` or carries a separator or NUL once decoded (`%2e%2e`,
|
|
926
|
+
* `%2f`, `\`). Empty segments (`//`, a trailing `/`) are dropped. */
|
|
927
|
+
function pathSegments(target) {
|
|
928
|
+
const rawPath = target.split(/[?#]/, 1)[0];
|
|
929
|
+
const segments = [];
|
|
930
|
+
for (const raw of rawPath.split("/")) {
|
|
931
|
+
if (raw === "") continue;
|
|
932
|
+
let segment;
|
|
933
|
+
try {
|
|
934
|
+
segment = decodeURIComponent(raw);
|
|
935
|
+
} catch {
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
if (segment === "." || segment === ".." || /[/\\\0]/.test(segment)) return null;
|
|
939
|
+
segments.push(segment);
|
|
940
|
+
}
|
|
941
|
+
return segments;
|
|
942
|
+
}
|
|
943
|
+
/** The key a mount path is stored under: the same decoded, normalized form a
|
|
944
|
+
* request path is matched in, so `/media/a%20b` and `/media/a b/` name one
|
|
945
|
+
* mount. A path no request could ever match is a programming error. */
|
|
946
|
+
function mountKey(mountPath) {
|
|
947
|
+
const segments = pathSegments(mountPath);
|
|
948
|
+
if (segments === null) throw new Error(`invalid mount path: '${mountPath}'`);
|
|
949
|
+
return `/${segments.join("/")}`;
|
|
950
|
+
}
|
|
951
|
+
/** Whether a bind address names the loopback interface only. */
|
|
952
|
+
function isLoopbackHost(host) {
|
|
953
|
+
return host === "localhost" || host === "::1" || host === "[::1]" || /^127(\.\d{1,3}){3}$/.test(host);
|
|
954
|
+
}
|
|
955
|
+
/** Whether `candidate` is `root` or lies below it. Both must be absolute,
|
|
956
|
+
* normalized paths. */
|
|
957
|
+
function isWithin(root, candidate) {
|
|
958
|
+
const rel = path.relative(root, candidate);
|
|
959
|
+
return rel === "" || rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
|
960
|
+
}
|
|
617
961
|
/**
|
|
618
962
|
* The single loopback HTTP+WS server for the worklet. One origin serves the
|
|
619
963
|
* web app (`/`), the media files mounted by plugins, and the framed-protocol
|
|
620
964
|
* WebSocket socket the page connects to. On device it binds `127.0.0.1` on an
|
|
621
|
-
* ephemeral port.
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
* cookie (set via `POST /login` with the one-time bootstrap nonce or the token)
|
|
626
|
-
* or the `X-Bare-Token` header (non-browser clients). The URL `?token=` query
|
|
627
|
-
* param is **not** accepted — a token must never appear in a URL an observer
|
|
628
|
-
* could read. All responses send `Referrer-Policy: no-referrer`.
|
|
965
|
+
* ephemeral port. Admission (origin rule, session credential, bootstrap login)
|
|
966
|
+
* is decided by the loopback gate (`loopback-gate.ts`); the protocol socket's
|
|
967
|
+
* lifecycle by the protocol socket slot (`protocol-socket.ts`). All responses
|
|
968
|
+
* send `Referrer-Policy: no-referrer`.
|
|
629
969
|
*/
|
|
630
970
|
function createLoopbackServer(options = {}) {
|
|
631
971
|
const host = options.host ?? "127.0.0.1";
|
|
632
972
|
const port = options.port ?? 0;
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
973
|
+
const auth = options.auth ?? true;
|
|
974
|
+
if (!auth && !isLoopbackHost(host)) throw new Error(`refusing to bind '${host}' with auth off: only a loopback address may serve without a credential`);
|
|
975
|
+
const gate = createLoopbackGate({
|
|
976
|
+
auth,
|
|
977
|
+
token: options.token,
|
|
978
|
+
allowedOrigins: options.allowedOrigins
|
|
979
|
+
});
|
|
980
|
+
const slot = createProtocolSocketSlot({
|
|
981
|
+
idleTimeoutMs: options.wsIdleTimeoutMs ?? 6e4,
|
|
982
|
+
deadPeerTimeoutMs: options.wsDeadPeerTimeoutMs ?? 1e4,
|
|
983
|
+
replaceMinIntervalMs: options.wsReplaceMinIntervalMs ?? 1e3,
|
|
984
|
+
maxQueuedBytes: options.wsMaxQueuedBytes ?? 2097152
|
|
985
|
+
});
|
|
640
986
|
const fileMounts = /* @__PURE__ */ new Map();
|
|
641
987
|
const dirMounts = /* @__PURE__ */ new Map();
|
|
642
988
|
const publicDirMounts = /* @__PURE__ */ new Set();
|
|
643
989
|
const routeHandlers = /* @__PURE__ */ new Map();
|
|
644
|
-
const connectionHandlers = [];
|
|
645
|
-
let token = options.token ?? null;
|
|
646
|
-
let bootstrapNonce = null;
|
|
647
|
-
/** The most recently spent bootstrap nonce, kept only to answer replays of
|
|
648
|
-
* it with 409 (valid-once-but-consumed) instead of 401 (bad credential). */
|
|
649
|
-
let spentBootstrapNonce = null;
|
|
650
|
-
let boundOrigin = "";
|
|
651
990
|
let originPromise = null;
|
|
652
|
-
let activeSocket = null;
|
|
653
|
-
let activeWriter = null;
|
|
654
|
-
let activeSince = 0;
|
|
655
|
-
/** Non-fatal, never-throwing debug for skipped work (a missing file, a failed
|
|
656
|
-
* write) — observable in the `core.logs` ring buffer, never able to alter
|
|
657
|
-
* control flow. */
|
|
658
|
-
function debugSkip(message) {
|
|
659
|
-
try {
|
|
660
|
-
console.debug(message);
|
|
661
|
-
} catch {}
|
|
662
|
-
}
|
|
663
|
-
/** The session cookie value derived from `token`, hashed once per token
|
|
664
|
-
* instead of on every request (the token is fixed after bind). */
|
|
665
|
-
let nonceToken = null;
|
|
666
|
-
let nonceValue = "";
|
|
667
|
-
function sessionCookieValue(forToken) {
|
|
668
|
-
if (nonceToken !== forToken) {
|
|
669
|
-
nonceValue = sessionNonce(forToken);
|
|
670
|
-
nonceToken = forToken;
|
|
671
|
-
}
|
|
672
|
-
return nonceValue;
|
|
673
|
-
}
|
|
674
|
-
function isAuthorized(headers) {
|
|
675
|
-
if (!authEnabled) return true;
|
|
676
|
-
if (token === null) return false;
|
|
677
|
-
if (secretEquals(tokenFromHeaders(headers) ?? "", token)) return true;
|
|
678
|
-
return secretEquals(cookieSession(headers) ?? "", sessionCookieValue(token));
|
|
679
|
-
}
|
|
680
|
-
/** DNS-rebinding and cross-origin defense for plain HTTP. In auth (device)
|
|
681
|
-
* mode the server never advertises a stable hostname, so a browser request
|
|
682
|
-
* must either carry a same-origin `Origin` header (fetches/subresources) or,
|
|
683
|
-
* for top-level navigations that send no Origin (the initial WebView load),
|
|
684
|
-
* a `Host` header pointing back at the bound 127.0.0.1:port. A DNS-rebinding
|
|
685
|
-
* navigation resolves the loopback address under an attacker-controlled
|
|
686
|
-
* hostname and therefore sends a foreign `Host`. Dev (auth off) is open. */
|
|
687
|
-
function originAllowed(headers) {
|
|
688
|
-
if (!authEnabled) return true;
|
|
689
|
-
if (boundOrigin === "") return true;
|
|
690
|
-
const boundHostPort = boundOrigin.slice(7);
|
|
691
|
-
const host = headers["host"];
|
|
692
|
-
if (typeof host !== "string" || host !== boundHostPort) return false;
|
|
693
|
-
const origin = headers["origin"];
|
|
694
|
-
if (typeof origin === "string" && origin.length > 0 && origin !== boundOrigin) return false;
|
|
695
|
-
return true;
|
|
696
|
-
}
|
|
697
991
|
function writeError(res, status, message) {
|
|
992
|
+
if (res.headersSent) {
|
|
993
|
+
res.destroy();
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
698
996
|
res.writeHead(status, {
|
|
699
997
|
"Content-Type": "text/plain",
|
|
700
998
|
"Referrer-Policy": "no-referrer"
|
|
701
999
|
});
|
|
702
1000
|
res.end(message);
|
|
703
1001
|
}
|
|
1002
|
+
/** Logs the cause and answers a bare 500: an exception message can carry
|
|
1003
|
+
* file paths or plugin internals, which never belong in a response body. */
|
|
1004
|
+
function writeInternalError(res, context, err) {
|
|
1005
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1006
|
+
console.error(`[loopback] ${context} failed: ${message}`);
|
|
1007
|
+
writeError(res, 500, "Internal Server Error");
|
|
1008
|
+
}
|
|
704
1009
|
function handleLogin(req, res) {
|
|
705
1010
|
collectRequestBody(req, 4096).then((body) => {
|
|
706
|
-
const
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
const bySession = authEnabled && isAuthorized(req.headers);
|
|
710
|
-
const accepted = !authEnabled || byToken || byBootstrap || bySession;
|
|
711
|
-
const bySpentBootstrap = bootstrapNonce === null && spentBootstrapNonce !== null && secretEquals(trimmed, spentBootstrapNonce);
|
|
712
|
-
if (!accepted) {
|
|
713
|
-
writeError(res, bySpentBootstrap ? 409 : 401, "Unauthorized");
|
|
1011
|
+
const verdict = gate.login(body, req.headers);
|
|
1012
|
+
if (!verdict.ok) {
|
|
1013
|
+
writeError(res, verdict.status, "Unauthorized");
|
|
714
1014
|
return;
|
|
715
1015
|
}
|
|
716
|
-
if (byBootstrap && bootstrapNonce !== null) {
|
|
717
|
-
spentBootstrapNonce = bootstrapNonce;
|
|
718
|
-
bootstrapNonce = null;
|
|
719
|
-
}
|
|
720
1016
|
const headers = {
|
|
721
1017
|
"Content-Type": "text/plain",
|
|
722
1018
|
"Cache-Control": "no-store",
|
|
723
1019
|
"Referrer-Policy": "no-referrer"
|
|
724
1020
|
};
|
|
725
|
-
if (
|
|
1021
|
+
if (verdict.cookie !== null) headers["Set-Cookie"] = verdict.cookie;
|
|
726
1022
|
res.writeHead(200, headers);
|
|
727
1023
|
res.end("ok");
|
|
728
|
-
}
|
|
1024
|
+
}, () => writeError(res, 413, "Request Entity Too Large")).catch((err) => writeInternalError(res, "POST /login", err));
|
|
729
1025
|
}
|
|
730
|
-
/** Resolves a request path to a file mount, or a directory mount +
|
|
731
|
-
*
|
|
732
|
-
function resolveMount(cleanPath) {
|
|
1026
|
+
/** Resolves a request path to a file mount, or a directory mount + the path
|
|
1027
|
+
* segments below it. Directory mounts win on longest-prefix match. */
|
|
1028
|
+
function resolveMount(cleanPath, segments) {
|
|
733
1029
|
const fileMount = fileMounts.get(cleanPath);
|
|
734
1030
|
if (fileMount) return {
|
|
735
1031
|
kind: "file",
|
|
736
1032
|
filePath: fileMount
|
|
737
1033
|
};
|
|
738
1034
|
let best = null;
|
|
739
|
-
let
|
|
1035
|
+
let bestDepth = -1;
|
|
740
1036
|
for (const [prefix, dirPath] of dirMounts) {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
};
|
|
752
|
-
bestPrefix = prefix.length;
|
|
753
|
-
}
|
|
1037
|
+
const prefixSegments = prefix.split("/").filter((s) => s !== "");
|
|
1038
|
+
const depth = prefixSegments.length;
|
|
1039
|
+
if (depth <= bestDepth) continue;
|
|
1040
|
+
if (!prefixSegments.every((s, i) => segments[i] === s)) continue;
|
|
1041
|
+
best = {
|
|
1042
|
+
dirPath,
|
|
1043
|
+
rel: segments.slice(depth),
|
|
1044
|
+
public: publicDirMounts.has(prefix)
|
|
1045
|
+
};
|
|
1046
|
+
bestDepth = depth;
|
|
754
1047
|
}
|
|
755
1048
|
return best ? {
|
|
756
1049
|
...best,
|
|
757
1050
|
kind: "dir"
|
|
758
1051
|
} : null;
|
|
759
1052
|
}
|
|
760
|
-
/** Resolves a directory-mount request to a concrete file
|
|
761
|
-
* (
|
|
762
|
-
* `
|
|
1053
|
+
/** Resolves a directory-mount request to a concrete file inside the mount
|
|
1054
|
+
* root (its real path, so a symlink cannot lead outside it). Only a
|
|
1055
|
+
* `public` (bootstrap) mount applies the SPA fallback (unknown navigation
|
|
1056
|
+
* GET → `index.html`); a non-public directory mount 404s unknown paths so
|
|
763
1057
|
* unauthenticated clients never receive bootstrap HTML. Subresource requests
|
|
764
|
-
* (`Accept` without `text/html`) never fall back — a missing asset is a
|
|
1058
|
+
* (`Accept` without `text/html`) never fall back — a missing asset is a
|
|
1059
|
+
* 404. */
|
|
765
1060
|
function resolveDirFile(mount, headers) {
|
|
766
|
-
|
|
1061
|
+
let root;
|
|
767
1062
|
try {
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
if (
|
|
778
|
-
|
|
1063
|
+
root = fs.realpathSync(mount.dirPath);
|
|
1064
|
+
} catch {
|
|
1065
|
+
console.debug(`[static-server] mount root ${mount.dirPath} is missing — 404`);
|
|
1066
|
+
return null;
|
|
1067
|
+
}
|
|
1068
|
+
const served = (file) => file && {
|
|
1069
|
+
...file,
|
|
1070
|
+
rel: path.relative(root, file.filePath).split(path.sep).join("/")
|
|
1071
|
+
};
|
|
1072
|
+
if (mount.rel.length > 0) {
|
|
1073
|
+
const found = fileWithin(root, path.join(root, ...mount.rel));
|
|
1074
|
+
if (found) return served(found);
|
|
1075
|
+
if (!mount.public) return null;
|
|
1076
|
+
const accept = headers["accept"];
|
|
1077
|
+
if (typeof accept !== "string" || !accept.includes("text/html")) return null;
|
|
1078
|
+
}
|
|
1079
|
+
return served(fileWithin(root, path.join(root, "index.html")));
|
|
779
1080
|
}
|
|
780
|
-
|
|
1081
|
+
/** `candidate` as a served file when it exists, is a regular file, and its
|
|
1082
|
+
* real path stays inside `root`. A missing file is routine (every SPA route,
|
|
1083
|
+
* every missing asset), so it is not logged. */
|
|
1084
|
+
function fileWithin(root, candidate) {
|
|
1085
|
+
let real;
|
|
1086
|
+
try {
|
|
1087
|
+
real = fs.realpathSync(candidate);
|
|
1088
|
+
} catch {
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
if (!isWithin(root, real)) {
|
|
1092
|
+
console.warn(`[static-server] refused a path that resolves outside its mount: ${candidate}`);
|
|
1093
|
+
return null;
|
|
1094
|
+
}
|
|
781
1095
|
try {
|
|
782
|
-
const
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
filePath: index,
|
|
1096
|
+
const stat = fs.statSync(real);
|
|
1097
|
+
return stat.isFile() ? {
|
|
1098
|
+
filePath: real,
|
|
786
1099
|
stat
|
|
787
|
-
};
|
|
1100
|
+
} : null;
|
|
788
1101
|
} catch {
|
|
789
|
-
|
|
1102
|
+
return null;
|
|
790
1103
|
}
|
|
791
|
-
return null;
|
|
792
1104
|
}
|
|
793
1105
|
/** Strict Content-Security-Policy for HTML documents served by the loopback
|
|
794
|
-
* server
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
|
|
799
|
-
|
|
1106
|
+
* server. Subresources stay same-origin; `connect-src` names the one
|
|
1107
|
+
* protocol socket the page may open (`ws:` alone would allow any host).
|
|
1108
|
+
* `style-src` needs `'unsafe-inline'` for the bundled web app's inline
|
|
1109
|
+
* styles. */
|
|
1110
|
+
function cspFor(socketSource) {
|
|
1111
|
+
return `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ${socketSource}; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'`;
|
|
1112
|
+
}
|
|
1113
|
+
/** Policy for mounted session files (media). They are loaded by `<img>` and
|
|
1114
|
+
* `<video>`, where a response CSP has no effect; it only bites when one is
|
|
1115
|
+
* opened as a document, so a hostile file (an SVG with script) runs in an
|
|
1116
|
+
* opaque, script-less sandbox instead of the app origin. */
|
|
1117
|
+
const MOUNTED_FILE_CSP = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
|
|
1118
|
+
function serveFile(req, res, filePath, knownStat, cache = "no-store", sandboxed = false) {
|
|
800
1119
|
let stat;
|
|
801
1120
|
if (knownStat) stat = knownStat;
|
|
802
1121
|
else try {
|
|
803
1122
|
stat = fs.statSync(filePath);
|
|
804
1123
|
} catch {
|
|
805
|
-
|
|
1124
|
+
console.debug(`[static-server] serveFile: stat failed for ${filePath} — 404`);
|
|
806
1125
|
writeError(res, 404, "Not found");
|
|
807
1126
|
return;
|
|
808
1127
|
}
|
|
@@ -811,7 +1130,10 @@ function createLoopbackServer(options = {}) {
|
|
|
811
1130
|
return;
|
|
812
1131
|
}
|
|
813
1132
|
const contentType = mimeTypeFor(filePath);
|
|
814
|
-
const cspHeader =
|
|
1133
|
+
const cspHeader = {
|
|
1134
|
+
"X-Content-Type-Options": "nosniff",
|
|
1135
|
+
...sandboxed ? { "Content-Security-Policy": MOUNTED_FILE_CSP } : contentType === "text/html" ? { "Content-Security-Policy": cspFor(gate.socketSource(req.headers)) } : {}
|
|
1136
|
+
};
|
|
815
1137
|
const range = parseRange(req.headers["range"], stat.size);
|
|
816
1138
|
if (range) {
|
|
817
1139
|
res.writeHead(206, {
|
|
@@ -870,146 +1192,98 @@ function createLoopbackServer(options = {}) {
|
|
|
870
1192
|
});
|
|
871
1193
|
stream.pipe(res);
|
|
872
1194
|
}
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
}
|
|
886
|
-
if (cleanPath.split("/").includes("..")) {
|
|
887
|
-
writeError(res, 400, "Bad request");
|
|
888
|
-
return;
|
|
889
|
-
}
|
|
890
|
-
const route = routeHandlers.get(`${req.method} ${cleanPath}`);
|
|
891
|
-
if (route) {
|
|
892
|
-
if (!isAuthorized(headers)) {
|
|
893
|
-
writeError(res, 401, "Unauthorized");
|
|
894
|
-
return;
|
|
895
|
-
}
|
|
896
|
-
try {
|
|
897
|
-
route(req, res);
|
|
898
|
-
} catch (err) {
|
|
899
|
-
writeError(res, 500, err instanceof Error ? err.message : String(err));
|
|
900
|
-
}
|
|
901
|
-
return;
|
|
902
|
-
}
|
|
903
|
-
const mount = resolveMount(cleanPath);
|
|
904
|
-
if (!(mount !== null && mount.kind === "dir" && mount.public) && !isAuthorized(headers)) {
|
|
905
|
-
writeError(res, 401, "Unauthorized");
|
|
906
|
-
return;
|
|
907
|
-
}
|
|
908
|
-
if (mount === null) {
|
|
909
|
-
writeError(res, 404, "Not found");
|
|
1195
|
+
function handleRequest(req, res) {
|
|
1196
|
+
const headers = req.headers;
|
|
1197
|
+
const segments = pathSegments(req.url ?? "/");
|
|
1198
|
+
if (segments === null) {
|
|
1199
|
+
writeError(res, 400, "Bad request");
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
const cleanPath = `/${segments.join("/")}`;
|
|
1203
|
+
if (req.method === "POST" && cleanPath === "/login") {
|
|
1204
|
+
const verdict = gate.admitRequest(headers, "public");
|
|
1205
|
+
if (!verdict.ok) {
|
|
1206
|
+
writeError(res, verdict.status, "Forbidden");
|
|
910
1207
|
return;
|
|
911
1208
|
}
|
|
912
|
-
|
|
913
|
-
|
|
1209
|
+
handleLogin(req, res);
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
const route = routeHandlers.get(`${req.method} ${cleanPath}`);
|
|
1213
|
+
if (route) {
|
|
1214
|
+
const verdict = gate.admitRequest(headers, "gated");
|
|
1215
|
+
if (!verdict.ok) {
|
|
1216
|
+
writeError(res, verdict.status, verdict.status === 401 ? "Unauthorized" : "Forbidden");
|
|
914
1217
|
return;
|
|
915
1218
|
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1219
|
+
try {
|
|
1220
|
+
route(req, res);
|
|
1221
|
+
} catch (err) {
|
|
1222
|
+
writeInternalError(res, `route ${req.method} ${cleanPath}`, err);
|
|
920
1223
|
}
|
|
921
|
-
|
|
922
|
-
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
const mount = resolveMount(cleanPath, segments);
|
|
1227
|
+
const verdict = gate.admitRequest(headers, mount !== null && mount.kind === "dir" && mount.public ? "public" : "gated");
|
|
1228
|
+
if (!verdict.ok) {
|
|
1229
|
+
writeError(res, verdict.status, verdict.status === 401 ? "Unauthorized" : "Forbidden");
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
if (mount === null) {
|
|
1233
|
+
writeError(res, 404, "Not found");
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
if (mount.kind === "file") {
|
|
1237
|
+
serveFile(req, res, mount.filePath, void 0, "no-store", true);
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
const resolved = resolveDirFile(mount, headers);
|
|
1241
|
+
if (resolved === null) {
|
|
1242
|
+
writeError(res, 404, "Not found");
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
serveFile(req, res, resolved.filePath, resolved.stat, cachePolicyFor(resolved.rel, mount.public), !mount.public);
|
|
1246
|
+
}
|
|
1247
|
+
const server = http.createServer((req, res) => {
|
|
1248
|
+
try {
|
|
1249
|
+
handleRequest(req, res);
|
|
923
1250
|
} catch (err) {
|
|
924
|
-
|
|
925
|
-
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
926
|
-
res.end(message);
|
|
1251
|
+
writeInternalError(res, `${req.method} request`, err);
|
|
927
1252
|
}
|
|
928
1253
|
});
|
|
929
1254
|
function origin() {
|
|
930
1255
|
if (!originPromise) originPromise = new Promise((resolve, reject) => {
|
|
931
|
-
token ??= randomSecret();
|
|
932
|
-
bootstrapNonce ??= randomSecret();
|
|
933
1256
|
server.on("error", reject);
|
|
934
1257
|
server.listen(port, host, () => {
|
|
935
1258
|
const address = server.address();
|
|
936
|
-
|
|
937
|
-
|
|
1259
|
+
const bound = `http://${host}:${address?.port}`;
|
|
1260
|
+
gate.bind(bound);
|
|
1261
|
+
resolve(bound);
|
|
938
1262
|
});
|
|
939
1263
|
});
|
|
940
1264
|
return originPromise;
|
|
941
1265
|
}
|
|
942
1266
|
server.on("upgrade", (req, socket, head) => {
|
|
943
|
-
const
|
|
944
|
-
const reject = (reason) => {
|
|
1267
|
+
const reject = (reason, status) => {
|
|
945
1268
|
console.error(`[loopback] upgrade rejected: ${reason}`);
|
|
946
|
-
|
|
1269
|
+
if (status === void 0) {
|
|
1270
|
+
socket.destroy();
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
socket.on("finish", () => socket.destroy());
|
|
1274
|
+
socket.on("error", () => socket.destroy());
|
|
1275
|
+
socket.end(`HTTP/1.1 ${status} ${http.STATUS_CODES[status]}\r\nConnection: close\r
|
|
1276
|
+
Content-Length: 0\r
|
|
1277
|
+
Referrer-Policy: no-referrer\r
|
|
1278
|
+
\r
|
|
1279
|
+
`);
|
|
947
1280
|
};
|
|
948
|
-
const
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
reject(`origin mismatch (got ${typeof origin === "string" && origin ? `'${origin}'` : "no Origin header"}, bound '${boundOrigin}')`);
|
|
952
|
-
return;
|
|
953
|
-
}
|
|
954
|
-
if (!isAuthorized(headers)) {
|
|
955
|
-
reject(`unauthorized (${headers["cookie"] ? "cookie present but invalid" : "no bare_session cookie"})`);
|
|
956
|
-
return;
|
|
957
|
-
}
|
|
958
|
-
if (activeSocket && Date.now() - activeSince < wsReplaceMinIntervalMs) {
|
|
959
|
-
reject("replacement rate limit (a protocol socket was just accepted)");
|
|
1281
|
+
const verdict = gate.admitUpgrade(req.headers);
|
|
1282
|
+
if (!verdict.ok) {
|
|
1283
|
+
reject(verdict.reason, verdict.status);
|
|
960
1284
|
return;
|
|
961
1285
|
}
|
|
962
|
-
|
|
963
|
-
if (err) {
|
|
964
|
-
reject(`handshake failed (${err.message})`);
|
|
965
|
-
return;
|
|
966
|
-
}
|
|
967
|
-
const raw = new ws.Socket({
|
|
968
|
-
socket,
|
|
969
|
-
isServer: true,
|
|
970
|
-
idleTimeout: wsDeadPeerTimeoutMs
|
|
971
|
-
});
|
|
972
|
-
const writer = createBoundedWriter(raw, {
|
|
973
|
-
maxQueuedBytes: wsMaxQueuedBytes,
|
|
974
|
-
onOverflow: () => {
|
|
975
|
-
console.error("[loopback] client is not reading and responses are backing up; closing the protocol socket");
|
|
976
|
-
raw.destroy();
|
|
977
|
-
}
|
|
978
|
-
});
|
|
979
|
-
const client = {
|
|
980
|
-
on: (event, listener) => raw.on(event, listener),
|
|
981
|
-
write: (data) => writer.write(data),
|
|
982
|
-
destroy: () => raw.destroy(),
|
|
983
|
-
pause: () => raw.pause?.(),
|
|
984
|
-
resume: () => raw.resume?.()
|
|
985
|
-
};
|
|
986
|
-
const previous = activeSocket;
|
|
987
|
-
activeSocket = client;
|
|
988
|
-
activeWriter = writer;
|
|
989
|
-
activeSince = Date.now();
|
|
990
|
-
if (previous) {
|
|
991
|
-
console.warn("[loopback] replaced the existing protocol socket");
|
|
992
|
-
try {
|
|
993
|
-
previous.destroy();
|
|
994
|
-
} catch {
|
|
995
|
-
debugSkip("[static-server] replace: previous socket already closed");
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
raw.on("close", () => {
|
|
999
|
-
writer.clear();
|
|
1000
|
-
if (activeSocket === client) {
|
|
1001
|
-
activeSocket = null;
|
|
1002
|
-
activeWriter = null;
|
|
1003
|
-
}
|
|
1004
|
-
});
|
|
1005
|
-
socket.setTimeout(wsIdleTimeoutMs, () => raw.destroy());
|
|
1006
|
-
for (const handler of connectionHandlers) try {
|
|
1007
|
-
handler(client, req);
|
|
1008
|
-
} catch (e) {
|
|
1009
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
1010
|
-
console.error("Error in loopback WS connection handler:", message);
|
|
1011
|
-
}
|
|
1012
|
-
});
|
|
1286
|
+
slot.accept(req, socket, head, reject);
|
|
1013
1287
|
});
|
|
1014
1288
|
async function url(path) {
|
|
1015
1289
|
return `${await origin()}${path}`;
|
|
@@ -1017,55 +1291,47 @@ function createLoopbackServer(options = {}) {
|
|
|
1017
1291
|
return {
|
|
1018
1292
|
origin,
|
|
1019
1293
|
url,
|
|
1020
|
-
token()
|
|
1021
|
-
return token ?? "";
|
|
1022
|
-
},
|
|
1294
|
+
token: () => gate.token(),
|
|
1023
1295
|
async credentials() {
|
|
1024
1296
|
const originUrl = await origin();
|
|
1025
1297
|
return {
|
|
1026
1298
|
origin: originUrl,
|
|
1027
1299
|
port: Number(originUrl.slice(originUrl.lastIndexOf(":") + 1)),
|
|
1028
|
-
token,
|
|
1029
|
-
bootstrap:
|
|
1300
|
+
token: gate.token(),
|
|
1301
|
+
bootstrap: gate.bootstrap()
|
|
1030
1302
|
};
|
|
1031
1303
|
},
|
|
1032
1304
|
mount(path, filePath) {
|
|
1033
|
-
fileMounts.set(path, filePath);
|
|
1305
|
+
fileMounts.set(mountKey(path), filePath);
|
|
1034
1306
|
},
|
|
1035
1307
|
unmount(path) {
|
|
1036
|
-
fileMounts.delete(path);
|
|
1308
|
+
fileMounts.delete(mountKey(path));
|
|
1037
1309
|
},
|
|
1038
1310
|
mountDir(prefix, dirPath, opts) {
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
onConnection(handler) {
|
|
1044
|
-
connectionHandlers.push(handler);
|
|
1311
|
+
const key = mountKey(prefix);
|
|
1312
|
+
dirMounts.set(key, dirPath);
|
|
1313
|
+
if (opts?.public) publicDirMounts.add(key);
|
|
1314
|
+
else publicDirMounts.delete(key);
|
|
1045
1315
|
},
|
|
1316
|
+
onConnection: (handler) => slot.onConnection(handler),
|
|
1046
1317
|
registerRoute(method, path, handler) {
|
|
1047
1318
|
routeHandlers.set(`${method.toUpperCase()} ${path}`, handler);
|
|
1048
1319
|
},
|
|
1049
|
-
push(frame)
|
|
1050
|
-
if (!activeWriter) return false;
|
|
1051
|
-
try {
|
|
1052
|
-
return activeWriter.write(frame, { droppable: true });
|
|
1053
|
-
} catch {
|
|
1054
|
-
debugSkip("[static-server] push: socket.write failed — frame dropped");
|
|
1055
|
-
return false;
|
|
1056
|
-
}
|
|
1057
|
-
},
|
|
1320
|
+
push: (frame) => slot.push(frame),
|
|
1058
1321
|
close(cb) {
|
|
1059
|
-
|
|
1060
|
-
activeSocket.destroy();
|
|
1061
|
-
} catch {
|
|
1062
|
-
debugSkip("[static-server] close: socket.destroy failed — already closed");
|
|
1063
|
-
}
|
|
1322
|
+
slot.close();
|
|
1064
1323
|
server.close(cb);
|
|
1065
1324
|
}
|
|
1066
1325
|
};
|
|
1067
1326
|
}
|
|
1068
1327
|
// core/logs/routes.ts
|
|
1328
|
+
const LOG_LEVELS = [
|
|
1329
|
+
"debug",
|
|
1330
|
+
"info",
|
|
1331
|
+
"warn",
|
|
1332
|
+
"error"
|
|
1333
|
+
];
|
|
1334
|
+
/** Parses the query string, or returns null when an escape is malformed. */
|
|
1069
1335
|
function queryParams(url) {
|
|
1070
1336
|
const out = {};
|
|
1071
1337
|
const query = (url ?? "").split("?")[1];
|
|
@@ -1073,14 +1339,32 @@ function queryParams(url) {
|
|
|
1073
1339
|
for (const pair of query.split("&")) {
|
|
1074
1340
|
const eq = pair.indexOf("=");
|
|
1075
1341
|
if (eq === -1) continue;
|
|
1076
|
-
|
|
1077
|
-
|
|
1342
|
+
try {
|
|
1343
|
+
out[decodeURIComponent(pair.slice(0, eq))] = decodeURIComponent(pair.slice(eq + 1));
|
|
1344
|
+
} catch {
|
|
1345
|
+
return null;
|
|
1346
|
+
}
|
|
1078
1347
|
}
|
|
1079
1348
|
return out;
|
|
1080
1349
|
}
|
|
1350
|
+
function isLogLevel(value) {
|
|
1351
|
+
return LOG_LEVELS.includes(value);
|
|
1352
|
+
}
|
|
1353
|
+
/** Longest ingested message and tag, in UTF-16 code units. The ring holds a
|
|
1354
|
+
* fixed number of entries, so this is what bounds its memory: without it a
|
|
1355
|
+
* page could pin capacity × 1 MiB in the worklet. */
|
|
1356
|
+
const MAX_INGEST_MESSAGE_CHARS = 8192;
|
|
1357
|
+
function truncate(value, max) {
|
|
1358
|
+
return value.length > max ? `${value.slice(0, max)}…` : value;
|
|
1359
|
+
}
|
|
1360
|
+
/** One entry per line: a line break inside a message would let a page write a
|
|
1361
|
+
* line that reads as a backend diagnostic. */
|
|
1362
|
+
function oneLine(value) {
|
|
1363
|
+
return value.replace(/\\/g, "\\\\").replace(/\r/g, "\\r").replace(/\n/g, "\\n");
|
|
1364
|
+
}
|
|
1081
1365
|
function formatText(e) {
|
|
1082
|
-
const tag = e.tag ? `[${e.tag}] ` : "";
|
|
1083
|
-
return `[${new Date(e.ts).toISOString()}] ${e.level.toUpperCase()} ${e.source} ${tag}${e.message}`;
|
|
1366
|
+
const tag = e.tag ? `[${oneLine(e.tag)}] ` : "";
|
|
1367
|
+
return `[${new Date(e.ts).toISOString()}] ${e.level.toUpperCase()} ${e.source} ${tag}${oneLine(e.message)}`;
|
|
1084
1368
|
}
|
|
1085
1369
|
/** Registers the loopback log routes on the unified server. They inherit the
|
|
1086
1370
|
* server's auth gate and single-server rule for free:
|
|
@@ -1093,9 +1377,17 @@ function formatText(e) {
|
|
|
1093
1377
|
function registerLogRoutes(server, store) {
|
|
1094
1378
|
server.registerRoute("GET", "/logs", (req, res) => {
|
|
1095
1379
|
const params = queryParams(req.url);
|
|
1380
|
+
if (params === null) {
|
|
1381
|
+
res.writeHead(400, {
|
|
1382
|
+
"Content-Type": "text/plain",
|
|
1383
|
+
"Referrer-Policy": "no-referrer"
|
|
1384
|
+
});
|
|
1385
|
+
res.end("Bad request");
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1096
1388
|
const tail = params.tail === void 0 ? void 0 : Number(params.tail);
|
|
1097
|
-
const level = params.level
|
|
1098
|
-
const source = params.source || void 0;
|
|
1389
|
+
const level = isLogLevel(params.level) ? params.level : void 0;
|
|
1390
|
+
const source = params.source === "web" || params.source === "backend" ? params.source : void 0;
|
|
1099
1391
|
const entries = store.view({
|
|
1100
1392
|
tail: tail !== void 0 && Number.isFinite(tail) ? tail : void 0,
|
|
1101
1393
|
level,
|
|
@@ -1121,6 +1413,9 @@ function registerLogRoutes(server, store) {
|
|
|
1121
1413
|
try {
|
|
1122
1414
|
parsed = JSON.parse(body);
|
|
1123
1415
|
} catch {
|
|
1416
|
+
parsed = null;
|
|
1417
|
+
}
|
|
1418
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
1124
1419
|
res.writeHead(400, {
|
|
1125
1420
|
"Content-Type": "text/plain",
|
|
1126
1421
|
"Referrer-Policy": "no-referrer"
|
|
@@ -1128,17 +1423,20 @@ function registerLogRoutes(server, store) {
|
|
|
1128
1423
|
res.end("Bad request");
|
|
1129
1424
|
return;
|
|
1130
1425
|
}
|
|
1131
|
-
const source =
|
|
1132
|
-
const
|
|
1426
|
+
const source = "web";
|
|
1427
|
+
const entries = parsed.entries;
|
|
1428
|
+
const batch = Array.isArray(entries) ? entries : [];
|
|
1133
1429
|
let accepted = 0;
|
|
1134
|
-
for (const
|
|
1135
|
-
if (
|
|
1430
|
+
for (const item of batch) {
|
|
1431
|
+
if (typeof item !== "object" || item === null) continue;
|
|
1432
|
+
const raw = item;
|
|
1433
|
+
if (typeof raw.message !== "string") continue;
|
|
1136
1434
|
store.append({
|
|
1137
1435
|
ts: Date.now(),
|
|
1138
|
-
level: raw.level
|
|
1436
|
+
level: isLogLevel(raw.level) ? raw.level : "info",
|
|
1139
1437
|
source,
|
|
1140
|
-
tag: raw.tag,
|
|
1141
|
-
message: raw.message
|
|
1438
|
+
tag: typeof raw.tag === "string" ? truncate(raw.tag, 64) : void 0,
|
|
1439
|
+
message: truncate(raw.message, MAX_INGEST_MESSAGE_CHARS)
|
|
1142
1440
|
});
|
|
1143
1441
|
accepted++;
|
|
1144
1442
|
}
|
|
@@ -1148,7 +1446,7 @@ function registerLogRoutes(server, store) {
|
|
|
1148
1446
|
"Cache-Control": "no-store"
|
|
1149
1447
|
});
|
|
1150
1448
|
res.end(JSON.stringify({ accepted }));
|
|
1151
|
-
}
|
|
1449
|
+
}, (err) => {
|
|
1152
1450
|
const message = err instanceof Error ? err.message : String(err);
|
|
1153
1451
|
console.error("Discarding oversized /logs batch:", message);
|
|
1154
1452
|
res.writeHead(413, {
|
|
@@ -1156,17 +1454,22 @@ function registerLogRoutes(server, store) {
|
|
|
1156
1454
|
"Referrer-Policy": "no-referrer"
|
|
1157
1455
|
});
|
|
1158
1456
|
res.end("Request Entity Too Large");
|
|
1457
|
+
}).catch((err) => {
|
|
1458
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1459
|
+
console.error(`[loopback] POST /logs failed: ${message}`);
|
|
1460
|
+
if (res.headersSent) {
|
|
1461
|
+
res.destroy();
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
res.writeHead(500, {
|
|
1465
|
+
"Content-Type": "text/plain",
|
|
1466
|
+
"Referrer-Policy": "no-referrer"
|
|
1467
|
+
});
|
|
1468
|
+
res.end("Internal Server Error");
|
|
1159
1469
|
});
|
|
1160
1470
|
});
|
|
1161
1471
|
}
|
|
1162
1472
|
// core/server/websocket-server.ts
|
|
1163
|
-
function toUint8Array$1(data) {
|
|
1164
|
-
if (data instanceof Uint8Array) return data;
|
|
1165
|
-
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
1166
|
-
throw new Error("Expected Uint8Array or ArrayBuffer from socket data");
|
|
1167
|
-
}
|
|
1168
|
-
/** Envelope encoder bound to a loopback server's connected protocol socket —
|
|
1169
|
-
* the server-initiated (backend → web) push seam. */
|
|
1170
1473
|
/** Routed calls pending on one socket before it stops being read. */
|
|
1171
1474
|
const MAX_PENDING_ROUTES = 32;
|
|
1172
1475
|
/** Builds the push seam for the server's connected protocol socket: encodes a
|
|
@@ -1181,49 +1484,34 @@ function createLoopbackPush(server, protocol) {
|
|
|
1181
1484
|
/**
|
|
1182
1485
|
* Attaches the framed-protocol socket to the unified loopback server. The
|
|
1183
1486
|
* server owns the WebSocket upgrade (origin check, cookie/token auth, single
|
|
1184
|
-
* client, idle timeout); this layer
|
|
1185
|
-
*
|
|
1487
|
+
* client, idle timeout); this layer runs one frame channel per socket. A peer
|
|
1488
|
+
* that sends a malformed frame is not recoverable on that socket, so it is
|
|
1489
|
+
* torn down and the client reconnects.
|
|
1186
1490
|
*/
|
|
1187
1491
|
function attachWebSocketProtocol(server, context) {
|
|
1188
1492
|
const { protocol, pluginRouter } = context;
|
|
1189
|
-
server.onConnection((socket
|
|
1190
|
-
const
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
const messages = decoder.push(data);
|
|
1204
|
-
if (decoder.error) {
|
|
1205
|
-
console.error("Error handling WebSocket message: " + decoder.error.message);
|
|
1206
|
-
socket.destroy();
|
|
1207
|
-
return;
|
|
1208
|
-
}
|
|
1209
|
-
if (messages.length === 0) return;
|
|
1210
|
-
for (const parsed of messages) {
|
|
1211
|
-
const work = pluginRouter.route(parsed.header, parsed.payload).then((pluginResponse) => {
|
|
1212
|
-
if (pluginResponse) socket.write(encodeResponse(protocol, pluginResponse));
|
|
1213
|
-
}).catch((err) => {
|
|
1214
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1215
|
-
console.error("Error handling WebSocket message:", message);
|
|
1216
|
-
});
|
|
1217
|
-
gate.track(work);
|
|
1493
|
+
server.onConnection((socket) => {
|
|
1494
|
+
const channel = createFrameChannel({
|
|
1495
|
+
name: "[ws]",
|
|
1496
|
+
protocol,
|
|
1497
|
+
router: pluginRouter,
|
|
1498
|
+
write: (frame) => socket.write(frame),
|
|
1499
|
+
onMalformed: {
|
|
1500
|
+
kind: "close",
|
|
1501
|
+
close: () => socket.destroy()
|
|
1502
|
+
},
|
|
1503
|
+
inbound: {
|
|
1504
|
+
maxPending: MAX_PENDING_ROUTES,
|
|
1505
|
+
pause: () => socket.pause?.(),
|
|
1506
|
+
resume: () => socket.resume?.()
|
|
1218
1507
|
}
|
|
1219
1508
|
});
|
|
1220
|
-
socket.on("
|
|
1221
|
-
|
|
1222
|
-
});
|
|
1509
|
+
socket.on("data", (chunk) => channel.push(chunk));
|
|
1510
|
+
socket.on("close", () => channel.close());
|
|
1223
1511
|
socket.on("error", (err) => {
|
|
1224
1512
|
const message = err instanceof Error ? err.message : String(err);
|
|
1225
|
-
console.error("
|
|
1226
|
-
|
|
1513
|
+
console.error("[ws] connection error:", message);
|
|
1514
|
+
channel.close();
|
|
1227
1515
|
socket.destroy();
|
|
1228
1516
|
});
|
|
1229
1517
|
});
|
|
@@ -1234,12 +1522,11 @@ function readBareArgv() {
|
|
|
1234
1522
|
}
|
|
1235
1523
|
/** Resolves the host-provided worklet configuration from `start(...)`
|
|
1236
1524
|
* arguments. On-device hosts pass `["<webAssets>", "<storage>", "<cache>"]`
|
|
1237
|
-
* (three existing directories, in that order), landing at `Bare.argv[0..2]
|
|
1238
|
-
*
|
|
1239
|
-
*
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1242
|
-
* device and dev runs. */
|
|
1525
|
+
* (three existing directories, in that order), landing at `Bare.argv[0..2]`.
|
|
1526
|
+
* Falls back to the labeled CLI token syntax (see `resolveCliConfig`) when the
|
|
1527
|
+
* positional shape doesn't match, so the same entry works for device and dev
|
|
1528
|
+
* runs. Nothing here turns auth off: the dev backend is selected only by an
|
|
1529
|
+
* explicit `mode=dev` token. */
|
|
1243
1530
|
function resolveWorkletConfig() {
|
|
1244
1531
|
const argv = readBareArgv();
|
|
1245
1532
|
const webAssets = argv[0];
|
|
@@ -1262,19 +1549,22 @@ function resolveWorkletConfig() {
|
|
|
1262
1549
|
return resolveCliConfig(argv);
|
|
1263
1550
|
}
|
|
1264
1551
|
/**
|
|
1265
|
-
* Parses
|
|
1266
|
-
* `webassets=<dir>`, `storage=<dir>`, `cache=<dir>`,
|
|
1267
|
-
* `port=<n>`, `auth=<on|off|true|false|1|0
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1552
|
+
* Parses labeled-token configuration from bare CLI arguments, one `key=value`
|
|
1553
|
+
* per token: `webassets=<dir>`, `storage=<dir>`, `cache=<dir>`,
|
|
1554
|
+
* `host=<addr>`, `port=<n>`, `auth=<on|off|true|false|1|0>`,
|
|
1555
|
+
* `mode=<device|dev>` and `origin=<scheme://host[:port]>` (repeatable; an
|
|
1556
|
+
* extra allowed page origin, e.g. the Vite dev server). Unknown labels (e.g.
|
|
1557
|
+
* `bootstrap=127.0.0.1:49737`) are ignored and stay consumer-owned.
|
|
1270
1558
|
*
|
|
1271
|
-
*
|
|
1272
|
-
*
|
|
1273
|
-
*
|
|
1274
|
-
* no labeled tokens are present
|
|
1559
|
+
* Without `mode=dev` the result keeps the secure device defaults (auth on, an
|
|
1560
|
+
* ephemeral port), and `auth=off` is ignored with a warning. `mode=dev` selects the dev backend: `deviceMode: false`,
|
|
1561
|
+
* `auth: false`, `port: 8080`, each overridable by a token. Returns `{}` when
|
|
1562
|
+
* no labeled tokens are present.
|
|
1275
1563
|
*/
|
|
1276
1564
|
function resolveCliConfig(argv = readBareArgv()) {
|
|
1277
1565
|
const parsed = {};
|
|
1566
|
+
const allowedOrigins = [];
|
|
1567
|
+
let dev = false;
|
|
1278
1568
|
let matched = false;
|
|
1279
1569
|
for (const token of argv) {
|
|
1280
1570
|
const m = /^([A-Za-z][A-Za-z0-9]*)=(.*)$/.exec(token);
|
|
@@ -1296,29 +1586,33 @@ function resolveCliConfig(argv = readBareArgv()) {
|
|
|
1296
1586
|
break;
|
|
1297
1587
|
case "port": {
|
|
1298
1588
|
const n = Number(value);
|
|
1299
|
-
if (Number.isInteger(n) && n >= 0 && n <= 65535) parsed.port = n;
|
|
1589
|
+
if (value !== "" && Number.isInteger(n) && n >= 0 && n <= 65535) parsed.port = n;
|
|
1300
1590
|
else console.warn(`[bare] ignoring invalid port= token: "${value}"`);
|
|
1301
1591
|
break;
|
|
1302
1592
|
}
|
|
1303
|
-
case "auth":
|
|
1593
|
+
case "auth":
|
|
1594
|
+
parsed.auth = value !== "off" && value !== "false" && value !== "0";
|
|
1595
|
+
break;
|
|
1596
|
+
case "mode":
|
|
1597
|
+
if (value === "dev") dev = true;
|
|
1598
|
+
else if (value !== "device") console.warn(`[bare] ignoring unknown mode= token: "${value}" (expected device or dev)`);
|
|
1599
|
+
break;
|
|
1600
|
+
case "origin": allowedOrigins.push(value);
|
|
1304
1601
|
}
|
|
1305
1602
|
}
|
|
1306
1603
|
if (!matched) return {};
|
|
1307
|
-
|
|
1604
|
+
if (allowedOrigins.length > 0) parsed.allowedOrigins = allowedOrigins;
|
|
1605
|
+
if (dev) return {
|
|
1308
1606
|
deviceMode: false,
|
|
1309
1607
|
auth: false,
|
|
1310
1608
|
port: 8080,
|
|
1311
1609
|
...parsed
|
|
1312
1610
|
};
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
return
|
|
1318
|
-
}
|
|
1319
|
-
function writeIpc(ipc, bytes) {
|
|
1320
|
-
if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") ipc.write(Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes));
|
|
1321
|
-
else ipc.write(bytes);
|
|
1611
|
+
if (parsed.auth === false) {
|
|
1612
|
+
console.warn("[bare] ignoring auth=off without mode=dev");
|
|
1613
|
+
delete parsed.auth;
|
|
1614
|
+
}
|
|
1615
|
+
return parsed;
|
|
1322
1616
|
}
|
|
1323
1617
|
/**
|
|
1324
1618
|
* Creates one worklet runtime — a per-instance, stateless factory (no
|
|
@@ -1327,36 +1621,32 @@ function writeIpc(ipc, bytes) {
|
|
|
1327
1621
|
* wire p2p". Mirrors the reference wiring in `core/main.core.ts`.
|
|
1328
1622
|
*/
|
|
1329
1623
|
function createWorkletRuntime(options = {}) {
|
|
1330
|
-
const deviceMode = options.deviceMode ??
|
|
1624
|
+
const deviceMode = options.deviceMode ?? true;
|
|
1331
1625
|
const auth = options.auth ?? deviceMode;
|
|
1332
1626
|
const port = options.port ?? (deviceMode ? 0 : 8080);
|
|
1333
|
-
const
|
|
1334
|
-
|
|
1335
|
-
const protocol = new MessageProtocol({
|
|
1336
|
-
encode: (str) => textEncoder.encode(str),
|
|
1337
|
-
decode: (bytes) => textDecoder.decode(bytes)
|
|
1338
|
-
});
|
|
1339
|
-
let ipc;
|
|
1340
|
-
try {
|
|
1341
|
-
ipc = getIPC();
|
|
1342
|
-
} catch (err) {
|
|
1343
|
-
console.debug(`[runtime] no host IPC channel available, running without a host: ` + (err instanceof Error ? err.message : String(err)));
|
|
1344
|
-
ipc = void 0;
|
|
1345
|
-
}
|
|
1346
|
-
const hostBridge = ipc ? createHostIpcBridge({
|
|
1347
|
-
ipc,
|
|
1348
|
-
protocol
|
|
1349
|
-
}) : null;
|
|
1350
|
-
const pluginRegistry = createPluginRegistry();
|
|
1627
|
+
const ipc = getIPC();
|
|
1628
|
+
if (!ipc) console.debug("[runtime] no host IPC channel available, running without a host");
|
|
1351
1629
|
const server = createLoopbackServer({
|
|
1352
1630
|
host: options.host,
|
|
1353
1631
|
port,
|
|
1354
1632
|
auth,
|
|
1633
|
+
allowedOrigins: options.allowedOrigins,
|
|
1355
1634
|
wsIdleTimeoutMs: options.wsIdleTimeoutMs,
|
|
1356
1635
|
wsDeadPeerTimeoutMs: options.wsDeadPeerTimeoutMs,
|
|
1357
1636
|
wsReplaceMinIntervalMs: options.wsReplaceMinIntervalMs,
|
|
1358
1637
|
wsMaxQueuedBytes: options.wsMaxQueuedBytes
|
|
1359
1638
|
});
|
|
1639
|
+
let hostBridge = null;
|
|
1640
|
+
const { protocol, pluginRegistry, pluginRouter } = createBareRuntimeContext([], {
|
|
1641
|
+
maxInflight: options.maxInflight,
|
|
1642
|
+
serialPlugins: options.serialPlugins,
|
|
1643
|
+
delegateToHost: (header, payload) => hostBridge?.invokeOnHost(header, payload) ?? Promise.resolve(null),
|
|
1644
|
+
getHostCapabilities: () => hostBridge?.queryCapabilities() ?? Promise.resolve([])
|
|
1645
|
+
});
|
|
1646
|
+
hostBridge = ipc ? createHostIpcBridge({
|
|
1647
|
+
ipc,
|
|
1648
|
+
protocol
|
|
1649
|
+
}) : null;
|
|
1360
1650
|
const logStore = createLogRingBuffer(options.logsCapacity ?? 500);
|
|
1361
1651
|
installConsoleCapture(logStore, "backend", { minLevel: options.logsMinLevel });
|
|
1362
1652
|
registerLogRoutes(server, logStore);
|
|
@@ -1368,39 +1658,17 @@ function createWorkletRuntime(options = {}) {
|
|
|
1368
1658
|
store: logStore
|
|
1369
1659
|
})) pluginRegistry.register(plugin);
|
|
1370
1660
|
for (const plugin of options.plugins ?? []) pluginRegistry.register(plugin);
|
|
1371
|
-
const pluginRouter = createPluginRouter(pluginRegistry, "bare", {
|
|
1372
|
-
maxInflight: options.maxInflight,
|
|
1373
|
-
serialPlugins: options.serialPlugins,
|
|
1374
|
-
delegateToHost: (header, payload) => hostBridge?.invokeOnHost(header, payload) ?? Promise.resolve(null),
|
|
1375
|
-
getHostCapabilities: () => hostBridge?.queryCapabilities() ?? Promise.resolve([])
|
|
1376
|
-
});
|
|
1377
1661
|
if (ipc) {
|
|
1378
|
-
const
|
|
1379
|
-
const
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
console.error("IPC channel permanently desynced after repeated malformed frames; stopping host→worklet frame parsing");
|
|
1387
|
-
return;
|
|
1388
|
-
}
|
|
1389
|
-
ipcDecoder.clear();
|
|
1390
|
-
return;
|
|
1391
|
-
}
|
|
1392
|
-
ipcHealth.noteSuccess();
|
|
1393
|
-
if (messages.length === 0) return;
|
|
1394
|
-
for (const parsed of messages) {
|
|
1395
|
-
if (hostBridge?.tryConsumeDownstream(parsed)) continue;
|
|
1396
|
-
pluginRouter.route(parsed.header, parsed.payload).then((pluginResponse) => {
|
|
1397
|
-
if (pluginResponse) writeIpc(ipc, encodeResponse(protocol, pluginResponse));
|
|
1398
|
-
}).catch((err) => {
|
|
1399
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1400
|
-
console.error(`Malformed IPC frame dropped: ${message}`);
|
|
1401
|
-
});
|
|
1402
|
-
}
|
|
1662
|
+
const bridge = hostBridge;
|
|
1663
|
+
const channel = createFrameChannel({
|
|
1664
|
+
name: "[ipc]",
|
|
1665
|
+
protocol,
|
|
1666
|
+
router: pluginRouter,
|
|
1667
|
+
write: (frame) => ipc.write(toIpcChunk(frame)),
|
|
1668
|
+
onMalformed: { kind: "tolerate" },
|
|
1669
|
+
intercept: (message) => bridge?.tryConsumeDownstream(message) ?? false
|
|
1403
1670
|
});
|
|
1671
|
+
ipc.on("data", (data) => channel.push(data));
|
|
1404
1672
|
}
|
|
1405
1673
|
async function start() {
|
|
1406
1674
|
if (options.storage) try {
|
|
@@ -1415,11 +1683,18 @@ function createWorkletRuntime(options = {}) {
|
|
|
1415
1683
|
pluginRouter
|
|
1416
1684
|
});
|
|
1417
1685
|
const credentials = await server.credentials();
|
|
1418
|
-
if (auth && options.storage && options.handoffFile !== false)
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1686
|
+
if (auth && options.storage && options.handoffFile !== false) {
|
|
1687
|
+
const { origin, port, bootstrap } = credentials;
|
|
1688
|
+
try {
|
|
1689
|
+
fs.writeFileSync(path.join(options.storage, "handoff.json"), JSON.stringify({
|
|
1690
|
+
origin,
|
|
1691
|
+
port,
|
|
1692
|
+
bootstrap
|
|
1693
|
+
}));
|
|
1694
|
+
} catch (err) {
|
|
1695
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1696
|
+
console.error(`Failed to write handoff file: ${message}`);
|
|
1697
|
+
}
|
|
1423
1698
|
}
|
|
1424
1699
|
return credentials;
|
|
1425
1700
|
}
|