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