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.
@@ -1,12 +1,33 @@
1
1
  #!/usr/bin/env bun
2
- // Generated from @bridgething/webapp-shared. It is yours now; tweak it freely.
3
- import { decode as msgpackDecode, encode as msgpackEncode } from '@msgpack/msgpack';
2
+ // Generated from @bridgething/webapp-shared.
3
+ // ../webapp-shared/src/push.ts
4
4
  import { spawn } from 'node:child_process';
5
- import { randomUUID } from 'node:crypto';
6
5
  import { existsSync, readFileSync } from 'node:fs';
7
6
  import { dirname, resolve } from 'node:path';
8
7
 
9
- function parseUuid(s: string): Uint8Array {
8
+ // ../webapp-shared/src/gateway.ts
9
+ import { decode as msgpackDecode, encode as msgpackEncode } from '@msgpack/msgpack';
10
+ import { randomUUID } from 'node:crypto';
11
+ import { lookup } from 'node:dns/promises';
12
+ import { isIP } from 'node:net';
13
+ var DEFAULT_DEVICE_HOST = 'bridgething.local';
14
+ function deviceHostName(host) {
15
+ return host ?? process.env.SUPERBIRD_HOST ?? DEFAULT_DEVICE_HOST;
16
+ }
17
+ async function resolveGatewayTarget(host) {
18
+ const name = deviceHostName(host);
19
+ const port = Number(process.env.BRIDGETHING_GATEWAY_PORT ?? 8892);
20
+ return { name, host: await resolveHost(name), port };
21
+ }
22
+ async function resolveHost(name) {
23
+ if (isIP(name)) return name;
24
+ try {
25
+ return (await lookup(name, { family: 4 })).address;
26
+ } catch {
27
+ return name;
28
+ }
29
+ }
30
+ function parseUuid(s) {
10
31
  const hex = s.replace(/-/g, '').toLowerCase();
11
32
  if (hex.length !== 32 || !/^[0-9a-f]+$/.test(hex)) {
12
33
  throw new Error(`invalid uuid: ${s}`);
@@ -15,30 +36,25 @@ function parseUuid(s: string): Uint8Array {
15
36
  for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
16
37
  return out;
17
38
  }
18
-
19
- function freshMsgId(): Uint8Array {
39
+ function freshMsgId() {
20
40
  return parseUuid(randomUUID());
21
41
  }
22
-
23
- function uuidToString(bytes: Uint8Array): string {
42
+ function uuidToString(bytes) {
24
43
  const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
25
44
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
26
45
  }
27
-
28
- function bundleDirName(id: string): string {
46
+ function bundleDirName(id) {
29
47
  const hex = id.replace(/-/g, '').toLowerCase();
30
48
  if (!/^[0-9a-f]{32}$/.test(hex)) throw new Error(`manifest id is not a uuid: ${id}`);
31
49
  return hex;
32
50
  }
33
-
34
- const FRAME_HEADER_LENGTH = 16;
35
- const FRAME_MAGIC = 0xdead;
36
- const FRAME_VERSION = 2;
37
- const COMPRESSION_NONE = 0x00;
38
- const ENCODING_MSGPACK = 0x00;
39
- const PRIORITY_NORMAL = 0x00;
40
-
41
- function writeFrameHeader(payloadLength: number): Uint8Array<ArrayBuffer> {
51
+ var FRAME_HEADER_LENGTH = 16;
52
+ var FRAME_MAGIC = 57005;
53
+ var FRAME_VERSION = 2;
54
+ var COMPRESSION_NONE = 0;
55
+ var ENCODING_MSGPACK = 0;
56
+ var PRIORITY_NORMAL = 0;
57
+ function writeFrameHeader(payloadLength) {
42
58
  const buf = new Uint8Array(FRAME_HEADER_LENGTH);
43
59
  const view = new DataView(buf.buffer);
44
60
  view.setUint16(0, FRAME_MAGIC, false);
@@ -49,8 +65,7 @@ function writeFrameHeader(payloadLength: number): Uint8Array<ArrayBuffer> {
49
65
  view.setBigUint64(8, BigInt(payloadLength), false);
50
66
  return buf;
51
67
  }
52
-
53
- function frame(message: unknown): Uint8Array<ArrayBuffer> {
68
+ function frame(message) {
54
69
  const body = msgpackEncode(message);
55
70
  const header = writeFrameHeader(body.length);
56
71
  const out = new Uint8Array(header.length + body.length);
@@ -59,20 +74,16 @@ function frame(message: unknown): Uint8Array<ArrayBuffer> {
59
74
  return out;
60
75
  }
61
76
 
62
- type GatewayMsg = { id: Uint8Array; meta: unknown; data: unknown };
63
-
64
77
  class FrameAccumulator {
65
- private buffer = new Uint8Array(0);
66
-
67
- append(chunk: Uint8Array): void {
78
+ buffer = new Uint8Array(0);
79
+ append(chunk) {
68
80
  if (chunk.length === 0) return;
69
81
  const merged = new Uint8Array(this.buffer.length + chunk.length);
70
82
  merged.set(this.buffer, 0);
71
83
  merged.set(chunk, this.buffer.length);
72
84
  this.buffer = merged;
73
85
  }
74
-
75
- next(): GatewayMsg | null {
86
+ next() {
76
87
  if (this.buffer.length < FRAME_HEADER_LENGTH) return null;
77
88
  const view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
78
89
  const magic = view.getUint16(0, false);
@@ -89,191 +100,211 @@ class FrameAccumulator {
89
100
  const total = FRAME_HEADER_LENGTH + len;
90
101
  if (this.buffer.length < total) return null;
91
102
  const body = this.buffer.subarray(FRAME_HEADER_LENGTH, total);
92
- const decoded = msgpackDecode(body) as GatewayMsg;
103
+ const decoded = msgpackDecode(body);
93
104
  this.buffer = this.buffer.slice(total);
94
105
  return decoded;
95
106
  }
96
107
  }
97
-
98
- function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
99
- if (a.length !== b.length) return false;
100
- let diff = 0;
101
- for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
102
- return diff === 0;
103
- }
104
-
105
- const ANNOUNCE_DATA = {
106
- type: 'capabilities',
107
- data: {
108
- event: 'announce',
109
- data: {
110
- gateway: {
111
- address: '',
112
- name: 'bridgething-webapp-push',
113
- osName: 'host',
114
- appName: 'bridgething-webapp-push',
115
- appVersion: '0.1.0',
116
- adapterVersion: 'host',
117
- libVersion: 'v0',
118
- libbridgethingVersion: 'v0',
119
- },
120
- uriSchemes: [],
121
- network: { kind: 'unknown', metered: false },
122
- available: { geo: false, notifications: false, netFetch: false, netWs: false, audioTts: false },
123
- audio: { earcons: [], voices: [] },
124
- },
125
- },
126
- };
127
-
128
- function sendRequest(host: string, port: number, data: unknown): Promise<unknown | null> {
129
- const url = `ws://${host}:${port}/`;
130
- const ws = new WebSocket(url);
131
- ws.binaryType = 'arraybuffer';
132
- const acc = new FrameAccumulator();
133
- const requestId = freshMsgId();
134
- let sent = false;
135
-
136
- return new Promise<unknown | null>((res, rej) => {
137
- const overall = setTimeout(() => {
138
- try {
139
- ws.close();
140
- } catch {}
141
- rej(new Error(`gateway request timed out (15s) against ${url}`));
142
- }, 15_000);
143
-
144
- const finish = (fn: () => void) => {
145
- clearTimeout(overall);
146
- try {
108
+ var OPEN_TIMEOUT_MS = 15000;
109
+ var REQUEST_TIMEOUT_MS = 15000;
110
+
111
+ class GatewayLink {
112
+ ws;
113
+ url;
114
+ acc = new FrameAccumulator();
115
+ pending = new Map();
116
+ handlers = new Set();
117
+ closers = new Set();
118
+ closed = null;
119
+ constructor(ws, url) {
120
+ this.ws = ws;
121
+ this.url = url;
122
+ ws.addEventListener('message', event => this.receive(event.data));
123
+ ws.addEventListener('close', event => this.finish(`gateway closed (code ${event.code})`));
124
+ ws.addEventListener('error', () => this.finish('gateway socket error'));
125
+ }
126
+ static open(target) {
127
+ const url = `ws://${target.host}:${target.port}/`;
128
+ return new Promise((res, rej) => {
129
+ const ws = new WebSocket(url);
130
+ ws.binaryType = 'arraybuffer';
131
+ const timer = setTimeout(() => {
147
132
  ws.close();
148
- } catch {}
149
- fn();
150
- };
151
-
152
- ws.addEventListener('open', () => {
153
- ws.send(frame({ id: freshMsgId(), meta: { kind: 'event' }, data: ANNOUNCE_DATA }));
154
- ws.send(frame({ id: requestId, meta: { kind: 'request' }, data }));
155
- sent = true;
133
+ rej(new Error(`gateway connect timed out (${OPEN_TIMEOUT_MS / 1000}s) against ${url}`));
134
+ }, OPEN_TIMEOUT_MS);
135
+ ws.addEventListener('open', () => {
136
+ clearTimeout(timer);
137
+ res(new GatewayLink(ws, url));
138
+ });
139
+ ws.addEventListener('error', () => {
140
+ clearTimeout(timer);
141
+ rej(new Error(`could not reach the gateway at ${url}`));
142
+ });
143
+ ws.addEventListener('close', event => {
144
+ clearTimeout(timer);
145
+ rej(new Error(`gateway closed before opening (code ${event.code})`));
146
+ });
156
147
  });
157
-
158
- ws.addEventListener('message', (event: MessageEvent) => {
159
- const raw = event.data;
160
- const bytes = raw instanceof ArrayBuffer ? new Uint8Array(raw) : raw instanceof Uint8Array ? raw : null;
161
- if (!bytes) return;
162
- try {
163
- acc.append(bytes);
164
- for (let msg = acc.next(); msg !== null; msg = acc.next()) {
165
- const meta = msg.meta as { kind?: string; data?: { requestId?: Uint8Array } };
166
- const respId = meta?.kind === 'response' ? meta.data?.requestId : undefined;
167
- if (respId && bytesEqual(respId, requestId)) {
168
- finish(() => res(msg.data));
169
- return;
170
- }
171
- }
172
- } catch (err) {
173
- finish(() => rej(err instanceof Error ? err : new Error(String(err))));
174
- }
148
+ }
149
+ get isOpen() {
150
+ return this.closed === null;
151
+ }
152
+ onMessage(handler) {
153
+ this.handlers.add(handler);
154
+ return () => this.handlers.delete(handler);
155
+ }
156
+ onClose(handler) {
157
+ this.closers.add(handler);
158
+ return () => this.closers.delete(handler);
159
+ }
160
+ event(data) {
161
+ this.write({ id: freshMsgId(), meta: { kind: 'event' }, data });
162
+ }
163
+ request(kind, data) {
164
+ if (this.closed !== null) return Promise.reject(new Error(this.closed));
165
+ const id = freshMsgId();
166
+ const key = uuidToString(id);
167
+ return new Promise((resolve, reject) => {
168
+ const timer = setTimeout(() => {
169
+ this.pending.delete(key);
170
+ reject(new Error(`gateway ${kind} timed out (${REQUEST_TIMEOUT_MS / 1000}s) against ${this.url}`));
171
+ }, REQUEST_TIMEOUT_MS);
172
+ this.pending.set(key, { resolve, reject, timer });
173
+ this.write({ id, meta: { kind }, data });
175
174
  });
176
-
177
- ws.addEventListener('close', (event: CloseEvent) => {
178
- clearTimeout(overall);
179
- if (sent && (event.code === 1000 || event.code === 1005)) {
180
- res(null);
175
+ }
176
+ close() {
177
+ try {
178
+ this.ws.close();
179
+ } catch {}
180
+ this.finish('gateway link closed');
181
+ }
182
+ write(message) {
183
+ if (this.closed !== null) return;
184
+ this.ws.send(frame(message));
185
+ }
186
+ receive(raw) {
187
+ const bytes = raw instanceof ArrayBuffer ? new Uint8Array(raw) : raw instanceof Uint8Array ? raw : null;
188
+ if (!bytes) return;
189
+ try {
190
+ this.acc.append(bytes);
191
+ for (let msg = this.acc.next(); msg !== null; msg = this.acc.next()) this.dispatch(msg);
192
+ } catch (err) {
193
+ this.finish(err instanceof Error ? err.message : String(err));
194
+ this.close();
195
+ }
196
+ }
197
+ dispatch(msg) {
198
+ if (msg.meta?.kind === 'response') {
199
+ const waiting = this.pending.get(uuidToString(msg.meta.data.requestId));
200
+ if (waiting) {
201
+ this.pending.delete(uuidToString(msg.meta.data.requestId));
202
+ clearTimeout(waiting.timer);
203
+ waiting.resolve(msg.data);
181
204
  return;
182
205
  }
183
- rej(new Error(`gateway ws closed before response (code ${event.code})`));
184
- });
185
- });
206
+ }
207
+ for (const handler of [...this.handlers]) handler(msg.data);
208
+ }
209
+ finish(reason) {
210
+ if (this.closed !== null) return;
211
+ this.closed = reason;
212
+ for (const waiting of this.pending.values()) {
213
+ clearTimeout(waiting.timer);
214
+ waiting.reject(new Error(reason));
215
+ }
216
+ this.pending.clear();
217
+ for (const closer of [...this.closers]) closer(reason);
218
+ }
186
219
  }
187
-
188
- type Outcome<T> = { ok: true; value: T | null } | { ok: false; reason: string };
189
-
190
- function interpret<T>(data: unknown | null, expected: string): Outcome<T> {
191
- if (data === null) return { ok: true, value: null };
192
- const outer = data as { type?: string; data?: unknown };
220
+ async function exchange(target, kind, data) {
221
+ const link = await GatewayLink.open(target);
222
+ try {
223
+ return await link.request(kind, data);
224
+ } finally {
225
+ link.close();
226
+ }
227
+ }
228
+ function interpret(data, expected) {
229
+ const outer = data;
193
230
  if (outer?.type !== 'webapp') {
194
231
  return { ok: false, reason: `unexpected response type ${JSON.stringify(outer?.type)}` };
195
232
  }
196
- const inner = outer.data as { event?: string; data?: unknown };
197
- if (inner?.event === expected) return { ok: true, value: inner.data as T };
233
+ const inner = outer.data;
234
+ if (inner?.event === expected) return { ok: true, value: inner.data };
198
235
  if (inner?.event === 'webappError') {
199
- const err = inner.data as { type?: string; data?: Record<string, unknown> } | undefined;
236
+ const err = inner.data;
200
237
  return { ok: false, reason: `daemon refused: ${err?.type} ${JSON.stringify(err?.data ?? {})}` };
201
238
  }
202
239
  return { ok: false, reason: `unexpected webapp response variant ${JSON.stringify(inner?.event)}` };
203
240
  }
204
-
205
- type ActiveWebapp = { id?: Uint8Array | null; name?: string | null };
206
- type Slots = { launcher?: Uint8Array | null; overlay?: Uint8Array | null };
207
- type Slot = 'launcher' | 'overlay';
208
-
209
- async function switchTo(host: string, port: number, id: string): Promise<Outcome<ActiveWebapp>> {
241
+ async function switchTo(target, id) {
210
242
  const data = { type: 'webapp', data: { event: 'switchTo', data: { id: parseUuid(id) } } };
211
- return interpret<ActiveWebapp>(await sendRequest(host, port, data), 'switched');
243
+ return interpret(await exchange(target, 'request', data), 'switched');
212
244
  }
213
-
214
- async function setSlot(host: string, port: number, slot: Slot, id: string | null): Promise<Outcome<Slots>> {
245
+ async function setSlot(target, slot, id) {
215
246
  const data = {
216
247
  type: 'webapp',
217
248
  data: { event: 'setSlot', data: { slot, id: id === null ? null : parseUuid(id) } },
218
249
  };
219
- return interpret<Slots>(await sendRequest(host, port, data), 'slots');
250
+ return interpret(await exchange(target, 'request', data), 'slots');
220
251
  }
221
252
 
222
- const WEBAPP_ROOT = '/var/bridgething/webapps';
223
- const SSH_OPTS = ['-o', 'UserKnownHostsFile=/dev/null', '-o', 'StrictHostKeyChecking=no', '-o', 'LogLevel=ERROR'];
224
-
225
- function run(cmd: string, args: string[], label: string, cwd?: string): Promise<void> {
226
- return new Promise<void>((res, rej) => {
253
+ // ../webapp-shared/src/push.ts
254
+ var WEBAPP_ROOT = '/var/bridgething/webapps';
255
+ var SSH_OPTS = ['-o', 'UserKnownHostsFile=/dev/null', '-o', 'StrictHostKeyChecking=no', '-o', 'LogLevel=ERROR'];
256
+ function run(cmd, args, label, cwd) {
257
+ return new Promise((res, rej) => {
227
258
  const child = spawn(cmd, args, { stdio: 'inherit', cwd });
228
259
  child.on('exit', code => (code === 0 ? res() : rej(new Error(`${label} exited ${code}`))));
229
260
  child.on('error', rej);
230
261
  });
231
262
  }
232
-
233
- async function pushBundle(localDir: string, host: string, dirName: string): Promise<void> {
234
- const src = localDir.endsWith('/') ? localDir : `${localDir}/`;
263
+ function pipeThrough(producer, consumer, label) {
264
+ return new Promise((res, rej) => {
265
+ const source = spawn(producer.cmd, producer.args, { stdio: ['ignore', 'pipe', 'inherit'] });
266
+ const sink = spawn(consumer.cmd, consumer.args, { stdio: ['pipe', 'inherit', 'inherit'] });
267
+ source.stdout.pipe(sink.stdin);
268
+ source.on('error', rej);
269
+ sink.on('error', rej);
270
+ source.on('exit', code => {
271
+ if (code !== 0) rej(new Error(`${producer.cmd} exited ${code}`));
272
+ });
273
+ sink.on('exit', code => (code === 0 ? res() : rej(new Error(`${label} exited ${code}`))));
274
+ });
275
+ }
276
+ async function pushBundle(localDir, host, dirName) {
235
277
  const staged = `.push.${dirName}`;
236
- const dest = `root@${host}:${WEBAPP_ROOT}/${staged}/`;
237
- console.log(`rsync ${src} -> root@${host}:${WEBAPP_ROOT}/${dirName}/`);
238
- await run('rsync', ['-avz', '--delete', '-e', ['ssh', ...SSH_OPTS].join(' '), src, dest], 'rsync');
239
- const swap = [
278
+ console.log(`copying ${localDir} -> root@${host}:${WEBAPP_ROOT}/${dirName}/`);
279
+ const receive = [
240
280
  'set -e',
241
281
  `cd ${WEBAPP_ROOT}`,
242
- `rm -rf .old.${dirName}`,
282
+ `rm -rf ${staged} .old.${dirName}`,
283
+ `mkdir -p ${staged}`,
284
+ `tar -xzf - -C ${staged}`,
243
285
  `if [ -d ${dirName} ]; then mv ${dirName} .old.${dirName}; fi`,
244
286
  `mv ${staged} ${dirName}`,
245
287
  `rm -rf .old.${dirName}`,
246
288
  ].join('; ');
247
- await run('ssh', [...SSH_OPTS, `root@${host}`, swap], 'ssh');
289
+ await pipeThrough(
290
+ { cmd: 'tar', args: ['-czf', '-', '-C', localDir, '.'] },
291
+ { cmd: 'ssh', args: [...SSH_OPTS, `root@${host}`, receive] },
292
+ 'ssh',
293
+ );
248
294
  }
249
-
250
- function buildBundle(repoDir: string): Promise<void> {
295
+ function buildBundle(repoDir) {
251
296
  console.log('bun run build');
252
297
  return run('bun', ['run', 'build'], 'bun run build', repoDir);
253
298
  }
254
-
255
- type Manifest = { id?: string; name?: string; role?: string; overlay?: string };
256
-
257
- function declaredSlots(manifest: Manifest): Slot[] {
258
- const slots: Slot[] = [];
299
+ function declaredSlots(manifest) {
300
+ const slots = [];
259
301
  if (manifest.role === 'launcher') slots.push('launcher');
260
302
  if (manifest.overlay) slots.push('overlay');
261
303
  return slots;
262
304
  }
263
-
264
- type Args = {
265
- host: string;
266
- port: number;
267
- skipBuild: boolean;
268
- claimSlots: boolean;
269
- release: boolean;
270
- switchAfter: boolean | null;
271
- };
272
-
273
- function parseArgs(argv: string[]): Args {
274
- const args: Args = {
305
+ function parseArgs(argv) {
306
+ const args = {
275
307
  host: process.env.SUPERBIRD_HOST ?? 'bridgething.local',
276
- port: Number(process.env.BRIDGETHING_GATEWAY_PORT ?? 8892),
277
308
  skipBuild: process.env.SKIP_BUILD === '1',
278
309
  claimSlots: true,
279
310
  release: false,
@@ -293,11 +324,10 @@ function parseArgs(argv: string[]): Args {
293
324
  }
294
325
  return args;
295
326
  }
296
-
297
- function printHelp(): void {
327
+ function printHelp() {
298
328
  console.log(`Usage: bun run push [host] [options]
299
329
 
300
- Build, rsync dist/ onto a connected Car Thing, and make it visible.
330
+ Build, copy dist/ onto a connected Car Thing, and make it visible.
301
331
 
302
332
  A plain webapp becomes the active app. A launcher also takes the home-screen
303
333
  slot; an overlay takes the overlay slot and the daemon reloads the kiosk so it
@@ -310,66 +340,54 @@ Options:
310
340
  --no-slot push without claiming any slot.
311
341
  --switch switch to this bundle even if it is overlay-only.
312
342
  --no-switch push without switching.
313
- --skip-build rsync whatever is already in dist/.
343
+ --skip-build copy whatever is already in dist/.
314
344
 
315
345
  Env: SUPERBIRD_HOST, BRIDGETHING_GATEWAY_PORT, SKIP_BUILD=1
316
346
  `);
317
347
  }
318
-
319
- export type BridgethingPushOptions = {
320
- scriptUrl: string;
321
- };
322
-
323
- export async function bridgethingPush({ scriptUrl }: BridgethingPushOptions): Promise<void> {
348
+ async function bridgethingPush({ scriptUrl }) {
324
349
  const args = parseArgs(process.argv.slice(2));
325
-
350
+ const target = await resolveGatewayTarget(args.host);
326
351
  const repoDir = resolve(dirname(new URL(scriptUrl).pathname), '..');
327
352
  const distDir = resolve(repoDir, 'dist');
328
353
  const manifestPath = resolve(distDir, 'manifest.json');
329
-
330
- const readManifest = (): Manifest => {
354
+ const readManifest = () => {
331
355
  if (!existsSync(manifestPath)) {
332
356
  throw new Error(`no manifest.json at ${manifestPath}; run 'bun run build' first or drop --skip-build`);
333
357
  }
334
- const parsed = JSON.parse(readFileSync(manifestPath, 'utf8')) as Manifest;
358
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
335
359
  if (!parsed.id) throw new Error(`${manifestPath} has no 'id' field`);
336
360
  return parsed;
337
361
  };
338
-
339
362
  if (args.release) {
340
- const manifest = readManifest();
341
- for (const slot of declaredSlots(manifest)) {
342
- const result = await setSlot(args.host, args.port, slot, null);
363
+ const manifest2 = readManifest();
364
+ for (const slot of declaredSlots(manifest2)) {
365
+ const result = await setSlot(target, slot, null);
343
366
  if (!result.ok) throw new Error(result.reason);
344
367
  console.log(`${slot} slot: reverted to the built-in one`);
345
368
  }
346
- if (declaredSlots(manifest).length === 0) console.log('this bundle declares no slots; nothing to release');
369
+ if (declaredSlots(manifest2).length === 0) console.log('this bundle declares no slots; nothing to release');
347
370
  return;
348
371
  }
349
-
350
372
  if (!args.skipBuild) await buildBundle(repoDir);
351
373
  const manifest = readManifest();
352
- const id = manifest.id as string;
374
+ const id = manifest.id;
353
375
  const slots = declaredSlots(manifest);
354
-
355
376
  await pushBundle(distDir, args.host, bundleDirName(id));
356
-
357
377
  if (args.claimSlots) {
358
378
  for (const slot of slots) {
359
- const result = await setSlot(args.host, args.port, slot, id);
379
+ const result = await setSlot(target, slot, id);
360
380
  if (!result.ok) throw new Error(result.reason);
361
381
  console.log(`${slot} slot: ${manifest.name ?? id}`);
362
382
  }
363
383
  }
364
-
365
384
  const overlayOnly = slots.length > 0 && slots.every(s => s === 'overlay');
366
385
  const shouldSwitch = args.switchAfter ?? !overlayOnly;
367
386
  if (!shouldSwitch) {
368
387
  console.log(overlayOnly ? 'overlay pushed; the kiosk reloaded with it injected' : 'skipping switch');
369
388
  return;
370
389
  }
371
-
372
- const switched = await switchTo(args.host, args.port, id);
390
+ const switched = await switchTo(target, id);
373
391
  if (!switched.ok) throw new Error(switched.reason);
374
392
  const active = switched.value;
375
393
  if (!active) {
@@ -379,6 +397,7 @@ export async function bridgethingPush({ scriptUrl }: BridgethingPushOptions): Pr
379
397
  const activeStr = active.id ? uuidToString(active.id) : '(none)';
380
398
  console.log(`active webapp: ${active.name ?? '(unnamed)'} ${activeStr}`);
381
399
  }
400
+ export { bridgethingPush };
382
401
 
383
402
  bridgethingPush({ scriptUrl: import.meta.url }).catch(err => {
384
403
  console.error(err instanceof Error ? err.message : err);
@@ -1,12 +1,9 @@
1
1
  import { BridgethingClient, type ConnectionState, type PlayerState } from '@bridgething/client';
2
2
  import { useEffect, useMemo, useState } from 'react';
3
-
4
- const wsUrl =
5
- import.meta.env.VITE_BRIDGETHING_URL ??
6
- (typeof window !== 'undefined' ? `ws://${window.location.host}/` : 'ws://127.0.0.1:8891/');
3
+ import { daemonUrl } from './daemon';
7
4
 
8
5
  export default function App() {
9
- const client = useMemo(() => new BridgethingClient({ url: wsUrl }), []);
6
+ const client = useMemo(() => new BridgethingClient({ url: daemonUrl() }), []);
10
7
  const [conn, setConn] = useState<ConnectionState>(client.connectionState);
11
8
  const [state, setState] = useState<PlayerState | null>(null);
12
9
  const [artUrl, setArtUrl] = useState<string | null>(null);
@@ -0,0 +1,14 @@
1
+ // Generated from @bridgething/webapp-shared.
2
+ /// <reference types="vite/client" />
3
+
4
+ const OFF_DEVICE_URL = 'ws://127.0.0.1:8891/';
5
+
6
+ export const DAEMON_PROXY_PATH = '/__bridgething';
7
+
8
+ export function daemonUrl(): string {
9
+ const override = import.meta.env.VITE_BRIDGETHING_URL;
10
+ if (override) return override;
11
+ if (typeof window === 'undefined') return OFF_DEVICE_URL;
12
+ const { host } = window.location;
13
+ return import.meta.env.DEV ? `ws://${host}${DAEMON_PROXY_PATH}/` : `ws://${host}/`;
14
+ }
@@ -1,14 +1,16 @@
1
1
  import tailwindcss from '@tailwindcss/vite';
2
2
  import react from '@vitejs/plugin-react';
3
3
  import { defineConfig } from 'vite';
4
+ import { bridgething, daemonProxy } from './scripts/bridgething';
4
5
 
5
- export default defineConfig({
6
- plugins: [react(), tailwindcss()],
6
+ export default defineConfig(async () => ({
7
+ plugins: [react(), tailwindcss(), bridgething()],
7
8
  build: {
8
9
  target: 'es2022',
9
10
  sourcemap: true,
10
11
  },
11
12
  server: {
12
13
  host: true,
14
+ proxy: await daemonProxy(),
13
15
  },
14
- });
16
+ }));
@@ -0,0 +1,23 @@
1
+ ## This project ships a native extension
2
+
3
+ `manifest.json` declares an `extension` block, so this bundle has two halves.
4
+
5
+ - `src/` is the webapp, running in the device's chromium kiosk.
6
+ - `extension/` is a Deno process on the desktop. The bridgething desktop app
7
+ spawns it while the app is installed and enabled, with or without a Car Thing
8
+ connected.
9
+
10
+ The two halves talk over the daemon's forward surface: `client.forward` in the
11
+ webapp, `device.send` and `ctx.on('message', ...)` in the extension. The daemon
12
+ delivers a forward while this webapp is the active one on that device.
13
+
14
+ Host access belongs in the extension, an ordinary Deno program with `npm:`,
15
+ `jsr:`, and `node:` available. Keep `src/` a view over what it sends. The webapp
16
+ also runs with nothing attached, so check `capabilities.available.forward` and
17
+ give the user something useful when it is false.
18
+
19
+ `manifest.json` starts with `"permissions": ["all"]`. Narrow it to the Deno
20
+ permissions you use before you publish.
21
+
22
+ The `ctx` contract, the permission grammar, the dev loop, and the gotchas:
23
+ `.claude/skills/bridgething/reference/extension.md`.
@@ -0,0 +1,20 @@
1
+ import { asJson, defineExtension, json } from '@bridgething/extension';
2
+
3
+ defineExtension({
4
+ start(ctx) {
5
+ ctx.log.info(`__PROJECT_NAME__ extension up, data in ${ctx.dataDir}`);
6
+
7
+ ctx.on('device', event => {
8
+ if (event.type !== 'connected') return;
9
+ const greeting = ctx.config(event.device).greeting ?? 'hello';
10
+ ctx.log.info(`${event.device.name} connected; greeting is "${greeting}"`);
11
+ event.device.send(json({ type: 'greeting', text: greeting }));
12
+ });
13
+
14
+ ctx.on('message', (device, message) => {
15
+ const payload = asJson<Record<string, unknown>>(message);
16
+ if (!payload) return;
17
+ device.send(json({ type: 'echo', payload }));
18
+ });
19
+ },
20
+ });