@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/dist/runtime.mjs CHANGED
@@ -1,46 +1,419 @@
1
- import { MessageProtocol, MessageType, createPluginRegistry, createPluginRouter } from "./index.mjs";
2
- import { t as createDefaultPlugins } from "./plugins-1KweEBct.mjs";
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
- import crypto from "bare-crypto";
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
- //#endregion
21
- //#region core/messages/host-ipc.ts
22
- function toUint8Array$2(data) {
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 (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) return new Uint8Array(data);
25
- return new Uint8Array(data);
27
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
28
+ throw new TypeError(`expected binary data, got ${data === null ? "null" : typeof data}`);
29
+ }
30
+ /** The chunk form Bare's IPC stream writes: a `Buffer` view over the same
31
+ * memory where `Buffer` exists (on Bare `encode` already returns one), the
32
+ * bytes themselves elsewhere. */
33
+ function toIpcChunk(bytes) {
34
+ if (typeof Buffer === "undefined" || Buffer.isBuffer(bytes)) return bytes;
35
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
36
+ }
37
+ // core/messages/backpressure.ts
38
+ /** Honours `write() === false`: once the target signals pressure, frames queue
39
+ * (bounded) until it drains instead of piling up inside the stream. */
40
+ function createBoundedWriter(target, options) {
41
+ const hardMax = options.hardMaxQueuedBytes ?? options.maxQueuedBytes * 8;
42
+ let queue = [];
43
+ let head = 0;
44
+ let queued = 0;
45
+ let blocked = false;
46
+ let dead = false;
47
+ let dropped = 0;
48
+ function flush() {
49
+ while (!blocked && head < queue.length) {
50
+ const next = queue[head++];
51
+ queued -= next.byteLength;
52
+ if (!target.write(next)) blocked = true;
53
+ }
54
+ if (head === queue.length) {
55
+ queue = [];
56
+ head = 0;
57
+ } else if (head > 1024) {
58
+ queue = queue.slice(head);
59
+ head = 0;
60
+ }
61
+ if (!blocked && dropped > 0) {
62
+ console.warn(`[loopback] dropped ${dropped} push frame(s) while the client was not reading`);
63
+ dropped = 0;
64
+ }
65
+ }
66
+ target.on("drain", () => {
67
+ blocked = false;
68
+ flush();
69
+ });
70
+ return {
71
+ write(frame, writeOptions) {
72
+ if (dead) return false;
73
+ if (!blocked && head === queue.length) {
74
+ if (!target.write(frame)) blocked = true;
75
+ return true;
76
+ }
77
+ const size = frame.byteLength;
78
+ if (writeOptions?.droppable) {
79
+ if (queued + size > options.maxQueuedBytes) {
80
+ if (dropped === 0) console.warn("[loopback] client is not reading; dropping push frames (newest first)");
81
+ dropped++;
82
+ return false;
83
+ }
84
+ } else if (queued > 0 && queued + size > hardMax) {
85
+ dead = true;
86
+ queue = [];
87
+ head = 0;
88
+ queued = 0;
89
+ options.onOverflow();
90
+ return false;
91
+ }
92
+ queue.push(frame);
93
+ queued += size;
94
+ return true;
95
+ },
96
+ clear() {
97
+ queue = [];
98
+ head = 0;
99
+ queued = 0;
100
+ },
101
+ get queuedBytes() {
102
+ return queued;
103
+ }
104
+ };
105
+ }
106
+ /** Pauses an inbound stream while too much routed work is pending, so a fast
107
+ * sender cannot queue work faster than it completes. */
108
+ function createInboundGate(options) {
109
+ let pending = 0;
110
+ let paused = false;
111
+ function done() {
112
+ pending--;
113
+ if (paused && pending <= options.low) {
114
+ paused = false;
115
+ options.resume();
116
+ }
117
+ }
118
+ return {
119
+ /** Counts `work` as pending until it settles. */
120
+ track(work) {
121
+ pending++;
122
+ if (!paused && pending >= options.high) {
123
+ paused = true;
124
+ options.pause();
125
+ }
126
+ work.then(done, done);
127
+ },
128
+ get pending() {
129
+ return pending;
130
+ }
131
+ };
132
+ }
133
+ // core/messages/channel-health.ts
134
+ /**
135
+ * Per-channel decode/framing health tracker for the host↔worklet IPC pipe.
136
+ *
137
+ * The IPC pipe is a raw byte stream with no reconnect seam. A single corrupted
138
+ * byte permanently desyncs the frame boundaries, so every subsequent frame
139
+ * fails to decode. Unconditionally clearing the decoder after each failure
140
+ * keeps parsing garbage forever — the channel is silently dead for the rest of
141
+ * the session, and all host-delegated invokes time out until a restart.
142
+ *
143
+ * Instead we count consecutive decode/framing failures and treat a sustained run
144
+ * as a fatal, unrecoverable desync. The caller then stops parsing rather than
145
+ * spinning on garbage. A single isolated bad chunk is tolerated (the counter
146
+ * resets on any successful frame batch), so transient corruption does not kill a
147
+ * healthy channel.
148
+ */
149
+ var ChannelHealth = class {
150
+ maxConsecutiveFailures;
151
+ consecutiveFailures = 0;
152
+ fatal = false;
153
+ constructor(maxConsecutiveFailures = 5) {
154
+ this.maxConsecutiveFailures = maxConsecutiveFailures;
155
+ }
156
+ /** Record a successful frame batch; resets the consecutive-failure counter. */
157
+ noteSuccess() {
158
+ this.consecutiveFailures = 0;
159
+ }
160
+ /**
161
+ * Record a decode/framing failure. Returns `true` once the run of
162
+ * consecutive failures reaches {@link maxConsecutiveFailures}, signalling the
163
+ * caller that the channel is fatally desynced and parsing should stop.
164
+ */
165
+ noteFailure() {
166
+ this.consecutiveFailures++;
167
+ if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
168
+ this.fatal = true;
169
+ return true;
170
+ }
171
+ return false;
172
+ }
173
+ /** Whether a fatal, unrecoverable desync has been reached. */
174
+ get isFatal() {
175
+ return this.fatal;
176
+ }
177
+ /** Number of consecutive failures since the last successful frame batch. */
178
+ get failures() {
179
+ return this.consecutiveFailures;
180
+ }
181
+ };
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
- function newCorrelationId() {
28
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
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
- function writeEncoded(ipc, bytes) {
31
- if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") ipc.write(Buffer.from(bytes));
32
- else ipc.write(bytes);
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
- pending.set(requestId, {
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(toUint8Array$2(raw));
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
- const h = msg.header;
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 = newCorrelationId();
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
- writeEncoded(ipc, protocol.encode(MessageType.ENVELOPE, header, null));
467
+ ipc.write(toIpcChunk(protocol.encode(MessageType.ENVELOPE, header, null)));
84
468
  return (await promise).capabilities ?? [];
85
469
  },
86
- async invokeOnHost(header, payload, timeoutMs = 3e5) {
87
- const requestId = header.requestId;
88
- if (!requestId) return null;
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
- writeEncoded(ipc, protocol.encode(MessageType.ENVELOPE, hostHeader, payload));
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: hostResp.requestId,
487
+ requestId: header.requestId,
104
488
  result: hostResp.result,
105
489
  error: hostResp.error
106
490
  };
107
491
  }
108
492
  };
109
493
  }
