@ekrooh/bare 0.4.0 → 0.5.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,12 +1,13 @@
1
- import { MessageProtocol, MessageType, createPluginRegistry, createPluginRouter } from "./index.mjs";
2
- import { t as createDefaultPlugins } from "./plugins-1KweEBct.mjs";
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";
3
+ import { t as createLogRingBuffer } from "./store-DTg1jN5E.mjs";
3
4
  import { TextDecoder, TextEncoder } from "bare-encoding";
4
5
  import fs from "bare-fs";
5
6
  import path from "bare-path";
7
+ import crypto from "bare-crypto";
6
8
  import http from "bare-http1";
7
9
  import ws from "bare-ws";
8
- import crypto from "bare-crypto";
9
- //#region core/lib/get-ipc.ts
10
+ // core/lib/get-ipc.ts
10
11
  /**
11
12
  * Returns the active IPC channel.
12
13
  * In BareKit (Android), it uses BareKit.IPC.
@@ -17,30 +18,230 @@ function getIPC() {
17
18
  if (IPC && typeof IPC.resume === "function") IPC.resume();
18
19
  return IPC;
19
20
  }
20
- //#endregion
21
- //#region core/messages/host-ipc.ts
21
+ // core/messages/encode-response.ts
22
+ /** Encodes a routed response. A response that cannot be encoded (a result over
23
+ * the 64 KB header limit, or one that does not serialize) is replaced by an
24
+ * error response for the same request, so the caller fails fast instead of
25
+ * waiting for a timeout that never explains why. */
26
+ function encodeResponse(protocol, response) {
27
+ try {
28
+ return protocol.encode(MessageType.ENVELOPE, response, null);
29
+ } catch (err) {
30
+ const reason = err instanceof Error ? err.message : String(err);
31
+ const tooLarge = /too large/i.test(reason);
32
+ console.error(`Response for ${response.pluginId}.${response.event} dropped: ${reason}`);
33
+ return protocol.encode(MessageType.ENVELOPE, {
34
+ type: "INVOKE_RESPONSE",
35
+ pluginId: response.pluginId,
36
+ event: response.event,
37
+ requestId: response.requestId,
38
+ error: {
39
+ code: tooLarge ? ErrorCode.FRAME_TOO_LARGE : ErrorCode.INVALID_RESPONSE,
40
+ message: `Response for ${response.pluginId}.${response.event} could not be sent: ${reason}`
41
+ }
42
+ }, null);
43
+ }
44
+ }
45
+ // core/messages/framing.ts
46
+ /** Smallest buffer allocated for a partial frame whose length is not yet known
47
+ * (fewer than 7 prefix bytes have arrived). */
48
+ const MIN_PARTIAL_BYTES = 64;
49
+ /** A partial-frame buffer larger than this is released once drained, so one
50
+ * large frame does not pin its memory for the rest of the session. */
51
+ const RETAIN_BYTES = 65536;
52
+ function createFrameDecoder(protocol) {
53
+ /** Partial-frame storage: the first `length` bytes are buffered data. Empty
54
+ * whenever nothing is buffered, so a chunk of whole frames is decoded
55
+ * straight from the caller's bytes without being copied in first. */
56
+ let buffer = /* @__PURE__ */ new Uint8Array(0);
57
+ let length = 0;
58
+ let error = null;
59
+ function fail(err) {
60
+ error = err;
61
+ buffer = /* @__PURE__ */ new Uint8Array(0);
62
+ length = 0;
63
+ }
64
+ /** Grows `buffer` to hold at least `bytes`, preserving buffered data. */
65
+ function reserve(bytes) {
66
+ if (buffer.byteLength >= bytes) return;
67
+ const next = new Uint8Array(Math.max(bytes, MIN_PARTIAL_BYTES));
68
+ next.set(buffer.subarray(0, length), 0);
69
+ buffer = next;
70
+ }
71
+ /** Keeps `rest` (the unconsumed tail of `source`) as the buffered partial,
72
+ * sized for `frameLen` when the incomplete frame's length is known. */
73
+ function retain(source, from, to, frameLen) {
74
+ const rest = to - from;
75
+ if (rest === 0) {
76
+ length = 0;
77
+ if (buffer.byteLength > RETAIN_BYTES) buffer = /* @__PURE__ */ new Uint8Array(0);
78
+ return;
79
+ }
80
+ if (source === buffer) {
81
+ buffer.copyWithin(0, from, to);
82
+ length = rest;
83
+ reserve(frameLen);
84
+ return;
85
+ }
86
+ length = 0;
87
+ reserve(Math.max(rest, frameLen));
88
+ buffer.set(source.subarray(from, to), 0);
89
+ length = rest;
90
+ }
91
+ function push(chunk) {
92
+ if (error) return [];
93
+ let source;
94
+ let end;
95
+ if (length === 0) {
96
+ source = chunk;
97
+ end = chunk.byteLength;
98
+ } else {
99
+ reserve(length + chunk.byteLength);
100
+ buffer.set(chunk, length);
101
+ length += chunk.byteLength;
102
+ source = buffer;
103
+ end = length;
104
+ }
105
+ const out = [];
106
+ let offset = 0;
107
+ let pendingFrameLen = 0;
108
+ while (end - offset >= 7) {
109
+ const headerLen = source[offset + 2] << 8 | source[offset + 3];
110
+ if (headerLen > 65535) {
111
+ fail(/* @__PURE__ */ new Error(`Header too large: ${headerLen} bytes, maximum is ${MAX_HEADER_BYTES}`));
112
+ return out;
113
+ }
114
+ const payloadLen = source[offset + 4] << 16 | source[offset + 5] << 8 | source[offset + 6];
115
+ const frameLen = 7 + headerLen + payloadLen;
116
+ if (frameLen > protocol.maxFrameBytes) {
117
+ fail(/* @__PURE__ */ new Error(`Frame too large: ${frameLen} bytes, maximum is ${protocol.maxFrameBytes}`));
118
+ return out;
119
+ }
120
+ if (end - offset < frameLen) {
121
+ pendingFrameLen = frameLen;
122
+ break;
123
+ }
124
+ const frame = Uint8Array.prototype.slice.call(source, offset, offset + frameLen);
125
+ offset += frameLen;
126
+ try {
127
+ out.push(protocol.decode(frame));
128
+ } catch (err) {
129
+ fail(err instanceof Error ? err : new Error(String(err)));
130
+ return out;
131
+ }
132
+ }
133
+ retain(source, offset, end, pendingFrameLen);
134
+ if (length > protocol.maxFrameBytes) fail(/* @__PURE__ */ new Error(`Frame buffer exceeded ${protocol.maxFrameBytes} bytes with no complete frame`));
135
+ return out;
136
+ }
137
+ return {
138
+ push,
139
+ clear() {
140
+ buffer = /* @__PURE__ */ new Uint8Array(0);
141
+ length = 0;
142
+ error = null;
143
+ },
144
+ get error() {
145
+ return error;
146
+ }
147
+ };
148
+ }
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;
186
+ }
187
+ return false;
188
+ }
189
+ /** Whether a fatal, unrecoverable desync has been reached. */
190
+ get isFatal() {
191
+ return this.fatal;
192
+ }
193
+ /** Number of consecutive failures since the last successful frame batch. */
194
+ get failures() {
195
+ return this.consecutiveFailures;
196
+ }
197
+ };
198
+ // core/messages/host-ipc.ts
22
199
  function toUint8Array$2(data) {
23
200
  if (data instanceof Uint8Array) return data;
24
201
  if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) return new Uint8Array(data);
