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/host/app.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host side of a contract.
|
|
3
|
+
*
|
|
4
|
+
* `createHostApp(contract)` returns a registry a developer fills in, and
|
|
5
|
+
* `mount()` hands the result to a Brobridge bridge. The layer is thin by
|
|
6
|
+
* design: it validates input, draws the error boundary, frames stream events,
|
|
7
|
+
* and turns cancellation into an `AbortSignal`. Transport, authentication and
|
|
8
|
+
* session handling stay entirely in Brobridge.
|
|
9
|
+
*
|
|
10
|
+
* Where to add an operation: declare it in the shared contract, then call
|
|
11
|
+
* `app.operation(name, handler)` here. The compiler will not let those two
|
|
12
|
+
* drift.
|
|
13
|
+
*/
|
|
14
|
+
import type { Bridge, StreamContext } from 'brobridge';
|
|
15
|
+
import type { BridgeStream } from '@brobridgejs/core';
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
AnyContract,
|
|
19
|
+
OperationInput,
|
|
20
|
+
OperationName,
|
|
21
|
+
OperationOutput,
|
|
22
|
+
StreamEvent,
|
|
23
|
+
StreamName,
|
|
24
|
+
StreamParams,
|
|
25
|
+
} from '../shared/contract.ts';
|
|
26
|
+
import { splitRoute } from '../shared/contract.ts';
|
|
27
|
+
import { PublicError } from '../shared/errors.ts';
|
|
28
|
+
import { encodeEvent } from '../shared/ndjson.ts';
|
|
29
|
+
import { ValidationError } from '../shared/schema.ts';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What an operation handler is told about its caller.
|
|
33
|
+
*
|
|
34
|
+
* There is no session identifier here. Brobridge passes a session id to
|
|
35
|
+
* *stream* handlers (`StreamContext`) but not to the methods of an exposed
|
|
36
|
+
* service, and Broapp does not invent one. Every call has nonetheless already
|
|
37
|
+
* passed Brobridge's trust fence and cookie check before a handler runs; the
|
|
38
|
+
* missing piece is only *which* authenticated tab called, which a v1 starter
|
|
39
|
+
* has no use for. An application that needs it should use a stream.
|
|
40
|
+
*/
|
|
41
|
+
export interface CallContext {
|
|
42
|
+
/** The route name, for logging. */
|
|
43
|
+
readonly route: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A unary operation implementation. */
|
|
47
|
+
export type OperationHandler<C extends AnyContract, K extends OperationName<C>> = (
|
|
48
|
+
input: OperationInput<C, K>,
|
|
49
|
+
context: CallContext,
|
|
50
|
+
) => OperationOutput<C, K> | Promise<OperationOutput<C, K>>;
|
|
51
|
+
|
|
52
|
+
/** What a stream handler is given to talk back with. */
|
|
53
|
+
export interface StreamSink<E> {
|
|
54
|
+
/**
|
|
55
|
+
* Send one event.
|
|
56
|
+
*
|
|
57
|
+
* Awaiting it is what applies backpressure: the promise settles when
|
|
58
|
+
* Brobridge's flow control has room, so a browser that stops reading slows
|
|
59
|
+
* the producer instead of filling a buffer. It rejects once the stream has
|
|
60
|
+
* ended, which includes the browser cancelling.
|
|
61
|
+
*/
|
|
62
|
+
emit(event: E): Promise<void>;
|
|
63
|
+
/** Aborted when the browser cancels, the tab disconnects, or the host shuts down. */
|
|
64
|
+
readonly signal: AbortSignal;
|
|
65
|
+
/** The authenticated Brobridge session this stream belongs to. */
|
|
66
|
+
readonly sessionId: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A stream implementation. Returning ends the stream cleanly. */
|
|
70
|
+
export type StreamHandlerFor<C extends AnyContract, K extends StreamName<C>> = (
|
|
71
|
+
params: StreamParams<C, K>,
|
|
72
|
+
sink: StreamSink<StreamEvent<C, K>>,
|
|
73
|
+
) => void | Promise<void>;
|
|
74
|
+
|
|
75
|
+
/** Diagnostics sink. Defaults to `console`. */
|
|
76
|
+
export interface HostLogger {
|
|
77
|
+
warn(message: string): void;
|
|
78
|
+
error(message: string): void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Options for {@link createHostApp}. */
|
|
82
|
+
export interface HostAppOptions {
|
|
83
|
+
readonly logger?: HostLogger;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A contract with implementations attached, ready to mount on a bridge. */
|
|
87
|
+
export interface HostApp<C extends AnyContract> {
|
|
88
|
+
/** Implement one operation from the contract. */
|
|
89
|
+
operation<K extends OperationName<C>>(name: K, handler: OperationHandler<C, K>): HostApp<C>;
|
|
90
|
+
/** Implement one stream from the contract. */
|
|
91
|
+
stream<K extends StreamName<C>>(name: K, handler: StreamHandlerFor<C, K>): HostApp<C>;
|
|
92
|
+
/**
|
|
93
|
+
* Register everything with a bridge.
|
|
94
|
+
*
|
|
95
|
+
* Throws when the contract declares a route no handler implements: a route
|
|
96
|
+
* that answers `NOT_FOUND` at runtime is a shipped bug, and startup is when
|
|
97
|
+
* a developer is present to see it.
|
|
98
|
+
*/
|
|
99
|
+
mount(bridge: Bridge): void;
|
|
100
|
+
/** Abort every stream this app currently has open. Called during shutdown. */
|
|
101
|
+
abortAll(reason: string): void;
|
|
102
|
+
/** How many streams are running right now. */
|
|
103
|
+
readonly activeStreams: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Build the host side of a contract. */
|
|
107
|
+
export function createHostApp<C extends AnyContract>(
|
|
108
|
+
contract: C,
|
|
109
|
+
options: HostAppOptions = {},
|
|
110
|
+
): HostApp<C> {
|
|
111
|
+
const logger: HostLogger = options.logger ?? console;
|
|
112
|
+
const operations = new Map<string, OperationHandler<C, never>>();
|
|
113
|
+
const streams = new Map<string, StreamHandlerFor<C, never>>();
|
|
114
|
+
const running = new Set<AbortController>();
|
|
115
|
+
|
|
116
|
+
function known(kind: 'operation' | 'stream', name: string): void {
|
|
117
|
+
const table = kind === 'operation' ? contract.operations : contract.streams;
|
|
118
|
+
if (!Object.prototype.hasOwnProperty.call(table, name)) {
|
|
119
|
+
throw new TypeError(`${kind} ${JSON.stringify(name)} is not declared in the contract`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const app: HostApp<C> = {
|
|
124
|
+
operation(name, handler) {
|
|
125
|
+
known('operation', name);
|
|
126
|
+
if (operations.has(name)) throw new TypeError(`operation ${JSON.stringify(name)} is already implemented`);
|
|
127
|
+
operations.set(name, handler as OperationHandler<C, never>);
|
|
128
|
+
return app;
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
stream(name, handler) {
|
|
132
|
+
known('stream', name);
|
|
133
|
+
if (streams.has(name)) throw new TypeError(`stream ${JSON.stringify(name)} is already implemented`);
|
|
134
|
+
streams.set(name, handler as StreamHandlerFor<C, never>);
|
|
135
|
+
return app;
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
get activeStreams() {
|
|
139
|
+
return running.size;
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
abortAll(reason) {
|
|
143
|
+
for (const controller of running) controller.abort(new Error(reason));
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
mount(bridge) {
|
|
147
|
+
const missing = [
|
|
148
|
+
...contract.routes.operations.filter((route) => !operations.has(route)),
|
|
149
|
+
...contract.routes.streams.filter((route) => !streams.has(route)),
|
|
150
|
+
];
|
|
151
|
+
if (missing.length > 0) {
|
|
152
|
+
throw new TypeError(`contract routes have no implementation: ${missing.join(', ')}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Brobridge exposes a *service object* per group, so the contract's
|
|
156
|
+
// dotted routes are collected back into groups here. This is the only
|
|
157
|
+
// place the two namings meet.
|
|
158
|
+
const groups = new Map<string, Record<string, unknown>>();
|
|
159
|
+
for (const [route, handler] of operations) {
|
|
160
|
+
const { group, member } = splitRoute(route);
|
|
161
|
+
const spec = contract.operations[route];
|
|
162
|
+
if (spec === undefined) continue;
|
|
163
|
+
const service = groups.get(group) ?? {};
|
|
164
|
+
service[member] = async (raw: unknown): Promise<unknown> => {
|
|
165
|
+
const context: CallContext = { route };
|
|
166
|
+
let input: unknown;
|
|
167
|
+
try {
|
|
168
|
+
input = spec.input.parse(raw);
|
|
169
|
+
} catch (cause) {
|
|
170
|
+
// A validation message names a field and a constraint from the
|
|
171
|
+
// contract the browser already has. It carries nothing the caller
|
|
172
|
+
// did not send, so it is safe to return and useful to see.
|
|
173
|
+
throw new PublicError(
|
|
174
|
+
'invalid_input',
|
|
175
|
+
cause instanceof ValidationError ? cause.message : 'invalid input',
|
|
176
|
+
).toBridgeError();
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const output = await (handler as OperationHandler<C, never>)(
|
|
180
|
+
input as never,
|
|
181
|
+
context,
|
|
182
|
+
);
|
|
183
|
+
return spec.output.parse(output);
|
|
184
|
+
} catch (cause) {
|
|
185
|
+
throw wrap(cause, route, logger);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
groups.set(group, service);
|
|
189
|
+
}
|
|
190
|
+
for (const [group, service] of groups) bridge.expose(group, service);
|
|
191
|
+
|
|
192
|
+
for (const [route, handler] of streams) {
|
|
193
|
+
const spec = contract.streams[route];
|
|
194
|
+
if (spec === undefined) continue;
|
|
195
|
+
bridge.stream(route, (stream: BridgeStream, streamContext: StreamContext) =>
|
|
196
|
+
runStream(stream, streamContext, route, spec, handler, running, logger),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return app;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Reduce a handler failure to something safe to send.
|
|
207
|
+
*
|
|
208
|
+
* A `PublicError` was written for the browser and keeps its message. Anything
|
|
209
|
+
* else is logged here — with its stack, on the host, where it belongs — and
|
|
210
|
+
* rethrown unchanged so Brobridge applies its own reduction to `"internal
|
|
211
|
+
* error"`. Broapp does not need to redact it a second time and must not
|
|
212
|
+
* accidentally undo the reduction by wrapping the message.
|
|
213
|
+
*/
|
|
214
|
+
function wrap(cause: unknown, route: string, logger: HostLogger): unknown {
|
|
215
|
+
if (cause instanceof PublicError) return cause.toBridgeError();
|
|
216
|
+
logger.error(`[broapp] ${route} failed: ${String(cause instanceof Error ? cause.stack ?? cause.message : cause)}`);
|
|
217
|
+
return cause;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Run one stream handler.
|
|
222
|
+
*
|
|
223
|
+
* Cancellation is the part worth reading. Brobridge's `BridgeStream.cancel()`
|
|
224
|
+
* on the browser side sends a `CANCEL` frame; the host's stream then fails
|
|
225
|
+
* with a `CANCELLED` `StreamError`, which shows up here as `stream.closed`
|
|
226
|
+
* rejecting. That rejection is the *only* reliable cancellation signal — a
|
|
227
|
+
* browser that merely stops iterating sends nothing, and a `for await` loop
|
|
228
|
+
* that a consumer breaks out of does not cancel the underlying stream. So the
|
|
229
|
+
* signal is wired from `stream.closed`, and the same `AbortController` is
|
|
230
|
+
* aborted on shutdown.
|
|
231
|
+
*/
|
|
232
|
+
async function runStream<E>(
|
|
233
|
+
stream: BridgeStream,
|
|
234
|
+
streamContext: StreamContext,
|
|
235
|
+
route: string,
|
|
236
|
+
spec: { params: { parse(value: unknown): unknown }; event: { parse(value: unknown): unknown } },
|
|
237
|
+
handler: (params: never, sink: StreamSink<E>) => void | Promise<void>,
|
|
238
|
+
running: Set<AbortController>,
|
|
239
|
+
logger: HostLogger,
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
const controller = new AbortController();
|
|
242
|
+
running.add(controller);
|
|
243
|
+
|
|
244
|
+
// `closed` rejects on cancellation and on a torn-down connection, and
|
|
245
|
+
// resolves on a clean end. Either way the handler must stop.
|
|
246
|
+
stream.closed.then(
|
|
247
|
+
() => controller.abort(new Error('stream closed')),
|
|
248
|
+
(cause: unknown) => controller.abort(cause),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
let params: unknown;
|
|
252
|
+
try {
|
|
253
|
+
params = spec.params.parse(streamContext.params);
|
|
254
|
+
} catch (cause) {
|
|
255
|
+
running.delete(controller);
|
|
256
|
+
throw new PublicError(
|
|
257
|
+
'invalid_input',
|
|
258
|
+
cause instanceof ValidationError ? cause.message : 'invalid stream parameters',
|
|
259
|
+
).toBridgeError();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const sink: StreamSink<E> = {
|
|
263
|
+
signal: controller.signal,
|
|
264
|
+
sessionId: streamContext.sessionId,
|
|
265
|
+
async emit(event: E): Promise<void> {
|
|
266
|
+
if (controller.signal.aborted) throw new Error('stream is no longer open');
|
|
267
|
+
await stream.write(encodeEvent(spec.event.parse(event)));
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
await handler(params as never, sink);
|
|
273
|
+
if (!controller.signal.aborted) await stream.end();
|
|
274
|
+
} catch (cause) {
|
|
275
|
+
// A cancelled stream is not a fault: the browser asked for it, the stream
|
|
276
|
+
// is already gone, and there is nothing to report.
|
|
277
|
+
if (controller.signal.aborted) return;
|
|
278
|
+
throw wrap(cause, route, logger);
|
|
279
|
+
} finally {
|
|
280
|
+
running.delete(controller);
|
|
281
|
+
controller.abort(new Error('handler finished'));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `broapp/host` — the host runtime.
|
|
3
|
+
*
|
|
4
|
+
* Never import this from browser code. It pulls in `node:fs`, `node:os` and
|
|
5
|
+
* `Bun.spawn`, none of which exist in a browser, and a bundler that follows
|
|
6
|
+
* the import would fail loudly — which is the intended outcome.
|
|
7
|
+
*/
|
|
8
|
+
export { createHostApp } from './app.ts';
|
|
9
|
+
export type {
|
|
10
|
+
CallContext,
|
|
11
|
+
HostApp,
|
|
12
|
+
HostAppOptions,
|
|
13
|
+
HostLogger,
|
|
14
|
+
OperationHandler,
|
|
15
|
+
StreamHandlerFor,
|
|
16
|
+
StreamSink,
|
|
17
|
+
} from './app.ts';
|
|
18
|
+
|
|
19
|
+
export { startApp } from './runtime.ts';
|
|
20
|
+
export type { LifecycleMode, RunningApp, ShutdownReason, StartAppOptions } from './runtime.ts';
|
|
21
|
+
|
|
22
|
+
export { dataDir, ensureDataDir, DATA_DIR_ENV } from './paths.ts';
|
|
23
|
+
export { openBrowser } from './open-browser.ts';
|
|
24
|
+
|
|
25
|
+
export { PublicError, publicError } from '../shared/errors.ts';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening the user's browser.
|
|
3
|
+
*
|
|
4
|
+
* Brobridge does not launch browsers — deliberately, since a launcher is
|
|
5
|
+
* policy, not protocol — so Broapp supplies one. It is the same
|
|
6
|
+
* platform-handler call every tool of this shape makes.
|
|
7
|
+
*
|
|
8
|
+
* The URL carries a one-time launch token. It is passed as a separate `argv`
|
|
9
|
+
* element to a directly spawned executable, never through a shell, so nothing
|
|
10
|
+
* in it can be interpreted as a command and nothing lands in a shell history.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Which command opens a URL on this platform. */
|
|
14
|
+
function launcher(url: string): string[] | null {
|
|
15
|
+
switch (process.platform) {
|
|
16
|
+
case 'darwin':
|
|
17
|
+
return ['open', url];
|
|
18
|
+
case 'win32':
|
|
19
|
+
// `start` is a `cmd` builtin, so `cmd /c` is unavoidable here. The empty
|
|
20
|
+
// string is `start`'s title argument: without it `start` would read a
|
|
21
|
+
// quoted URL as the window title and open nothing.
|
|
22
|
+
return ['cmd', '/c', 'start', '', url];
|
|
23
|
+
case 'linux':
|
|
24
|
+
case 'freebsd':
|
|
25
|
+
case 'openbsd':
|
|
26
|
+
return ['xdg-open', url];
|
|
27
|
+
default:
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Try to open `url`. Resolves `false` when no browser could be launched — the
|
|
34
|
+
* caller is expected to have already printed the URL for manual use.
|
|
35
|
+
*/
|
|
36
|
+
export async function openBrowser(url: string): Promise<boolean> {
|
|
37
|
+
const argv = launcher(url);
|
|
38
|
+
if (argv === null) return false;
|
|
39
|
+
try {
|
|
40
|
+
const child = Bun.spawn(argv, { stdout: 'ignore', stderr: 'ignore', stdin: 'ignore' });
|
|
41
|
+
return (await child.exited) === 0;
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a compiled Broapp application keeps its data.
|
|
3
|
+
*
|
|
4
|
+
* A single-file executable may sit in `/usr/local/bin`, in a read-only
|
|
5
|
+
* `/Applications` bundle, or on a share. Writing next to it is therefore not
|
|
6
|
+
* an option, and neither is writing to the working directory — the same
|
|
7
|
+
* executable run from two directories would find two different databases.
|
|
8
|
+
* Broapp resolves one per-user location per application, up front, and hands
|
|
9
|
+
* it to the application.
|
|
10
|
+
*/
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { mkdirSync } from 'node:fs';
|
|
14
|
+
|
|
15
|
+
/** The environment variable that overrides the resolved directory. */
|
|
16
|
+
export const DATA_DIR_ENV = 'BROAPP_DATA_DIR';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The per-user data directory for `appName`.
|
|
20
|
+
*
|
|
21
|
+
* Resolution order:
|
|
22
|
+
*
|
|
23
|
+
* 1. `BROAPP_DATA_DIR`, used verbatim — for tests, for portable installs, and
|
|
24
|
+
* for a user who keeps application data on another volume.
|
|
25
|
+
* 2. The platform convention: `%APPDATA%` on Windows,
|
|
26
|
+
* `~/Library/Application Support` on macOS, and `$XDG_DATA_HOME` or
|
|
27
|
+
* `~/.local/share` elsewhere.
|
|
28
|
+
*
|
|
29
|
+
* The directory is not created here. {@link ensureDataDir} does that, so a
|
|
30
|
+
* `--data-dir`-style diagnostic can print the path without a side effect.
|
|
31
|
+
*/
|
|
32
|
+
export function dataDir(appName: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
33
|
+
const override = env[DATA_DIR_ENV];
|
|
34
|
+
if (override !== undefined && override !== '') return override;
|
|
35
|
+
|
|
36
|
+
const platform = process.platform;
|
|
37
|
+
if (platform === 'win32') {
|
|
38
|
+
const base = env['APPDATA'] ?? join(homedir(), 'AppData', 'Roaming');
|
|
39
|
+
return join(base, appName);
|
|
40
|
+
}
|
|
41
|
+
if (platform === 'darwin') {
|
|
42
|
+
return join(homedir(), 'Library', 'Application Support', appName);
|
|
43
|
+
}
|
|
44
|
+
const base = env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share');
|
|
45
|
+
return join(base, appName);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Resolve the data directory and create it, including parents. */
|
|
49
|
+
export function ensureDataDir(appName: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
50
|
+
const directory = dataDir(appName, env);
|
|
51
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
52
|
+
return directory;
|
|
53
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starting, supervising and stopping a Broapp application.
|
|
3
|
+
*
|
|
4
|
+
* This is the lifecycle the spec calls for, made explicit. Two modes:
|
|
5
|
+
*
|
|
6
|
+
* - `interactive` (the default) — the process exists to serve a browser tab.
|
|
7
|
+
* When the last tab has been gone for `idleGraceMs` and no work is running,
|
|
8
|
+
* the process exits. A launch that no browser ever reaches gives up after
|
|
9
|
+
* `launchTimeoutMs` with a nonzero status, so a broken launcher fails
|
|
10
|
+
* loudly instead of leaving a listener behind forever.
|
|
11
|
+
*
|
|
12
|
+
* - `background` — the process keeps running when the UI closes, and is
|
|
13
|
+
* stopped by `Ctrl+C`, `SIGTERM`, or whatever supervises it. It is an
|
|
14
|
+
* ordinary foreground process; Broapp does not daemonise and does not
|
|
15
|
+
* install a service.
|
|
16
|
+
*
|
|
17
|
+
* "Attached" is not the same as "session exists". Brobridge retains a session
|
|
18
|
+
* after its socket drops so a reconnecting tab can resume, so counting
|
|
19
|
+
* `bridge.sessions` would keep the process alive for a minute after the last
|
|
20
|
+
* tab closed. Attachment is `endpoint.state === "open"`, which is what a live
|
|
21
|
+
* connection actually looks like.
|
|
22
|
+
*/
|
|
23
|
+
import type { Bridge, BridgeOptions } from 'brobridge';
|
|
24
|
+
import { createBridge } from 'brobridge';
|
|
25
|
+
|
|
26
|
+
import { openBrowser } from './open-browser.ts';
|
|
27
|
+
|
|
28
|
+
/** Which lifecycle policy the application follows. */
|
|
29
|
+
export type LifecycleMode = 'interactive' | 'background';
|
|
30
|
+
|
|
31
|
+
/** Options for {@link startApp}. */
|
|
32
|
+
export interface StartAppOptions {
|
|
33
|
+
/** The complete, self-contained HTML document served at `/`. */
|
|
34
|
+
readonly page: string;
|
|
35
|
+
/** Human-readable application name, used in banner output. */
|
|
36
|
+
readonly appName: string;
|
|
37
|
+
/** Application version, used in banner output. */
|
|
38
|
+
readonly version: string;
|
|
39
|
+
/** Register routes on the bridge. Called once, before the browser is opened. */
|
|
40
|
+
readonly register: (bridge: Bridge) => void | Promise<void>;
|
|
41
|
+
/** Default `"interactive"`. */
|
|
42
|
+
readonly mode?: LifecycleMode;
|
|
43
|
+
/** Open the browser at startup. Default `true`. */
|
|
44
|
+
readonly openBrowser?: boolean;
|
|
45
|
+
/** Interactive mode: how long with no attached tab before exiting. Default 20 000 ms. */
|
|
46
|
+
readonly idleGraceMs?: number;
|
|
47
|
+
/** Interactive mode: how long to wait for the first tab. Default 120 000 ms. */
|
|
48
|
+
readonly launchTimeoutMs?: number;
|
|
49
|
+
/** True while work is running that must not be discarded by an idle exit. */
|
|
50
|
+
readonly isBusy?: () => boolean;
|
|
51
|
+
/** Run before the bridge closes: flush, checkpoint, close a database. */
|
|
52
|
+
readonly onShutdown?: (reason: ShutdownReason) => void | Promise<void>;
|
|
53
|
+
/** Passed through to Brobridge. Loopback binding and auth are not overridable here. */
|
|
54
|
+
readonly bridge?: Omit<BridgeOptions, 'index' | 'allowNonLoopback' | 'host'>;
|
|
55
|
+
/** Where banner output goes. Default `console`. */
|
|
56
|
+
readonly stdout?: { log(message: string): void };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Why the application is stopping. */
|
|
60
|
+
export type ShutdownReason = 'signal' | 'idle' | 'never-connected' | 'requested';
|
|
61
|
+
|
|
62
|
+
/** A running application. */
|
|
63
|
+
export interface RunningApp {
|
|
64
|
+
readonly bridge: Bridge;
|
|
65
|
+
/** Resolves with the intended process exit code when the application stops. */
|
|
66
|
+
readonly done: Promise<number>;
|
|
67
|
+
/** Stop it. Safe to call more than once. */
|
|
68
|
+
stop(reason?: ShutdownReason): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const POLL_INTERVAL_MS = 1_000;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Start an application.
|
|
75
|
+
*
|
|
76
|
+
* Binds loopback on an ephemeral port — `allowNonLoopback` is deliberately not
|
|
77
|
+
* forwarded, so a starter cannot grow LAN exposure through a config typo.
|
|
78
|
+
*/
|
|
79
|
+
export async function startApp(options: StartAppOptions): Promise<RunningApp> {
|
|
80
|
+
const out = options.stdout ?? console;
|
|
81
|
+
const mode: LifecycleMode = options.mode ?? 'interactive';
|
|
82
|
+
const idleGraceMs = options.idleGraceMs ?? 20_000;
|
|
83
|
+
const launchTimeoutMs = options.launchTimeoutMs ?? 120_000;
|
|
84
|
+
|
|
85
|
+
// Attachment is recorded from Brobridge's own session hook rather than by
|
|
86
|
+
// polling. A tab that connects and closes inside one poll interval — a
|
|
87
|
+
// reload, a quick script, a test — would otherwise never be *seen* to have
|
|
88
|
+
// attached, and interactive mode would then treat the run as "no browser
|
|
89
|
+
// ever connected" and exit with a failure status.
|
|
90
|
+
let everAttached = false;
|
|
91
|
+
const bridge = await createBridge({
|
|
92
|
+
...options.bridge,
|
|
93
|
+
index: { body: options.page, contentType: 'text/html; charset=utf-8' },
|
|
94
|
+
onSession: (session) => {
|
|
95
|
+
everAttached = true;
|
|
96
|
+
options.bridge?.onSession?.(session);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await options.register(bridge);
|
|
102
|
+
} catch (cause) {
|
|
103
|
+
await bridge.close();
|
|
104
|
+
throw cause;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let settle: (code: number) => void = () => undefined;
|
|
108
|
+
const done = new Promise<number>((resolve) => {
|
|
109
|
+
settle = resolve;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
let stopping: Promise<void> | null = null;
|
|
113
|
+
let poll: ReturnType<typeof setInterval> | null = null;
|
|
114
|
+
|
|
115
|
+
const stop = (reason: ShutdownReason = 'requested'): Promise<void> => {
|
|
116
|
+
if (stopping !== null) return stopping;
|
|
117
|
+
stopping = (async () => {
|
|
118
|
+
if (poll !== null) clearInterval(poll);
|
|
119
|
+
process.off('SIGINT', onSigint);
|
|
120
|
+
process.off('SIGTERM', onSigterm);
|
|
121
|
+
try {
|
|
122
|
+
await options.onShutdown?.(reason);
|
|
123
|
+
} catch (cause) {
|
|
124
|
+
out.log(`shutdown hook failed: ${String(cause instanceof Error ? cause.message : cause)}`);
|
|
125
|
+
}
|
|
126
|
+
// `close()` sends GOAWAY, ends open streams, then releases the listener.
|
|
127
|
+
// Handlers observe that as their stream aborting, which is the same path
|
|
128
|
+
// a browser-side cancel takes.
|
|
129
|
+
await bridge.close();
|
|
130
|
+
settle(reason === 'never-connected' ? 1 : 0);
|
|
131
|
+
})();
|
|
132
|
+
return stopping;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const onSigint = (): void => {
|
|
136
|
+
out.log('');
|
|
137
|
+
void stop('signal');
|
|
138
|
+
};
|
|
139
|
+
const onSigterm = (): void => void stop('signal');
|
|
140
|
+
process.on('SIGINT', onSigint);
|
|
141
|
+
process.on('SIGTERM', onSigterm);
|
|
142
|
+
|
|
143
|
+
// The launch URL carries a one-time token and is a credential until it is
|
|
144
|
+
// redeemed. It is written to the terminal on purpose, because a user whose
|
|
145
|
+
// browser did not open needs it — and to the terminal only. Nothing here
|
|
146
|
+
// puts it in a log file, and Brobridge sets `Referrer-Policy: no-referrer`
|
|
147
|
+
// and `Cache-Control: no-store` so the browser does not persist it either.
|
|
148
|
+
out.log(`${options.appName} v${options.version}`);
|
|
149
|
+
out.log(`Open this address if your browser does not: ${bridge.url}`);
|
|
150
|
+
out.log(
|
|
151
|
+
mode === 'interactive'
|
|
152
|
+
? 'Close the tab to quit, or press Ctrl+C.'
|
|
153
|
+
: 'Running in the background. Press Ctrl+C to quit.',
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
if (options.openBrowser !== false) {
|
|
157
|
+
void openBrowser(bridge.url).then((opened) => {
|
|
158
|
+
if (!opened) out.log('Could not open a browser automatically. Use the address above.');
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (mode === 'interactive') {
|
|
163
|
+
const startedAt = Date.now();
|
|
164
|
+
let idleSince: number | null = null;
|
|
165
|
+
|
|
166
|
+
poll = setInterval(() => {
|
|
167
|
+
const attached = bridge.sessions.some((session) => session.endpoint.state === 'open');
|
|
168
|
+
const now = Date.now();
|
|
169
|
+
|
|
170
|
+
if (attached) {
|
|
171
|
+
idleSince = null;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (!everAttached) {
|
|
175
|
+
if (now - startedAt >= launchTimeoutMs) {
|
|
176
|
+
out.log('No browser ever connected. Stopping.');
|
|
177
|
+
void stop('never-connected');
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// Work in flight outranks the idle timer: exiting here would discard it
|
|
182
|
+
// silently, which is the one thing the grace period is meant to prevent.
|
|
183
|
+
if (options.isBusy?.() === true) {
|
|
184
|
+
idleSince = null;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
idleSince ??= now;
|
|
188
|
+
if (now - idleSince >= idleGraceMs) {
|
|
189
|
+
out.log('Browser closed. Stopping.');
|
|
190
|
+
void stop('idle');
|
|
191
|
+
}
|
|
192
|
+
}, POLL_INTERVAL_MS);
|
|
193
|
+
// Do not let the poll timer alone hold the event loop open.
|
|
194
|
+
poll.unref?.();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { bridge, done, stop };
|
|
198
|
+
}
|