@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.
- package/LICENSE +201 -0
- package/README.md +228 -0
- package/dist/index-DjulYFJL.d.mts +261 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +512 -0
- package/dist/plugins-1KweEBct.mjs +125 -0
- package/dist/plugins-discovery-events.d.mts +21 -0
- package/dist/plugins-discovery-events.mjs +13 -0
- package/dist/plugins-health-events.d.mts +57 -0
- package/dist/plugins-health-events.mjs +35 -0
- package/dist/plugins-media-events.d.mts +39 -0
- package/dist/plugins-media-events.mjs +27 -0
- package/dist/plugins-permissions-events.d.mts +39 -0
- package/dist/plugins-permissions-events.mjs +26 -0
- package/dist/plugins.d.mts +26 -0
- package/dist/plugins.mjs +2 -0
- package/dist/runtime.d.mts +96 -0
- package/dist/runtime.mjs +637 -0
- package/dist/static-file-server-CxeI_mQr.d.mts +74 -0
- package/dist/transports.d.mts +38 -0
- package/dist/transports.mjs +384 -0
- package/package.json +112 -0
package/dist/runtime.mjs
ADDED
|
@@ -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 };
|