25
202
  return new Uint8Array(data);
26
203
  }
27
- function newCorrelationId() {
28
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
29
- }
204
+ /** Hard ceiling on concurrently pending host calls. Above this, the oldest call
205
+ * is dropped (its timer cleared and promise rejected) so a flood of invokes
206
+ * cannot grow the pending map without bound. */
207
+ const MAX_CONCURRENT_HOST_CALLS = 1024;
208
+ /** Sane default for a host invoke: five-minute waits let a single stalled host
209
+ * call pin a slot indefinitely, so we default to 30s instead. */
210
+ const DEFAULT_HOST_INVOKE_TIMEOUT_MS = 3e4;
30
211
  function writeEncoded(ipc, bytes) {
31
- if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") ipc.write(Buffer.from(bytes));
212
+ if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") ipc.write(Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes));
32
213
  else ipc.write(bytes);
33
214
  }
34
215
  function createHostIpcBridge(config) {
35
216
  const { ipc, protocol } = config;
36
217
  const pending = /* @__PURE__ */ new Map();
218
+ function track(requestId, slot) {
219
+ if (pending.size >= MAX_CONCURRENT_HOST_CALLS) {
220
+ const oldest = pending.keys().next().value;
221
+ if (oldest !== void 0) {
222
+ const dropped = pending.get(oldest);
223
+ if (dropped) {
224
+ clearTimeout(dropped.timer);
225
+ dropped.reject(/* @__PURE__ */ new Error(`host invoke dropped: too many concurrent host calls (${oldest})`));
226
+ }
227
+ pending.delete(oldest);
228
+ }
229
+ }
230
+ const existing = pending.get(requestId);
231
+ if (existing) {
232
+ clearTimeout(existing.timer);
233
+ existing.reject(/* @__PURE__ */ new Error(`host invoke superseded by a new call (${requestId})`));
234
+ pending.delete(requestId);
235
+ }
236
+ pending.set(requestId, slot);
237
+ }
37
238
  function waitFor(requestId, expectedType, timeoutMs) {
38
239
  return new Promise((resolve, reject) => {
39
240
  const timer = setTimeout(() => {
40
241
  pending.delete(requestId);
41
242
  reject(/* @__PURE__ */ new Error(`Host IPC timeout waiting for ${String(expectedType)} (${requestId})`));
42
243
  }, timeoutMs);
43
- pending.set(requestId, {
244
+ track(requestId, {
44
245
  timer,
45
246
  resolve: (h) => {
46
247
  clearTimeout(timer);
@@ -50,31 +251,42 @@ function createHostIpcBridge(config) {
50
251
  return;
51
252
  }
52
253
  reject(/* @__PURE__ */ new Error(`Unexpected host response type ${h.type}, expected ${String(expectedType)}`));
53
- }
254
+ },
255
+ reject
54
256
  });
55
257
  });
56
258
  }
259
+ /** Classifies a pre-decoded downstream message: resolves the matching pending
260
+ * host call when it is a `HOST_CAPABILITIES_RESPONSE` or `HOST_INVOKE_RESPONSE`
261
+ * for a known `requestId`; otherwise returns false so the caller can route the
262
+ * message through the plugin router (host responses never reach plugins). */
263
+ function consumeDownstream(parsed) {
264
+ const h = parsed.header;
265
+ if (h.type !== "HOST_CAPABILITIES_RESPONSE" && h.type !== "HOST_INVOKE_RESPONSE") return false;
266
+ const id = h.requestId;
267
+ if (!id) return false;
268
+ const slot = pending.get(id);
269
+ if (!slot) return false;
270
+ clearTimeout(slot.timer);
271
+ pending.delete(id);
272
+ slot.resolve(h);
273
+ return true;
274
+ }
57
275
  return {
276
+ tryConsumeDownstream: consumeDownstream,
58
277
  tryConsumeDownstreamFromHost(raw) {
59
278
  let msg;
60
279
  try {
61
280
  msg = protocol.decode(toUint8Array$2(raw));
62
- } catch {
281
+ } catch (err) {
282
+ const message = err instanceof Error ? err.message : String(err);
283
+ console.debug(`[ipc] dropped undecodable host frame: ${message}`);
63
284
  return false;
64
285
  }
65
- const h = msg.header;
66
- if (h.type !== "HOST_CAPABILITIES_RESPONSE" && h.type !== "HOST_INVOKE_RESPONSE") return false;
67
- const id = h.requestId;
68
- if (!id) return false;
69
- const slot = pending.get(id);
70
- if (!slot) return false;
71
- clearTimeout(slot.timer);
72
- pending.delete(id);
73
- slot.resolve(h);
74
- return true;
286
+ return consumeDownstream(msg);
75
287
  },
76
288
  async queryCapabilities(timeoutMs = 5e3) {
77
- const requestId = newCorrelationId();
289
+ const requestId = createRequestId();
78
290
  const promise = waitFor(requestId, "HOST_CAPABILITIES_RESPONSE", timeoutMs);
79
291
  const header = {
80
292
  type: "HOST_CAPABILITIES_QUERY",
@@ -83,7 +295,7 @@ function createHostIpcBridge(config) {
83
295
  writeEncoded(ipc, protocol.encode(MessageType.ENVELOPE, header, null));
84
296
  return (await promise).capabilities ?? [];
85
297
  },
86
- async invokeOnHost(header, payload, timeoutMs = 3e5) {
298
+ async invokeOnHost(header, payload, timeoutMs = DEFAULT_HOST_INVOKE_TIMEOUT_MS) {
87
299
  const requestId = header.requestId;
88
300
  if (!requestId) return null;
89
301
  const promise = waitFor(requestId, "HOST_INVOKE_RESPONSE", timeoutMs);
@@ -107,8 +319,171 @@ function createHostIpcBridge(config) {
107
319
  }
108
320
  };
109
321
  }
110
- //#endregion
111
- //#region core/server/static-file-server.ts
322
+ // core/logs/capture.ts
323
+ const LEVEL_RANK = {
324
+ debug: 0,
325
+ info: 1,
326
+ warn: 2,
327
+ error: 3
328
+ };
329
+ function safeStringify(value) {
330
+ if (typeof value === "string") return value;
331
+ if (value instanceof Error) return value.stack ?? value.message;
332
+ try {
333
+ return JSON.stringify(value);
334
+ } catch {
335
+ return String(value);
336
+ }
337
+ }
338
+ function formatArgs(args) {
339
+ if (args.length === 0) return "";
340
+ return args.map((a) => safeStringify(a)).join(" ");
341
+ }
342
+ /**
343
+ * Installs console interception at startup: binds the original console methods,
344
+ * forwards to them (so dev stdout and e2e console assertions stay intact), and
345
+ * feeds a {@link LogStore}. Every step is try/catch-wrapped so a failure in the
346
+ * seam can never break boot or swallow the consumer's own log call.
347
+ */
348
+ function installConsoleCapture(store, source = "backend", options = {}) {
349
+ const minRank = LEVEL_RANK[options.minLevel ?? "debug"];
350
+ const original = {
351
+ debug: console.debug,
352
+ log: console.log,
353
+ info: console.info,
354
+ warn: console.warn,
355
+ error: console.error
356
+ };
357
+ const wrap = (method, level) => {
358
+ const recorded = LEVEL_RANK[level] >= minRank;
359
+ return (...args) => {
360
+ if (recorded) try {
361
+ store.append({
362
+ ts: Date.now(),
363
+ level,
364
+ source,
365
+ message: formatArgs(args)
366
+ });
367
+ } catch {}
368
+ try {
369
+ original[method].apply(console, args);
370
+ } catch {}
371
+ };
372
+ };
373
+ console.debug = wrap("debug", "debug");
374
+ console.log = wrap("log", "info");
375
+ console.info = wrap("info", "info");
376
+ console.warn = wrap("warn", "warn");
377
+ console.error = wrap("error", "error");
378
+ return {
379
+ source,
380
+ store,
381
+ restore() {
382
+ console.debug = original.debug;
383
+ console.log = original.log;
384
+ console.info = original.info;
385
+ console.warn = original.warn;
386
+ console.error = original.error;
387
+ }
388
+ };
389
+ }
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
+ }
418
+ }
419
+ target.on("drain", () => {
420
+ blocked = false;
421
+ flush();
422
+ });
423
+ 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;
448
+ },
449
+ clear() {
450
+ queue = [];
451
+ head = 0;
452
+ queued = 0;
453
+ },
454
+ get queuedBytes() {
455
+ return queued;
456
+ }
457
+ };
458
+ }
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();
469
+ }
470
+ }
471
+ 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);
480
+ },
481
+ get pending() {
482
+ return pending;
483
+ }
484
+ };
485
+ }
486
+ // core/server/static-file-server.ts
112
487
  const MIME_BY_EXTENSION = {
113
488
  html: "text/html",
114
489
  js: "text/javascript",
@@ -133,17 +508,31 @@ function mimeTypeFor(filePath) {
133
508
  const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
134
509
  return MIME_BY_EXTENSION[ext] ?? "application/octet-stream";
135
510
  }
136
- /** Extracts the `token` query param from a raw query string (`a=1&token=x`). */
137
- function tokenFromQuery(query) {
138
- for (const pair of query.split("&")) {
139
- const eq = pair.indexOf("=");
140
- if (eq === -1) continue;
141
- if (pair.slice(0, eq) === "token") return pair.slice(eq + 1);
142
- }
143
- return null;
511
+ /** Files under `assets/` whose name ends in an 8-character content hash before
512
+ * the extension (the Vite/Rolldown default, e.g. `assets/main-DkY3x9qZ.js`)
513
+ * are treated as content-addressed. Anything else — including `index.html` —
514
+ * must be revalidated. */
515
+ const HASHED_ASSET = /^assets\/[^/]+-[A-Za-z0-9_-]{8}\.[A-Za-z0-9]+$/;
516
+ function cachePolicyFor(rel, mountIsPublic) {
517
+ if (!mountIsPublic) return "no-store";
518
+ return HASHED_ASSET.test(rel) ? "immutable" : "revalidate";
519
+ }
520
+ /** Weak validator from size and mtime. Weak because the same bytes can be
521
+ * re-written with a new mtime, which only costs one extra full response. */
522
+ function etagFor(stat) {
523
+ return `W/"${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`;
524
+ }
525
+ /** Whether an `If-None-Match` header (a list of validators, or `*`) matches. */
526
+ function etagMatches(header, etag) {
527
+ if (typeof header !== "string") return false;
528
+ return header.split(",").some((candidate) => candidate.trim() === etag || candidate.trim() === "*");
144
529
  }
145
530
  /** Reads the token from the `X-Bare-Token` request header (parser-lowercased).
146
- * `fetch`-based non-browser clients that cannot carry cookies use this. */
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. */
147
536
  function tokenFromHeaders(headers) {
148
537
  const value = headers["x-bare-token"];
149
538
  return typeof value === "string" ? value : null;
@@ -164,6 +553,14 @@ function cookieSession(headers) {
164
553
  function sessionNonce(token) {
165
554
  return crypto.createHash("blake2b-256").update(token).digest("hex");
166
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
+ }
167
564
  /** Parses a `Range: bytes=<start>-<end>` header. Returns `null` when absent or
168
565
  * unsatisfiable (caller falls back to a 200 full response). */
169
566
  function parseRange(value, size) {
@@ -190,47 +587,112 @@ function parseRange(value, size) {
190
587
  * Buffers, bare-http1 emits Uint8Arrays, and a setEncoding'd stream emits
191
588
  * strings — `String(uint8array)` would join bytes with commas, so decode
192
589
  * explicitly (the `/login` token body must match byte-for-byte). */
193
- function collectRequestBody(req) {
194
- return new Promise((resolve) => {
590
+ function collectRequestBody(req, maxBytes = Infinity) {
591
+ return new Promise((resolve, reject) => {
195
592
  const decoder = new TextDecoder();
196
593
  let body = "";
594
+ let byteCount = 0;
595
+ let exceeded = false;
197
596
  req.on("data", (chunk) => {
198
- body += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
597
+ if (exceeded) return;
598
+ const str = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
599
+ body += str;
600
+ byteCount += typeof chunk === "string" ? new TextEncoder().encode(chunk).byteLength : chunk.byteLength;
601
+ if (byteCount > maxBytes) {
602
+ exceeded = true;
603
+ reject(/* @__PURE__ */ new Error("Request body too large"));
604
+ }
605
+ });
606
+ req.on("end", () => {
607
+ if (exceeded) return;
608
+ const final = body + decoder.decode(/* @__PURE__ */ new Uint8Array(0));
609
+ if (byteCount > maxBytes) {
610
+ reject(/* @__PURE__ */ new Error("Request body too large"));
611
+ return;
612
+ }
613
+ resolve(final);
199
614
  });
200
- req.on("end", () => resolve(body + decoder.decode(/* @__PURE__ */ new Uint8Array(0))));
201
615
  });
202
616
  }
203
617
  /**
204
618
  * The single loopback HTTP+WS server for the worklet. One origin serves the
205
619
  * web app (`/`), the media files mounted by plugins, and the framed-protocol
206
620
  * WebSocket socket the page connects to. On device it binds `127.0.0.1` on an
207
- * ephemeral port. The bundled web app (directory mounts) is public content — a
208
- * fresh WebView must load the page before the page can `POST /login`. Mounted
209
- * session files (media) and the WS upgrade are gated by a per-session token,
210
- * accepted as a `bare_session` cookie (set via `POST /login`), the
211
- * `X-Bare-Token` header, or a `?token=` query param — defense in depth for
212
- * non-browser clients. All responses send `Referrer-Policy: no-referrer` so
213
- * token-bearing URLs never leak through a Referer header.
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`.
214
629
  */
215
630
  function createLoopbackServer(options = {}) {
216
631
  const host = options.host ?? "127.0.0.1";
217
632
  const port = options.port ?? 0;
218
633
  const authEnabled = options.auth ?? true;
219
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(/=+$/, "");
220
640
  const fileMounts = /* @__PURE__ */ new Map();
221
641
  const dirMounts = /* @__PURE__ */ new Map();
642
+ const publicDirMounts = /* @__PURE__ */ new Set();
222
643
  const routeHandlers = /* @__PURE__ */ new Map();
223
644
  const connectionHandlers = [];
224
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;
225
650
  let boundOrigin = "";
226
651
  let originPromise = null;
227
652
  let activeSocket = null;
228
- function isAuthorized(headers, query) {
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) {
229
675
  if (!authEnabled) return true;
230
676
  if (token === null) return false;
231
- if (tokenFromQuery(query) === token) return true;
232
- if (tokenFromHeaders(headers) === token) return true;
233
- return cookieSession(headers) === sessionNonce(token);
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;
234
696
  }
235
697
  function writeError(res, status, message) {
236
698
  res.writeHead(status, {
@@ -240,20 +702,30 @@ function createLoopbackServer(options = {}) {
240
702
  res.end(message);
241
703
  }
242
704
  function handleLogin(req, res) {
243
- collectRequestBody(req).then((body) => {
244
- if (!(!authEnabled || token !== null && body.trim() === token)) {
245
- writeError(res, 401, "Unauthorized");
705
+ 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");
246
714
  return;
247
715
  }
716
+ if (byBootstrap && bootstrapNonce !== null) {
717
+ spentBootstrapNonce = bootstrapNonce;
718
+ bootstrapNonce = null;
719
+ }
248
720
  const headers = {
249
721
  "Content-Type": "text/plain",
250
722
  "Cache-Control": "no-store",
251
723
  "Referrer-Policy": "no-referrer"
252
724
  };
253
- if (authEnabled && token !== null) headers["Set-Cookie"] = `bare_session=${sessionNonce(token)}; HttpOnly; SameSite=Lax; Path=/`;
725
+ if (authEnabled && token !== null) headers["Set-Cookie"] = `bare_session=${sessionCookieValue(token)}; HttpOnly; SameSite=Lax; Path=/`;
254
726
  res.writeHead(200, headers);
255
727
  res.end("ok");
256
- });
728
+ }).catch(() => writeError(res, 413, "Request Entity Too Large"));
257
729
  }
258
730
  /** Resolves a request path to a file mount, or a directory mount + relative
259
731
  * path. Directory mounts win on longest-prefix match. */
@@ -274,26 +746,33 @@ function createLoopbackServer(options = {}) {
274
746
  if (prefix.length > bestPrefix) {
275
747
  best = {
276
748
  dirPath,
277
- rel
749
+ rel,
750
+ public: publicDirMounts.has(prefix)
278
751
  };
279
752
  bestPrefix = prefix.length;
280
753
  }
281
754
  }
282
755
  return best ? {
283
- kind: "dir",
284
- dirPath: best.dirPath,
285
- rel: best.rel
756
+ ...best,
757
+ kind: "dir"
286
758
  } : null;
287
759
  }
288
- /** Resolves a directory-mount request to a concrete file, applying the SPA
289
- * fallback (unknown GET navigation → `index.html`). Subresource requests
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
763
+ * unauthenticated clients never receive bootstrap HTML. Subresource requests
290
764
  * (`Accept` without `text/html`) never fall back — a missing asset is a 404. */
291
765
  function resolveDirFile(mount, headers) {
292
766
  const candidate = mount.rel === "" ? mount.dirPath : path.join(mount.dirPath, mount.rel);
293
767
  try {
294
- if (fs.statSync(candidate).isFile()) return candidate;
768
+ const stat = fs.statSync(candidate);
769
+ if (stat.isFile()) return {
770
+ filePath: candidate,
771
+ stat
772
+ };
295
773
  } catch {}
296
774
  if (mount.rel === "") return indexHtmlIfPresent(mount.dirPath);
775
+ if (!mount.public) return null;
297
776
  const accept = headers["accept"];
298
777
  if (typeof accept !== "string" || !accept.includes("text/html")) return null;
299
778
  return indexHtmlIfPresent(mount.dirPath);
@@ -301,15 +780,29 @@ function createLoopbackServer(options = {}) {
301
780
  function indexHtmlIfPresent(dirPath) {
302
781
  try {
303
782
  const index = path.join(dirPath, "index.html");
304
- if (fs.statSync(index).isFile()) return index;
305
- } catch {}
783
+ const stat = fs.statSync(index);
784
+ if (stat.isFile()) return {
785
+ filePath: index,
786
+ stat
787
+ };
788
+ } catch {
789
+ debugSkip(`[static-server] indexHtmlIfPresent: no index.html under ${dirPath} — 404`);
790
+ }
306
791
  return null;
307
792
  }
308
- function serveFile(req, res, filePath) {
793
+ /** 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") {
309
800
  let stat;
310
- try {
801
+ if (knownStat) stat = knownStat;
802
+ else try {
311
803
  stat = fs.statSync(filePath);
312
804
  } catch {
805
+ debugSkip(`[static-server] serveFile: stat failed for ${filePath} — 404`);
313
806
  writeError(res, 404, "Not found");
314
807
  return;
315
808
  }
@@ -317,14 +810,16 @@ function createLoopbackServer(options = {}) {
317
810
  writeError(res, 404, "Not found");
318
811
  return;
319
812
  }
813
+ const contentType = mimeTypeFor(filePath);
814
+ const cspHeader = contentType === "text/html" ? { "Content-Security-Policy": CSP_HEADER } : {};
320
815
  const range = parseRange(req.headers["range"], stat.size);
321
816
  if (range) {
322
817
  res.writeHead(206, {
323
- "Content-Type": mimeTypeFor(filePath),
818
+ "Content-Type": contentType,
324
819
  "Content-Range": `bytes ${range.start}-${range.end}/${stat.size}`,
325
820
  "Content-Length": range.end - range.start + 1,
326
821
  "Accept-Ranges": "bytes",
327
- "Access-Control-Allow-Origin": "*",
822
+ ...cspHeader,
328
823
  "Referrer-Policy": "no-referrer",
329
824
  "Cache-Control": "no-store"
330
825
  });
@@ -332,13 +827,25 @@ function createLoopbackServer(options = {}) {
332
827
  pipeFile(res, filePath, range);
333
828
  return;
334
829
  }
830
+ const cacheHeaders = cache === "no-store" ? { "Cache-Control": "no-store" } : {
831
+ "Cache-Control": cache === "immutable" ? "max-age=31536000, immutable" : "no-cache",
832
+ ETag: etagFor(stat)
833
+ };
834
+ if (cache !== "no-store" && etagMatches(req.headers["if-none-match"], cacheHeaders.ETag)) {
835
+ res.writeHead(304, {
836
+ ...cacheHeaders,
837
+ "Referrer-Policy": "no-referrer"
838
+ });
839
+ res.end();
840
+ return;
841
+ }
335
842
  res.writeHead(200, {
336
- "Content-Type": mimeTypeFor(filePath),
843
+ "Content-Type": contentType,
337
844
  "Content-Length": stat.size,
338
845
  "Accept-Ranges": "bytes",
339
- "Access-Control-Allow-Origin": "*",
846
+ ...cspHeader,
340
847
  "Referrer-Policy": "no-referrer",
341
- "Cache-Control": "no-store"
848
+ ...cacheHeaders
342
849
  });
343
850
  res.flushHeaders();
344
851
  pipeFile(res, filePath);
@@ -365,9 +872,13 @@ function createLoopbackServer(options = {}) {
365
872
  }
366
873
  const server = http.createServer((req, res) => {
367
874
  try {
368
- const [rawPath, query = ""] = (req.url ?? "/").split("?");
875
+ const [rawPath] = (req.url ?? "/").split("?");
369
876
  const cleanPath = rawPath.split("#")[0].replace(/\/+$/, "") || "/";
370
877
  const headers = req.headers;
878
+ if (!originAllowed(headers)) {
879
+ writeError(res, 403, "Forbidden");
880
+ return;
881
+ }
371
882
  if (req.method === "POST" && cleanPath === "/login") {
372
883
  handleLogin(req, res);
373
884
  return;
@@ -378,7 +889,7 @@ function createLoopbackServer(options = {}) {
378
889
  }
379
890
  const route = routeHandlers.get(`${req.method} ${cleanPath}`);
380
891
  if (route) {
381
- if (!isAuthorized(headers, query)) {
892
+ if (!isAuthorized(headers)) {
382
893
  writeError(res, 401, "Unauthorized");
383
894
  return;
384
895
  }
@@ -390,16 +901,25 @@ function createLoopbackServer(options = {}) {
390
901
  return;
391
902
  }
392
903
  const mount = resolveMount(cleanPath);
393
- if (!(mount !== null && mount.kind === "dir") && !isAuthorized(headers, query)) {
904
+ if (!(mount !== null && mount.kind === "dir" && mount.public) && !isAuthorized(headers)) {
394
905
  writeError(res, 401, "Unauthorized");
395
906
  return;
396
907
  }
397
- const filePath = mount === null ? null : mount.kind === "file" ? mount.filePath : resolveDirFile(mount, headers);
398
- if (filePath === null) {
908
+ if (mount === null) {
399
909
  writeError(res, 404, "Not found");
400
910
  return;
401
911
  }
402
- serveFile(req, res, filePath);
912
+ if (mount.kind === "file") {
913
+ serveFile(req, res, mount.filePath);
914
+ return;
915
+ }
916
+ const resolved = resolveDirFile(mount, headers);
917
+ if (resolved === null) {
918
+ writeError(res, 404, "Not found");
919
+ return;
920
+ }
921
+ const servedRel = resolved.filePath.slice(mount.dirPath.length).replace(/\\/g, "/").replace(/^\/+/, "");
922
+ serveFile(req, res, resolved.filePath, resolved.stat, cachePolicyFor(servedRel, mount.public));
403
923
  } catch (err) {
404
924
  const message = err instanceof Error ? err.message : String(err);
405
925
  res.writeHead(500, { "Content-Type": "text/plain" });
@@ -408,11 +928,12 @@ function createLoopbackServer(options = {}) {
408
928
  });
409
929
  function origin() {
410
930
  if (!originPromise) originPromise = new Promise((resolve, reject) => {
411
- token ??= crypto.randomBytes(32).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
931
+ token ??= randomSecret();
932
+ bootstrapNonce ??= randomSecret();
412
933
  server.on("error", reject);
413
934
  server.listen(port, host, () => {
414
935
  const address = server.address();
415
- boundOrigin = `http://${host}:${address.port}`;
936
+ boundOrigin = `http://${host}:${address?.port}`;
416
937
  resolve(boundOrigin);
417
938
  });
418
939
  });
@@ -420,34 +941,68 @@ function createLoopbackServer(options = {}) {
420
941
  }
421
942
  server.on("upgrade", (req, socket, head) => {
422
943
  const headers = req.headers;
423
- const [, query = ""] = (req.url ?? "").split("?");
424
- const origin = headers["origin"];
425
- if (authEnabled && typeof origin === "string" && origin.length > 0 && boundOrigin !== "" && origin !== boundOrigin) {
944
+ const reject = (reason) => {
945
+ console.error(`[loopback] upgrade rejected: ${reason}`);
426
946
  socket.destroy();
947
+ };
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}')`);
427
952
  return;
428
953
  }
429
- if (!isAuthorized(headers, query)) {
430
- socket.destroy();
954
+ if (!isAuthorized(headers)) {
955
+ reject(`unauthorized (${headers["cookie"] ? "cookie present but invalid" : "no bare_session cookie"})`);
431
956
  return;
432
957
  }
433
- if (activeSocket) {
434
- socket.destroy();
958
+ if (activeSocket && Date.now() - activeSince < wsReplaceMinIntervalMs) {
959
+ reject("replacement rate limit (a protocol socket was just accepted)");
435
960
  return;
436
961
  }
437
962
  ws.Server.handshake(req, socket, head, (err) => {
438
963
  if (err) {
439
- socket.destroy(err);
964
+ reject(`handshake failed (${err.message})`);
440
965
  return;
441
966
  }
442
- const client = new ws.Socket({
967
+ const raw = new ws.Socket({
443
968
  socket,
444
- isServer: true
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
+ }
445
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;
446
987
  activeSocket = client;
447
- client.on("close", () => {
448
- if (activeSocket === client) activeSocket = null;
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
+ }
449
1004
  });
450
- socket.setTimeout(wsIdleTimeoutMs, () => client.destroy());
1005
+ socket.setTimeout(wsIdleTimeoutMs, () => raw.destroy());
451
1006
  for (const handler of connectionHandlers) try {
452
1007
  handler(client, req);
453
1008
  } catch (e) {
@@ -470,7 +1025,8 @@ function createLoopbackServer(options = {}) {
470
1025
  return {
471
1026
  origin: originUrl,
472
1027
  port: Number(originUrl.slice(originUrl.lastIndexOf(":") + 1)),
473
- token
1028
+ token,
1029
+ bootstrap: bootstrapNonce
474
1030
  };
475
1031
  },
476
1032
  mount(path, filePath) {
@@ -479,8 +1035,10 @@ function createLoopbackServer(options = {}) {
479
1035
  unmount(path) {
480
1036
  fileMounts.delete(path);
481
1037
  },
482
- mountDir(prefix, dirPath) {
1038
+ mountDir(prefix, dirPath, opts) {
483
1039
  dirMounts.set(prefix, dirPath);
1040
+ if (opts?.public) publicDirMounts.add(prefix);
1041
+ else publicDirMounts.delete(prefix);
484
1042
  },
485
1043
  onConnection(handler) {
486
1044
  connectionHandlers.push(handler);
@@ -489,29 +1047,128 @@ function createLoopbackServer(options = {}) {
489
1047
  routeHandlers.set(`${method.toUpperCase()} ${path}`, handler);
490
1048
  },
491
1049
  push(frame) {
492
- if (!activeSocket) return false;
1050
+ if (!activeWriter) return false;
493
1051
  try {
494
- activeSocket.write(frame);
495
- return true;
1052
+ return activeWriter.write(frame, { droppable: true });
496
1053
  } catch {
1054
+ debugSkip("[static-server] push: socket.write failed — frame dropped");
497
1055
  return false;
498
1056
  }
499
1057
  },
500
1058
  close(cb) {
501
1059
  if (activeSocket) try {
502
1060
  activeSocket.destroy();
503
- } catch {}
1061
+ } catch {
1062
+ debugSkip("[static-server] close: socket.destroy failed — already closed");
1063
+ }
504
1064
  server.close(cb);
505
1065
  }
506
1066
  };
507
1067
  }
508
- //#endregion
509
- //#region core/server/websocket-server.ts
1068
+ // core/logs/routes.ts
1069
+ function queryParams(url) {
1070
+ const out = {};
1071
+ const query = (url ?? "").split("?")[1];
1072
+ if (!query) return out;
1073
+ for (const pair of query.split("&")) {
1074
+ const eq = pair.indexOf("=");
1075
+ if (eq === -1) continue;
1076
+ const key = decodeURIComponent(pair.slice(0, eq));
1077
+ out[key] = decodeURIComponent(pair.slice(eq + 1));
1078
+ }
1079
+ return out;
1080
+ }
1081
+ 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}`;
1084
+ }
1085
+ /** Registers the loopback log routes on the unified server. They inherit the
1086
+ * server's auth gate and single-server rule for free:
1087
+ *
1088
+ * - `GET /logs?tail=&level=&source=&format=jsonl|text` — bulk read-back,
1089
+ * deliberately off the framed invoke so there is no `MAX_HEADER_BYTES`
1090
+ * ceiling.
1091
+ * - `POST /logs` — ingest for web/browser log batches (`{ source, entries }`).
1092
+ */
1093
+ function registerLogRoutes(server, store) {
1094
+ server.registerRoute("GET", "/logs", (req, res) => {
1095
+ const params = queryParams(req.url);
1096
+ 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;
1099
+ const entries = store.view({
1100
+ tail: tail !== void 0 && Number.isFinite(tail) ? tail : void 0,
1101
+ level,
1102
+ source
1103
+ });
1104
+ const format = params.format === "jsonl" ? "jsonl" : "text";
1105
+ res.writeHead(200, {
1106
+ "Content-Type": format === "jsonl" ? "application/x-ndjson" : "text/plain",
1107
+ "Referrer-Policy": "no-referrer",
1108
+ "Cache-Control": "no-store"
1109
+ });
1110
+ if (format === "jsonl") {
1111
+ const lines = entries.map((e) => JSON.stringify(e)).join("\n");
1112
+ res.end(lines.length > 0 ? `${lines}\n` : "");
1113
+ } else {
1114
+ const lines = entries.map(formatText).join("\n");
1115
+ res.end(lines.length > 0 ? `${lines}\n` : "");
1116
+ }
1117
+ });
1118
+ server.registerRoute("POST", "/logs", (req, res) => {
1119
+ collectRequestBody(req, 1048576).then((body) => {
1120
+ let parsed;
1121
+ try {
1122
+ parsed = JSON.parse(body);
1123
+ } catch {
1124
+ res.writeHead(400, {
1125
+ "Content-Type": "text/plain",
1126
+ "Referrer-Policy": "no-referrer"
1127
+ });
1128
+ res.end("Bad request");
1129
+ return;
1130
+ }
1131
+ const source = parsed.source === "backend" ? "backend" : "web";
1132
+ const batch = Array.isArray(parsed.entries) ? parsed.entries : [];
1133
+ let accepted = 0;
1134
+ for (const raw of batch) {
1135
+ if (!raw || typeof raw.message !== "string") continue;
1136
+ store.append({
1137
+ ts: Date.now(),
1138
+ level: raw.level ?? "info",
1139
+ source,
1140
+ tag: raw.tag,
1141
+ message: raw.message
1142
+ });
1143
+ accepted++;
1144
+ }
1145
+ res.writeHead(200, {
1146
+ "Content-Type": "application/json",
1147
+ "Referrer-Policy": "no-referrer",
1148
+ "Cache-Control": "no-store"
1149
+ });
1150
+ res.end(JSON.stringify({ accepted }));
1151
+ }).catch((err) => {
1152
+ const message = err instanceof Error ? err.message : String(err);
1153
+ console.error("Discarding oversized /logs batch:", message);
1154
+ res.writeHead(413, {
1155
+ "Content-Type": "text/plain",
1156
+ "Referrer-Policy": "no-referrer"
1157
+ });
1158
+ res.end("Request Entity Too Large");
1159
+ });
1160
+ });
1161
+ }
1162
+ // core/server/websocket-server.ts
510
1163
  function toUint8Array$1(data) {
511
1164
  if (data instanceof Uint8Array) return data;
512
1165
  if (data instanceof ArrayBuffer) return new Uint8Array(data);
513
1166
  throw new Error("Expected Uint8Array or ArrayBuffer from socket data");
514
1167
  }
1168
+ /** Envelope encoder bound to a loopback server's connected protocol socket —
1169
+ * the server-initiated (backend → web) push seam. */
1170
+ /** Routed calls pending on one socket before it stops being read. */
1171
+ const MAX_PENDING_ROUTES = 32;
515
1172
  /** Builds the push seam for the server's connected protocol socket: encodes a
516
1173
  * `DISPATCH`-style envelope and writes it, mirroring how `attachWebSocketProtocol`
517
1174
  * writes responses. Returns false when no socket is connected. */
@@ -530,23 +1187,48 @@ function createLoopbackPush(server, protocol) {
530
1187
  function attachWebSocketProtocol(server, context) {
531
1188
  const { protocol, pluginRouter } = context;
532
1189
  server.onConnection((socket, _request) => {
533
- socket.on("data", async (raw) => {
534
- try {
535
- const data = toUint8Array$1(raw);
536
- if (data.byteLength === 0) return;
537
- const parsed = protocol.decode(data);
538
- const header = parsed.header;
539
- const pluginResponse = await pluginRouter.route(header, parsed.payload);
540
- if (pluginResponse) socket.write(protocol.encode(MessageType.ENVELOPE, pluginResponse, null));
541
- } catch (err) {
542
- const message = err instanceof Error ? err.message : String(err);
543
- console.error("Error handling WebSocket message:", message);
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;
544
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);
1218
+ }
1219
+ });
1220
+ socket.on("close", () => {
1221
+ decoder.clear();
1222
+ });
1223
+ socket.on("error", (err) => {
1224
+ const message = err instanceof Error ? err.message : String(err);
1225
+ console.error("WebSocket connection error:", message);
1226
+ decoder.clear();
1227
+ socket.destroy();
545
1228
  });
546
1229
  });
547
1230
  }
548
- //#endregion
549
- //#region core/runtime.ts
1231
+ // core/runtime.ts
550
1232
  function readBareArgv() {
551
1233
  return typeof Bare !== "undefined" && Array.isArray(Bare.argv) ? Bare.argv : [];
552
1234
  }
@@ -574,7 +1256,9 @@ function resolveWorkletConfig() {
574
1256
  cache: cacheDir
575
1257
  };
576
1258
  }
577
- } catch {}
1259
+ } catch (err) {
1260
+ console.debug(`[runtime] config dir check failed; not a device configuration: ` + (err instanceof Error ? err.message : String(err)));
1261
+ }
578
1262
  return resolveCliConfig(argv);
579
1263
  }
580
1264
  /**
@@ -633,7 +1317,7 @@ function toUint8Array(data) {
633
1317
  return new Uint8Array(data);
634
1318
  }
635
1319
  function writeIpc(ipc, bytes) {
636
- if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") ipc.write(Buffer.from(bytes));
1320
+ if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") ipc.write(Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes));
637
1321
  else ipc.write(bytes);
638
1322
  }
639
1323
  /**
@@ -646,14 +1330,17 @@ function createWorkletRuntime(options = {}) {
646
1330
  const deviceMode = options.deviceMode ?? options.storage !== void 0;
647
1331
  const auth = options.auth ?? deviceMode;
648
1332
  const port = options.port ?? (deviceMode ? 0 : 8080);
1333
+ const textEncoder = new TextEncoder();
1334
+ const textDecoder = new TextDecoder();
649
1335
  const protocol = new MessageProtocol({
650
- encode: (str) => new TextEncoder().encode(str),
651
- decode: (bytes) => new TextDecoder().decode(bytes)
1336
+ encode: (str) => textEncoder.encode(str),
1337
+ decode: (bytes) => textDecoder.decode(bytes)
652
1338
  });
653
1339
  let ipc;
654
1340
  try {
655
1341
  ipc = getIPC();
656
- } catch {
1342
+ } catch (err) {
1343
+ console.debug(`[runtime] no host IPC channel available, running without a host: ` + (err instanceof Error ? err.message : String(err)));
657
1344
  ipc = void 0;
658
1345
  }
659
1346
  const hostBridge = ipc ? createHostIpcBridge({
@@ -665,41 +1352,70 @@ function createWorkletRuntime(options = {}) {
665
1352
  host: options.host,
666
1353
  port,
667
1354
  auth,
668
- wsIdleTimeoutMs: options.wsIdleTimeoutMs
1355
+ wsIdleTimeoutMs: options.wsIdleTimeoutMs,
1356
+ wsDeadPeerTimeoutMs: options.wsDeadPeerTimeoutMs,
1357
+ wsReplaceMinIntervalMs: options.wsReplaceMinIntervalMs,
1358
+ wsMaxQueuedBytes: options.wsMaxQueuedBytes
669
1359
  });
1360
+ const logStore = createLogRingBuffer(options.logsCapacity ?? 500);
1361
+ installConsoleCapture(logStore, "backend", { minLevel: options.logsMinLevel });
1362
+ registerLogRoutes(server, logStore);
670
1363
  for (const plugin of createDefaultPlugins({
671
1364
  listBareCapabilities: () => pluginRegistry.listCapabilities(),
672
1365
  queryHostCapabilities: () => hostBridge?.queryCapabilities() ?? Promise.resolve([]),
673
1366
  staticServer: server,
674
- invokeOnHost: (header, payload) => hostBridge?.invokeOnHost(header, payload) ?? Promise.resolve(null)
1367
+ invokeOnHost: (header, payload) => hostBridge?.invokeOnHost(header, payload) ?? Promise.resolve(null),
1368
+ store: logStore
675
1369
  })) pluginRegistry.register(plugin);
676
1370
  for (const plugin of options.plugins ?? []) pluginRegistry.register(plugin);
677
- const pluginRouter = createPluginRouter(pluginRegistry, "bare", { delegateToHost: (header, payload) => hostBridge?.invokeOnHost(header, payload) ?? Promise.resolve(null) });
678
- if (ipc) ipc.on("data", async (data) => {
679
- try {
680
- const raw = toUint8Array(data);
681
- if (hostBridge?.tryConsumeDownstreamFromHost(raw)) return;
682
- const parsed = protocol.decode(raw);
683
- const header = parsed.header;
684
- const pluginResponse = await pluginRouter.route(header, parsed.payload);
685
- if (pluginResponse) writeIpc(ipc, protocol.encode(MessageType.ENVELOPE, pluginResponse, null));
686
- } catch (e) {
687
- const message = e instanceof Error ? e.message : String(e);
688
- console.error(`Malformed IPC frame dropped: ${message}`);
689
- }
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([])
690
1376
  });
1377
+ 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
+ }
1403
+ });
1404
+ }
691
1405
  async function start() {
692
1406
  if (options.storage) try {
693
1407
  fs.rmSync(path.join(options.storage, "handoff.json"), { force: true });
694
- } catch {}
695
- if (options.webAssets) server.mountDir("/", options.webAssets);
1408
+ } catch {
1409
+ console.debug("[runtime] no existing handoff file to remove");
1410
+ }
1411
+ if (options.webAssets) server.mountDir("/", options.webAssets, { public: true });
696
1412
  attachWebSocketProtocol(server, {
697
1413
  protocol,
698
1414
  pluginRegistry,
699
1415
  pluginRouter
700
1416
  });
701
1417
  const credentials = await server.credentials();
702
- if (auth && options.storage) try {
1418
+ if (auth && options.storage && options.handoffFile !== false) try {
703
1419
  fs.writeFileSync(path.join(options.storage, "handoff.json"), JSON.stringify(credentials));
704
1420
  } catch (err) {
705
1421
  const message = err instanceof Error ? err.message : String(err);
@@ -725,5 +1441,4 @@ function createWorkletRuntime(options = {}) {
725
1441
  }
726
1442
  };
727
1443
  }
728
- //#endregion
729
1444
  export { attachWebSocketProtocol, collectRequestBody, createLoopbackPush, createLoopbackServer, createWorkletRuntime, getIPC, resolveCliConfig, resolveWorkletConfig };