@ekrooh/bare 0.1.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.
@@ -0,0 +1,637 @@
1
+ import { MessageProtocol, MessageType, createPluginRegistry, createPluginRouter } from "./index.mjs";
2
+ import { t as createDefaultPlugins } from "./plugins-1KweEBct.mjs";
3
+ import { TextDecoder, TextEncoder } from "bare-encoding";
4
+ import fs from "bare-fs";
5
+ import path from "bare-path";
6
+ import http from "bare-http1";
7
+ import ws from "bare-ws";
8
+ import crypto from "bare-crypto";
9
+ //#region core/lib/get-ipc.ts
10
+ /**
11
+ * Returns the active IPC channel.
12
+ * In BareKit (Android), it uses BareKit.IPC.
13
+ * In Bare (Sidecar), it uses Bare.IPC.
14
+ */
15
+ function getIPC() {
16
+ const IPC = typeof BareKit !== "undefined" ? BareKit.IPC : Bare.IPC;
17
+ if (IPC && typeof IPC.resume === "function") IPC.resume();
18
+ return IPC;
19
+ }
20
+ //#endregion
21
+ //#region core/messages/host-ipc.ts
22
+ function toUint8Array$2(data) {
23
+ if (data instanceof Uint8Array) return data;
24
+ if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) return new Uint8Array(data);
25
+ return new Uint8Array(data);
26
+ }
27
+ function newCorrelationId() {
28
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
29
+ }
30
+ function writeEncoded(ipc, bytes) {
31
+ if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") ipc.write(Buffer.from(bytes));
32
+ else ipc.write(bytes);
33
+ }
34
+ function createHostIpcBridge(config) {
35
+ const { ipc, protocol } = config;
36
+ const pending = /* @__PURE__ */ new Map();
37
+ function waitFor(requestId, expectedType, timeoutMs) {
38
+ return new Promise((resolve, reject) => {
39
+ const timer = setTimeout(() => {
40
+ pending.delete(requestId);
41
+ reject(/* @__PURE__ */ new Error(`Host IPC timeout waiting for ${String(expectedType)} (${requestId})`));
42
+ }, timeoutMs);
43
+ pending.set(requestId, {
44
+ timer,
45
+ resolve: (h) => {
46
+ clearTimeout(timer);
47
+ pending.delete(requestId);
48
+ if (h.type === expectedType) {
49
+ resolve(h);
50
+ return;
51
+ }
52
+ reject(/* @__PURE__ */ new Error(`Unexpected host response type ${h.type}, expected ${String(expectedType)}`));
53
+ }
54
+ });
55
+ });
56
+ }
57
+ return {
58
+ tryConsumeDownstreamFromHost(raw) {
59
+ let msg;
60
+ try {
61
+ msg = protocol.decode(toUint8Array$2(raw));
62
+ } catch {
63
+ return false;
64
+ }
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;
75
+ },
76
+ async queryCapabilities(timeoutMs = 5e3) {
77
+ const requestId = newCorrelationId();
78
+ const promise = waitFor(requestId, "HOST_CAPABILITIES_RESPONSE", timeoutMs);
79
+ const header = {
80
+ type: "HOST_CAPABILITIES_QUERY",
81
+ requestId
82
+ };
83
+ writeEncoded(ipc, protocol.encode(MessageType.ENVELOPE, header, null));
84
+ return (await promise).capabilities ?? [];
85
+ },
86
+ async invokeOnHost(header, payload, timeoutMs = 3e5) {
87
+ const requestId = header.requestId;
88
+ if (!requestId) return null;
89
+ const promise = waitFor(requestId, "HOST_INVOKE_RESPONSE", timeoutMs);
90
+ const hostHeader = {
91
+ type: "HOST_INVOKE_REQUEST",
92
+ requestId,
93
+ pluginId: header.pluginId,
94
+ event: header.event,
95
+ args: header.args
96
+ };
97
+ writeEncoded(ipc, protocol.encode(MessageType.ENVELOPE, hostHeader, payload));
98
+ const hostResp = await promise;
99
+ return {
100
+ type: "INVOKE_RESPONSE",
101
+ pluginId: hostResp.pluginId,
102
+ event: hostResp.event,
103
+ requestId: hostResp.requestId,
104
+ result: hostResp.result,
105
+ error: hostResp.error
106
+ };
107
+ }
108
+ };
109
+ }
110
+ //#endregion
111
+ //#region core/server/static-file-server.ts
112
+ const MIME_BY_EXTENSION = {
113
+ html: "text/html",
114
+ js: "text/javascript",
115
+ mjs: "text/javascript",
116
+ css: "text/css",
117
+ json: "application/json",
118
+ jpg: "image/jpeg",
119
+ jpeg: "image/jpeg",
120
+ png: "image/png",
121
+ gif: "image/gif",
122
+ webp: "image/webp",
123
+ svg: "image/svg+xml",
124
+ heic: "image/heic",
125
+ heif: "image/heif",
126
+ mp4: "video/mp4",
127
+ mov: "video/quicktime",
128
+ m4v: "video/x-m4v",
129
+ woff: "font/woff",
130
+ woff2: "font/woff2"
131
+ };
132
+ function mimeTypeFor(filePath) {
133
+ const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
134
+ return MIME_BY_EXTENSION[ext] ?? "application/octet-stream";
135
+ }
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;
144
+ }
145
+ /** Reads the token from the `X-Bare-Token` request header (parser-lowercased).
146
+ * `fetch`-based non-browser clients that cannot carry cookies use this. */
147
+ function tokenFromHeaders(headers) {
148
+ const value = headers["x-bare-token"];
149
+ return typeof value === "string" ? value : null;
150
+ }
151
+ /** Reads the `bare_session` value from the `Cookie` request header. */
152
+ function cookieSession(headers) {
153
+ const cookie = headers["cookie"];
154
+ if (typeof cookie !== "string") return null;
155
+ for (const part of cookie.split(";")) {
156
+ const eq = part.indexOf("=");
157
+ if (eq === -1) continue;
158
+ if (part.slice(0, eq).trim() === "bare_session") return part.slice(eq + 1).trim();
159
+ }
160
+ return null;
161
+ }
162
+ /** Derives the cookie value from the session token so the token itself is not
163
+ * echoed into a request header a local attacker could read back. */
164
+ function sessionNonce(token) {
165
+ return crypto.createHash("blake2b-256").update(token).digest("hex");
166
+ }
167
+ /** Parses a `Range: bytes=<start>-<end>` header. Returns `null` when absent or
168
+ * unsatisfiable (caller falls back to a 200 full response). */
169
+ function parseRange(value, size) {
170
+ if (typeof value !== "string") return null;
171
+ const match = /^bytes=(\d*)-(\d*)$/.exec(value.trim());
172
+ if (!match) return null;
173
+ let start = match[1] === "" ? NaN : parseInt(match[1], 10);
174
+ let end = match[2] === "" ? NaN : parseInt(match[2], 10);
175
+ if (Number.isNaN(start) && Number.isNaN(end)) return null;
176
+ if (Number.isNaN(start)) {
177
+ start = Math.max(0, size - end);
178
+ end = size - 1;
179
+ } else {
180
+ if (Number.isNaN(end)) end = size - 1;
181
+ end = Math.min(end, size - 1);
182
+ }
183
+ if (start >= size || start > end) return null;
184
+ return {
185
+ start,
186
+ end
187
+ };
188
+ }
189
+ /** Accumulates an HTTP request body into a UTF-8 string. Node http emits
190
+ * Buffers, bare-http1 emits Uint8Arrays, and a setEncoding'd stream emits
191
+ * strings — `String(uint8array)` would join bytes with commas, so decode
192
+ * explicitly (the `/login` token body must match byte-for-byte). */
193
+ function collectRequestBody(req) {
194
+ return new Promise((resolve) => {
195
+ const decoder = new TextDecoder();
196
+ let body = "";
197
+ req.on("data", (chunk) => {
198
+ body += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
199
+ });
200
+ req.on("end", () => resolve(body + decoder.decode(/* @__PURE__ */ new Uint8Array(0))));
201
+ });
202
+ }
203
+ /**
204
+ * The single loopback HTTP+WS server for the worklet. One origin serves the
205
+ * web app (`/`), the media files mounted by plugins, and the framed-protocol
206
+ * 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.
214
+ */
215
+ function createLoopbackServer(options = {}) {
216
+ const host = options.host ?? "127.0.0.1";
217
+ const port = options.port ?? 0;
218
+ const authEnabled = options.auth ?? true;
219
+ const wsIdleTimeoutMs = options.wsIdleTimeoutMs ?? 6e4;
220
+ const fileMounts = /* @__PURE__ */ new Map();
221
+ const dirMounts = /* @__PURE__ */ new Map();
222
+ const connectionHandlers = [];
223
+ let token = options.token ?? null;
224
+ let boundOrigin = "";
225
+ let originPromise = null;
226
+ let activeSocket = null;
227
+ function isAuthorized(headers, query) {
228
+ if (!authEnabled) return true;
229
+ if (token === null) return false;
230
+ if (tokenFromQuery(query) === token) return true;
231
+ if (tokenFromHeaders(headers) === token) return true;
232
+ return cookieSession(headers) === sessionNonce(token);
233
+ }
234
+ function writeError(res, status, message) {
235
+ res.writeHead(status, {
236
+ "Content-Type": "text/plain",
237
+ "Referrer-Policy": "no-referrer"
238
+ });
239
+ res.end(message);
240
+ }
241
+ function handleLogin(req, res) {
242
+ collectRequestBody(req).then((body) => {
243
+ if (!(!authEnabled || token !== null && body.trim() === token)) {
244
+ writeError(res, 401, "Unauthorized");
245
+ return;
246
+ }
247
+ const headers = {
248
+ "Content-Type": "text/plain",
249
+ "Cache-Control": "no-store",
250
+ "Referrer-Policy": "no-referrer"
251
+ };
252
+ if (authEnabled && token !== null) headers["Set-Cookie"] = `bare_session=${sessionNonce(token)}; HttpOnly; SameSite=Lax; Path=/`;
253
+ res.writeHead(200, headers);
254
+ res.end("ok");
255
+ });
256
+ }
257
+ /** Resolves a request path to a file mount, or a directory mount + relative
258
+ * path. Directory mounts win on longest-prefix match. */
259
+ function resolveMount(cleanPath) {
260
+ const fileMount = fileMounts.get(cleanPath);
261
+ if (fileMount) return {
262
+ kind: "file",
263
+ filePath: fileMount
264
+ };
265
+ let best = null;
266
+ let bestPrefix = -1;
267
+ for (const [prefix, dirPath] of dirMounts) {
268
+ let rel;
269
+ if (cleanPath === prefix) rel = "";
270
+ else if (prefix === "/") rel = cleanPath.replace(/^\/+/, "");
271
+ else if (cleanPath.startsWith(`${prefix}/`)) rel = cleanPath.slice(prefix.length + 1);
272
+ else continue;
273
+ if (prefix.length > bestPrefix) {
274
+ best = {
275
+ dirPath,
276
+ rel
277
+ };
278
+ bestPrefix = prefix.length;
279
+ }
280
+ }
281
+ return best ? {
282
+ kind: "dir",
283
+ dirPath: best.dirPath,
284
+ rel: best.rel
285
+ } : null;
286
+ }
287
+ /** Resolves a directory-mount request to a concrete file, applying the SPA
288
+ * fallback (unknown GET navigation → `index.html`). Subresource requests
289
+ * (`Accept` without `text/html`) never fall back — a missing asset is a 404. */
290
+ function resolveDirFile(mount, headers) {
291
+ const candidate = mount.rel === "" ? mount.dirPath : path.join(mount.dirPath, mount.rel);
292
+ try {
293
+ if (fs.statSync(candidate).isFile()) return candidate;
294
+ } catch {}
295
+ if (mount.rel === "") return indexHtmlIfPresent(mount.dirPath);
296
+ const accept = headers["accept"];
297
+ if (typeof accept !== "string" || !accept.includes("text/html")) return null;
298
+ return indexHtmlIfPresent(mount.dirPath);
299
+ }
300
+ function indexHtmlIfPresent(dirPath) {
301
+ try {
302
+ const index = path.join(dirPath, "index.html");
303
+ if (fs.statSync(index).isFile()) return index;
304
+ } catch {}
305
+ return null;
306
+ }
307
+ function serveFile(req, res, filePath) {
308
+ let stat;
309
+ try {
310
+ stat = fs.statSync(filePath);
311
+ } catch {
312
+ writeError(res, 404, "Not found");
313
+ return;
314
+ }
315
+ if (!stat.isFile()) {
316
+ writeError(res, 404, "Not found");
317
+ return;
318
+ }
319
+ const range = parseRange(req.headers["range"], stat.size);
320
+ if (range) {
321
+ res.writeHead(206, {
322
+ "Content-Type": mimeTypeFor(filePath),
323
+ "Content-Range": `bytes ${range.start}-${range.end}/${stat.size}`,
324
+ "Content-Length": range.end - range.start + 1,
325
+ "Accept-Ranges": "bytes",
326
+ "Access-Control-Allow-Origin": "*",
327
+ "Referrer-Policy": "no-referrer",
328
+ "Cache-Control": "no-store"
329
+ });
330
+ res.flushHeaders();
331
+ pipeFile(res, filePath, range);
332
+ return;
333
+ }
334
+ res.writeHead(200, {
335
+ "Content-Type": mimeTypeFor(filePath),
336
+ "Content-Length": stat.size,
337
+ "Accept-Ranges": "bytes",
338
+ "Access-Control-Allow-Origin": "*",
339
+ "Referrer-Policy": "no-referrer",
340
+ "Cache-Control": "no-store"
341
+ });
342
+ res.flushHeaders();
343
+ pipeFile(res, filePath);
344
+ }
345
+ /** Pipes a file's bytes to the response with an error handler. bare-fs opens
346
+ * its fd asynchronously, so a file evicted between `statSync` and the open
347
+ * (e.g. the spool LRU eviction in #8) emits `'error'` with no listeners —
348
+ * an unhandled error would crash the whole worklet. Headers are flushed
349
+ * before piping, so a mid-stream error aborts the response rather than
350
+ * writing a late status. */
351
+ function pipeFile(res, filePath, range) {
352
+ const stream = range ? fs.createReadStream(filePath, {
353
+ start: range.start,
354
+ end: range.end
355
+ }) : fs.createReadStream(filePath);
356
+ stream.on("error", (err) => {
357
+ if (!res.headersSent) {
358
+ writeError(res, err.code === "ENOENT" ? 404 : 500, "Failed to read file");
359
+ return;
360
+ }
361
+ res.destroy();
362
+ });
363
+ stream.pipe(res);
364
+ }
365
+ const server = http.createServer((req, res) => {
366
+ try {
367
+ const [rawPath, query = ""] = (req.url ?? "/").split("?");
368
+ const cleanPath = rawPath.split("#")[0].replace(/\/+$/, "") || "/";
369
+ const headers = req.headers;
370
+ if (req.method === "POST" && cleanPath === "/login") {
371
+ handleLogin(req, res);
372
+ return;
373
+ }
374
+ if (cleanPath.split("/").includes("..")) {
375
+ writeError(res, 400, "Bad request");
376
+ return;
377
+ }
378
+ const mount = resolveMount(cleanPath);
379
+ if (!(mount !== null && mount.kind === "dir") && !isAuthorized(headers, query)) {
380
+ writeError(res, 401, "Unauthorized");
381
+ return;
382
+ }
383
+ const filePath = mount === null ? null : mount.kind === "file" ? mount.filePath : resolveDirFile(mount, headers);
384
+ if (filePath === null) {
385
+ writeError(res, 404, "Not found");
386
+ return;
387
+ }
388
+ serveFile(req, res, filePath);
389
+ } catch (err) {
390
+ const message = err instanceof Error ? err.message : String(err);
391
+ res.writeHead(500, { "Content-Type": "text/plain" });
392
+ res.end(message);
393
+ }
394
+ });
395
+ function origin() {
396
+ if (!originPromise) originPromise = new Promise((resolve, reject) => {
397
+ token ??= crypto.randomBytes(32).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
398
+ server.on("error", reject);
399
+ server.listen(port, host, () => {
400
+ const address = server.address();
401
+ boundOrigin = `http://${host}:${address.port}`;
402
+ resolve(boundOrigin);
403
+ });
404
+ });
405
+ return originPromise;
406
+ }
407
+ server.on("upgrade", (req, socket, head) => {
408
+ const headers = req.headers;
409
+ const [, query = ""] = (req.url ?? "").split("?");
410
+ const origin = headers["origin"];
411
+ if (authEnabled && typeof origin === "string" && origin.length > 0 && boundOrigin !== "" && origin !== boundOrigin) {
412
+ socket.destroy();
413
+ return;
414
+ }
415
+ if (!isAuthorized(headers, query)) {
416
+ socket.destroy();
417
+ return;
418
+ }
419
+ if (activeSocket) {
420
+ socket.destroy();
421
+ return;
422
+ }
423
+ ws.Server.handshake(req, socket, head, (err) => {
424
+ if (err) {
425
+ socket.destroy(err);
426
+ return;
427
+ }
428
+ const client = new ws.Socket({
429
+ socket,
430
+ isServer: true
431
+ });
432
+ activeSocket = client;
433
+ client.on("close", () => {
434
+ if (activeSocket === client) activeSocket = null;
435
+ });
436
+ socket.setTimeout(wsIdleTimeoutMs, () => client.destroy());
437
+ for (const handler of connectionHandlers) try {
438
+ handler(client, req);
439
+ } catch (e) {
440
+ const message = e instanceof Error ? e.message : String(e);
441
+ console.error("Error in loopback WS connection handler:", message);
442
+ }
443
+ });
444
+ });
445
+ async function url(path) {
446
+ return `${await origin()}${path}`;
447
+ }
448
+ return {
449
+ origin,
450
+ url,
451
+ token() {
452
+ return token ?? "";
453
+ },
454
+ async credentials() {
455
+ const originUrl = await origin();
456
+ return {
457
+ origin: originUrl,
458
+ port: Number(originUrl.slice(originUrl.lastIndexOf(":") + 1)),
459
+ token
460
+ };
461
+ },
462
+ mount(path, filePath) {
463
+ fileMounts.set(path, filePath);
464
+ },
465
+ unmount(path) {
466
+ fileMounts.delete(path);
467
+ },
468
+ mountDir(prefix, dirPath) {
469
+ dirMounts.set(prefix, dirPath);
470
+ },
471
+ onConnection(handler) {
472
+ connectionHandlers.push(handler);
473
+ },
474
+ close(cb) {
475
+ if (activeSocket) try {
476
+ activeSocket.destroy();
477
+ } catch {}
478
+ server.close(cb);
479
+ }
480
+ };
481
+ }
482
+ //#endregion
483
+ //#region core/server/websocket-server.ts
484
+ function toUint8Array$1(data) {
485
+ if (data instanceof Uint8Array) return data;
486
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
487
+ throw new Error("Expected Uint8Array or ArrayBuffer from socket data");
488
+ }
489
+ /**
490
+ * Attaches the framed-protocol socket to the unified loopback server. The
491
+ * server owns the WebSocket upgrade (origin check, cookie/token auth, single
492
+ * client, idle timeout); this layer only routes decoded envelopes through the
493
+ * plugin router and writes responses back as raw frames.
494
+ */
495
+ function attachWebSocketProtocol(server, context) {
496
+ const { protocol, pluginRouter } = context;
497
+ server.onConnection((socket, _request) => {
498
+ socket.on("data", async (raw) => {
499
+ try {
500
+ const data = toUint8Array$1(raw);
501
+ if (data.byteLength === 0) return;
502
+ const parsed = protocol.decode(data);
503
+ const header = parsed.header;
504
+ const pluginResponse = await pluginRouter.route(header, parsed.payload);
505
+ if (pluginResponse) socket.write(protocol.encode(MessageType.ENVELOPE, pluginResponse, null));
506
+ } catch (err) {
507
+ const message = err instanceof Error ? err.message : String(err);
508
+ console.error("Error handling WebSocket message:", message);
509
+ }
510
+ });
511
+ });
512
+ }
513
+ //#endregion
514
+ //#region core/runtime.ts
515
+ /** Resolves the host-provided worklet configuration from `start(...)`
516
+ * arguments. On-device hosts pass `["<webAssets>", "<storage>", "<cache>"]`,
517
+ * landing at `Bare.argv[0..2]`; the Bare CLI (dev) passes the binary and the
518
+ * script path instead, so a missing/absent config means dev mode (auth off,
519
+ * fixed port, no handoff file). */
520
+ function resolveWorkletConfig() {
521
+ const argv = typeof Bare !== "undefined" && Array.isArray(Bare.argv) ? Bare.argv : [];
522
+ const webAssets = argv[0];
523
+ const storage = argv[1];
524
+ const cache = argv[2];
525
+ if (typeof webAssets === "string" && typeof storage === "string") try {
526
+ const isDir = (p) => fs.statSync(p).isDirectory();
527
+ if (isDir(webAssets) && isDir(storage)) {
528
+ const cacheDir = typeof cache === "string" && isDir(cache) ? cache : storage;
529
+ if (cacheDir !== cache) console.warn("[bare] cache dir missing or not a directory; falling back to the storage dir");
530
+ return {
531
+ webAssets,
532
+ storage,
533
+ cache: cacheDir
534
+ };
535
+ }
536
+ } catch {}
537
+ return {};
538
+ }
539
+ function toUint8Array(data) {
540
+ if (data instanceof Uint8Array) return data;
541
+ if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) return new Uint8Array(data);
542
+ return new Uint8Array(data);
543
+ }
544
+ function writeIpc(ipc, bytes) {
545
+ if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") ipc.write(Buffer.from(bytes));
546
+ else ipc.write(bytes);
547
+ }
548
+ /**
549
+ * Creates one worklet runtime — a per-instance, stateless factory (no
550
+ * module-scope singletons, no hardcoded port) so the dev/test backend can host
551
+ * N of them and a consumer worklet entry can be just "register plugins +
552
+ * wire p2p". Mirrors the reference wiring in `core/main.core.ts`.
553
+ */
554
+ function createWorkletRuntime(options = {}) {
555
+ const deviceMode = options.storage !== void 0;
556
+ const auth = options.auth ?? deviceMode;
557
+ const port = options.port ?? (deviceMode ? 0 : 8080);
558
+ const protocol = new MessageProtocol({
559
+ encode: (str) => new TextEncoder().encode(str),
560
+ decode: (bytes) => new TextDecoder().decode(bytes)
561
+ });
562
+ let ipc;
563
+ try {
564
+ ipc = getIPC();
565
+ } catch {
566
+ ipc = void 0;
567
+ }
568
+ const hostBridge = ipc ? createHostIpcBridge({
569
+ ipc,
570
+ protocol
571
+ }) : null;
572
+ const pluginRegistry = createPluginRegistry();
573
+ const server = createLoopbackServer({
574
+ host: options.host,
575
+ port,
576
+ auth,
577
+ wsIdleTimeoutMs: options.wsIdleTimeoutMs
578
+ });
579
+ for (const plugin of createDefaultPlugins({
580
+ listBareCapabilities: () => pluginRegistry.listCapabilities(),
581
+ queryHostCapabilities: () => hostBridge?.queryCapabilities() ?? Promise.resolve([]),
582
+ staticServer: server,
583
+ invokeOnHost: (header, payload) => hostBridge?.invokeOnHost(header, payload) ?? Promise.resolve(null)
584
+ })) pluginRegistry.register(plugin);
585
+ const pluginRouter = createPluginRouter(pluginRegistry, "bare", { delegateToHost: (header, payload) => hostBridge?.invokeOnHost(header, payload) ?? Promise.resolve(null) });
586
+ if (ipc) ipc.on("data", async (data) => {
587
+ try {
588
+ const raw = toUint8Array(data);
589
+ if (hostBridge?.tryConsumeDownstreamFromHost(raw)) return;
590
+ const parsed = protocol.decode(raw);
591
+ const header = parsed.header;
592
+ const pluginResponse = await pluginRouter.route(header, parsed.payload);
593
+ if (pluginResponse) writeIpc(ipc, protocol.encode(MessageType.ENVELOPE, pluginResponse, null));
594
+ } catch (e) {
595
+ const message = e instanceof Error ? e.message : String(e);
596
+ console.error(`Malformed IPC frame dropped: ${message}`);
597
+ }
598
+ });
599
+ async function start() {
600
+ if (options.storage) try {
601
+ fs.rmSync(path.join(options.storage, "handoff.json"), { force: true });
602
+ } catch {}
603
+ if (options.webAssets) server.mountDir("/", options.webAssets);
604
+ attachWebSocketProtocol(server, {
605
+ protocol,
606
+ pluginRegistry,
607
+ pluginRouter
608
+ });
609
+ const credentials = await server.credentials();
610
+ if (deviceMode && options.storage) try {
611
+ fs.writeFileSync(path.join(options.storage, "handoff.json"), JSON.stringify(credentials));
612
+ } catch (err) {
613
+ const message = err instanceof Error ? err.message : String(err);
614
+ console.error(`Failed to write handoff file: ${message}`);
615
+ }
616
+ return credentials;
617
+ }
618
+ return {
619
+ protocol,
620
+ pluginRegistry,
621
+ pluginRouter,
622
+ server,
623
+ hostBridge,
624
+ config: {
625
+ webAssets: options.webAssets,
626
+ storage: options.storage,
627
+ cache: options.cache,
628
+ deviceMode
629
+ },
630
+ start,
631
+ close(cb) {
632
+ server.close(cb);
633
+ }
634
+ };
635
+ }
636
+ //#endregion
637
+ export { attachWebSocketProtocol, createLoopbackServer, createWorkletRuntime, getIPC, resolveWorkletConfig };