@ekrooh/bare 0.5.0 → 0.6.1

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