@ekrooh/bare 0.5.0 → 0.6.0

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