@ekrooh/bare 0.3.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/README.md +129 -195
- package/dist/{index-DjulYFJL.d.mts → index-BkwUzwVt.d.mts} +94 -20
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1 -512
- package/dist/messages-BbpF3wNH.mjs +849 -0
- package/dist/{plugins-1KweEBct.mjs → plugins-DZu_NWVp.mjs} +46 -18
- package/dist/plugins-discovery-events.d.mts +5 -8
- package/dist/plugins-discovery-events.mjs +2 -3
- package/dist/plugins-health-events.d.mts +4 -7
- package/dist/plugins-health-events.mjs +2 -3
- package/dist/plugins-logs-events.d.mts +34 -0
- package/dist/plugins-logs-events.mjs +25 -0
- package/dist/plugins-media-events.d.mts +6 -11
- package/dist/plugins-media-events.mjs +2 -3
- package/dist/plugins-permissions-events.d.mts +7 -10
- package/dist/plugins-permissions-events.mjs +2 -3
- package/dist/plugins.d.mts +9 -11
- package/dist/plugins.mjs +1 -2
- package/dist/runtime.d.mts +49 -28
- package/dist/runtime.mjs +861 -145
- package/dist/{static-file-server-DY9EKEVf.d.mts → static-file-server-C8pIX1JD.d.mts} +44 -17
- package/dist/store-DTg1jN5E.mjs +49 -0
- package/dist/transports.d.mts +14 -11
- package/dist/transports.mjs +170 -82
- package/package.json +13 -8
package/dist/runtime.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t as createDefaultPlugins } from "./plugins-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
/**
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return
|
|
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
|
-
|
|
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.
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* non-browser clients.
|
|
213
|
-
* token
|
|
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
|
-
|
|
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 (
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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=${
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
rel: best.rel
|
|
756
|
+
...best,
|
|
757
|
+
kind: "dir"
|
|
286
758
|
} : null;
|
|
287
759
|
}
|
|
288
|
-
/** Resolves a directory-mount request to a concrete file
|
|
289
|
-
* fallback (unknown GET
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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":
|
|
843
|
+
"Content-Type": contentType,
|
|
337
844
|
"Content-Length": stat.size,
|
|
338
845
|
"Accept-Ranges": "bytes",
|
|
339
|
-
|
|
846
|
+
...cspHeader,
|
|
340
847
|
"Referrer-Policy": "no-referrer",
|
|
341
|
-
|
|
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
|
|
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
|
|
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
|
|
904
|
+
if (!(mount !== null && mount.kind === "dir" && mount.public) && !isAuthorized(headers)) {
|
|
394
905
|
writeError(res, 401, "Unauthorized");
|
|
395
906
|
return;
|
|
396
907
|
}
|
|
397
|
-
|
|
398
|
-
if (filePath === null) {
|
|
908
|
+
if (mount === null) {
|
|
399
909
|
writeError(res, 404, "Not found");
|
|
400
910
|
return;
|
|
401
911
|
}
|
|
402
|
-
|
|
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 ??=
|
|
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
|
|
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
|
|
424
|
-
|
|
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
|
|
430
|
-
|
|
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
|
|
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
|
-
|
|
964
|
+
reject(`handshake failed (${err.message})`);
|
|
440
965
|
return;
|
|
441
966
|
}
|
|
442
|
-
const
|
|
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
|
-
|
|
448
|
-
|
|
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, () =>
|
|
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 (!
|
|
1050
|
+
if (!activeWriter) return false;
|
|
493
1051
|
try {
|
|
494
|
-
|
|
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
|
-
|
|
509
|
-
|
|
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,33 +1187,59 @@ function createLoopbackPush(server, protocol) {
|
|
|
530
1187
|
function attachWebSocketProtocol(server, context) {
|
|
531
1188
|
const { protocol, pluginRouter } = context;
|
|
532
1189
|
server.onConnection((socket, _request) => {
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
console.
|
|
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
|
-
|
|
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
|
}
|
|
553
1235
|
/** Resolves the host-provided worklet configuration from `start(...)`
|
|
554
|
-
* arguments. On-device hosts pass `["<webAssets>", "<storage>", "<cache>"]
|
|
555
|
-
* landing at `Bare.argv[0..2]`;
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
* same entry works for both
|
|
1236
|
+
* 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. */
|
|
560
1243
|
function resolveWorkletConfig() {
|
|
561
1244
|
const argv = readBareArgv();
|
|
562
1245
|
const webAssets = argv[0];
|
|
@@ -566,14 +1249,16 @@ function resolveWorkletConfig() {
|
|
|
566
1249
|
const isDir = (p) => fs.statSync(p).isDirectory();
|
|
567
1250
|
if (isDir(webAssets) && isDir(storage)) {
|
|
568
1251
|
const cacheDir = typeof cache === "string" && isDir(cache) ? cache : storage;
|
|
569
|
-
if (cacheDir !== cache) console.warn("[bare] cache dir missing or not a directory; falling back to the storage dir");
|
|
1252
|
+
if (cacheDir !== cache) console.warn("[bare] cache dir missing or not a directory; falling back to the storage dir. Pass three dirs in order to the worklet: [webAssets, storage, cache].");
|
|
570
1253
|
return {
|
|
571
1254
|
webAssets,
|
|
572
1255
|
storage,
|
|
573
1256
|
cache: cacheDir
|
|
574
1257
|
};
|
|
575
1258
|
}
|
|
576
|
-
} catch {
|
|
1259
|
+
} catch (err) {
|
|
1260
|
+
console.debug(`[runtime] config dir check failed; not a device configuration: ` + (err instanceof Error ? err.message : String(err)));
|
|
1261
|
+
}
|
|
577
1262
|
return resolveCliConfig(argv);
|
|
578
1263
|
}
|
|
579
1264
|
/**
|
|
@@ -632,7 +1317,7 @@ function toUint8Array(data) {
|
|
|
632
1317
|
return new Uint8Array(data);
|
|
633
1318
|
}
|
|
634
1319
|
function writeIpc(ipc, bytes) {
|
|
635
|
-
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));
|
|
636
1321
|
else ipc.write(bytes);
|
|
637
1322
|
}
|
|
638
1323
|
/**
|
|
@@ -645,14 +1330,17 @@ function createWorkletRuntime(options = {}) {
|
|
|
645
1330
|
const deviceMode = options.deviceMode ?? options.storage !== void 0;
|
|
646
1331
|
const auth = options.auth ?? deviceMode;
|
|
647
1332
|
const port = options.port ?? (deviceMode ? 0 : 8080);
|
|
1333
|
+
const textEncoder = new TextEncoder();
|
|
1334
|
+
const textDecoder = new TextDecoder();
|
|
648
1335
|
const protocol = new MessageProtocol({
|
|
649
|
-
encode: (str) =>
|
|
650
|
-
decode: (bytes) =>
|
|
1336
|
+
encode: (str) => textEncoder.encode(str),
|
|
1337
|
+
decode: (bytes) => textDecoder.decode(bytes)
|
|
651
1338
|
});
|
|
652
1339
|
let ipc;
|
|
653
1340
|
try {
|
|
654
1341
|
ipc = getIPC();
|
|
655
|
-
} catch {
|
|
1342
|
+
} catch (err) {
|
|
1343
|
+
console.debug(`[runtime] no host IPC channel available, running without a host: ` + (err instanceof Error ? err.message : String(err)));
|
|
656
1344
|
ipc = void 0;
|
|
657
1345
|
}
|
|
658
1346
|
const hostBridge = ipc ? createHostIpcBridge({
|
|
@@ -664,41 +1352,70 @@ function createWorkletRuntime(options = {}) {
|
|
|
664
1352
|
host: options.host,
|
|
665
1353
|
port,
|
|
666
1354
|
auth,
|
|
667
|
-
wsIdleTimeoutMs: options.wsIdleTimeoutMs
|
|
1355
|
+
wsIdleTimeoutMs: options.wsIdleTimeoutMs,
|
|
1356
|
+
wsDeadPeerTimeoutMs: options.wsDeadPeerTimeoutMs,
|
|
1357
|
+
wsReplaceMinIntervalMs: options.wsReplaceMinIntervalMs,
|
|
1358
|
+
wsMaxQueuedBytes: options.wsMaxQueuedBytes
|
|
668
1359
|
});
|
|
1360
|
+
const logStore = createLogRingBuffer(options.logsCapacity ?? 500);
|
|
1361
|
+
installConsoleCapture(logStore, "backend", { minLevel: options.logsMinLevel });
|
|
1362
|
+
registerLogRoutes(server, logStore);
|
|
669
1363
|
for (const plugin of createDefaultPlugins({
|
|
670
1364
|
listBareCapabilities: () => pluginRegistry.listCapabilities(),
|
|
671
1365
|
queryHostCapabilities: () => hostBridge?.queryCapabilities() ?? Promise.resolve([]),
|
|
672
1366
|
staticServer: server,
|
|
673
|
-
invokeOnHost: (header, payload) => hostBridge?.invokeOnHost(header, payload) ?? Promise.resolve(null)
|
|
1367
|
+
invokeOnHost: (header, payload) => hostBridge?.invokeOnHost(header, payload) ?? Promise.resolve(null),
|
|
1368
|
+
store: logStore
|
|
674
1369
|
})) pluginRegistry.register(plugin);
|
|
675
1370
|
for (const plugin of options.plugins ?? []) pluginRegistry.register(plugin);
|
|
676
|
-
const pluginRouter = createPluginRouter(pluginRegistry, "bare", {
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
const parsed = protocol.decode(raw);
|
|
682
|
-
const header = parsed.header;
|
|
683
|
-
const pluginResponse = await pluginRouter.route(header, parsed.payload);
|
|
684
|
-
if (pluginResponse) writeIpc(ipc, protocol.encode(MessageType.ENVELOPE, pluginResponse, null));
|
|
685
|
-
} catch (e) {
|
|
686
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
687
|
-
console.error(`Malformed IPC frame dropped: ${message}`);
|
|
688
|
-
}
|
|
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([])
|
|
689
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
|
+
}
|
|
690
1405
|
async function start() {
|
|
691
1406
|
if (options.storage) try {
|
|
692
1407
|
fs.rmSync(path.join(options.storage, "handoff.json"), { force: true });
|
|
693
|
-
} catch {
|
|
694
|
-
|
|
1408
|
+
} catch {
|
|
1409
|
+
console.debug("[runtime] no existing handoff file to remove");
|
|
1410
|
+
}
|
|
1411
|
+
if (options.webAssets) server.mountDir("/", options.webAssets, { public: true });
|
|
695
1412
|
attachWebSocketProtocol(server, {
|
|
696
1413
|
protocol,
|
|
697
1414
|
pluginRegistry,
|
|
698
1415
|
pluginRouter
|
|
699
1416
|
});
|
|
700
1417
|
const credentials = await server.credentials();
|
|
701
|
-
if (auth && options.storage) try {
|
|
1418
|
+
if (auth && options.storage && options.handoffFile !== false) try {
|
|
702
1419
|
fs.writeFileSync(path.join(options.storage, "handoff.json"), JSON.stringify(credentials));
|
|
703
1420
|
} catch (err) {
|
|
704
1421
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -724,5 +1441,4 @@ function createWorkletRuntime(options = {}) {
|
|
|
724
1441
|
}
|
|
725
1442
|
};
|
|
726
1443
|
}
|
|
727
|
-
//#endregion
|
|
728
1444
|
export { attachWebSocketProtocol, collectRequestBody, createLoopbackPush, createLoopbackServer, createWorkletRuntime, getIPC, resolveCliConfig, resolveWorkletConfig };
|