110
- //#endregion
111
- //#region core/server/static-file-server.ts
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
- /** Extracts the `token` query param from a raw query string (`a=1&token=x`). */
137
- function tokenFromQuery(query) {
138
- for (const pair of query.split("&")) {
139
- const eq = pair.indexOf("=");
140
- if (eq === -1) continue;
141
- if (pair.slice(0, eq) === "token") return pair.slice(eq + 1);
142
- }
143
- return null;
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
- /** Reads the token from the `X-Bare-Token` request header (parser-lowercased).
146
- * `fetch`-based non-browser clients that cannot carry cookies use this. */
147
- function tokenFromHeaders(headers) {
148
- const value = headers["x-bare-token"];
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
- /** Reads the `bare_session` value from the `Cookie` request header. */
152
- function cookieSession(headers) {
153
- const cookie = headers["cookie"];
154
- if (typeof cookie !== "string") return null;
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
- body += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
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. The bundled web app (directory mounts) is public content — a
208
- * fresh WebView must load the page before the page can `POST /login`. Mounted
209
- * session files (media) and the WS upgrade are gated by a per-session token,
210
- * accepted as a `bare_session` cookie (set via `POST /login`), the
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 authEnabled = options.auth ?? true;
219
- const wsIdleTimeoutMs = options.wsIdleTimeoutMs ?? 6e4;
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
- if (!(!authEnabled || token !== null && body.trim() === token)) {
245
- writeError(res, 401, "Unauthorized");
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 (authEnabled && token !== null) headers["Set-Cookie"] = `bare_session=${sessionNonce(token)}; HttpOnly; SameSite=Lax; Path=/`;
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 + relative
259
- * path. Directory mounts win on longest-prefix match. */
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 bestPrefix = -1;
1035
+ let bestDepth = -1;
268
1036
  for (const [prefix, dirPath] of dirMounts) {
269
- let rel;
270
- if (cleanPath === prefix) rel = "";
271
- else if (prefix === "/") rel = cleanPath.replace(/^\/+/, "");
272
- else if (cleanPath.startsWith(`${prefix}/`)) rel = cleanPath.slice(prefix.length + 1);
273
- else continue;
274
- if (prefix.length > bestPrefix) {
275
- best = {
276
- dirPath,
277
- rel
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
- kind: "dir",
284
- dirPath: best.dirPath,
285
- rel: best.rel
1049
+ ...best,
1050
+ kind: "dir"
286
1051
  } : null;
287
1052
  }
288
- /** Resolves a directory-mount request to a concrete file, applying the SPA
289
- * fallback (unknown GET navigation → `index.html`). Subresource requests
290
- * (`Accept` without `text/html`) never fall back — a missing asset is a 404. */
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
- const candidate = mount.rel === "" ? mount.dirPath : path.join(mount.dirPath, mount.rel);
1061
+ let root;
293
1062
  try {
294
- if (fs.statSync(candidate).isFile()) return candidate;
295
- } catch {}
296
- if (mount.rel === "") return indexHtmlIfPresent(mount.dirPath);
297
- const accept = headers["accept"];
298
- if (typeof accept !== "string" || !accept.includes("text/html")) return null;
299
- return indexHtmlIfPresent(mount.dirPath);
300
- }
301
- function indexHtmlIfPresent(dirPath) {
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 index = path.join(dirPath, "index.html");
304
- if (fs.statSync(index).isFile()) return index;
305
- } catch {}
306
- return null;
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
- function serveFile(req, res, filePath) {
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
- try {
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": mimeTypeFor(filePath),
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
- "Access-Control-Allow-Origin": "*",
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": mimeTypeFor(filePath),
1165
+ "Content-Type": contentType,
337
1166
  "Content-Length": stat.size,
338
1167
  "Accept-Ranges": "bytes",
339
- "Access-Control-Allow-Origin": "*",
1168
+ ...cspHeader,
340
1169
  "Referrer-Policy": "no-referrer",
341
- "Cache-Control": "no-store"
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
- const server = http.createServer((req, res) => {
367
- try {
368
- const [rawPath, query = ""] = (req.url ?? "/").split("?");
369
- const cleanPath = rawPath.split("#")[0].replace(/\/+$/, "") || "/";
370
- const headers = req.headers;
371
- if (req.method === "POST" && cleanPath === "/login") {
372
- handleLogin(req, res);
373
- return;
374
- }
375
- if (cleanPath.split("/").includes("..")) {
376
- writeError(res, 400, "Bad request");
377
- return;
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
- const mount = resolveMount(cleanPath);
393
- if (!(mount !== null && mount.kind === "dir") && !isAuthorized(headers, query)) {
394
- writeError(res, 401, "Unauthorized");
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
- const filePath = mount === null ? null : mount.kind === "file" ? mount.filePath : resolveDirFile(mount, headers);
398
- if (filePath === null) {
399
- writeError(res, 404, "Not found");
400
- return;
1219
+ try {
1220
+ route(req, res);
1221
+ } catch (err) {
1222
+ writeInternalError(res, `route ${req.method} ${cleanPath}`, err);
401
1223
  }
402
- serveFile(req, res, filePath);
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
- const message = err instanceof Error ? err.message : String(err);
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
- boundOrigin = `http://${host}:${address.port}`;
416
- resolve(boundOrigin);
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 headers = req.headers;
423
- const [, query = ""] = (req.url ?? "").split("?");
424
- const origin = headers["origin"];
425
- if (authEnabled && typeof origin === "string" && origin.length > 0 && boundOrigin !== "" && origin !== boundOrigin) {
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
- const client = new ws.Socket({
443
- socket,
444
- isServer: true
445
- });
446
- activeSocket = client;
447
- client.on("close", () => {
448
- if (activeSocket === client) activeSocket = null;
449
- });
450
- socket.setTimeout(wsIdleTimeoutMs, () => client.destroy());
451
- for (const handler of connectionHandlers) try {
452
- handler(client, req);
453
- } catch (e) {
454
- const message = e instanceof Error ? e.message : String(e);
455
- console.error("Error in loopback WS connection handler:", message);
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
- dirMounts.set(prefix, dirPath);
484
- },
485
- onConnection(handler) {
486
- connectionHandlers.push(handler);
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
- if (activeSocket) try {
502
- activeSocket.destroy();
503
- } catch {}
1322
+ slot.close();
504
1323
  server.close(cb);
505
1324
  }
506
1325
  };
507
1326
  }
508
- //#endregion
509
- //#region core/server/websocket-server.ts
510
- function toUint8Array$1(data) {
511
- if (data instanceof Uint8Array) return data;
512
- if (data instanceof ArrayBuffer) return new Uint8Array(data);
513
- throw new Error("Expected Uint8Array or ArrayBuffer from socket data");
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 only routes decoded envelopes through the
528
- * plugin router and writes responses back as raw frames.
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, _request) => {
533
- socket.on("data", async (raw) => {
534
- try {
535
- const data = toUint8Array$1(raw);
536
- if (data.byteLength === 0) return;
537
- const parsed = protocol.decode(data);
538
- const header = parsed.header;
539
- const pluginResponse = await pluginRouter.route(header, parsed.payload);
540
- if (pluginResponse) socket.write(protocol.encode(MessageType.ENVELOPE, pluginResponse, null));
541
- } catch (err) {
542
- const message = err instanceof Error ? err.message : String(err);
543
- console.error("Error handling WebSocket message:", message);
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
- //#endregion
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
- * the Bare CLI (dev) passes the binary and the script path instead, so a
557
- * missing/absent config means dev mode (auth off, fixed port, no handoff
558
- * file). Falls back to the labeled CLI token syntax (see `resolveCliConfig`)
559
- * when the positional shape doesn't match, so the same entry works for both
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 the dev/CLI labeled-token configuration from bare CLI arguments:
582
- * `webassets=<dir>`, `storage=<dir>`, `cache=<dir>`, `host=<addr>`,
583
- * `port=<n>`, `auth=<on|off|true|false|1|0>` — a single `key=value` separator.
584
- * Unknown labels (e.g. `bootstrap=127.0.0.1:49737`) are ignored and stay
585
- * consumer-owned.
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
- * Returns the equivalent `WorkletRuntimeOptions` in **dev mode**: `deviceMode:
588
- * false` (so a `storage=` token does not flip auth on or make the port
589
- * ephemeral), `auth: false`, `port: 8080`, each overridable by a token. When
590
- * no labeled tokens are present it returns `{}` (plain dev defaults).
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": parsed.auth = value !== "off" && value !== "false" && value !== "0";
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
- return {
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
- function toUint8Array(data) {
631
- if (data instanceof Uint8Array) return data;
632
- if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) return new Uint8Array(data);
633
- return new Uint8Array(data);
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 ?? options.storage !== void 0;
1624
+ const deviceMode = options.deviceMode ?? true;
647
1625
  const auth = options.auth ?? deviceMode;
648
1626
  const port = options.port ?? (deviceMode ? 0 : 8080);
649
- const protocol = new MessageProtocol({
650
- encode: (str) => new TextEncoder().encode(str),
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
- wsIdleTimeoutMs: options.wsIdleTimeoutMs
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
- const pluginRouter = createPluginRouter(pluginRegistry, "bare", { delegateToHost: (header, payload) => hostBridge?.invokeOnHost(header, payload) ?? Promise.resolve(null) });
678
- if (ipc) ipc.on("data", async (data) => {
679
- try {
680
- const raw = toUint8Array(data);
681
- if (hostBridge?.tryConsumeDownstreamFromHost(raw)) return;
682
- const parsed = protocol.decode(raw);
683
- const header = parsed.header;
684
- const pluginResponse = await pluginRouter.route(header, parsed.payload);
685
- if (pluginResponse) writeIpc(ipc, protocol.encode(MessageType.ENVELOPE, pluginResponse, null));
686
- } catch (e) {
687
- const message = e instanceof Error ? e.message : String(e);
688
- console.error(`Malformed IPC frame dropped: ${message}`);
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
- if (options.webAssets) server.mountDir("/", options.webAssets);
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) try {
703
- fs.writeFileSync(path.join(options.storage, "handoff.json"), JSON.stringify(credentials));
704
- } catch (err) {
705
- const message = err instanceof Error ? err.message : String(err);
706
- console.error(`Failed to write handoff file: ${message}`);
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 };