@ekrooh/bare 0.4.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 +139 -195
- package/dist/{index-DjulYFJL.d.mts → index-BkwUzwVt.d.mts} +94 -20
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1 -512
- package/dist/messages-CevD8qkV.mjs +851 -0
- package/dist/{plugins-1KweEBct.mjs → plugins-Ddp2AgOe.mjs} +51 -20
- package/dist/plugins-discovery-events.d.mts +5 -8
- package/dist/plugins-discovery-events.mjs +2 -3
- package/dist/plugins-health-events.d.mts +4 -7
- package/dist/plugins-health-events.mjs +2 -3
- package/dist/plugins-logs-events.d.mts +34 -0
- package/dist/plugins-logs-events.mjs +25 -0
- package/dist/plugins-media-events.d.mts +6 -11
- package/dist/plugins-media-events.mjs +2 -3
- package/dist/plugins-permissions-events.d.mts +7 -10
- package/dist/plugins-permissions-events.mjs +2 -3
- package/dist/plugins.d.mts +9 -11
- package/dist/plugins.mjs +1 -2
- package/dist/runtime.d.mts +75 -46
- package/dist/runtime.mjs +1314 -324
- package/dist/static-file-server-COxd-PFR.d.mts +120 -0
- package/dist/store-DTg1jN5E.mjs +49 -0
- package/dist/transports.d.mts +15 -14
- package/dist/transports.mjs +169 -82
- package/package.json +12 -8
- package/dist/static-file-server-DY9EKEVf.d.mts +0 -88
package/dist/runtime.mjs
CHANGED
|
@@ -1,46 +1,419 @@
|
|
|
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
|
+
import { t as createLogRingBuffer } from "./store-DTg1jN5E.mjs";
|
|
3
4
|
import { TextDecoder, TextEncoder } from "bare-encoding";
|
|
4
5
|
import fs from "bare-fs";
|
|
5
6
|
import path from "bare-path";
|
|
7
|
+
import crypto from "bare-crypto";
|
|
6
8
|
import http from "bare-http1";
|
|
7
9
|
import ws from "bare-ws";
|
|
8
|
-
|
|
9
|
-
//#region core/lib/get-ipc.ts
|
|
10
|
+
// core/lib/get-ipc.ts
|
|
10
11
|
/**
|
|
11
|
-
* Returns the active IPC channel.
|
|
12
|
-
* 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.
|
|
13
14
|
* In Bare (Sidecar), it uses Bare.IPC.
|
|
14
15
|
*/
|
|
15
16
|
function getIPC() {
|
|
16
17
|
const IPC = typeof BareKit !== "undefined" ? BareKit.IPC : typeof Bare !== "undefined" ? Bare.IPC : void 0;
|
|
17
18
|
if (IPC && typeof IPC.resume === "function") IPC.resume();
|
|
18
|
-
return IPC;
|
|
19
|
+
return IPC ?? null;
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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) {
|
|
23
26
|
if (data instanceof Uint8Array) return data;
|
|
24
|
-
if (
|
|
25
|
-
|
|
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
|
+
};
|
|
182
|
+
// core/messages/encode-response.ts
|
|
183
|
+
/** Encodes a routed response. A response that cannot be encoded (a result over
|
|
184
|
+
* the 64 KB header limit, or one that does not serialize) is replaced by an
|
|
185
|
+
* error response for the same request, so the caller fails fast instead of
|
|
186
|
+
* waiting for a timeout that never explains why. */
|
|
187
|
+
function encodeResponse(protocol, response) {
|
|
188
|
+
try {
|
|
189
|
+
return protocol.encode(MessageType.ENVELOPE, response, null);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
192
|
+
const tooLarge = /too large/i.test(reason);
|
|
193
|
+
console.error(`Response for ${response.pluginId}.${response.event} dropped: ${reason}`);
|
|
194
|
+
return protocol.encode(MessageType.ENVELOPE, {
|
|
195
|
+
type: "INVOKE_RESPONSE",
|
|
196
|
+
pluginId: response.pluginId,
|
|
197
|
+
event: response.event,
|
|
198
|
+
requestId: response.requestId,
|
|
199
|
+
error: {
|
|
200
|
+
code: tooLarge ? ErrorCode.FRAME_TOO_LARGE : ErrorCode.INVALID_RESPONSE,
|
|
201
|
+
message: `Response for ${response.pluginId}.${response.event} could not be sent: ${reason}`
|
|
202
|
+
}
|
|
203
|
+
}, null);
|
|
204
|
+
}
|
|
26
205
|
}
|
|
27
|
-
|
|
28
|
-
|
|
206
|
+
// core/messages/framing.ts
|
|
207
|
+
/** Smallest buffer allocated for a partial frame whose length is not yet known
|
|
208
|
+
* (fewer than 7 prefix bytes have arrived). */
|
|
209
|
+
const MIN_PARTIAL_BYTES = 64;
|
|
210
|
+
/** A partial-frame buffer larger than this is released once drained, so one
|
|
211
|
+
* large frame does not pin its memory for the rest of the session. */
|
|
212
|
+
const RETAIN_BYTES = 65536;
|
|
213
|
+
function createFrameDecoder(protocol) {
|
|
214
|
+
/** Partial-frame storage: the first `length` bytes are buffered data. Empty
|
|
215
|
+
* whenever nothing is buffered, so a chunk of whole frames is decoded
|
|
216
|
+
* straight from the caller's bytes without being copied in first. */
|
|
217
|
+
let buffer = /* @__PURE__ */ new Uint8Array(0);
|
|
218
|
+
let length = 0;
|
|
219
|
+
let error = null;
|
|
220
|
+
function fail(err) {
|
|
221
|
+
error = err;
|
|
222
|
+
buffer = /* @__PURE__ */ new Uint8Array(0);
|
|
223
|
+
length = 0;
|
|
224
|
+
}
|
|
225
|
+
/** Grows `buffer` to hold at least `bytes`, preserving buffered data. */
|
|
226
|
+
function reserve(bytes) {
|
|
227
|
+
if (buffer.byteLength >= bytes) return;
|
|
228
|
+
const next = new Uint8Array(Math.max(bytes, MIN_PARTIAL_BYTES));
|
|
229
|
+
next.set(buffer.subarray(0, length), 0);
|
|
230
|
+
buffer = next;
|
|
231
|
+
}
|
|
232
|
+
/** Keeps `rest` (the unconsumed tail of `source`) as the buffered partial,
|
|
233
|
+
* sized for `frameLen` when the incomplete frame's length is known. */
|
|
234
|
+
function retain(source, from, to, frameLen) {
|
|
235
|
+
const rest = to - from;
|
|
236
|
+
if (rest === 0) {
|
|
237
|
+
length = 0;
|
|
238
|
+
if (buffer.byteLength > RETAIN_BYTES) buffer = /* @__PURE__ */ new Uint8Array(0);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (source === buffer) {
|
|
242
|
+
buffer.copyWithin(0, from, to);
|
|
243
|
+
length = rest;
|
|
244
|
+
reserve(frameLen);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
length = 0;
|
|
248
|
+
reserve(Math.max(rest, frameLen));
|
|
249
|
+
buffer.set(source.subarray(from, to), 0);
|
|
250
|
+
length = rest;
|
|
251
|
+
}
|
|
252
|
+
function push(chunk) {
|
|
253
|
+
if (error) return [];
|
|
254
|
+
let source;
|
|
255
|
+
let end;
|
|
256
|
+
if (length === 0) {
|
|
257
|
+
source = chunk;
|
|
258
|
+
end = chunk.byteLength;
|
|
259
|
+
} else {
|
|
260
|
+
reserve(length + chunk.byteLength);
|
|
261
|
+
buffer.set(chunk, length);
|
|
262
|
+
length += chunk.byteLength;
|
|
263
|
+
source = buffer;
|
|
264
|
+
end = length;
|
|
265
|
+
}
|
|
266
|
+
const out = [];
|
|
267
|
+
let offset = 0;
|
|
268
|
+
let pendingFrameLen = 0;
|
|
269
|
+
while (end - offset >= 7) {
|
|
270
|
+
const headerLen = source[offset + 2] << 8 | source[offset + 3];
|
|
271
|
+
if (headerLen > 65535) {
|
|
272
|
+
fail(/* @__PURE__ */ new Error(`Header too large: ${headerLen} bytes, maximum is ${MAX_HEADER_BYTES}`));
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
const payloadLen = source[offset + 4] << 16 | source[offset + 5] << 8 | source[offset + 6];
|
|
276
|
+
const frameLen = 7 + headerLen + payloadLen;
|
|
277
|
+
if (frameLen > protocol.maxFrameBytes) {
|
|
278
|
+
fail(/* @__PURE__ */ new Error(`Frame too large: ${frameLen} bytes, maximum is ${protocol.maxFrameBytes}`));
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
if (end - offset < frameLen) {
|
|
282
|
+
pendingFrameLen = frameLen;
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
const frame = Uint8Array.prototype.slice.call(source, offset, offset + frameLen);
|
|
286
|
+
offset += frameLen;
|
|
287
|
+
try {
|
|
288
|
+
out.push(protocol.decode(frame));
|
|
289
|
+
} catch (err) {
|
|
290
|
+
fail(err instanceof Error ? err : new Error(String(err)));
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
retain(source, offset, end, pendingFrameLen);
|
|
295
|
+
if (length > protocol.maxFrameBytes) fail(/* @__PURE__ */ new Error(`Frame buffer exceeded ${protocol.maxFrameBytes} bytes with no complete frame`));
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
push,
|
|
300
|
+
clear() {
|
|
301
|
+
buffer = /* @__PURE__ */ new Uint8Array(0);
|
|
302
|
+
length = 0;
|
|
303
|
+
error = null;
|
|
304
|
+
},
|
|
305
|
+
get error() {
|
|
306
|
+
return error;
|
|
307
|
+
}
|
|
308
|
+
};
|
|
29
309
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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;
|
|
330
|
+
}
|
|
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();
|
|
336
|
+
}
|
|
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);
|
|
345
|
+
}
|
|
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
|
+
};
|
|
33
378
|
}
|
|
379
|
+
// core/messages/host-ipc.ts
|
|
380
|
+
/** Hard ceiling on concurrently pending host calls. Above this, the oldest call
|
|
381
|
+
* is dropped (its timer cleared and promise rejected) so a flood of invokes
|
|
382
|
+
* cannot grow the pending map without bound. */
|
|
383
|
+
const MAX_CONCURRENT_HOST_CALLS = 1024;
|
|
384
|
+
/** Sane default for a host invoke: five-minute waits let a single stalled host
|
|
385
|
+
* call pin a slot indefinitely, so we default to 30s instead. */
|
|
386
|
+
const DEFAULT_HOST_INVOKE_TIMEOUT_MS = 3e4;
|
|
34
387
|
function createHostIpcBridge(config) {
|
|
35
388
|
const { ipc, protocol } = config;
|
|
36
389
|
const pending = /* @__PURE__ */ new Map();
|
|
390
|
+
function track(requestId, slot) {
|
|
391
|
+
if (pending.size >= MAX_CONCURRENT_HOST_CALLS) {
|
|
392
|
+
const oldest = pending.keys().next().value;
|
|
393
|
+
if (oldest !== void 0) {
|
|
394
|
+
const dropped = pending.get(oldest);
|
|
395
|
+
if (dropped) {
|
|
396
|
+
clearTimeout(dropped.timer);
|
|
397
|
+
dropped.reject(/* @__PURE__ */ new Error(`host invoke dropped: too many concurrent host calls (${oldest})`));
|
|
398
|
+
}
|
|
399
|
+
pending.delete(oldest);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const existing = pending.get(requestId);
|
|
403
|
+
if (existing) {
|
|
404
|
+
clearTimeout(existing.timer);
|
|
405
|
+
existing.reject(/* @__PURE__ */ new Error(`host invoke superseded by a new call (${requestId})`));
|
|
406
|
+
pending.delete(requestId);
|
|
407
|
+
}
|
|
408
|
+
pending.set(requestId, slot);
|
|
409
|
+
}
|
|
37
410
|
function waitFor(requestId, expectedType, timeoutMs) {
|
|
38
411
|
return new Promise((resolve, reject) => {
|
|
39
412
|
const timer = setTimeout(() => {
|
|
40
413
|
pending.delete(requestId);
|
|
41
414
|
reject(/* @__PURE__ */ new Error(`Host IPC timeout waiting for ${String(expectedType)} (${requestId})`));
|
|
42
415
|
}, timeoutMs);
|
|
43
|
-
|
|
416
|
+
track(requestId, {
|
|
44
417
|
timer,
|
|
45
418
|
resolve: (h) => {
|
|
46
419
|
clearTimeout(timer);
|
|
@@ -50,42 +423,53 @@ function createHostIpcBridge(config) {
|
|
|
50
423
|
return;
|
|
51
424
|
}
|
|
52
425
|
reject(/* @__PURE__ */ new Error(`Unexpected host response type ${h.type}, expected ${String(expectedType)}`));
|
|
53
|
-
}
|
|
426
|
+
},
|
|
427
|
+
reject
|
|
54
428
|
});
|
|
55
429
|
});
|
|
56
430
|
}
|
|
431
|
+
/** Classifies a pre-decoded downstream message: resolves the matching pending
|
|
432
|
+
* host call when it is a `HOST_CAPABILITIES_RESPONSE` or `HOST_INVOKE_RESPONSE`
|
|
433
|
+
* for a known `requestId`; otherwise returns false so the caller can route the
|
|
434
|
+
* message through the plugin router (host responses never reach plugins). */
|
|
435
|
+
function consumeDownstream(parsed) {
|
|
436
|
+
const h = parsed.header;
|
|
437
|
+
if (h.type !== "HOST_CAPABILITIES_RESPONSE" && h.type !== "HOST_INVOKE_RESPONSE") return false;
|
|
438
|
+
const id = h.requestId;
|
|
439
|
+
if (!id) return false;
|
|
440
|
+
const slot = pending.get(id);
|
|
441
|
+
if (!slot) return false;
|
|
442
|
+
clearTimeout(slot.timer);
|
|
443
|
+
pending.delete(id);
|
|
444
|
+
slot.resolve(h);
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
57
447
|
return {
|
|
448
|
+
tryConsumeDownstream: consumeDownstream,
|
|
58
449
|
tryConsumeDownstreamFromHost(raw) {
|
|
59
450
|
let msg;
|
|
60
451
|
try {
|
|
61
|
-
msg = protocol.decode(
|
|
62
|
-
} catch {
|
|
452
|
+
msg = protocol.decode(toBytes(raw));
|
|
453
|
+
} catch (err) {
|
|
454
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
455
|
+
console.debug(`[ipc] dropped undecodable host frame: ${message}`);
|
|
63
456
|
return false;
|
|
64
457
|
}
|
|
65
|
-
|
|
66
|
-
if (h.type !== "HOST_CAPABILITIES_RESPONSE" && h.type !== "HOST_INVOKE_RESPONSE") return false;
|
|
67
|
-
const id = h.requestId;
|
|
68
|
-
if (!id) return false;
|
|
69
|
-
const slot = pending.get(id);
|
|
70
|
-
if (!slot) return false;
|
|
71
|
-
clearTimeout(slot.timer);
|
|
72
|
-
pending.delete(id);
|
|
73
|
-
slot.resolve(h);
|
|
74
|
-
return true;
|
|
458
|
+
return consumeDownstream(msg);
|
|
75
459
|
},
|
|
76
460
|
async queryCapabilities(timeoutMs = 5e3) {
|
|
77
|
-
const requestId =
|
|
461
|
+
const requestId = createRequestId();
|
|
78
462
|
const promise = waitFor(requestId, "HOST_CAPABILITIES_RESPONSE", timeoutMs);
|
|
79
463
|
const header = {
|
|
80
464
|
type: "HOST_CAPABILITIES_QUERY",
|
|
81
465
|
requestId
|
|
82
466
|
};
|
|
83
|
-
|
|
467
|
+
ipc.write(toIpcChunk(protocol.encode(MessageType.ENVELOPE, header, null)));
|
|
84
468
|
return (await promise).capabilities ?? [];
|
|
85
469
|
},
|
|
86
|
-
async invokeOnHost(header, payload, timeoutMs =
|
|
87
|
-
|
|
88
|
-
|
|
470
|
+
async invokeOnHost(header, payload, timeoutMs = DEFAULT_HOST_INVOKE_TIMEOUT_MS) {
|
|
471
|
+
if (!header.requestId) return null;
|
|
472
|
+
const requestId = createRequestId();
|
|
89
473
|
const promise = waitFor(requestId, "HOST_INVOKE_RESPONSE", timeoutMs);
|
|
90
474
|
const hostHeader = {
|
|
91
475
|
type: "HOST_INVOKE_REQUEST",
|
|
@@ -94,21 +478,352 @@ function createHostIpcBridge(config) {
|
|
|
94
478
|
event: header.event,
|
|
95
479
|
args: header.args
|
|
96
480
|
};
|
|
97
|
-
|
|
481
|
+
ipc.write(toIpcChunk(protocol.encode(MessageType.ENVELOPE, hostHeader, payload)));
|
|
98
482
|
const hostResp = await promise;
|
|
99
483
|
return {
|
|
100
484
|
type: "INVOKE_RESPONSE",
|
|
101
485
|
pluginId: hostResp.pluginId,
|
|
102
486
|
event: hostResp.event,
|
|
103
|
-
requestId:
|
|
487
|
+
requestId: header.requestId,
|
|
104
488
|
result: hostResp.result,
|
|
105
489
|
error: hostResp.error
|
|
106
490
|
};
|
|
107
491
|
}
|
|
108
492
|
};
|
|
109
493
|
}
|
|
110
|
-
|
|
111
|
-
|
|
494
|
+
// core/logs/capture.ts
|
|
495
|
+
const LEVEL_RANK = {
|
|
496
|
+
debug: 0,
|
|
497
|
+
info: 1,
|
|
498
|
+
warn: 2,
|
|
499
|
+
error: 3
|
|
500
|
+
};
|
|
501
|
+
function safeStringify(value) {
|
|
502
|
+
if (typeof value === "string") return value;
|
|
503
|
+
if (value instanceof Error) return value.stack ?? value.message;
|
|
504
|
+
try {
|
|
505
|
+
return JSON.stringify(value);
|
|
506
|
+
} catch {
|
|
507
|
+
return String(value);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
function formatArgs(args) {
|
|
511
|
+
if (args.length === 0) return "";
|
|
512
|
+
return args.map((a) => safeStringify(a)).join(" ");
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Installs console interception at startup: binds the original console methods,
|
|
516
|
+
* forwards to them (so dev stdout and e2e console assertions stay intact), and
|
|
517
|
+
* feeds a {@link LogStore}. Every step is try/catch-wrapped so a failure in the
|
|
518
|
+
* seam can never break boot or swallow the consumer's own log call.
|
|
519
|
+
*/
|
|
520
|
+
function installConsoleCapture(store, source = "backend", options = {}) {
|
|
521
|
+
const minRank = LEVEL_RANK[options.minLevel ?? "debug"];
|
|
522
|
+
const original = {
|
|
523
|
+
debug: console.debug,
|
|
524
|
+
log: console.log,
|
|
525
|
+
info: console.info,
|
|
526
|
+
warn: console.warn,
|
|
527
|
+
error: console.error
|
|
528
|
+
};
|
|
529
|
+
const wrap = (method, level) => {
|
|
530
|
+
const recorded = LEVEL_RANK[level] >= minRank;
|
|
531
|
+
return (...args) => {
|
|
532
|
+
if (recorded) try {
|
|
533
|
+
store.append({
|
|
534
|
+
ts: Date.now(),
|
|
535
|
+
level,
|
|
536
|
+
source,
|
|
537
|
+
message: formatArgs(args)
|
|
538
|
+
});
|
|
539
|
+
} catch {}
|
|
540
|
+
try {
|
|
541
|
+
original[method].apply(console, args);
|
|
542
|
+
} catch {}
|
|
543
|
+
};
|
|
544
|
+
};
|
|
545
|
+
console.debug = wrap("debug", "debug");
|
|
546
|
+
console.log = wrap("log", "info");
|
|
547
|
+
console.info = wrap("info", "info");
|
|
548
|
+
console.warn = wrap("warn", "warn");
|
|
549
|
+
console.error = wrap("error", "error");
|
|
550
|
+
return {
|
|
551
|
+
source,
|
|
552
|
+
store,
|
|
553
|
+
restore() {
|
|
554
|
+
console.debug = original.debug;
|
|
555
|
+
console.log = original.log;
|
|
556
|
+
console.info = original.info;
|
|
557
|
+
console.warn = original.warn;
|
|
558
|
+
console.error = original.error;
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
}
|
|
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 };
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
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
|
+
]);
|
|
675
|
+
},
|
|
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 };
|
|
687
|
+
},
|
|
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 : ""}`;
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
}
|
|
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;
|
|
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
|
+
});
|
|
807
|
+
}
|
|
808
|
+
return {
|
|
809
|
+
accept,
|
|
810
|
+
onConnection(handler) {
|
|
811
|
+
handlers.push(handler);
|
|
812
|
+
},
|
|
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
|
+
}
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
// core/server/static-file-server.ts
|
|
112
827
|
const MIME_BY_EXTENSION = {
|
|
113
828
|
html: "text/html",
|
|
114
829
|
js: "text/javascript",
|
|
@@ -133,36 +848,24 @@ function mimeTypeFor(filePath) {
|
|
|
133
848
|
const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
|
|
134
849
|
return MIME_BY_EXTENSION[ext] ?? "application/octet-stream";
|
|
135
850
|
}
|
|
136
|
-
/**
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return
|
|
851
|
+
/** Files under `assets/` whose name ends in an 8-character content hash before
|
|
852
|
+
* the extension (the Vite/Rolldown default, e.g. `assets/main-DkY3x9qZ.js`)
|
|
853
|
+
* are treated as content-addressed. Anything else — including `index.html` —
|
|
854
|
+
* must be revalidated. */
|
|
855
|
+
const HASHED_ASSET = /^assets\/[^/]+-[A-Za-z0-9_-]{8}\.[A-Za-z0-9]+$/;
|
|
856
|
+
function cachePolicyFor(rel, mountIsPublic) {
|
|
857
|
+
if (!mountIsPublic) return "no-store";
|
|
858
|
+
return HASHED_ASSET.test(rel) ? "immutable" : "revalidate";
|
|
144
859
|
}
|
|
145
|
-
/**
|
|
146
|
-
*
|
|
147
|
-
function
|
|
148
|
-
|
|
149
|
-
return typeof value === "string" ? value : null;
|
|
860
|
+
/** Weak validator from size and mtime. Weak because the same bytes can be
|
|
861
|
+
* re-written with a new mtime, which only costs one extra full response. */
|
|
862
|
+
function etagFor(stat) {
|
|
863
|
+
return `W/"${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`;
|
|
150
864
|
}
|
|
151
|
-
/**
|
|
152
|
-
function
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
for (const part of cookie.split(";")) {
|
|
156
|
-
const eq = part.indexOf("=");
|
|
157
|
-
if (eq === -1) continue;
|
|
158
|
-
if (part.slice(0, eq).trim() === "bare_session") return part.slice(eq + 1).trim();
|
|
159
|
-
}
|
|
160
|
-
return null;
|
|
161
|
-
}
|
|
162
|
-
/** Derives the cookie value from the session token so the token itself is not
|
|
163
|
-
* echoed into a request header a local attacker could read back. */
|
|
164
|
-
function sessionNonce(token) {
|
|
165
|
-
return crypto.createHash("blake2b-256").update(token).digest("hex");
|
|
865
|
+
/** Whether an `If-None-Match` header (a list of validators, or `*`) matches. */
|
|
866
|
+
function etagMatches(header, etag) {
|
|
867
|
+
if (typeof header !== "string") return false;
|
|
868
|
+
return header.split(",").some((candidate) => candidate.trim() === etag || candidate.trim() === "*");
|
|
166
869
|
}
|
|
167
870
|
/** Parses a `Range: bytes=<start>-<end>` header. Returns `null` when absent or
|
|
168
871
|
* unsatisfiable (caller falls back to a 200 full response). */
|
|
@@ -190,59 +893,124 @@ function parseRange(value, size) {
|
|
|
190
893
|
* Buffers, bare-http1 emits Uint8Arrays, and a setEncoding'd stream emits
|
|
191
894
|
* strings — `String(uint8array)` would join bytes with commas, so decode
|
|
192
895
|
* explicitly (the `/login` token body must match byte-for-byte). */
|
|
193
|
-
function collectRequestBody(req) {
|
|
194
|
-
return new Promise((resolve) => {
|
|
896
|
+
function collectRequestBody(req, maxBytes = Infinity) {
|
|
897
|
+
return new Promise((resolve, reject) => {
|
|
195
898
|
const decoder = new TextDecoder();
|
|
196
899
|
let body = "";
|
|
900
|
+
let byteCount = 0;
|
|
901
|
+
let exceeded = false;
|
|
197
902
|
req.on("data", (chunk) => {
|
|
198
|
-
|
|
903
|
+
if (exceeded) return;
|
|
904
|
+
const str = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
905
|
+
body += str;
|
|
906
|
+
byteCount += typeof chunk === "string" ? new TextEncoder().encode(chunk).byteLength : chunk.byteLength;
|
|
907
|
+
if (byteCount > maxBytes) {
|
|
908
|
+
exceeded = true;
|
|
909
|
+
reject(/* @__PURE__ */ new Error("Request body too large"));
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
req.on("end", () => {
|
|
913
|
+
if (exceeded) return;
|
|
914
|
+
const final = body + decoder.decode(/* @__PURE__ */ new Uint8Array(0));
|
|
915
|
+
if (byteCount > maxBytes) {
|
|
916
|
+
reject(/* @__PURE__ */ new Error("Request body too large"));
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
resolve(final);
|
|
199
920
|
});
|
|
200
|
-
req.on("end", () => resolve(body + decoder.decode(/* @__PURE__ */ new Uint8Array(0))));
|
|
201
921
|
});
|
|
202
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
|
+
}
|
|
203
961
|
/**
|
|
204
962
|
* The single loopback HTTP+WS server for the worklet. One origin serves the
|
|
205
963
|
* web app (`/`), the media files mounted by plugins, and the framed-protocol
|
|
206
964
|
* WebSocket socket the page connects to. On device it binds `127.0.0.1` on an
|
|
207
|
-
* ephemeral port.
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
* `X-Bare-Token` header, or a `?token=` query param — defense in depth for
|
|
212
|
-
* non-browser clients. All responses send `Referrer-Policy: no-referrer` so
|
|
213
|
-
* token-bearing URLs never leak through a Referer header.
|
|
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`.
|
|
214
969
|
*/
|
|
215
970
|
function createLoopbackServer(options = {}) {
|
|
216
971
|
const host = options.host ?? "127.0.0.1";
|
|
217
972
|
const port = options.port ?? 0;
|
|
218
|
-
const
|
|
219
|
-
|
|
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
|
+
});
|
|
220
986
|
const fileMounts = /* @__PURE__ */ new Map();
|
|
221
987
|
const dirMounts = /* @__PURE__ */ new Map();
|
|
988
|
+
const publicDirMounts = /* @__PURE__ */ new Set();
|
|
222
989
|
const routeHandlers = /* @__PURE__ */ new Map();
|
|
223
|
-
const connectionHandlers = [];
|
|
224
|
-
let token = options.token ?? null;
|
|
225
|
-
let boundOrigin = "";
|
|
226
990
|
let originPromise = null;
|
|
227
|
-
let activeSocket = null;
|
|
228
|
-
function isAuthorized(headers, query) {
|
|
229
|
-
if (!authEnabled) return true;
|
|
230
|
-
if (token === null) return false;
|
|
231
|
-
if (tokenFromQuery(query) === token) return true;
|
|
232
|
-
if (tokenFromHeaders(headers) === token) return true;
|
|
233
|
-
return cookieSession(headers) === sessionNonce(token);
|
|
234
|
-
}
|
|
235
991
|
function writeError(res, status, message) {
|
|
992
|
+
if (res.headersSent) {
|
|
993
|
+
res.destroy();
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
236
996
|
res.writeHead(status, {
|
|
237
997
|
"Content-Type": "text/plain",
|
|
238
998
|
"Referrer-Policy": "no-referrer"
|
|
239
999
|
});
|
|
240
1000
|
res.end(message);
|
|
241
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
|
+
}
|
|
242
1009
|
function handleLogin(req, res) {
|
|
243
|
-
collectRequestBody(req).then((body) => {
|
|
244
|
-
|
|
245
|
-
|
|
1010
|
+
collectRequestBody(req, 4096).then((body) => {
|
|
1011
|
+
const verdict = gate.login(body, req.headers);
|
|
1012
|
+
if (!verdict.ok) {
|
|
1013
|
+
writeError(res, verdict.status, "Unauthorized");
|
|
246
1014
|
return;
|
|
247
1015
|
}
|
|
248
1016
|
const headers = {
|
|
@@ -250,66 +1018,110 @@ function createLoopbackServer(options = {}) {
|
|
|
250
1018
|
"Cache-Control": "no-store",
|
|
251
1019
|
"Referrer-Policy": "no-referrer"
|
|
252
1020
|
};
|
|
253
|
-
if (
|
|
1021
|
+
if (verdict.cookie !== null) headers["Set-Cookie"] = verdict.cookie;
|
|
254
1022
|
res.writeHead(200, headers);
|
|
255
1023
|
res.end("ok");
|
|
256
|
-
});
|
|
1024
|
+
}, () => writeError(res, 413, "Request Entity Too Large")).catch((err) => writeInternalError(res, "POST /login", err));
|
|
257
1025
|
}
|
|
258
|
-
/** Resolves a request path to a file mount, or a directory mount +
|
|
259
|
-
*
|
|
260
|
-
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) {
|
|
261
1029
|
const fileMount = fileMounts.get(cleanPath);
|
|
262
1030
|
if (fileMount) return {
|
|
263
1031
|
kind: "file",
|
|
264
1032
|
filePath: fileMount
|
|
265
1033
|
};
|
|
266
1034
|
let best = null;
|
|
267
|
-
let
|
|
1035
|
+
let bestDepth = -1;
|
|
268
1036
|
for (const [prefix, dirPath] of dirMounts) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
bestPrefix = prefix.length;
|
|
280
|
-
}
|
|
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;
|
|
281
1047
|
}
|
|
282
1048
|
return best ? {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
rel: best.rel
|
|
1049
|
+
...best,
|
|
1050
|
+
kind: "dir"
|
|
286
1051
|
} : null;
|
|
287
1052
|
}
|
|
288
|
-
/** Resolves a directory-mount request to a concrete file
|
|
289
|
-
*
|
|
290
|
-
*
|
|
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
|
|
1057
|
+
* unauthenticated clients never receive bootstrap HTML. Subresource requests
|
|
1058
|
+
* (`Accept` without `text/html`) never fall back — a missing asset is a
|
|
1059
|
+
* 404. */
|
|
291
1060
|
function resolveDirFile(mount, headers) {
|
|
292
|
-
|
|
1061
|
+
let root;
|
|
293
1062
|
try {
|
|
294
|
-
|
|
295
|
-
} catch {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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")));
|
|
1080
|
+
}
|
|
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
|
+
}
|
|
302
1095
|
try {
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
1096
|
+
const stat = fs.statSync(real);
|
|
1097
|
+
return stat.isFile() ? {
|
|
1098
|
+
filePath: real,
|
|
1099
|
+
stat
|
|
1100
|
+
} : null;
|
|
1101
|
+
} catch {
|
|
1102
|
+
return null;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
/** Strict Content-Security-Policy for HTML documents served by the loopback
|
|
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'`;
|
|
307
1112
|
}
|
|
308
|
-
|
|
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) {
|
|
309
1119
|
let stat;
|
|
310
|
-
|
|
1120
|
+
if (knownStat) stat = knownStat;
|
|
1121
|
+
else try {
|
|
311
1122
|
stat = fs.statSync(filePath);
|
|
312
1123
|
} catch {
|
|
1124
|
+
console.debug(`[static-server] serveFile: stat failed for ${filePath} — 404`);
|
|
313
1125
|
writeError(res, 404, "Not found");
|
|
314
1126
|
return;
|
|
315
1127
|
}
|
|
@@ -317,14 +1129,19 @@ function createLoopbackServer(options = {}) {
|
|
|
317
1129
|
writeError(res, 404, "Not found");
|
|
318
1130
|
return;
|
|
319
1131
|
}
|
|
1132
|
+
const contentType = mimeTypeFor(filePath);
|
|
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
|
+
};
|
|
320
1137
|
const range = parseRange(req.headers["range"], stat.size);
|
|
321
1138
|
if (range) {
|
|
322
1139
|
res.writeHead(206, {
|
|
323
|
-
"Content-Type":
|
|
1140
|
+
"Content-Type": contentType,
|
|
324
1141
|
"Content-Range": `bytes ${range.start}-${range.end}/${stat.size}`,
|
|
325
1142
|
"Content-Length": range.end - range.start + 1,
|
|
326
1143
|
"Accept-Ranges": "bytes",
|
|
327
|
-
|
|
1144
|
+
...cspHeader,
|
|
328
1145
|
"Referrer-Policy": "no-referrer",
|
|
329
1146
|
"Cache-Control": "no-store"
|
|
330
1147
|
});
|
|
@@ -332,13 +1149,25 @@ function createLoopbackServer(options = {}) {
|
|
|
332
1149
|
pipeFile(res, filePath, range);
|
|
333
1150
|
return;
|
|
334
1151
|
}
|
|
1152
|
+
const cacheHeaders = cache === "no-store" ? { "Cache-Control": "no-store" } : {
|
|
1153
|
+
"Cache-Control": cache === "immutable" ? "max-age=31536000, immutable" : "no-cache",
|
|
1154
|
+
ETag: etagFor(stat)
|
|
1155
|
+
};
|
|
1156
|
+
if (cache !== "no-store" && etagMatches(req.headers["if-none-match"], cacheHeaders.ETag)) {
|
|
1157
|
+
res.writeHead(304, {
|
|
1158
|
+
...cacheHeaders,
|
|
1159
|
+
"Referrer-Policy": "no-referrer"
|
|
1160
|
+
});
|
|
1161
|
+
res.end();
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
335
1164
|
res.writeHead(200, {
|
|
336
|
-
"Content-Type":
|
|
1165
|
+
"Content-Type": contentType,
|
|
337
1166
|
"Content-Length": stat.size,
|
|
338
1167
|
"Accept-Ranges": "bytes",
|
|
339
|
-
|
|
1168
|
+
...cspHeader,
|
|
340
1169
|
"Referrer-Policy": "no-referrer",
|
|
341
|
-
|
|
1170
|
+
...cacheHeaders
|
|
342
1171
|
});
|
|
343
1172
|
res.flushHeaders();
|
|
344
1173
|
pipeFile(res, filePath);
|
|
@@ -363,98 +1192,98 @@ function createLoopbackServer(options = {}) {
|
|
|
363
1192
|
});
|
|
364
1193
|
stream.pipe(res);
|
|
365
1194
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}
|
|
379
|
-
const route = routeHandlers.get(`${req.method} ${cleanPath}`);
|
|
380
|
-
if (route) {
|
|
381
|
-
if (!isAuthorized(headers, query)) {
|
|
382
|
-
writeError(res, 401, "Unauthorized");
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
try {
|
|
386
|
-
route(req, res);
|
|
387
|
-
} catch (err) {
|
|
388
|
-
writeError(res, 500, err instanceof Error ? err.message : String(err));
|
|
389
|
-
}
|
|
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");
|
|
390
1207
|
return;
|
|
391
1208
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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");
|
|
395
1217
|
return;
|
|
396
1218
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
1219
|
+
try {
|
|
1220
|
+
route(req, res);
|
|
1221
|
+
} catch (err) {
|
|
1222
|
+
writeInternalError(res, `route ${req.method} ${cleanPath}`, err);
|
|
401
1223
|
}
|
|
402
|
-
|
|
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);
|
|
403
1250
|
} catch (err) {
|
|
404
|
-
|
|
405
|
-
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
406
|
-
res.end(message);
|
|
1251
|
+
writeInternalError(res, `${req.method} request`, err);
|
|
407
1252
|
}
|
|
408
1253
|
});
|
|
409
1254
|
function origin() {
|
|
410
1255
|
if (!originPromise) originPromise = new Promise((resolve, reject) => {
|
|
411
|
-
token ??= crypto.randomBytes(32).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
412
1256
|
server.on("error", reject);
|
|
413
1257
|
server.listen(port, host, () => {
|
|
414
1258
|
const address = server.address();
|
|
415
|
-
|
|
416
|
-
|
|
1259
|
+
const bound = `http://${host}:${address?.port}`;
|
|
1260
|
+
gate.bind(bound);
|
|
1261
|
+
resolve(bound);
|
|
417
1262
|
});
|
|
418
1263
|
});
|
|
419
1264
|
return originPromise;
|
|
420
1265
|
}
|
|
421
1266
|
server.on("upgrade", (req, socket, head) => {
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
socket.destroy();
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
if (!isAuthorized(headers, query)) {
|
|
430
|
-
socket.destroy();
|
|
431
|
-
return;
|
|
432
|
-
}
|
|
433
|
-
if (activeSocket) {
|
|
434
|
-
socket.destroy();
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
437
|
-
ws.Server.handshake(req, socket, head, (err) => {
|
|
438
|
-
if (err) {
|
|
439
|
-
socket.destroy(err);
|
|
1267
|
+
const reject = (reason, status) => {
|
|
1268
|
+
console.error(`[loopback] upgrade rejected: ${reason}`);
|
|
1269
|
+
if (status === void 0) {
|
|
1270
|
+
socket.destroy();
|
|
440
1271
|
return;
|
|
441
1272
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
});
|
|
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
|
+
`);
|
|
1280
|
+
};
|
|
1281
|
+
const verdict = gate.admitUpgrade(req.headers);
|
|
1282
|
+
if (!verdict.ok) {
|
|
1283
|
+
reject(verdict.reason, verdict.status);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
slot.accept(req, socket, head, reject);
|
|
458
1287
|
});
|
|
459
1288
|
async function url(path) {
|
|
460
1289
|
return `${await origin()}${path}`;
|
|
@@ -462,56 +1291,187 @@ function createLoopbackServer(options = {}) {
|
|
|
462
1291
|
return {
|
|
463
1292
|
origin,
|
|
464
1293
|
url,
|
|
465
|
-
token()
|
|
466
|
-
return token ?? "";
|
|
467
|
-
},
|
|
1294
|
+
token: () => gate.token(),
|
|
468
1295
|
async credentials() {
|
|
469
1296
|
const originUrl = await origin();
|
|
470
1297
|
return {
|
|
471
1298
|
origin: originUrl,
|
|
472
1299
|
port: Number(originUrl.slice(originUrl.lastIndexOf(":") + 1)),
|
|
473
|
-
token
|
|
1300
|
+
token: gate.token(),
|
|
1301
|
+
bootstrap: gate.bootstrap()
|
|
474
1302
|
};
|
|
475
1303
|
},
|
|
476
1304
|
mount(path, filePath) {
|
|
477
|
-
fileMounts.set(path, filePath);
|
|
1305
|
+
fileMounts.set(mountKey(path), filePath);
|
|
478
1306
|
},
|
|
479
1307
|
unmount(path) {
|
|
480
|
-
fileMounts.delete(path);
|
|
1308
|
+
fileMounts.delete(mountKey(path));
|
|
481
1309
|
},
|
|
482
|
-
mountDir(prefix, dirPath) {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
1310
|
+
mountDir(prefix, dirPath, opts) {
|
|
1311
|
+
const key = mountKey(prefix);
|
|
1312
|
+
dirMounts.set(key, dirPath);
|
|
1313
|
+
if (opts?.public) publicDirMounts.add(key);
|
|
1314
|
+
else publicDirMounts.delete(key);
|
|
487
1315
|
},
|
|
1316
|
+
onConnection: (handler) => slot.onConnection(handler),
|
|
488
1317
|
registerRoute(method, path, handler) {
|
|
489
1318
|
routeHandlers.set(`${method.toUpperCase()} ${path}`, handler);
|
|
490
1319
|
},
|
|
491
|
-
push(frame)
|
|
492
|
-
if (!activeSocket) return false;
|
|
493
|
-
try {
|
|
494
|
-
activeSocket.write(frame);
|
|
495
|
-
return true;
|
|
496
|
-
} catch {
|
|
497
|
-
return false;
|
|
498
|
-
}
|
|
499
|
-
},
|
|
1320
|
+
push: (frame) => slot.push(frame),
|
|
500
1321
|
close(cb) {
|
|
501
|
-
|
|
502
|
-
activeSocket.destroy();
|
|
503
|
-
} catch {}
|
|
1322
|
+
slot.close();
|
|
504
1323
|
server.close(cb);
|
|
505
1324
|
}
|
|
506
1325
|
};
|
|
507
1326
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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. */
|
|
1335
|
+
function queryParams(url) {
|
|
1336
|
+
const out = {};
|
|
1337
|
+
const query = (url ?? "").split("?")[1];
|
|
1338
|
+
if (!query) return out;
|
|
1339
|
+
for (const pair of query.split("&")) {
|
|
1340
|
+
const eq = pair.indexOf("=");
|
|
1341
|
+
if (eq === -1) continue;
|
|
1342
|
+
try {
|
|
1343
|
+
out[decodeURIComponent(pair.slice(0, eq))] = decodeURIComponent(pair.slice(eq + 1));
|
|
1344
|
+
} catch {
|
|
1345
|
+
return null;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
return out;
|
|
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");
|
|
514
1364
|
}
|
|
1365
|
+
function formatText(e) {
|
|
1366
|
+
const tag = e.tag ? `[${oneLine(e.tag)}] ` : "";
|
|
1367
|
+
return `[${new Date(e.ts).toISOString()}] ${e.level.toUpperCase()} ${e.source} ${tag}${oneLine(e.message)}`;
|
|
1368
|
+
}
|
|
1369
|
+
/** Registers the loopback log routes on the unified server. They inherit the
|
|
1370
|
+
* server's auth gate and single-server rule for free:
|
|
1371
|
+
*
|
|
1372
|
+
* - `GET /logs?tail=&level=&source=&format=jsonl|text` — bulk read-back,
|
|
1373
|
+
* deliberately off the framed invoke so there is no `MAX_HEADER_BYTES`
|
|
1374
|
+
* ceiling.
|
|
1375
|
+
* - `POST /logs` — ingest for web/browser log batches (`{ source, entries }`).
|
|
1376
|
+
*/
|
|
1377
|
+
function registerLogRoutes(server, store) {
|
|
1378
|
+
server.registerRoute("GET", "/logs", (req, res) => {
|
|
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
|
+
}
|
|
1388
|
+
const tail = params.tail === void 0 ? void 0 : Number(params.tail);
|
|
1389
|
+
const level = isLogLevel(params.level) ? params.level : void 0;
|
|
1390
|
+
const source = params.source === "web" || params.source === "backend" ? params.source : void 0;
|
|
1391
|
+
const entries = store.view({
|
|
1392
|
+
tail: tail !== void 0 && Number.isFinite(tail) ? tail : void 0,
|
|
1393
|
+
level,
|
|
1394
|
+
source
|
|
1395
|
+
});
|
|
1396
|
+
const format = params.format === "jsonl" ? "jsonl" : "text";
|
|
1397
|
+
res.writeHead(200, {
|
|
1398
|
+
"Content-Type": format === "jsonl" ? "application/x-ndjson" : "text/plain",
|
|
1399
|
+
"Referrer-Policy": "no-referrer",
|
|
1400
|
+
"Cache-Control": "no-store"
|
|
1401
|
+
});
|
|
1402
|
+
if (format === "jsonl") {
|
|
1403
|
+
const lines = entries.map((e) => JSON.stringify(e)).join("\n");
|
|
1404
|
+
res.end(lines.length > 0 ? `${lines}\n` : "");
|
|
1405
|
+
} else {
|
|
1406
|
+
const lines = entries.map(formatText).join("\n");
|
|
1407
|
+
res.end(lines.length > 0 ? `${lines}\n` : "");
|
|
1408
|
+
}
|
|
1409
|
+
});
|
|
1410
|
+
server.registerRoute("POST", "/logs", (req, res) => {
|
|
1411
|
+
collectRequestBody(req, 1048576).then((body) => {
|
|
1412
|
+
let parsed;
|
|
1413
|
+
try {
|
|
1414
|
+
parsed = JSON.parse(body);
|
|
1415
|
+
} catch {
|
|
1416
|
+
parsed = null;
|
|
1417
|
+
}
|
|
1418
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
1419
|
+
res.writeHead(400, {
|
|
1420
|
+
"Content-Type": "text/plain",
|
|
1421
|
+
"Referrer-Policy": "no-referrer"
|
|
1422
|
+
});
|
|
1423
|
+
res.end("Bad request");
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
const source = "web";
|
|
1427
|
+
const entries = parsed.entries;
|
|
1428
|
+
const batch = Array.isArray(entries) ? entries : [];
|
|
1429
|
+
let accepted = 0;
|
|
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;
|
|
1434
|
+
store.append({
|
|
1435
|
+
ts: Date.now(),
|
|
1436
|
+
level: isLogLevel(raw.level) ? raw.level : "info",
|
|
1437
|
+
source,
|
|
1438
|
+
tag: typeof raw.tag === "string" ? truncate(raw.tag, 64) : void 0,
|
|
1439
|
+
message: truncate(raw.message, MAX_INGEST_MESSAGE_CHARS)
|
|
1440
|
+
});
|
|
1441
|
+
accepted++;
|
|
1442
|
+
}
|
|
1443
|
+
res.writeHead(200, {
|
|
1444
|
+
"Content-Type": "application/json",
|
|
1445
|
+
"Referrer-Policy": "no-referrer",
|
|
1446
|
+
"Cache-Control": "no-store"
|
|
1447
|
+
});
|
|
1448
|
+
res.end(JSON.stringify({ accepted }));
|
|
1449
|
+
}, (err) => {
|
|
1450
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1451
|
+
console.error("Discarding oversized /logs batch:", message);
|
|
1452
|
+
res.writeHead(413, {
|
|
1453
|
+
"Content-Type": "text/plain",
|
|
1454
|
+
"Referrer-Policy": "no-referrer"
|
|
1455
|
+
});
|
|
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");
|
|
1469
|
+
});
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
// core/server/websocket-server.ts
|
|
1473
|
+
/** Routed calls pending on one socket before it stops being read. */
|
|
1474
|
+
const MAX_PENDING_ROUTES = 32;
|
|
515
1475
|
/** Builds the push seam for the server's connected protocol socket: encodes a
|
|
516
1476
|
* `DISPATCH`-style envelope and writes it, mirroring how `attachWebSocketProtocol`
|
|
517
1477
|
* writes responses. Returns false when no socket is connected. */
|
|
@@ -524,40 +1484,49 @@ function createLoopbackPush(server, protocol) {
|
|
|
524
1484
|
/**
|
|
525
1485
|
* Attaches the framed-protocol socket to the unified loopback server. The
|
|
526
1486
|
* server owns the WebSocket upgrade (origin check, cookie/token auth, single
|
|
527
|
-
* client, idle timeout); this layer
|
|
528
|
-
*
|
|
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.
|
|
529
1490
|
*/
|
|
530
1491
|
function attachWebSocketProtocol(server, context) {
|
|
531
1492
|
const { protocol, pluginRouter } = context;
|
|
532
|
-
server.onConnection((socket
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
|
|
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?.()
|
|
544
1507
|
}
|
|
545
1508
|
});
|
|
1509
|
+
socket.on("data", (chunk) => channel.push(chunk));
|
|
1510
|
+
socket.on("close", () => channel.close());
|
|
1511
|
+
socket.on("error", (err) => {
|
|
1512
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1513
|
+
console.error("[ws] connection error:", message);
|
|
1514
|
+
channel.close();
|
|
1515
|
+
socket.destroy();
|
|
1516
|
+
});
|
|
546
1517
|
});
|
|
547
1518
|
}
|
|
548
|
-
|
|
549
|
-
//#region core/runtime.ts
|
|
1519
|
+
// core/runtime.ts
|
|
550
1520
|
function readBareArgv() {
|
|
551
1521
|
return typeof Bare !== "undefined" && Array.isArray(Bare.argv) ? Bare.argv : [];
|
|
552
1522
|
}
|
|
553
1523
|
/** Resolves the host-provided worklet configuration from `start(...)`
|
|
554
1524
|
* arguments. On-device hosts pass `["<webAssets>", "<storage>", "<cache>"]`
|
|
555
|
-
* (three existing directories, in that order), landing at `Bare.argv[0..2]
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
* 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. */
|
|
561
1530
|
function resolveWorkletConfig() {
|
|
562
1531
|
const argv = readBareArgv();
|
|
563
1532
|
const webAssets = argv[0];
|
|
@@ -574,23 +1543,28 @@ function resolveWorkletConfig() {
|
|
|
574
1543
|
cache: cacheDir
|
|
575
1544
|
};
|
|
576
1545
|
}
|
|
577
|
-
} catch {
|
|
1546
|
+
} catch (err) {
|
|
1547
|
+
console.debug(`[runtime] config dir check failed; not a device configuration: ` + (err instanceof Error ? err.message : String(err)));
|
|
1548
|
+
}
|
|
578
1549
|
return resolveCliConfig(argv);
|
|
579
1550
|
}
|
|
580
1551
|
/**
|
|
581
|
-
* Parses
|
|
582
|
-
* `webassets=<dir>`, `storage=<dir>`, `cache=<dir>`,
|
|
583
|
-
* `port=<n>`, `auth=<on|off|true|false|1|0
|
|
584
|
-
*
|
|
585
|
-
*
|
|
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.
|
|
586
1558
|
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
* 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.
|
|
591
1563
|
*/
|
|
592
1564
|
function resolveCliConfig(argv = readBareArgv()) {
|
|
593
1565
|
const parsed = {};
|
|
1566
|
+
const allowedOrigins = [];
|
|
1567
|
+
let dev = false;
|
|
594
1568
|
let matched = false;
|
|
595
1569
|
for (const token of argv) {
|
|
596
1570
|
const m = /^([A-Za-z][A-Za-z0-9]*)=(.*)$/.exec(token);
|
|
@@ -612,29 +1586,33 @@ function resolveCliConfig(argv = readBareArgv()) {
|
|
|
612
1586
|
break;
|
|
613
1587
|
case "port": {
|
|
614
1588
|
const n = Number(value);
|
|
615
|
-
if (Number.isInteger(n) && n >= 0 && n <= 65535) parsed.port = n;
|
|
1589
|
+
if (value !== "" && Number.isInteger(n) && n >= 0 && n <= 65535) parsed.port = n;
|
|
616
1590
|
else console.warn(`[bare] ignoring invalid port= token: "${value}"`);
|
|
617
1591
|
break;
|
|
618
1592
|
}
|
|
619
|
-
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);
|
|
620
1601
|
}
|
|
621
1602
|
}
|
|
622
1603
|
if (!matched) return {};
|
|
623
|
-
|
|
1604
|
+
if (allowedOrigins.length > 0) parsed.allowedOrigins = allowedOrigins;
|
|
1605
|
+
if (dev) return {
|
|
624
1606
|
deviceMode: false,
|
|
625
1607
|
auth: false,
|
|
626
1608
|
port: 8080,
|
|
627
1609
|
...parsed
|
|
628
1610
|
};
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
return
|
|
634
|
-
}
|
|
635
|
-
function writeIpc(ipc, bytes) {
|
|
636
|
-
if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") ipc.write(Buffer.from(bytes));
|
|
637
|
-
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;
|
|
638
1616
|
}
|
|
639
1617
|
/**
|
|
640
1618
|
* Creates one worklet runtime — a per-instance, stateless factory (no
|
|
@@ -643,67 +1621,80 @@ function writeIpc(ipc, bytes) {
|
|
|
643
1621
|
* wire p2p". Mirrors the reference wiring in `core/main.core.ts`.
|
|
644
1622
|
*/
|
|
645
1623
|
function createWorkletRuntime(options = {}) {
|
|
646
|
-
const deviceMode = options.deviceMode ??
|
|
1624
|
+
const deviceMode = options.deviceMode ?? true;
|
|
647
1625
|
const auth = options.auth ?? deviceMode;
|
|
648
1626
|
const port = options.port ?? (deviceMode ? 0 : 8080);
|
|
649
|
-
const
|
|
650
|
-
|
|
651
|
-
decode: (bytes) => new TextDecoder().decode(bytes)
|
|
652
|
-
});
|
|
653
|
-
let ipc;
|
|
654
|
-
try {
|
|
655
|
-
ipc = getIPC();
|
|
656
|
-
} catch {
|
|
657
|
-
ipc = void 0;
|
|
658
|
-
}
|
|
659
|
-
const hostBridge = ipc ? createHostIpcBridge({
|
|
660
|
-
ipc,
|
|
661
|
-
protocol
|
|
662
|
-
}) : null;
|
|
663
|
-
const pluginRegistry = createPluginRegistry();
|
|
1627
|
+
const ipc = getIPC();
|
|
1628
|
+
if (!ipc) console.debug("[runtime] no host IPC channel available, running without a host");
|
|
664
1629
|
const server = createLoopbackServer({
|
|
665
1630
|
host: options.host,
|
|
666
1631
|
port,
|
|
667
1632
|
auth,
|
|
668
|
-
|
|
1633
|
+
allowedOrigins: options.allowedOrigins,
|
|
1634
|
+
wsIdleTimeoutMs: options.wsIdleTimeoutMs,
|
|
1635
|
+
wsDeadPeerTimeoutMs: options.wsDeadPeerTimeoutMs,
|
|
1636
|
+
wsReplaceMinIntervalMs: options.wsReplaceMinIntervalMs,
|
|
1637
|
+
wsMaxQueuedBytes: options.wsMaxQueuedBytes
|
|
669
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;
|
|
1650
|
+
const logStore = createLogRingBuffer(options.logsCapacity ?? 500);
|
|
1651
|
+
installConsoleCapture(logStore, "backend", { minLevel: options.logsMinLevel });
|
|
1652
|
+
registerLogRoutes(server, logStore);
|
|
670
1653
|
for (const plugin of createDefaultPlugins({
|
|
671
1654
|
listBareCapabilities: () => pluginRegistry.listCapabilities(),
|
|
672
1655
|
queryHostCapabilities: () => hostBridge?.queryCapabilities() ?? Promise.resolve([]),
|
|
673
1656
|
staticServer: server,
|
|
674
|
-
invokeOnHost: (header, payload) => hostBridge?.invokeOnHost(header, payload) ?? Promise.resolve(null)
|
|
1657
|
+
invokeOnHost: (header, payload) => hostBridge?.invokeOnHost(header, payload) ?? Promise.resolve(null),
|
|
1658
|
+
store: logStore
|
|
675
1659
|
})) pluginRegistry.register(plugin);
|
|
676
1660
|
for (const plugin of options.plugins ?? []) pluginRegistry.register(plugin);
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
}
|
|
690
|
-
});
|
|
1661
|
+
if (ipc) {
|
|
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
|
|
1670
|
+
});
|
|
1671
|
+
ipc.on("data", (data) => channel.push(data));
|
|
1672
|
+
}
|
|
691
1673
|
async function start() {
|
|
692
1674
|
if (options.storage) try {
|
|
693
1675
|
fs.rmSync(path.join(options.storage, "handoff.json"), { force: true });
|
|
694
|
-
} catch {
|
|
695
|
-
|
|
1676
|
+
} catch {
|
|
1677
|
+
console.debug("[runtime] no existing handoff file to remove");
|
|
1678
|
+
}
|
|
1679
|
+
if (options.webAssets) server.mountDir("/", options.webAssets, { public: true });
|
|
696
1680
|
attachWebSocketProtocol(server, {
|
|
697
1681
|
protocol,
|
|
698
1682
|
pluginRegistry,
|
|
699
1683
|
pluginRouter
|
|
700
1684
|
});
|
|
701
1685
|
const credentials = await server.credentials();
|
|
702
|
-
if (auth && options.storage)
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
+
}
|
|
707
1698
|
}
|
|
708
1699
|
return credentials;
|
|
709
1700
|
}
|
|
@@ -725,5 +1716,4 @@ function createWorkletRuntime(options = {}) {
|
|
|
725
1716
|
}
|
|
726
1717
|
};
|
|
727
1718
|
}
|
|
728
|
-
//#endregion
|
|
729
1719
|
export { attachWebSocketProtocol, collectRequestBody, createLoopbackPush, createLoopbackServer, createWorkletRuntime, getIPC, resolveCliConfig, resolveWorkletConfig };
|