broapp 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 +21 -0
- package/README.md +46 -0
- package/package.json +64 -0
- package/src/cli/build-binary.ts +98 -0
- package/src/cli/build-page.ts +222 -0
- package/src/cli/config.ts +77 -0
- package/src/cli/dev.ts +158 -0
- package/src/cli/index.ts +17 -0
- package/src/cli/main.ts +183 -0
- package/src/cli/targets.ts +51 -0
- package/src/client/client.ts +203 -0
- package/src/client/index.ts +12 -0
- package/src/host/app.ts +283 -0
- package/src/host/index.ts +25 -0
- package/src/host/open-browser.ts +45 -0
- package/src/host/paths.ts +53 -0
- package/src/host/runtime.ts +198 -0
- package/src/react/hooks.tsx +389 -0
- package/src/react/index.ts +15 -0
- package/src/shared/contract.ts +134 -0
- package/src/shared/errors.ts +134 -0
- package/src/shared/index.ts +36 -0
- package/src/shared/ndjson.ts +72 -0
- package/src/shared/schema.ts +248 -0
package/src/cli/dev.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `broapp dev` — one command that watches, rebuilds and restarts.
|
|
3
|
+
*
|
|
4
|
+
* ## Why there is no hot module replacement
|
|
5
|
+
*
|
|
6
|
+
* HMR needs a channel the page can pull new modules over. In a Broapp
|
|
7
|
+
* application there is exactly one such channel — the authenticated Brobridge
|
|
8
|
+
* bridge — and every other route is a `404`, on purpose. The usual shortcut is
|
|
9
|
+
* a second dev server on another port with permissive CORS, which is precisely
|
|
10
|
+
* the thing this starter must not do: it would serve the application from an
|
|
11
|
+
* unauthenticated origin, and the development build would stop resembling the
|
|
12
|
+
* shipped one in the way that matters most.
|
|
13
|
+
*
|
|
14
|
+
* So the workflow here is rebuild-and-reload, and it is honest about it. A
|
|
15
|
+
* change to UI code rebuilds the page and restarts the host, and the browser
|
|
16
|
+
* tab reconnects. Reload cost is roughly the bundle time plus a page load,
|
|
17
|
+
* which for a starter-sized UI is well under a second.
|
|
18
|
+
*
|
|
19
|
+
* ## Why the browser opens only once
|
|
20
|
+
*
|
|
21
|
+
* A restart mints a new one-time launch token, so the old tab's cookie is for
|
|
22
|
+
* a host that no longer exists — Brobridge sessions do not survive the process
|
|
23
|
+
* that minted them, and pretending otherwise would be exactly the "silent
|
|
24
|
+
* resume" the spec warns against. `broapp dev` therefore opens the browser on
|
|
25
|
+
* the first start only and, on every restart afterwards, prints the fresh
|
|
26
|
+
* launch URL for the developer to reload with. A rebuild does not spawn a tab.
|
|
27
|
+
*/
|
|
28
|
+
import { watch } from 'node:fs';
|
|
29
|
+
import { existsSync } from 'node:fs';
|
|
30
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
31
|
+
|
|
32
|
+
import { buildPage } from './build-page.ts';
|
|
33
|
+
import type { ResolvedConfig } from './config.ts';
|
|
34
|
+
|
|
35
|
+
/** Options for {@link runDev}. */
|
|
36
|
+
export interface DevOptions {
|
|
37
|
+
readonly config: ResolvedConfig;
|
|
38
|
+
/** Directories to watch. Default: the whole of `src`. */
|
|
39
|
+
readonly watchDirs?: readonly string[];
|
|
40
|
+
/** Open a browser on the first successful start. Default `true`. */
|
|
41
|
+
readonly open?: boolean;
|
|
42
|
+
/** Debounce for filesystem events. Default 120 ms. */
|
|
43
|
+
readonly debounceMs?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Run the development loop. Resolves when the developer stops it. */
|
|
47
|
+
export async function runDev(options: DevOptions): Promise<number> {
|
|
48
|
+
const { config } = options;
|
|
49
|
+
const debounceMs = options.debounceMs ?? 120;
|
|
50
|
+
const watchDirs = (options.watchDirs ?? ['src']).map((dir) => resolve(config.root, dir));
|
|
51
|
+
|
|
52
|
+
let child: ReturnType<typeof Bun.spawn> | null = null;
|
|
53
|
+
let generation = 0;
|
|
54
|
+
let restarting: Promise<void> = Promise.resolve();
|
|
55
|
+
let stopped = false;
|
|
56
|
+
let firstStart = true;
|
|
57
|
+
|
|
58
|
+
const stopChild = async (): Promise<void> => {
|
|
59
|
+
const current = child;
|
|
60
|
+
child = null;
|
|
61
|
+
if (current === null) return;
|
|
62
|
+
// SIGTERM lets the host run its shutdown hook — close the database, end
|
|
63
|
+
// streams — before the listener goes. A host that ignores it gets 3 s.
|
|
64
|
+
current.kill('SIGTERM');
|
|
65
|
+
const deadline = Bun.sleep(3_000).then(() => 'timeout' as const);
|
|
66
|
+
const exited = await Promise.race([current.exited, deadline]);
|
|
67
|
+
if (exited === 'timeout') current.kill('SIGKILL');
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const cycle = async (): Promise<void> => {
|
|
71
|
+
const mine = (generation += 1);
|
|
72
|
+
await stopChild();
|
|
73
|
+
if (stopped || mine !== generation) return;
|
|
74
|
+
|
|
75
|
+
const started = Date.now();
|
|
76
|
+
try {
|
|
77
|
+
const page = await buildPage({
|
|
78
|
+
root: config.root,
|
|
79
|
+
entry: config.uiEntry,
|
|
80
|
+
template: config.uiTemplate,
|
|
81
|
+
outFile: config.pageOut,
|
|
82
|
+
minify: false,
|
|
83
|
+
csp: config.csp,
|
|
84
|
+
});
|
|
85
|
+
if (stopped || mine !== generation) return;
|
|
86
|
+
console.log(
|
|
87
|
+
`[broapp] ui ${(page.bytes / 1024).toFixed(0)} KiB in ${String(Date.now() - started)} ms`,
|
|
88
|
+
);
|
|
89
|
+
} catch (cause) {
|
|
90
|
+
console.error(`[broapp] build failed:\n${String(cause instanceof Error ? cause.message : cause)}`);
|
|
91
|
+
console.error('[broapp] waiting for a change…');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (stopped || mine !== generation) return;
|
|
96
|
+
child = Bun.spawn(['bun', 'run', resolve(config.root, config.hostEntry)], {
|
|
97
|
+
cwd: config.root,
|
|
98
|
+
stdout: 'inherit',
|
|
99
|
+
stderr: 'inherit',
|
|
100
|
+
env: {
|
|
101
|
+
...process.env,
|
|
102
|
+
// The host opens the browser itself on a cold start only. On a restart
|
|
103
|
+
// the developer already has a tab; a second one per save is noise.
|
|
104
|
+
BROAPP_OPEN_BROWSER: firstStart && options.open !== false ? '1' : '0',
|
|
105
|
+
// A dev host that exits because the tab is momentarily gone would race
|
|
106
|
+
// every restart. The developer stops it with Ctrl+C.
|
|
107
|
+
BROAPP_LIFECYCLE: 'background',
|
|
108
|
+
BROAPP_DEV: '1',
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
if (!firstStart) {
|
|
112
|
+
console.log('[broapp] host restarted — reload the tab (its previous session ended with the old process)');
|
|
113
|
+
}
|
|
114
|
+
firstStart = false;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const queue = (): void => {
|
|
118
|
+
restarting = restarting.then(cycle, cycle);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
122
|
+
const bump = (): void => {
|
|
123
|
+
if (timer !== null) clearTimeout(timer);
|
|
124
|
+
timer = setTimeout(queue, debounceMs);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const watchers = watchDirs
|
|
128
|
+
.filter((dir) => existsSync(dir))
|
|
129
|
+
.map((dir) =>
|
|
130
|
+
watch(dir, { recursive: true }, (_event, filename) => {
|
|
131
|
+
if (filename === null) return;
|
|
132
|
+
// Ignore the artefact this loop writes, or every build triggers another.
|
|
133
|
+
const written = relative(dir, resolve(config.root, config.pageOut));
|
|
134
|
+
if (filename === written || filename.startsWith(`${dirname(written)}/`)) return;
|
|
135
|
+
if (!/\.(?:tsx?|jsx?|css|html|json)$/.test(filename)) return;
|
|
136
|
+
console.log(`[broapp] changed: ${join(relative(config.root, dir), filename)}`);
|
|
137
|
+
bump();
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
if (watchers.length === 0) {
|
|
142
|
+
console.warn(`[broapp] nothing to watch under ${watchDirs.join(', ')}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
queue();
|
|
146
|
+
|
|
147
|
+
return await new Promise<number>((resolveExit) => {
|
|
148
|
+
const shutdown = (): void => {
|
|
149
|
+
if (stopped) return;
|
|
150
|
+
stopped = true;
|
|
151
|
+
if (timer !== null) clearTimeout(timer);
|
|
152
|
+
for (const watcher of watchers) watcher.close();
|
|
153
|
+
void restarting.then(stopChild).then(() => resolveExit(0));
|
|
154
|
+
};
|
|
155
|
+
process.on('SIGINT', shutdown);
|
|
156
|
+
process.on('SIGTERM', shutdown);
|
|
157
|
+
});
|
|
158
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `broapp/build` — the build tooling, as a library.
|
|
3
|
+
*
|
|
4
|
+
* The `broapp` command is the usual way in. This entry point exists so a
|
|
5
|
+
* project with an unusual pipeline can call the same functions directly
|
|
6
|
+
* instead of reimplementing them.
|
|
7
|
+
*/
|
|
8
|
+
export { buildPage } from './build-page.ts';
|
|
9
|
+
export type { BuildPageOptions, BuildPageResult } from './build-page.ts';
|
|
10
|
+
export { buildBinary } from './build-binary.ts';
|
|
11
|
+
export type { BuildBinaryOptions, BuiltBinary } from './build-binary.ts';
|
|
12
|
+
export { currentTarget, findTarget, TARGETS } from './targets.ts';
|
|
13
|
+
export type { Target } from './targets.ts';
|
|
14
|
+
export { defineConfig, loadConfig } from './config.ts';
|
|
15
|
+
export type { BroappConfig, ResolvedConfig } from './config.ts';
|
|
16
|
+
export { runDev } from './dev.ts';
|
|
17
|
+
export type { DevOptions } from './dev.ts';
|
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* The `broapp` command.
|
|
4
|
+
*
|
|
5
|
+
* Three subcommands, and no plugin system: `dev`, `build`, and `build --page`
|
|
6
|
+
* for the rare case where only the UI needs rebuilding.
|
|
7
|
+
*/
|
|
8
|
+
import { runDev } from './dev.ts';
|
|
9
|
+
import { buildPage } from './build-page.ts';
|
|
10
|
+
import { buildBinary } from './build-binary.ts';
|
|
11
|
+
import { loadConfig } from './config.ts';
|
|
12
|
+
import { currentTarget, TARGETS } from './targets.ts';
|
|
13
|
+
|
|
14
|
+
const VERSION = '0.1.0';
|
|
15
|
+
|
|
16
|
+
const USAGE = `broapp ${VERSION} — build tooling for local Bun + Brobridge applications
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
broapp dev [--no-open] Watch, rebuild and restart the host
|
|
20
|
+
broapp build [options] Build the UI and compile the executable
|
|
21
|
+
broapp build --page Build the UI document only
|
|
22
|
+
|
|
23
|
+
Build options:
|
|
24
|
+
--target <id> Compile for one target. Repeatable. Default: this machine.
|
|
25
|
+
--all-targets Compile every supported target.
|
|
26
|
+
--out-dir <path> Where executables go. Default: release
|
|
27
|
+
--no-minify Keep the bundle readable.
|
|
28
|
+
--no-bytecode Skip bytecode compilation.
|
|
29
|
+
|
|
30
|
+
Common:
|
|
31
|
+
-h, --help Show this message.
|
|
32
|
+
-v, --version Show the version.
|
|
33
|
+
|
|
34
|
+
Targets: ${TARGETS.map((target) => target.id).join(', ')}
|
|
35
|
+
|
|
36
|
+
Cross-compiled binaries are built, not run. Only a binary compiled for the
|
|
37
|
+
machine that built it has been executed by this command.`;
|
|
38
|
+
|
|
39
|
+
interface Flags {
|
|
40
|
+
readonly command: string;
|
|
41
|
+
readonly targets: string[];
|
|
42
|
+
readonly allTargets: boolean;
|
|
43
|
+
readonly outDir: string | undefined;
|
|
44
|
+
readonly pageOnly: boolean;
|
|
45
|
+
readonly minify: boolean;
|
|
46
|
+
readonly bytecode: boolean;
|
|
47
|
+
readonly open: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parse(argv: readonly string[]): Flags | { readonly help: true } | { readonly version: true } {
|
|
51
|
+
const targets: string[] = [];
|
|
52
|
+
let command = '';
|
|
53
|
+
let allTargets = false;
|
|
54
|
+
let outDir: string | undefined;
|
|
55
|
+
let pageOnly = false;
|
|
56
|
+
let minify = true;
|
|
57
|
+
let bytecode = true;
|
|
58
|
+
let open = true;
|
|
59
|
+
|
|
60
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
61
|
+
const argument = argv[index];
|
|
62
|
+
if (argument === undefined) continue;
|
|
63
|
+
switch (argument) {
|
|
64
|
+
case '-h':
|
|
65
|
+
case '--help':
|
|
66
|
+
return { help: true };
|
|
67
|
+
case '-v':
|
|
68
|
+
case '--version':
|
|
69
|
+
return { version: true };
|
|
70
|
+
case '--all-targets':
|
|
71
|
+
allTargets = true;
|
|
72
|
+
break;
|
|
73
|
+
case '--page':
|
|
74
|
+
pageOnly = true;
|
|
75
|
+
break;
|
|
76
|
+
case '--no-minify':
|
|
77
|
+
minify = false;
|
|
78
|
+
break;
|
|
79
|
+
case '--no-bytecode':
|
|
80
|
+
bytecode = false;
|
|
81
|
+
break;
|
|
82
|
+
case '--no-open':
|
|
83
|
+
open = false;
|
|
84
|
+
break;
|
|
85
|
+
case '--target': {
|
|
86
|
+
const value = argv[index + 1];
|
|
87
|
+
if (value === undefined) throw new Error('--target needs a value');
|
|
88
|
+
targets.push(value);
|
|
89
|
+
index += 1;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
case '--out-dir': {
|
|
93
|
+
const value = argv[index + 1];
|
|
94
|
+
if (value === undefined) throw new Error('--out-dir needs a value');
|
|
95
|
+
outDir = value;
|
|
96
|
+
index += 1;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
default:
|
|
100
|
+
if (argument.startsWith('-')) throw new Error(`unknown option ${JSON.stringify(argument)}`);
|
|
101
|
+
if (command === '') command = argument;
|
|
102
|
+
else throw new Error(`unexpected argument ${JSON.stringify(argument)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { command, targets, allTargets, outDir, pageOnly, minify, bytecode, open };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function main(): Promise<number> {
|
|
109
|
+
let flags: ReturnType<typeof parse>;
|
|
110
|
+
try {
|
|
111
|
+
flags = parse(process.argv.slice(2));
|
|
112
|
+
} catch (cause) {
|
|
113
|
+
console.error(String(cause instanceof Error ? cause.message : cause));
|
|
114
|
+
console.error('\nRun `broapp --help`.');
|
|
115
|
+
return 2;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if ('help' in flags) {
|
|
119
|
+
console.log(USAGE);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
if ('version' in flags) {
|
|
123
|
+
console.log(VERSION);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
if (flags.command === '') {
|
|
127
|
+
console.log(USAGE);
|
|
128
|
+
return 2;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const config = await loadConfig();
|
|
132
|
+
|
|
133
|
+
if (flags.command === 'dev') {
|
|
134
|
+
return await runDev({ config, open: flags.open });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (flags.command === 'build') {
|
|
138
|
+
const page = await buildPage({
|
|
139
|
+
root: config.root,
|
|
140
|
+
entry: config.uiEntry,
|
|
141
|
+
template: config.uiTemplate,
|
|
142
|
+
outFile: config.pageOut,
|
|
143
|
+
minify: flags.minify,
|
|
144
|
+
csp: config.csp,
|
|
145
|
+
});
|
|
146
|
+
console.log(`ui ${config.pageOut} ${(page.bytes / 1024).toFixed(1)} KiB`);
|
|
147
|
+
if (flags.pageOnly) return 0;
|
|
148
|
+
|
|
149
|
+
const targets = flags.allTargets
|
|
150
|
+
? TARGETS.map((target) => target.id)
|
|
151
|
+
: flags.targets.length > 0
|
|
152
|
+
? flags.targets
|
|
153
|
+
: [currentTarget().id];
|
|
154
|
+
|
|
155
|
+
const built = await buildBinary({
|
|
156
|
+
root: config.root,
|
|
157
|
+
entry: config.hostEntry,
|
|
158
|
+
name: config.binaryName,
|
|
159
|
+
outDir: flags.outDir ?? config.outDir,
|
|
160
|
+
targets,
|
|
161
|
+
minify: flags.minify,
|
|
162
|
+
bytecode: flags.bytecode && config.bytecode,
|
|
163
|
+
});
|
|
164
|
+
for (const binary of built) {
|
|
165
|
+
console.log(
|
|
166
|
+
`bin ${binary.path} ${(binary.bytes / 1024 / 1024).toFixed(1)} MiB ${binary.target.label}${binary.native ? '' : ' (cross-compiled, not run)'}`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
console.error(`unknown command ${JSON.stringify(flags.command)}\n`);
|
|
173
|
+
console.error(USAGE);
|
|
174
|
+
return 2;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
main().then(
|
|
178
|
+
(code) => process.exit(code),
|
|
179
|
+
(cause: unknown) => {
|
|
180
|
+
console.error(String(cause instanceof Error ? (cause.stack ?? cause.message) : cause));
|
|
181
|
+
process.exit(1);
|
|
182
|
+
},
|
|
183
|
+
);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compilation targets.
|
|
3
|
+
*
|
|
4
|
+
* These are Bun's `--target=bun-<platform>-<arch>` names. Cross-compilation
|
|
5
|
+
* works for pure-JavaScript dependency trees; it does not make a native addon
|
|
6
|
+
* portable, and `bun:sqlite` in particular links a platform SQLite into the
|
|
7
|
+
* produced executable — so a cross-compiled binary is a build artifact that
|
|
8
|
+
* has not been *run*. Broapp's release workflow therefore separates "compiled"
|
|
9
|
+
* from "smoke-tested", and only claims the second where a matching runner
|
|
10
|
+
* exists.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** A supported compilation target. */
|
|
14
|
+
export interface Target {
|
|
15
|
+
/** Bun's target name, without the `bun-` prefix. */
|
|
16
|
+
readonly id: string;
|
|
17
|
+
/** Executable suffix. */
|
|
18
|
+
readonly ext: '' | '.exe';
|
|
19
|
+
/** Human-readable label for release notes. */
|
|
20
|
+
readonly label: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Every target Broapp builds by default. */
|
|
24
|
+
export const TARGETS: readonly Target[] = [
|
|
25
|
+
{ id: 'darwin-arm64', ext: '', label: 'macOS (Apple silicon)' },
|
|
26
|
+
{ id: 'darwin-x64', ext: '', label: 'macOS (Intel)' },
|
|
27
|
+
{ id: 'linux-x64', ext: '', label: 'Linux x86-64 (glibc)' },
|
|
28
|
+
{ id: 'linux-arm64', ext: '', label: 'Linux arm64 (glibc)' },
|
|
29
|
+
{ id: 'linux-x64-musl', ext: '', label: 'Linux x86-64 (musl / Alpine)' },
|
|
30
|
+
{ id: 'windows-x64', ext: '.exe', label: 'Windows x64' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** Look a target up by id. */
|
|
34
|
+
export function findTarget(id: string): Target | undefined {
|
|
35
|
+
return TARGETS.find((target) => target.id === id);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The target matching the machine running the build. */
|
|
39
|
+
export function currentTarget(): Target {
|
|
40
|
+
const arch = process.arch === 'arm64' ? 'arm64' : 'x64';
|
|
41
|
+
const platform =
|
|
42
|
+
process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'windows' : 'linux';
|
|
43
|
+
const id = platform === 'windows' ? 'windows-x64' : `${platform}-${arch}`;
|
|
44
|
+
const found = findTarget(id);
|
|
45
|
+
if (found === undefined) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`no Broapp target for ${process.platform}/${process.arch}. Supported: ${TARGETS.map((t) => t.id).join(', ')}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return found;
|
|
51
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser side of a contract.
|
|
3
|
+
*
|
|
4
|
+
* A thin, typed wrapper over `@brobridgejs/client`. It adds four things and
|
|
5
|
+
* nothing else: contract-derived types, output validation, the error
|
|
6
|
+
* translation described in `shared/errors.ts`, and a stream subscription with
|
|
7
|
+
* a `cancel()` that really cancels.
|
|
8
|
+
*
|
|
9
|
+
* Importing this module pulls in the contract, which is data. It does not pull
|
|
10
|
+
* in anything under `host/` — that separation is what keeps host code and host
|
|
11
|
+
* configuration out of the browser bundle, and there is a test that asserts it.
|
|
12
|
+
*/
|
|
13
|
+
import type { Bridge, BridgeState, ConnectOptions, Unsubscribe } from '@brobridgejs/client';
|
|
14
|
+
import { connect } from '@brobridgejs/client';
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
AnyContract,
|
|
18
|
+
OperationInput,
|
|
19
|
+
OperationName,
|
|
20
|
+
OperationOutput,
|
|
21
|
+
StreamEvent,
|
|
22
|
+
StreamName,
|
|
23
|
+
StreamParams,
|
|
24
|
+
} from '../shared/contract.ts';
|
|
25
|
+
import { BroappError, fromTransportError } from '../shared/errors.ts';
|
|
26
|
+
import { NdjsonDecoder } from '../shared/ndjson.ts';
|
|
27
|
+
|
|
28
|
+
export type { BridgeState };
|
|
29
|
+
|
|
30
|
+
/** Callbacks for a stream subscription. */
|
|
31
|
+
export interface StreamCallbacks<E> {
|
|
32
|
+
onEvent(event: E): void;
|
|
33
|
+
/** The stream finished normally. Not called after `onError` or a cancel. */
|
|
34
|
+
onDone?(): void;
|
|
35
|
+
/** The stream failed. Not called for a cancellation the caller asked for. */
|
|
36
|
+
onError?(error: BroappError): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A running stream subscription. */
|
|
40
|
+
export interface Subscription {
|
|
41
|
+
/**
|
|
42
|
+
* Stop the stream and tell the host to stop producing.
|
|
43
|
+
*
|
|
44
|
+
* This sends a `CANCEL` frame. Simply dropping the subscription would not:
|
|
45
|
+
* abandoning an async iterator does not cancel the stream underneath it, and
|
|
46
|
+
* the host would keep computing until it finished.
|
|
47
|
+
*/
|
|
48
|
+
cancel(): void;
|
|
49
|
+
/** True once `cancel()` ran or the stream ended. */
|
|
50
|
+
readonly closed: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A connected, contract-typed client. */
|
|
54
|
+
export interface BroappClient<C extends AnyContract> {
|
|
55
|
+
/** Invoke an operation. Rejects with {@link BroappError}. */
|
|
56
|
+
call<K extends OperationName<C>>(
|
|
57
|
+
name: K,
|
|
58
|
+
input: OperationInput<C, K>,
|
|
59
|
+
): Promise<OperationOutput<C, K>>;
|
|
60
|
+
|
|
61
|
+
/** Open a stream. Returns as soon as the stream is open. */
|
|
62
|
+
subscribe<K extends StreamName<C>>(
|
|
63
|
+
name: K,
|
|
64
|
+
params: StreamParams<C, K>,
|
|
65
|
+
callbacks: StreamCallbacks<StreamEvent<C, K>>,
|
|
66
|
+
): Promise<Subscription>;
|
|
67
|
+
|
|
68
|
+
/** What the connection is doing right now. */
|
|
69
|
+
readonly state: BridgeState;
|
|
70
|
+
/** Subscribe to connection state changes. */
|
|
71
|
+
onState(listener: (state: BridgeState) => void): Unsubscribe;
|
|
72
|
+
/** Round-trip time to the host, in milliseconds. */
|
|
73
|
+
ping(): Promise<number>;
|
|
74
|
+
/** The underlying Brobridge client, for anything this wrapper does not cover. */
|
|
75
|
+
readonly bridge: Bridge;
|
|
76
|
+
/** Disconnect. */
|
|
77
|
+
close(): Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Options for {@link createClient}. */
|
|
81
|
+
export interface CreateClientOptions extends ConnectOptions {
|
|
82
|
+
/**
|
|
83
|
+
* The URL to connect to, including any `?bt=` launch token. Defaults to
|
|
84
|
+
* `location.href`, which is where the token is on first load.
|
|
85
|
+
*/
|
|
86
|
+
readonly url?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Connect to the host that served this page.
|
|
91
|
+
*
|
|
92
|
+
* The launch token in `location.href` is redeemed once by Brobridge and never
|
|
93
|
+
* retained. Reconnection is on by default and carries the session cookie, so a
|
|
94
|
+
* dropped socket recovers without a second token.
|
|
95
|
+
*/
|
|
96
|
+
export async function createClient<C extends AnyContract>(
|
|
97
|
+
contract: C,
|
|
98
|
+
options: CreateClientOptions = {},
|
|
99
|
+
): Promise<BroappClient<C>> {
|
|
100
|
+
const { url, ...connectOptions } = options;
|
|
101
|
+
const bridge = await connect(url ?? globalThis.location.href, connectOptions);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
bridge,
|
|
105
|
+
get state() {
|
|
106
|
+
return bridge.state;
|
|
107
|
+
},
|
|
108
|
+
onState(listener) {
|
|
109
|
+
return bridge.on('state', listener);
|
|
110
|
+
},
|
|
111
|
+
ping: () => bridge.ping(),
|
|
112
|
+
close: () => bridge.close(),
|
|
113
|
+
|
|
114
|
+
async call(name, input) {
|
|
115
|
+
let raw: unknown;
|
|
116
|
+
try {
|
|
117
|
+
raw = await bridge.call(name, input);
|
|
118
|
+
} catch (cause) {
|
|
119
|
+
throw fromTransportError(cause);
|
|
120
|
+
}
|
|
121
|
+
const spec = contract.operations[name];
|
|
122
|
+
if (spec === undefined) throw new BroappError('internal', 'unknown operation');
|
|
123
|
+
try {
|
|
124
|
+
// The host is trusted, so this is a contract check rather than a
|
|
125
|
+
// security boundary: it catches a host and a browser built from
|
|
126
|
+
// different versions of the contract, which otherwise shows up as a
|
|
127
|
+
// confusing render bug far from its cause.
|
|
128
|
+
return spec.output.parse(raw) as OperationOutput<C, typeof name>;
|
|
129
|
+
} catch {
|
|
130
|
+
throw new BroappError('internal', 'The host returned a response this app did not expect.');
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
async subscribe(name, params, callbacks) {
|
|
135
|
+
const spec = contract.streams[name];
|
|
136
|
+
if (spec === undefined) throw new BroappError('internal', 'unknown stream');
|
|
137
|
+
|
|
138
|
+
// Params are checked here as well as on the host, and the two checks do
|
|
139
|
+
// different jobs. The host's is the security boundary and is not
|
|
140
|
+
// optional. This one is for the developer: a stream's `OPEN` is
|
|
141
|
+
// acknowledged before its handler runs, so a host-side rejection arrives
|
|
142
|
+
// *after* `subscribe` has already resolved — through `onError`, one tick
|
|
143
|
+
// later. Checking here as well means an obviously wrong parameter fails
|
|
144
|
+
// where the call was made.
|
|
145
|
+
try {
|
|
146
|
+
spec.params.parse(params);
|
|
147
|
+
} catch (cause) {
|
|
148
|
+
throw new BroappError(
|
|
149
|
+
'invalid_input',
|
|
150
|
+
cause instanceof Error ? cause.message : 'invalid stream parameters',
|
|
151
|
+
cause,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let stream: Awaited<ReturnType<Bridge['openStream']>>;
|
|
156
|
+
try {
|
|
157
|
+
stream = await bridge.openStream(name, params as Readonly<Record<string, unknown>>, {
|
|
158
|
+
mode: 'read',
|
|
159
|
+
});
|
|
160
|
+
} catch (cause) {
|
|
161
|
+
throw fromTransportError(cause);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let cancelled = false;
|
|
165
|
+
let closed = false;
|
|
166
|
+
const decoder = new NdjsonDecoder();
|
|
167
|
+
|
|
168
|
+
const subscription: Subscription = {
|
|
169
|
+
get closed() {
|
|
170
|
+
return closed || cancelled;
|
|
171
|
+
},
|
|
172
|
+
cancel() {
|
|
173
|
+
if (cancelled || closed) return;
|
|
174
|
+
cancelled = true;
|
|
175
|
+
stream.cancel('cancelled by the user');
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
void (async () => {
|
|
180
|
+
try {
|
|
181
|
+
for await (const chunk of stream) {
|
|
182
|
+
if (cancelled) break;
|
|
183
|
+
for (const event of decoder.push(chunk)) {
|
|
184
|
+
callbacks.onEvent(spec.event.parse(event) as StreamEvent<C, typeof name>);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (cancelled) return;
|
|
188
|
+
for (const event of decoder.flush()) {
|
|
189
|
+
callbacks.onEvent(spec.event.parse(event) as StreamEvent<C, typeof name>);
|
|
190
|
+
}
|
|
191
|
+
closed = true;
|
|
192
|
+
callbacks.onDone?.();
|
|
193
|
+
} catch (cause) {
|
|
194
|
+
closed = true;
|
|
195
|
+
if (cancelled) return;
|
|
196
|
+
callbacks.onError?.(fromTransportError(cause));
|
|
197
|
+
}
|
|
198
|
+
})();
|
|
199
|
+
|
|
200
|
+
return subscription;
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** `broapp/client` — the browser client. Framework-agnostic. */
|
|
2
|
+
export { createClient } from './client.ts';
|
|
3
|
+
export type {
|
|
4
|
+
BridgeState,
|
|
5
|
+
BroappClient,
|
|
6
|
+
CreateClientOptions,
|
|
7
|
+
StreamCallbacks,
|
|
8
|
+
Subscription,
|
|
9
|
+
} from './client.ts';
|
|
10
|
+
|
|
11
|
+
export { BroappError } from '../shared/errors.ts';
|
|
12
|
+
export type { PublicErrorCode } from '../shared/errors.ts';
|