create-bridgething 0.11.0 → 0.12.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,1086 @@
1
+ // Generated from @bridgething/webapp-shared.
2
+ import { createRequire } from 'node:module';
3
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
+
5
+ // ../webapp-shared/src/dev.ts
6
+ import { spawn as spawn2 } from 'node:child_process';
7
+ import { isIPv4 } from 'node:net';
8
+ import { networkInterfaces } from 'node:os';
9
+ import { relative } from 'node:path';
10
+
11
+ // ../webapp-shared/src/daemon.ts
12
+ var DAEMON_PROXY_PATH = '/__bridgething';
13
+
14
+ // ../webapp-shared/src/extension.ts
15
+ import { spawn } from 'node:child_process';
16
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
17
+ import { createRequire as createRequire2 } from 'node:module';
18
+ import { homedir, platform } from 'node:os';
19
+ import { delimiter, dirname, join as join2, resolve } from 'node:path';
20
+ import { createInterface } from 'node:readline';
21
+
22
+ // ../webapp-shared/src/gateway.ts
23
+ import { decode as msgpackDecode, encode as msgpackEncode } from '@msgpack/msgpack';
24
+ import { randomUUID } from 'node:crypto';
25
+ import { lookup } from 'node:dns/promises';
26
+ import { isIP } from 'node:net';
27
+ var DEFAULT_DEVICE_HOST = 'bridgething.local';
28
+ function deviceHostName(host) {
29
+ return host ?? process.env.SUPERBIRD_HOST ?? DEFAULT_DEVICE_HOST;
30
+ }
31
+ async function resolveGatewayTarget(host) {
32
+ const name = deviceHostName(host);
33
+ const port = Number(process.env.BRIDGETHING_GATEWAY_PORT ?? 8892);
34
+ return { name, host: await resolveHost(name), port };
35
+ }
36
+ async function resolveHost(name) {
37
+ if (isIP(name)) return name;
38
+ try {
39
+ return (await lookup(name, { family: 4 })).address;
40
+ } catch {
41
+ return name;
42
+ }
43
+ }
44
+ function parseUuid(s) {
45
+ const hex = s.replace(/-/g, '').toLowerCase();
46
+ if (hex.length !== 32 || !/^[0-9a-f]+$/.test(hex)) {
47
+ throw new Error(`invalid uuid: ${s}`);
48
+ }
49
+ const out = new Uint8Array(16);
50
+ for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
51
+ return out;
52
+ }
53
+ function freshMsgId() {
54
+ return parseUuid(randomUUID());
55
+ }
56
+ function uuidToString(bytes) {
57
+ const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
58
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
59
+ }
60
+ var FRAME_HEADER_LENGTH = 16;
61
+ var FRAME_MAGIC = 57005;
62
+ var FRAME_VERSION = 2;
63
+ var COMPRESSION_NONE = 0;
64
+ var ENCODING_MSGPACK = 0;
65
+ var PRIORITY_NORMAL = 0;
66
+ function writeFrameHeader(payloadLength) {
67
+ const buf = new Uint8Array(FRAME_HEADER_LENGTH);
68
+ const view = new DataView(buf.buffer);
69
+ view.setUint16(0, FRAME_MAGIC, false);
70
+ view.setUint8(2, FRAME_VERSION);
71
+ view.setUint8(3, COMPRESSION_NONE);
72
+ view.setUint8(4, ENCODING_MSGPACK);
73
+ view.setUint8(5, PRIORITY_NORMAL);
74
+ view.setBigUint64(8, BigInt(payloadLength), false);
75
+ return buf;
76
+ }
77
+ function frame(message) {
78
+ const body = msgpackEncode(message);
79
+ const header = writeFrameHeader(body.length);
80
+ const out = new Uint8Array(header.length + body.length);
81
+ out.set(header, 0);
82
+ out.set(body, header.length);
83
+ return out;
84
+ }
85
+
86
+ class FrameAccumulator {
87
+ buffer = new Uint8Array(0);
88
+ append(chunk) {
89
+ if (chunk.length === 0) return;
90
+ const merged = new Uint8Array(this.buffer.length + chunk.length);
91
+ merged.set(this.buffer, 0);
92
+ merged.set(chunk, this.buffer.length);
93
+ this.buffer = merged;
94
+ }
95
+ next() {
96
+ if (this.buffer.length < FRAME_HEADER_LENGTH) return null;
97
+ const view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
98
+ const magic = view.getUint16(0, false);
99
+ if (magic !== FRAME_MAGIC) throw new Error(`bad framing magic 0x${magic.toString(16)}`);
100
+ const version = view.getUint8(2);
101
+ if (version !== FRAME_VERSION) throw new Error(`unsupported frame version ${version}`);
102
+ const compression = view.getUint8(3);
103
+ if (compression !== COMPRESSION_NONE) {
104
+ throw new Error(`unsupported inbound compression ${compression} (this script only handles uncompressed)`);
105
+ }
106
+ const encoding = view.getUint8(4);
107
+ if (encoding !== ENCODING_MSGPACK) throw new Error(`unsupported inbound encoding ${encoding}`);
108
+ const len = Number(view.getBigUint64(8, false));
109
+ const total = FRAME_HEADER_LENGTH + len;
110
+ if (this.buffer.length < total) return null;
111
+ const body = this.buffer.subarray(FRAME_HEADER_LENGTH, total);
112
+ const decoded = msgpackDecode(body);
113
+ this.buffer = this.buffer.slice(total);
114
+ return decoded;
115
+ }
116
+ }
117
+ var OPEN_TIMEOUT_MS = 15000;
118
+ var REQUEST_TIMEOUT_MS = 15000;
119
+
120
+ class GatewayLink {
121
+ ws;
122
+ url;
123
+ acc = new FrameAccumulator();
124
+ pending = new Map();
125
+ handlers = new Set();
126
+ closers = new Set();
127
+ closed = null;
128
+ constructor(ws, url) {
129
+ this.ws = ws;
130
+ this.url = url;
131
+ ws.addEventListener('message', event => this.receive(event.data));
132
+ ws.addEventListener('close', event => this.finish(`gateway closed (code ${event.code})`));
133
+ ws.addEventListener('error', () => this.finish('gateway socket error'));
134
+ }
135
+ static open(target) {
136
+ const url = `ws://${target.host}:${target.port}/`;
137
+ return new Promise((res, rej) => {
138
+ const ws = new WebSocket(url);
139
+ ws.binaryType = 'arraybuffer';
140
+ const timer = setTimeout(() => {
141
+ ws.close();
142
+ rej(new Error(`gateway connect timed out (${OPEN_TIMEOUT_MS / 1000}s) against ${url}`));
143
+ }, OPEN_TIMEOUT_MS);
144
+ ws.addEventListener('open', () => {
145
+ clearTimeout(timer);
146
+ res(new GatewayLink(ws, url));
147
+ });
148
+ ws.addEventListener('error', () => {
149
+ clearTimeout(timer);
150
+ rej(new Error(`could not reach the gateway at ${url}`));
151
+ });
152
+ ws.addEventListener('close', event => {
153
+ clearTimeout(timer);
154
+ rej(new Error(`gateway closed before opening (code ${event.code})`));
155
+ });
156
+ });
157
+ }
158
+ get isOpen() {
159
+ return this.closed === null;
160
+ }
161
+ onMessage(handler) {
162
+ this.handlers.add(handler);
163
+ return () => this.handlers.delete(handler);
164
+ }
165
+ onClose(handler) {
166
+ this.closers.add(handler);
167
+ return () => this.closers.delete(handler);
168
+ }
169
+ event(data) {
170
+ this.write({ id: freshMsgId(), meta: { kind: 'event' }, data });
171
+ }
172
+ request(kind, data) {
173
+ if (this.closed !== null) return Promise.reject(new Error(this.closed));
174
+ const id = freshMsgId();
175
+ const key = uuidToString(id);
176
+ return new Promise((resolve, reject) => {
177
+ const timer = setTimeout(() => {
178
+ this.pending.delete(key);
179
+ reject(new Error(`gateway ${kind} timed out (${REQUEST_TIMEOUT_MS / 1000}s) against ${this.url}`));
180
+ }, REQUEST_TIMEOUT_MS);
181
+ this.pending.set(key, { resolve, reject, timer });
182
+ this.write({ id, meta: { kind }, data });
183
+ });
184
+ }
185
+ close() {
186
+ try {
187
+ this.ws.close();
188
+ } catch {}
189
+ this.finish('gateway link closed');
190
+ }
191
+ write(message) {
192
+ if (this.closed !== null) return;
193
+ this.ws.send(frame(message));
194
+ }
195
+ receive(raw) {
196
+ const bytes = raw instanceof ArrayBuffer ? new Uint8Array(raw) : raw instanceof Uint8Array ? raw : null;
197
+ if (!bytes) return;
198
+ try {
199
+ this.acc.append(bytes);
200
+ for (let msg = this.acc.next(); msg !== null; msg = this.acc.next()) this.dispatch(msg);
201
+ } catch (err) {
202
+ this.finish(err instanceof Error ? err.message : String(err));
203
+ this.close();
204
+ }
205
+ }
206
+ dispatch(msg) {
207
+ if (msg.meta?.kind === 'response') {
208
+ const waiting = this.pending.get(uuidToString(msg.meta.data.requestId));
209
+ if (waiting) {
210
+ this.pending.delete(uuidToString(msg.meta.data.requestId));
211
+ clearTimeout(waiting.timer);
212
+ waiting.resolve(msg.data);
213
+ return;
214
+ }
215
+ }
216
+ for (const handler of [...this.handlers]) handler(msg.data);
217
+ }
218
+ finish(reason) {
219
+ if (this.closed !== null) return;
220
+ this.closed = reason;
221
+ for (const waiting of this.pending.values()) {
222
+ clearTimeout(waiting.timer);
223
+ waiting.reject(new Error(reason));
224
+ }
225
+ this.pending.clear();
226
+ for (const closer of [...this.closers]) closer(reason);
227
+ }
228
+ }
229
+ async function exchange(target, kind, data) {
230
+ const link = await GatewayLink.open(target);
231
+ try {
232
+ return await link.request(kind, data);
233
+ } finally {
234
+ link.close();
235
+ }
236
+ }
237
+ function interpret(data, expected) {
238
+ const outer = data;
239
+ if (outer?.type !== 'webapp') {
240
+ return { ok: false, reason: `unexpected response type ${JSON.stringify(outer?.type)}` };
241
+ }
242
+ const inner = outer.data;
243
+ if (inner?.event === expected) return { ok: true, value: inner.data };
244
+ if (inner?.event === 'webappError') {
245
+ const err = inner.data;
246
+ return { ok: false, reason: `daemon refused: ${err?.type} ${JSON.stringify(err?.data ?? {})}` };
247
+ }
248
+ return { ok: false, reason: `unexpected webapp response variant ${JSON.stringify(inner?.event)}` };
249
+ }
250
+ async function switchTo(target, id) {
251
+ const data = { type: 'webapp', data: { event: 'switchTo', data: { id: parseUuid(id) } } };
252
+ return interpret(await exchange(target, 'request', data), 'switched');
253
+ }
254
+ async function listWebapps(target) {
255
+ const data = { type: 'webapp', data: { event: 'list' } };
256
+ return interpret(await exchange(target, 'request', data), 'webapps');
257
+ }
258
+ async function navigateKiosk(target, url) {
259
+ await exchange(target, 'command', { type: 'chrome', data: { event: 'navigate', data: { url } } });
260
+ }
261
+ async function getActive(link) {
262
+ return interpret(await link.request('request', { type: 'webapp', data: { event: 'getActive' } }), 'active');
263
+ }
264
+ async function listConfig(link, id) {
265
+ const data = { type: 'webapp', data: { event: 'configList', data: { id: parseUuid(id) } } };
266
+ return interpret(await link.request('request', data), 'configList');
267
+ }
268
+ async function getNickname(link) {
269
+ const reply = await link.request('request', { type: 'system', data: { event: 'deviceGetNickname' } });
270
+ if (reply?.type !== 'system' || reply.data?.event !== 'deviceNickname') return null;
271
+ return reply.data.data?.nickname ?? null;
272
+ }
273
+
274
+ // ../webapp-shared/src/permissions.ts
275
+ import { join } from 'node:path';
276
+ var KINDS = ['net', 'read', 'write', 'run', 'env', 'sys', 'ffi'];
277
+ var PATHY = new Set(['read', 'write', 'run', 'ffi']);
278
+
279
+ class PermissionError extends Error {
280
+ descriptor;
281
+ constructor(descriptor, reason) {
282
+ super(`invalid permission ${JSON.stringify(descriptor)}: ${reason}`);
283
+ this.descriptor = descriptor;
284
+ this.name = 'PermissionError';
285
+ }
286
+ }
287
+ function parse(descriptor) {
288
+ const cut = descriptor.indexOf(':');
289
+ const kind = cut < 0 ? descriptor : descriptor.slice(0, cut);
290
+ const scope = cut < 0 ? null : descriptor.slice(cut + 1);
291
+ if (scope === '') throw new PermissionError(descriptor, 'scope is empty');
292
+ if (scope?.includes(',')) throw new PermissionError(descriptor, 'scope contains a comma');
293
+ if (kind === 'all') {
294
+ if (scope !== null) throw new PermissionError(descriptor, '`all` takes no scope');
295
+ return { kind: 'all' };
296
+ }
297
+ if (!KINDS.includes(kind)) throw new PermissionError(descriptor, 'unknown permission kind');
298
+ return { kind, scope };
299
+ }
300
+ function expandHome(permission, home) {
301
+ if (permission.kind === 'all' || permission.scope === null || !PATHY.has(permission.kind)) return permission;
302
+ const { scope } = permission;
303
+ if (scope === '~') return { ...permission, scope: home };
304
+ if (scope.startsWith('~/')) return { ...permission, scope: join(home, scope.slice(2)) };
305
+ return permission;
306
+ }
307
+ function denoFlags(descriptors, home) {
308
+ const permissions = descriptors.map(parse).map(p => (home === undefined ? p : expandHome(p, home)));
309
+ if (permissions.some(p => p.kind === 'all')) return ['--allow-all'];
310
+ const flags = [];
311
+ for (const kind of KINDS) {
312
+ const ofKind = permissions.filter(p => p.kind === kind);
313
+ if (ofKind.length === 0) continue;
314
+ const scopes = [];
315
+ let bare = false;
316
+ for (const permission of ofKind) {
317
+ if (permission.scope === null) bare = true;
318
+ else if (!scopes.includes(permission.scope)) scopes.push(permission.scope);
319
+ }
320
+ flags.push(bare || scopes.length === 0 ? `--allow-${kind}` : `--allow-${kind}=${scopes.join(',')}`);
321
+ }
322
+ return flags;
323
+ }
324
+
325
+ // ../webapp-shared/src/extension.ts
326
+ var EXTENSION_SOURCE = 'extension/main.ts';
327
+ var EXTENSION_DATA_DIR = '.dev-extension';
328
+ var DENO_PACKAGE_VERSION = '2.9.6';
329
+ var STOP_GRACE_MS = 1500;
330
+ var TERM_GRACE_MS = 1500;
331
+ var CRASH_BACKOFF_BASE_MS = 1000;
332
+ var CRASH_BACKOFF_CEILING_MS = 60000;
333
+ var LINK_BACKOFF_BASE_MS = 1000;
334
+ var LINK_BACKOFF_CEILING_MS = 1e4;
335
+ function readManifest(publicDir) {
336
+ const manifest = JSON.parse(readFileSync(join2(publicDir, 'manifest.json'), 'utf8'));
337
+ if (!manifest.id) throw new Error('public/manifest.json has no id');
338
+ return manifest;
339
+ }
340
+ function extensionBuildOptions(source, outfile) {
341
+ return {
342
+ entryPoints: [source],
343
+ outfile,
344
+ bundle: true,
345
+ format: 'esm',
346
+ platform: 'node',
347
+ external: ['npm:*', 'jsr:*', 'node:*'],
348
+ target: 'esnext',
349
+ minify: false,
350
+ sourcemap: false,
351
+ logLevel: 'silent',
352
+ };
353
+ }
354
+ async function buildExtension(root, outDir, extension) {
355
+ const esbuild = await import('esbuild');
356
+ const outfile = resolve(root, outDir, extension.entry);
357
+ const result = await esbuild.build(extensionBuildOptions(resolve(root, EXTENSION_SOURCE), outfile));
358
+ if (result.errors.length > 0) {
359
+ const lines = await esbuild.formatMessages(result.errors, { kind: 'error', color: false });
360
+ throw new Error(
361
+ lines.join(`
362
+ `),
363
+ );
364
+ }
365
+ return outfile;
366
+ }
367
+ async function resolveDeno(root) {
368
+ const explicit = process.env.BRIDGETHING_DENO;
369
+ if (explicit) return explicit;
370
+ const exeName = platform() === 'win32' ? 'deno.exe' : 'deno';
371
+ const packaged = denoPackageDir(root);
372
+ if (packaged) {
373
+ const exe = join2(packaged, exeName);
374
+ if (!existsSync(exe)) await runToExit(process.execPath, [join2(packaged, 'bin.cjs'), '--version'], root);
375
+ if (existsSync(exe)) return exe;
376
+ }
377
+ for (const dir of (process.env.PATH ?? '').split(delimiter)) {
378
+ if (dir && existsSync(join2(dir, exeName))) return join2(dir, exeName);
379
+ }
380
+ throw new Error(
381
+ `deno is not installed; \`bun add -d deno@${DENO_PACKAGE_VERSION}\` puts the runtime the desktop app uses into node_modules`,
382
+ );
383
+ }
384
+ function denoPackageDir(root) {
385
+ try {
386
+ return dirname(createRequire2(join2(root, 'package.json')).resolve('deno/package.json'));
387
+ } catch {
388
+ return null;
389
+ }
390
+ }
391
+ function runToExit(cmd, args, cwd) {
392
+ return new Promise((res, rej) => {
393
+ const child = spawn(cmd, args, { cwd, stdio: 'ignore' });
394
+ child.on('exit', code => (code === 0 ? res() : rej(new Error(`${cmd} ${args.join(' ')} exited ${code}`))));
395
+ child.on('error', rej);
396
+ });
397
+ }
398
+ async function openInBrowser(url) {
399
+ const os = platform();
400
+ const [cmd, args] =
401
+ os === 'darwin' ? ['open', [url]] : os === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]];
402
+ await new Promise((res, rej) => {
403
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
404
+ child.on('error', rej);
405
+ child.on('spawn', () => {
406
+ child.unref();
407
+ res();
408
+ });
409
+ });
410
+ }
411
+ function readKv(path, log) {
412
+ if (!existsSync(path)) return new Map();
413
+ try {
414
+ return new Map(Object.entries(JSON.parse(readFileSync(path, 'utf8'))));
415
+ } catch (err) {
416
+ log.warn(`extension store ${path} did not parse (${String(err)}); it starts empty`);
417
+ return new Map();
418
+ }
419
+ }
420
+ function writeKv(path, held) {
421
+ mkdirSync(dirname(path), { recursive: true });
422
+ const staging = `${path}.part`;
423
+ writeFileSync(staging, JSON.stringify(Object.fromEntries(held)));
424
+ renameSync(staging, path);
425
+ }
426
+ function toWire(message) {
427
+ if (message.encoding === 'binary') {
428
+ return { encoding: 'binary', data: Buffer.from(message.data).toString('base64') };
429
+ }
430
+ return message;
431
+ }
432
+ function fromWire(message) {
433
+ if (message.encoding === 'binary')
434
+ return { encoding: 'binary', data: new Uint8Array(Buffer.from(message.data, 'base64')) };
435
+ return message;
436
+ }
437
+ function sleep(ms) {
438
+ return new Promise(res => setTimeout(res, ms));
439
+ }
440
+
441
+ class ExtensionDevHost {
442
+ opts;
443
+ dataDir;
444
+ kvPath;
445
+ outfile;
446
+ kv;
447
+ log;
448
+ id;
449
+ deno = '';
450
+ context = null;
451
+ child = null;
452
+ generation = 0;
453
+ crashes = 0;
454
+ restartTimer = null;
455
+ link = null;
456
+ device = null;
457
+ waiting = null;
458
+ closing = false;
459
+ linkLoop = Promise.resolve();
460
+ constructor(opts) {
461
+ this.opts = opts;
462
+ this.log = opts.log;
463
+ this.id = opts.manifest.id.toLowerCase();
464
+ this.dataDir = resolve(opts.root, EXTENSION_DATA_DIR);
465
+ this.kvPath = join2(this.dataDir, 'kv.json');
466
+ this.outfile = join2(this.dataDir, 'build', 'desktop.mjs');
467
+ this.kv = readKv(this.kvPath, this.log);
468
+ }
469
+ async start() {
470
+ mkdirSync(this.dataDir, { recursive: true });
471
+ this.deno = await resolveDeno(this.opts.root);
472
+ const esbuild = await import('esbuild');
473
+ this.context = await esbuild.context({
474
+ ...extensionBuildOptions(resolve(this.opts.root, EXTENSION_SOURCE), this.outfile),
475
+ plugins: [
476
+ {
477
+ name: 'bridgething:extension-host',
478
+ setup: build => {
479
+ build.onEnd(result => {
480
+ this.built(result);
481
+ });
482
+ },
483
+ },
484
+ ],
485
+ });
486
+ await this.context.watch();
487
+ this.linkLoop = this.runLink();
488
+ }
489
+ get running() {
490
+ return this.child?.ready ?? false;
491
+ }
492
+ get pendingAuthorize() {
493
+ return this.waiting ? { url: this.waiting.url } : null;
494
+ }
495
+ settleAuthorize(callback) {
496
+ const waiting = this.waiting;
497
+ if (!waiting) return false;
498
+ this.waiting = null;
499
+ this.reply(waiting.generation, { t: 'reply', id: waiting.id, ok: true, value: callback });
500
+ return true;
501
+ }
502
+ cancelAuthorize() {
503
+ const waiting = this.waiting;
504
+ if (!waiting) return false;
505
+ this.waiting = null;
506
+ this.reply(waiting.generation, { t: 'reply', id: waiting.id, ok: false, error: 'cancelled' });
507
+ return true;
508
+ }
509
+ async close() {
510
+ if (this.closing) return;
511
+ this.closing = true;
512
+ if (this.restartTimer) clearTimeout(this.restartTimer);
513
+ await this.context?.dispose();
514
+ const child = this.child;
515
+ if (child) {
516
+ this.stopChild(child, 'stop');
517
+ await child.exited;
518
+ }
519
+ this.publishRunning([]);
520
+ this.link?.close();
521
+ await this.linkLoop;
522
+ }
523
+ async built(result) {
524
+ if (this.closing) return;
525
+ if (result.errors.length > 0) {
526
+ const esbuild = await import('esbuild');
527
+ const lines = await esbuild.formatMessages(result.errors, { kind: 'error', color: false });
528
+ this.log.error(`extension build failed; the previous build keeps running
529
+ ${lines.join(`
530
+ `)}`);
531
+ return;
532
+ }
533
+ if (this.restartTimer) {
534
+ clearTimeout(this.restartTimer);
535
+ this.restartTimer = null;
536
+ }
537
+ this.crashes = 0;
538
+ if (this.child) {
539
+ this.log.info('extension rebuilt; restarting it');
540
+ this.stopChild(this.child, 'restart');
541
+ return;
542
+ }
543
+ this.spawnChild();
544
+ }
545
+ spawnChild() {
546
+ if (this.closing || this.child) return;
547
+ const generation = ++this.generation;
548
+ const args = [
549
+ 'run',
550
+ '--no-prompt',
551
+ ...denoFlags(this.opts.manifest.extension.permissions ?? [], homedir()),
552
+ this.outfile,
553
+ ];
554
+ const process_ = spawn(this.deno, args, {
555
+ cwd: this.dataDir,
556
+ stdio: ['pipe', 'pipe', 'pipe'],
557
+ env: { ...process.env, DENO_NO_PACKAGE_JSON: '1', DENO_NO_UPDATE_CHECK: '1', NO_COLOR: '1' },
558
+ });
559
+ const exited = new Promise(res => {
560
+ process_.on('exit', (code, signal) => {
561
+ this.exited(generation, code, signal);
562
+ res();
563
+ });
564
+ });
565
+ process_.on('error', err => this.log.error(`extension did not start: ${err.message}`));
566
+ const child = { process: process_, generation, ready: false, intent: 'run', exited };
567
+ this.child = child;
568
+ createInterface({ input: process_.stdout }).on('line', line => this.fromChild(child, line));
569
+ createInterface({ input: process_.stderr }).on('line', line => {
570
+ if (line.trim()) this.log.warn(`[${this.appName()} stderr] ${line}`);
571
+ });
572
+ this.log.info(`extension starting (deno ${args.slice(1, -1).join(' ')})`);
573
+ this.write(child, {
574
+ t: 'hello',
575
+ api: this.opts.manifest.extension.api,
576
+ webapp: {
577
+ id: this.opts.manifest.id,
578
+ name: this.appName(),
579
+ version: this.opts.manifest.version ?? '0.0.0',
580
+ },
581
+ dataDir: this.dataDir,
582
+ });
583
+ if (this.device) this.write(child, this.connected(this.device));
584
+ }
585
+ stopChild(child, intent) {
586
+ child.intent = intent;
587
+ this.write(child, { t: 'stop' });
588
+ child.process.stdin?.end();
589
+ const process_ = child.process;
590
+ setTimeout(() => {
591
+ if (process_.exitCode === null && process_.signalCode === null) process_.kill('SIGTERM');
592
+ setTimeout(() => {
593
+ if (process_.exitCode === null && process_.signalCode === null) process_.kill('SIGKILL');
594
+ }, TERM_GRACE_MS).unref();
595
+ }, STOP_GRACE_MS).unref();
596
+ }
597
+ exited(generation, code, signal) {
598
+ const child = this.child;
599
+ if (!child || child.generation !== generation) return;
600
+ this.child = null;
601
+ if (this.waiting?.generation === generation) this.waiting = null;
602
+ if (child.ready) this.publishRunning([]);
603
+ if (this.closing || child.intent === 'stop') return;
604
+ if (child.intent === 'restart') {
605
+ this.spawnChild();
606
+ return;
607
+ }
608
+ const delay = Math.min(CRASH_BACKOFF_BASE_MS * 2 ** this.crashes, CRASH_BACKOFF_CEILING_MS);
609
+ this.crashes += 1;
610
+ this.log.error(
611
+ `extension exited (${signal ?? `code ${code}`}); restarting in ${Math.round(delay / 1000)}s, or on the next save`,
612
+ );
613
+ this.restartTimer = setTimeout(() => {
614
+ this.restartTimer = null;
615
+ this.spawnChild();
616
+ }, delay);
617
+ }
618
+ write(child, message) {
619
+ const stdin = child.process.stdin;
620
+ if (!stdin || stdin.destroyed || stdin.writableEnded) return;
621
+ stdin.write(`${JSON.stringify(message)}
622
+ `);
623
+ }
624
+ reply(generation, message) {
625
+ if (this.child?.generation === generation) this.write(this.child, message);
626
+ }
627
+ fromChild(child, line) {
628
+ if (!line.trim()) return;
629
+ let message;
630
+ try {
631
+ message = JSON.parse(line);
632
+ } catch {
633
+ this.log.warn(`[${this.appName()} stdout] ${line}`);
634
+ return;
635
+ }
636
+ switch (message.t) {
637
+ case 'ready':
638
+ child.ready = true;
639
+ this.crashes = 0;
640
+ this.log.info('extension is ready');
641
+ this.publishRunning([this.id]);
642
+ return;
643
+ case 'log':
644
+ this.tap(message.level, message.message);
645
+ return;
646
+ case 'device.send':
647
+ this.sendToDevice(message.device, message.message);
648
+ return;
649
+ case 'kv.get':
650
+ this.write(child, { t: 'reply', id: message.id, ok: true, value: this.kv.get(message.key) ?? null });
651
+ return;
652
+ case 'kv.set':
653
+ this.kv.set(message.key, message.value);
654
+ this.persist(child, message.id);
655
+ return;
656
+ case 'kv.delete':
657
+ this.kv.delete(message.key);
658
+ this.persist(child, message.id);
659
+ return;
660
+ case 'kv.list':
661
+ this.write(child, { t: 'reply', id: message.id, ok: true, value: [...this.kv.keys()] });
662
+ return;
663
+ case 'auth.authorize':
664
+ this.authorize(child, message.id, message.url);
665
+ return;
666
+ }
667
+ }
668
+ persist(child, id) {
669
+ try {
670
+ writeKv(this.kvPath, this.kv);
671
+ this.write(child, { t: 'reply', id, ok: true, value: null });
672
+ } catch (err) {
673
+ this.write(child, { t: 'reply', id, ok: false, error: `write failed: ${String(err)}` });
674
+ }
675
+ }
676
+ async authorize(child, id, url) {
677
+ if (this.waiting) {
678
+ this.write(child, { t: 'reply', id, ok: false, error: 'busy: an authorization is already in flight' });
679
+ return;
680
+ }
681
+ if (!/^https?:\/\//i.test(url)) {
682
+ this.write(child, { t: 'reply', id, ok: false, error: 'unsupported: only http(s) urls open in a browser' });
683
+ return;
684
+ }
685
+ this.waiting = { id, url, generation: child.generation };
686
+ try {
687
+ await (this.opts.openUrl ?? openInBrowser)(url);
688
+ } catch (err) {
689
+ this.waiting = null;
690
+ this.write(child, { t: 'reply', id, ok: false, error: `unsupported: ${String(err)}` });
691
+ return;
692
+ }
693
+ const page = this.opts.authorizePage ? ` at ${this.opts.authorizePage}` : '';
694
+ this.log.info(`extension asked to authorize ${url}; paste the callback url the browser lands on${page}`);
695
+ }
696
+ tap(level, text) {
697
+ const line = `[${this.appName()}] ${text}`;
698
+ if (level === 'error') this.log.error(line);
699
+ else if (level === 'warn') this.log.warn(line);
700
+ else this.log.info(line);
701
+ }
702
+ appName() {
703
+ return this.opts.manifest.name ?? this.opts.manifest.id;
704
+ }
705
+ connected(device) {
706
+ return {
707
+ t: 'device.connected',
708
+ device: device.id,
709
+ name: device.name,
710
+ config: device.config,
711
+ active: device.active,
712
+ };
713
+ }
714
+ sendToDevice(device, message) {
715
+ const target = this.device;
716
+ if (!target || !this.link?.isOpen) return;
717
+ if (device !== undefined ? device !== target.id : !target.active) return;
718
+ this.link.event({
719
+ type: 'forward',
720
+ data: { event: 'routed', data: { webapp: parseUuid(this.id), message: fromWire(message) } },
721
+ });
722
+ }
723
+ publishRunning(webapps) {
724
+ if (!this.link?.isOpen) return;
725
+ this.link.event({
726
+ type: 'forward',
727
+ data: { event: 'extensionsRunning', data: { webapps: webapps.map(parseUuid) } },
728
+ });
729
+ }
730
+ async runLink() {
731
+ let failures = 0;
732
+ while (!this.closing) {
733
+ let link;
734
+ try {
735
+ link = await GatewayLink.open(this.opts.target);
736
+ } catch (err) {
737
+ if (failures === 0) {
738
+ this.log.warn(
739
+ `no car thing at ${this.opts.target.name}:${this.opts.target.port} (${err instanceof Error ? err.message : String(err)}); the extension runs and links up when one appears`,
740
+ );
741
+ }
742
+ failures += 1;
743
+ await sleep(Math.min(LINK_BACKOFF_BASE_MS * 2 ** Math.min(failures, 8), LINK_BACKOFF_CEILING_MS));
744
+ continue;
745
+ }
746
+ failures = 0;
747
+ this.link = link;
748
+ const closed = new Promise(res => link.onClose(res));
749
+ link.onMessage(data => this.fromDevice(data));
750
+ await this.linked(link);
751
+ const reason = await closed;
752
+ if (this.link === link) this.link = null;
753
+ if (this.device) {
754
+ const gone = this.device;
755
+ this.device = null;
756
+ if (this.child) this.write(this.child, { t: 'device.disconnected', device: gone.id });
757
+ }
758
+ if (this.closing) break;
759
+ this.log.warn(`car thing link dropped (${reason}); reconnecting`);
760
+ await sleep(LINK_BACKOFF_BASE_MS);
761
+ }
762
+ }
763
+ async linked(link) {
764
+ const results = await Promise.allSettled([getActive(link), listConfig(link, this.id), getNickname(link)]);
765
+ if (!link.isOpen || this.closing) return;
766
+ const [active, config, nickname] = results;
767
+ if (active.status === 'rejected' || config.status === 'rejected') {
768
+ const failure =
769
+ active.status === 'rejected' ? active.reason : config.status === 'rejected' ? config.reason : null;
770
+ this.log.error(`could not read the car thing's state (${String(failure)}); dropping the link to retry`);
771
+ link.close();
772
+ return;
773
+ }
774
+ if (!active.value.ok) this.log.warn(`active webapp unknown: ${active.value.reason}`);
775
+ if (!config.value.ok) this.log.warn(`config unreadable: ${config.value.reason}`);
776
+ const activeId = active.value.ok && active.value.value.id ? uuidToString(active.value.value.id) : null;
777
+ const entries = config.value.ok ? config.value.value.entries : [];
778
+ const device = {
779
+ id: this.opts.target.name,
780
+ name: (nickname.status === 'fulfilled' && nickname.value) || 'car thing',
781
+ config: Object.fromEntries(entries.map(entry => [entry.key, entry.value])),
782
+ active: activeId === this.id,
783
+ };
784
+ this.device = device;
785
+ this.log.info(
786
+ `car thing linked (${device.name}); ${device.active ? 'this app is active, forwards flow both ways' : 'this app is not active, so forwards will not route until it is'}`,
787
+ );
788
+ if (this.child) this.write(this.child, this.connected(device));
789
+ if (this.child?.ready) this.publishRunning([this.id]);
790
+ }
791
+ fromDevice(data) {
792
+ const outer = data;
793
+ const device = this.device;
794
+ if (!device) return;
795
+ if (outer?.type === 'webapp' && outer.data?.event === 'activeChanged') {
796
+ const changed = outer.data.data;
797
+ const active = !!changed.id && uuidToString(changed.id) === this.id;
798
+ if (active === device.active) return;
799
+ device.active = active;
800
+ this.log.info(
801
+ active ? 'this app is now active on the car thing' : 'this app is no longer active on the car thing',
802
+ );
803
+ if (this.child) this.write(this.child, { t: 'device.active', device: device.id, active });
804
+ return;
805
+ }
806
+ if (outer?.type === 'webapp' && outer.data?.event === 'configChanged') {
807
+ const changed = outer.data.data;
808
+ if (uuidToString(changed.id) !== this.id) return;
809
+ if (changed.value === null) delete device.config[changed.key];
810
+ else device.config[changed.key] = changed.value;
811
+ if (this.child) {
812
+ this.write(this.child, { t: 'config.changed', device: device.id, key: changed.key, value: changed.value });
813
+ }
814
+ return;
815
+ }
816
+ if (outer?.type === 'forward' && outer.data?.event === 'routed') {
817
+ const routed = outer.data.data;
818
+ if (uuidToString(routed.webapp) !== this.id || !this.child) return;
819
+ this.write(this.child, { t: 'device.message', device: device.id, message: toWire(routed.message) });
820
+ }
821
+ }
822
+ }
823
+
824
+ // ../webapp-shared/src/dev.ts
825
+ var DEVICE_MODE = 'device';
826
+ var AUTHORIZE_PATH = '/__extension/authorize';
827
+ var DAEMON_PORT = 8891;
828
+ var SWITCH_SETTLE_MS = 1000;
829
+ async function daemonProxyTarget() {
830
+ const explicit = process.env.BRIDGETHING_DAEMON_URL;
831
+ if (explicit) return explicit.replace(/\/$/, '');
832
+ return `ws://${await resolveHost(deviceHostName())}:${DAEMON_PORT}`;
833
+ }
834
+ async function daemonProxy() {
835
+ return {
836
+ [DAEMON_PROXY_PATH]: {
837
+ target: await daemonProxyTarget(),
838
+ ws: true,
839
+ changeOrigin: true,
840
+ rewrite: path => path.slice(DAEMON_PROXY_PATH.length) || '/',
841
+ },
842
+ };
843
+ }
844
+ function bridgething() {
845
+ let command = 'serve';
846
+ let mode = '';
847
+ let logger;
848
+ let root = process.cwd();
849
+ let publicDir = '';
850
+ let outDir = 'dist';
851
+ let device = null;
852
+ let host = null;
853
+ return {
854
+ name: 'bridgething',
855
+ configResolved(config) {
856
+ command = config.command;
857
+ mode = config.mode;
858
+ logger = config.logger;
859
+ root = config.root;
860
+ publicDir = config.publicDir;
861
+ outDir = config.build.outDir;
862
+ },
863
+ configureServer(server) {
864
+ if (!logger) return;
865
+ const log = logger;
866
+ const http = server.httpServer;
867
+ if (!http) return;
868
+ let manifest;
869
+ try {
870
+ manifest = readManifest(publicDir);
871
+ } catch (err) {
872
+ log.warn(
873
+ `bridgething: ${err instanceof Error ? err.message : String(err)}; device mode and extensions are off`,
874
+ );
875
+ return;
876
+ }
877
+ const extension = manifest.extension;
878
+ if (extension)
879
+ server.middlewares.use(
880
+ AUTHORIZE_PATH,
881
+ authorizePage(() => host),
882
+ );
883
+ http.once('listening', () => {
884
+ (async () => {
885
+ const target = await resolveGatewayTarget();
886
+ try {
887
+ if (mode === DEVICE_MODE) device = await attachDevice(server, root, manifest, target, log);
888
+ else if (extension) await makeActive(root, manifest, target, log);
889
+ } catch (err) {
890
+ log.error(`device mode: ${err instanceof Error ? err.message : String(err)}`);
891
+ }
892
+ if (!extension) return;
893
+ const address = http.address();
894
+ const started = new ExtensionDevHost({
895
+ root,
896
+ manifest: { ...manifest, extension },
897
+ target,
898
+ log,
899
+ authorizePage: address ? `http://localhost:${address.port}${AUTHORIZE_PATH}` : undefined,
900
+ });
901
+ try {
902
+ await started.start();
903
+ host = started;
904
+ } catch (err) {
905
+ log.error(`extension: ${err instanceof Error ? err.message : String(err)}`);
906
+ }
907
+ })();
908
+ });
909
+ const close = server.close.bind(server);
910
+ server.close = async () => {
911
+ const ending = device;
912
+ device = null;
913
+ const stopping = host;
914
+ host = null;
915
+ await stopping?.close().catch(err => {
916
+ log.warn(`extension: did not stop cleanly: ${err instanceof Error ? err.message : String(err)}`);
917
+ });
918
+ if (ending) {
919
+ await ending.release().catch(err => {
920
+ log.warn(
921
+ `device mode: could not hand the screen back: ${err instanceof Error ? err.message : String(err)}`,
922
+ );
923
+ });
924
+ }
925
+ await close();
926
+ };
927
+ const onSignal = () => {
928
+ server.close().finally(() => process.exit(0));
929
+ };
930
+ process.once('SIGINT', onSignal);
931
+ process.once('SIGTERM', onSignal);
932
+ },
933
+ async closeBundle() {
934
+ if (command !== 'build') return;
935
+ const manifest = readManifest(publicDir);
936
+ if (!manifest.extension) return;
937
+ const outfile = await buildExtension(root, outDir, manifest.extension);
938
+ logger?.info(`wrote ${relative(root, outfile)}`);
939
+ },
940
+ };
941
+ }
942
+ async function attachDevice(server, root, manifest, target, log) {
943
+ if (!isIPv4(target.host)) throw new Error(`could not resolve ${target.name}; is the car thing plugged in?`);
944
+ const local = hostAddressToward(target.host);
945
+ if (!local) throw new Error(`no interface on this machine shares a subnet with ${target.name} (${target.host})`);
946
+ const address = server.httpServer?.address();
947
+ if (!address) throw new Error('the dev server is not listening');
948
+ const url = `http://${local}:${address.port}/`;
949
+ await installOnce(root, manifest, target, log);
950
+ await activate(target, manifest.id);
951
+ await new Promise(res => setTimeout(res, SWITCH_SETTLE_MS));
952
+ await navigateKiosk(target, url);
953
+ log.info(
954
+ `car thing is showing ${url}; edits hot-reload on the device, ctrl-c hands the screen back to the installed build`,
955
+ );
956
+ return {
957
+ async release() {
958
+ await activate(target, manifest.id);
959
+ },
960
+ };
961
+ }
962
+ async function makeActive(root, manifest, target, log) {
963
+ const name = manifest.name ?? manifest.id;
964
+ try {
965
+ await installOnce(root, manifest, target, log);
966
+ await activate(target, manifest.id);
967
+ log.info(`${name} is the active webapp on the car thing, so forwards reach the extension`);
968
+ } catch (err) {
969
+ log.warn(
970
+ `could not make ${name} active on the car thing (${err instanceof Error ? err.message : String(err)}); forwards only route while it is`,
971
+ );
972
+ }
973
+ }
974
+ async function installOnce(root, manifest, target, log) {
975
+ if (await installed(target, manifest.id)) return;
976
+ log.info(`${manifest.name ?? manifest.id} is not on the car thing yet; installing it once so the daemon knows it`);
977
+ await run('bun', ['run', 'push', '--no-switch', target.name], root);
978
+ }
979
+ async function installed(target, id) {
980
+ const listed = await listWebapps(target);
981
+ if (!listed.ok) throw new Error(listed.reason);
982
+ const wanted = id.toLowerCase();
983
+ return listed.value.webapps.some(webapp => uuidToString(webapp.id) === wanted);
984
+ }
985
+ async function activate(target, id) {
986
+ const switched = await switchTo(target, id);
987
+ if (!switched.ok) throw new Error(switched.reason);
988
+ }
989
+ function hostAddressToward(ip) {
990
+ const wanted = ipv4Bits(ip);
991
+ if (wanted === null) return null;
992
+ for (const entries of Object.values(networkInterfaces())) {
993
+ for (const entry of entries ?? []) {
994
+ if (entry.family !== 'IPv4' || entry.internal || !entry.cidr) continue;
995
+ const prefix = Number(entry.cidr.split('/')[1]);
996
+ const own = ipv4Bits(entry.address);
997
+ if (own === null || !Number.isFinite(prefix)) continue;
998
+ const mask = prefix === 0 ? 0 : (4294967295 << (32 - prefix)) >>> 0;
999
+ if ((own & mask) >>> 0 === (wanted & mask) >>> 0) return entry.address;
1000
+ }
1001
+ }
1002
+ return null;
1003
+ }
1004
+ function ipv4Bits(ip) {
1005
+ const parts = ip.split('.').map(Number);
1006
+ if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) return null;
1007
+ return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
1008
+ }
1009
+ function run(cmd, args, cwd) {
1010
+ return new Promise((res, rej) => {
1011
+ const child = spawn2(cmd, args, { stdio: 'inherit', cwd });
1012
+ child.on('exit', code => (code === 0 ? res() : rej(new Error(`${cmd} ${args.join(' ')} exited ${code}`))));
1013
+ child.on('error', rej);
1014
+ });
1015
+ }
1016
+ function authorizePage(host) {
1017
+ return (req, res, next) => {
1018
+ if (req.method === 'GET') {
1019
+ res.setHeader('content-type', 'text/html; charset=utf-8');
1020
+ res.end(renderAuthorize(host()?.pendingAuthorize?.url ?? null, null));
1021
+ return;
1022
+ }
1023
+ if (req.method !== 'POST') {
1024
+ next();
1025
+ return;
1026
+ }
1027
+ let body = '';
1028
+ req.setEncoding('utf8');
1029
+ req.on('data', chunk => {
1030
+ body += chunk;
1031
+ });
1032
+ req.on('end', () => {
1033
+ const form = new URLSearchParams(body);
1034
+ const current = host();
1035
+ let note;
1036
+ if (!current) note = 'no extension is running';
1037
+ else if (form.has('cancel')) note = current.cancelAuthorize() ? 'cancelled' : 'nothing was waiting';
1038
+ else {
1039
+ const callback = normalizeCallback(form.get('url') ?? '');
1040
+ if (!callback) note = 'that is not a url';
1041
+ else note = current.settleAuthorize(callback) ? 'delivered to the extension' : 'nothing was waiting';
1042
+ }
1043
+ res.setHeader('content-type', 'text/html; charset=utf-8');
1044
+ res.end(renderAuthorize(current?.pendingAuthorize?.url ?? null, note));
1045
+ });
1046
+ };
1047
+ }
1048
+ function normalizeCallback(raw) {
1049
+ const trimmed = raw.trim();
1050
+ if (!trimmed) return null;
1051
+ try {
1052
+ const url = new URL(trimmed);
1053
+ return `bridgething://oauth/callback${url.search}${url.hash}`;
1054
+ } catch {
1055
+ return null;
1056
+ }
1057
+ }
1058
+ function escapeHtml(text) {
1059
+ return text.replace(/[&<>"']/g, ch => `&#${ch.charCodeAt(0)};`);
1060
+ }
1061
+ function renderAuthorize(pending, note) {
1062
+ const status = pending
1063
+ ? `<p>the extension is waiting on <a href="${escapeHtml(pending)}" target="_blank" rel="noreferrer">${escapeHtml(pending)}</a>.</p>
1064
+ <p>finish signing in there. the provider sends the browser to bridgething.com's callback page, which tries to open the desktop app; copy that page's address and paste it here.</p>
1065
+ <form method="post">
1066
+ <input name="url" placeholder="https://bridgething.com/oauth/callback?code=..." autofocus />
1067
+ <button type="submit">deliver</button>
1068
+ <button type="submit" name="cancel" value="1">cancel</button>
1069
+ </form>`
1070
+ : '<p>nothing is waiting for authorization. this page fills in when the extension calls <code>ctx.auth.authorize</code>.</p>';
1071
+ return `<!doctype html>
1072
+ <meta charset="utf-8" />
1073
+ <title>bridgething extension authorize</title>
1074
+ <style>
1075
+ body { font: 15px/1.5 system-ui, sans-serif; background: #111; color: #eee; max-width: 44rem; margin: 3rem auto; padding: 0 1rem; }
1076
+ a { color: #8ab4f8; word-break: break-all; }
1077
+ input { width: 100%; box-sizing: border-box; padding: .5rem; margin: .5rem 0; background: #222; color: #eee; border: 1px solid #444; }
1078
+ button { padding: .4rem .9rem; margin-right: .5rem; }
1079
+ .note { color: #9f9; }
1080
+ </style>
1081
+ <h1>extension authorize</h1>
1082
+ ${note ? `<p class="note">${escapeHtml(note)}</p>` : ''}
1083
+ ${status}
1084
+ `;
1085
+ }
1086
+ export { AUTHORIZE_PATH, bridgething, buildExtension, daemonProxy, daemonProxyTarget, DEVICE_MODE };