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
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React bindings.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately small: a provider that owns the connection, a hook that reads
|
|
5
|
+
* it, a hook for one operation call, and a hook for one stream subscription.
|
|
6
|
+
* Everything here is a convenience over `createClient`; nothing in Broapp
|
|
7
|
+
* requires React, and a template that wanted Svelte would replace this file
|
|
8
|
+
* and keep the rest.
|
|
9
|
+
*
|
|
10
|
+
* The connection lifecycle is the part that is easy to get wrong, so it is
|
|
11
|
+
* handled once here: connect on mount, expose an honest state, and — the
|
|
12
|
+
* important one — cancel every live stream when a component unmounts, because
|
|
13
|
+
* dropping a subscription does not stop the host.
|
|
14
|
+
*/
|
|
15
|
+
import * as React from 'react';
|
|
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 { BroappError } from '../shared/errors.ts';
|
|
27
|
+
import type { BridgeState, BroappClient, CreateClientOptions, Subscription } from '../client/client.ts';
|
|
28
|
+
import { createClient } from '../client/client.ts';
|
|
29
|
+
|
|
30
|
+
/** How the application should render its connection. */
|
|
31
|
+
export type ConnectionStatus =
|
|
32
|
+
| { readonly phase: 'connecting' }
|
|
33
|
+
/** Connected and usable. */
|
|
34
|
+
| { readonly phase: 'ready'; readonly transport: BridgeState }
|
|
35
|
+
/**
|
|
36
|
+
* The socket dropped and is being re-established; live streams will resume.
|
|
37
|
+
*
|
|
38
|
+
* `since` is when the connection was lost, and `resumable` says whether a
|
|
39
|
+
* successful reconnect could still restore live streams. Brobridge retains a
|
|
40
|
+
* protocol session for `sessionTtlMs` after the last disconnect (60 seconds
|
|
41
|
+
* by default); past that the session has been reaped and a reconnect starts
|
|
42
|
+
* a fresh one. This is not an invented timeout — it is the host's documented
|
|
43
|
+
* retention window, and it is the honest point at which an interface should
|
|
44
|
+
* stop implying that work in progress is coming back.
|
|
45
|
+
*
|
|
46
|
+
* Note what it does *not* tell you: whether the host is still running. A
|
|
47
|
+
* client cannot distinguish a host that died from one that is slow, so the
|
|
48
|
+
* state stays `reconnecting` and the retries continue.
|
|
49
|
+
*/
|
|
50
|
+
| { readonly phase: 'reconnecting'; readonly since: number; readonly resumable: boolean }
|
|
51
|
+
/**
|
|
52
|
+
* The connection is gone for good. In a local application the usual cause is
|
|
53
|
+
* that the host process exited — which also destroys the session, so there
|
|
54
|
+
* is nothing to reconnect to and the honest instruction is "start it again".
|
|
55
|
+
*/
|
|
56
|
+
| { readonly phase: 'lost' }
|
|
57
|
+
/** The first connection never succeeded. */
|
|
58
|
+
| { readonly phase: 'failed'; readonly error: BroappError };
|
|
59
|
+
|
|
60
|
+
interface ContextValue<C extends AnyContract> {
|
|
61
|
+
/** The connected client, once there is one. */
|
|
62
|
+
readonly client: BroappClient<C> | null;
|
|
63
|
+
/**
|
|
64
|
+
* The client as a promise, resolved by the same connection attempt.
|
|
65
|
+
*
|
|
66
|
+
* `client` is null for the second or so between the first render and the
|
|
67
|
+
* connection settling — and a user can click a button inside that window.
|
|
68
|
+
* Failing those clicks with "not connected yet" is technically true and
|
|
69
|
+
* useless: the application is starting, not broken. So the hooks await this
|
|
70
|
+
* instead, and a genuinely failed connection surfaces as this promise
|
|
71
|
+
* rejecting, which is a real error worth showing.
|
|
72
|
+
*/
|
|
73
|
+
readonly ready: Promise<BroappClient<C>>;
|
|
74
|
+
readonly status: ConnectionStatus;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const BroappContext = React.createContext<ContextValue<AnyContract> | null>(null);
|
|
78
|
+
|
|
79
|
+
/** Props for {@link BroappProvider}. */
|
|
80
|
+
export interface BroappProviderProps<C extends AnyContract> {
|
|
81
|
+
readonly contract: C;
|
|
82
|
+
readonly options?: CreateClientOptions;
|
|
83
|
+
/**
|
|
84
|
+
* How long the host retains a protocol session after a disconnect. Must
|
|
85
|
+
* match the host's `sessionTtlMs`; the default is Brobridge's own default.
|
|
86
|
+
* Used only to decide when `reconnecting` stops being `resumable`.
|
|
87
|
+
*/
|
|
88
|
+
readonly sessionTtlMs?: number;
|
|
89
|
+
readonly children: React.ReactNode;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function toStatus(state: BridgeState, since: number, now: number, ttlMs: number): ConnectionStatus {
|
|
93
|
+
switch (state) {
|
|
94
|
+
case 'open':
|
|
95
|
+
case 'degraded':
|
|
96
|
+
return { phase: 'ready', transport: state };
|
|
97
|
+
case 'connecting':
|
|
98
|
+
return { phase: 'connecting' };
|
|
99
|
+
case 'resuming':
|
|
100
|
+
return { phase: 'reconnecting', since, resumable: now - since < ttlMs };
|
|
101
|
+
case 'closed':
|
|
102
|
+
return { phase: 'lost' };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Owns the connection for everything below it. */
|
|
107
|
+
export function BroappProvider<C extends AnyContract>({
|
|
108
|
+
contract,
|
|
109
|
+
options,
|
|
110
|
+
sessionTtlMs = 60_000,
|
|
111
|
+
children,
|
|
112
|
+
}: BroappProviderProps<C>): React.ReactElement {
|
|
113
|
+
const [client, setClient] = React.useState<BroappClient<C> | null>(null);
|
|
114
|
+
const [status, setStatus] = React.useState<ConnectionStatus>({ phase: 'connecting' });
|
|
115
|
+
// Created before the effect runs, so the very first render already has
|
|
116
|
+
// something for a hook to await.
|
|
117
|
+
const gate = React.useRef<{
|
|
118
|
+
promise: Promise<BroappClient<C>>;
|
|
119
|
+
resolve: (client: BroappClient<C>) => void;
|
|
120
|
+
reject: (cause: unknown) => void;
|
|
121
|
+
} | null>(null);
|
|
122
|
+
if (gate.current === null) {
|
|
123
|
+
let resolve!: (client: BroappClient<C>) => void;
|
|
124
|
+
let reject!: (cause: unknown) => void;
|
|
125
|
+
const promise = new Promise<BroappClient<C>>((res, rej) => {
|
|
126
|
+
resolve = res;
|
|
127
|
+
reject = rej;
|
|
128
|
+
});
|
|
129
|
+
// Nothing may await this before a hook does, and an unobserved rejection
|
|
130
|
+
// would otherwise be reported as unhandled.
|
|
131
|
+
promise.catch(() => undefined);
|
|
132
|
+
gate.current = { promise, resolve, reject };
|
|
133
|
+
}
|
|
134
|
+
const ready = gate.current.promise;
|
|
135
|
+
|
|
136
|
+
React.useEffect(() => {
|
|
137
|
+
let live = true;
|
|
138
|
+
let connected: BroappClient<C> | null = null;
|
|
139
|
+
let unsubscribe: (() => void) | undefined;
|
|
140
|
+
let lostAt = Date.now();
|
|
141
|
+
// While reconnecting, the state stops changing but the *meaning* of it
|
|
142
|
+
// does, so the status is refreshed on a slow tick rather than only on a
|
|
143
|
+
// transport event.
|
|
144
|
+
let tick: ReturnType<typeof setInterval> | undefined;
|
|
145
|
+
|
|
146
|
+
void createClient(contract, options).then(
|
|
147
|
+
(next) => {
|
|
148
|
+
if (!live) {
|
|
149
|
+
void next.close();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
connected = next;
|
|
153
|
+
gate.current?.resolve(next);
|
|
154
|
+
setClient(next);
|
|
155
|
+
setStatus(toStatus(next.state, lostAt, Date.now(), sessionTtlMs));
|
|
156
|
+
unsubscribe = next.onState((state) => {
|
|
157
|
+
if (state === 'resuming' || state === 'connecting') {
|
|
158
|
+
// Only the first transition away from `open` starts the clock.
|
|
159
|
+
if (connected?.state === 'open') lostAt = Date.now();
|
|
160
|
+
} else {
|
|
161
|
+
lostAt = Date.now();
|
|
162
|
+
}
|
|
163
|
+
setStatus(toStatus(state, lostAt, Date.now(), sessionTtlMs));
|
|
164
|
+
});
|
|
165
|
+
tick = setInterval(() => {
|
|
166
|
+
const current = connected;
|
|
167
|
+
if (current === null) return;
|
|
168
|
+
setStatus(toStatus(current.state, lostAt, Date.now(), sessionTtlMs));
|
|
169
|
+
}, 2_000);
|
|
170
|
+
},
|
|
171
|
+
(cause: unknown) => {
|
|
172
|
+
const error =
|
|
173
|
+
cause instanceof BroappError
|
|
174
|
+
? cause
|
|
175
|
+
: new BroappError('unavailable', 'Could not reach the application host.', cause);
|
|
176
|
+
gate.current?.reject(error);
|
|
177
|
+
if (!live) return;
|
|
178
|
+
setStatus({ phase: 'failed', error });
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
return () => {
|
|
183
|
+
live = false;
|
|
184
|
+
if (tick !== undefined) clearInterval(tick);
|
|
185
|
+
unsubscribe?.();
|
|
186
|
+
void connected?.close();
|
|
187
|
+
};
|
|
188
|
+
// The contract is a module-level constant and the options object is
|
|
189
|
+
// expected to be stable; re-running this effect would drop the connection.
|
|
190
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
191
|
+
}, []);
|
|
192
|
+
|
|
193
|
+
const value = React.useMemo<ContextValue<C>>(
|
|
194
|
+
() => ({ client, ready, status }),
|
|
195
|
+
[client, ready, status],
|
|
196
|
+
);
|
|
197
|
+
return (
|
|
198
|
+
<BroappContext.Provider value={value as ContextValue<AnyContract>}>
|
|
199
|
+
{children}
|
|
200
|
+
</BroappContext.Provider>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function useContextValue<C extends AnyContract>(): ContextValue<C> {
|
|
205
|
+
const value = React.useContext(BroappContext);
|
|
206
|
+
if (value === null) throw new Error('useBroapp must be used inside <BroappProvider>');
|
|
207
|
+
return value as ContextValue<C>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The connection status, for a status indicator. */
|
|
211
|
+
export function useConnection(): ConnectionStatus {
|
|
212
|
+
return useContextValue().status;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The client, or `null` until the first connection settles. */
|
|
216
|
+
export function useBroapp<C extends AnyContract>(): BroappClient<C> | null {
|
|
217
|
+
return useContextValue<C>().client;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The client as a promise.
|
|
222
|
+
*
|
|
223
|
+
* Resolves once connected, rejects if the first connection fails. Useful for
|
|
224
|
+
* code outside a hook that must not care whether startup has finished.
|
|
225
|
+
*/
|
|
226
|
+
export function useBroappReady<C extends AnyContract>(): Promise<BroappClient<C>> {
|
|
227
|
+
return useContextValue<C>().ready;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** What {@link useOperation} returns. */
|
|
231
|
+
export interface OperationHook<C extends AnyContract, K extends OperationName<C>> {
|
|
232
|
+
/** Run it. Never rejects; the outcome lands in `data` or `error`. */
|
|
233
|
+
run(input: OperationInput<C, K>): Promise<void>;
|
|
234
|
+
readonly data: OperationOutput<C, K> | null;
|
|
235
|
+
readonly error: BroappError | null;
|
|
236
|
+
readonly pending: boolean;
|
|
237
|
+
/** Clear `data` and `error`. */
|
|
238
|
+
reset(): void;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* One operation, with its loading and error state.
|
|
243
|
+
*
|
|
244
|
+
* Only the most recent call may settle the state, so an earlier slow response
|
|
245
|
+
* cannot overwrite a later one.
|
|
246
|
+
*/
|
|
247
|
+
export function useOperation<C extends AnyContract, K extends OperationName<C>>(
|
|
248
|
+
name: K,
|
|
249
|
+
): OperationHook<C, K> {
|
|
250
|
+
const { client, ready } = useContextValue<C>();
|
|
251
|
+
const [data, setData] = React.useState<OperationOutput<C, K> | null>(null);
|
|
252
|
+
const [error, setError] = React.useState<BroappError | null>(null);
|
|
253
|
+
const [pending, setPending] = React.useState(false);
|
|
254
|
+
const generation = React.useRef(0);
|
|
255
|
+
|
|
256
|
+
const run = React.useCallback(
|
|
257
|
+
async (input: OperationInput<C, K>): Promise<void> => {
|
|
258
|
+
const mine = (generation.current += 1);
|
|
259
|
+
setPending(true);
|
|
260
|
+
setError(null);
|
|
261
|
+
try {
|
|
262
|
+
// Awaiting the connection rather than requiring it: a click during the
|
|
263
|
+
// first second of startup should run, not fail.
|
|
264
|
+
const connected = client ?? (await ready);
|
|
265
|
+
const result = await connected.call(name, input);
|
|
266
|
+
if (generation.current !== mine) return;
|
|
267
|
+
setData(result);
|
|
268
|
+
} catch (cause) {
|
|
269
|
+
if (generation.current !== mine) return;
|
|
270
|
+
setError(
|
|
271
|
+
cause instanceof BroappError
|
|
272
|
+
? cause
|
|
273
|
+
: new BroappError('internal', 'The operation failed.', cause),
|
|
274
|
+
);
|
|
275
|
+
} finally {
|
|
276
|
+
if (generation.current === mine) setPending(false);
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
[client, ready, name],
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const reset = React.useCallback(() => {
|
|
283
|
+
generation.current += 1;
|
|
284
|
+
setData(null);
|
|
285
|
+
setError(null);
|
|
286
|
+
setPending(false);
|
|
287
|
+
}, []);
|
|
288
|
+
|
|
289
|
+
return { run, data, error, pending, reset };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** What {@link useStream} returns. */
|
|
293
|
+
export interface StreamHook<C extends AnyContract, K extends StreamName<C>> {
|
|
294
|
+
/** Open the stream. Cancels any stream this hook already had open. */
|
|
295
|
+
start(params: StreamParams<C, K>): Promise<void>;
|
|
296
|
+
/** Cancel it. The host stops producing. */
|
|
297
|
+
cancel(): void;
|
|
298
|
+
readonly last: StreamEvent<C, K> | null;
|
|
299
|
+
readonly error: BroappError | null;
|
|
300
|
+
readonly running: boolean;
|
|
301
|
+
/** True when the previous run ended because `cancel()` was called. */
|
|
302
|
+
readonly cancelled: boolean;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* One stream, with cancellation.
|
|
307
|
+
*
|
|
308
|
+
* The subscription is cancelled on unmount. That matters more here than in a
|
|
309
|
+
* web application: the producer is a process on the user's machine, and a
|
|
310
|
+
* stream nobody cancels goes on burning their CPU.
|
|
311
|
+
*/
|
|
312
|
+
export function useStream<C extends AnyContract, K extends StreamName<C>>(
|
|
313
|
+
name: K,
|
|
314
|
+
): StreamHook<C, K> {
|
|
315
|
+
const { client, ready } = useContextValue<C>();
|
|
316
|
+
const [last, setLast] = React.useState<StreamEvent<C, K> | null>(null);
|
|
317
|
+
const [error, setError] = React.useState<BroappError | null>(null);
|
|
318
|
+
const [running, setRunning] = React.useState(false);
|
|
319
|
+
const [cancelled, setCancelled] = React.useState(false);
|
|
320
|
+
const active = React.useRef<Subscription | null>(null);
|
|
321
|
+
const mounted = React.useRef(true);
|
|
322
|
+
|
|
323
|
+
React.useEffect(() => {
|
|
324
|
+
mounted.current = true;
|
|
325
|
+
return () => {
|
|
326
|
+
mounted.current = false;
|
|
327
|
+
active.current?.cancel();
|
|
328
|
+
active.current = null;
|
|
329
|
+
};
|
|
330
|
+
}, []);
|
|
331
|
+
|
|
332
|
+
const cancel = React.useCallback(() => {
|
|
333
|
+
if (active.current === null) return;
|
|
334
|
+
active.current.cancel();
|
|
335
|
+
active.current = null;
|
|
336
|
+
setCancelled(true);
|
|
337
|
+
setRunning(false);
|
|
338
|
+
}, []);
|
|
339
|
+
|
|
340
|
+
const start = React.useCallback(
|
|
341
|
+
async (params: StreamParams<C, K>): Promise<void> => {
|
|
342
|
+
active.current?.cancel();
|
|
343
|
+
active.current = null;
|
|
344
|
+
setError(null);
|
|
345
|
+
setLast(null);
|
|
346
|
+
setCancelled(false);
|
|
347
|
+
setRunning(true);
|
|
348
|
+
try {
|
|
349
|
+
const connected = client ?? (await ready);
|
|
350
|
+
if (!mounted.current) {
|
|
351
|
+
setRunning(false);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const subscription = await connected.subscribe(name, params, {
|
|
355
|
+
onEvent: (event) => {
|
|
356
|
+
if (mounted.current) setLast(event);
|
|
357
|
+
},
|
|
358
|
+
onDone: () => {
|
|
359
|
+
if (!mounted.current) return;
|
|
360
|
+
active.current = null;
|
|
361
|
+
setRunning(false);
|
|
362
|
+
},
|
|
363
|
+
onError: (cause) => {
|
|
364
|
+
if (!mounted.current) return;
|
|
365
|
+
active.current = null;
|
|
366
|
+
setError(cause);
|
|
367
|
+
setRunning(false);
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
if (!mounted.current) {
|
|
371
|
+
subscription.cancel();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
active.current = subscription;
|
|
375
|
+
} catch (cause) {
|
|
376
|
+
if (!mounted.current) return;
|
|
377
|
+
setRunning(false);
|
|
378
|
+
setError(
|
|
379
|
+
cause instanceof BroappError
|
|
380
|
+
? cause
|
|
381
|
+
: new BroappError('internal', 'The stream could not be started.', cause),
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
[client, ready, name],
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
return { start, cancel, last, error, running, cancelled };
|
|
389
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** `broapp/react` — React bindings over `broapp/client`. */
|
|
2
|
+
export {
|
|
3
|
+
BroappProvider,
|
|
4
|
+
useBroapp,
|
|
5
|
+
useBroappReady,
|
|
6
|
+
useConnection,
|
|
7
|
+
useOperation,
|
|
8
|
+
useStream,
|
|
9
|
+
} from './hooks.tsx';
|
|
10
|
+
export type {
|
|
11
|
+
BroappProviderProps,
|
|
12
|
+
ConnectionStatus,
|
|
13
|
+
OperationHook,
|
|
14
|
+
StreamHook,
|
|
15
|
+
} from './hooks.tsx';
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract: the one description of an application's host surface that
|
|
3
|
+
* both sides import.
|
|
4
|
+
*
|
|
5
|
+
* A contract is data, not code. It names operations and streams and gives
|
|
6
|
+
* each a schema. The host imports it to register implementations; the browser
|
|
7
|
+
* imports it to make typed calls. Because it holds no implementation, a
|
|
8
|
+
* bundler that follows the browser's import of the contract does not pull the
|
|
9
|
+
* host in with it — which is what keeps host code and host secrets out of the
|
|
10
|
+
* browser bundle.
|
|
11
|
+
*
|
|
12
|
+
* Broapp maps a contract onto Brobridge's existing surface and nothing more:
|
|
13
|
+
* an operation `"greet"` in group `"system"` is exactly
|
|
14
|
+
* `bridge.expose("system", { greet })` on the host and
|
|
15
|
+
* `bridge.call("system.greet", input)` in the browser. There is no second
|
|
16
|
+
* dispatch path and no re-implemented transport.
|
|
17
|
+
*/
|
|
18
|
+
import type { Infer, Schema } from './schema.ts';
|
|
19
|
+
|
|
20
|
+
/** One unary operation: JSON in, JSON out. */
|
|
21
|
+
export interface OperationSpec<I = unknown, O = unknown> {
|
|
22
|
+
readonly input: Schema<I>;
|
|
23
|
+
readonly output: Schema<O>;
|
|
24
|
+
/** Shown in generated documentation and in the developer panel. */
|
|
25
|
+
readonly summary?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One stream route.
|
|
30
|
+
*
|
|
31
|
+
* The wire format is newline-delimited JSON: each event is one JSON value on
|
|
32
|
+
* one line. Brobridge streams carry bytes, and chunk boundaries are not
|
|
33
|
+
* message boundaries, so Broapp frames them ({@link encodeEvent},
|
|
34
|
+
* {@link NdjsonDecoder}) rather than assuming one chunk is one event.
|
|
35
|
+
*/
|
|
36
|
+
export interface StreamSpec<P = unknown, E = unknown> {
|
|
37
|
+
readonly params: Schema<P>;
|
|
38
|
+
readonly event: Schema<E>;
|
|
39
|
+
readonly summary?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The operation and stream tables an application declares. */
|
|
43
|
+
export interface ContractShape {
|
|
44
|
+
readonly operations: Record<string, OperationSpec>;
|
|
45
|
+
readonly streams: Record<string, StreamSpec>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A validated contract. */
|
|
49
|
+
export interface Contract<C extends ContractShape> {
|
|
50
|
+
readonly operations: C['operations'];
|
|
51
|
+
readonly streams: C['streams'];
|
|
52
|
+
/** Route names, for diagnostics and for the developer panel. */
|
|
53
|
+
readonly routes: {
|
|
54
|
+
readonly operations: readonly string[];
|
|
55
|
+
readonly streams: readonly string[];
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Any contract, for a generic parameter's constraint.
|
|
61
|
+
*
|
|
62
|
+
* Every public type below is written against `AnyContract` rather than against
|
|
63
|
+
* `ContractShape`, because the thing an application has in hand is
|
|
64
|
+
* `typeof contract` — the value `defineContract` returned. Making that the
|
|
65
|
+
* parameter means `useOperation<AppContract, "demo.greet">` reads the way it
|
|
66
|
+
* looks like it should, instead of needing the shape to be dug back out.
|
|
67
|
+
*/
|
|
68
|
+
export type AnyContract = Contract<ContractShape>;
|
|
69
|
+
|
|
70
|
+
/** The shape inside a contract. */
|
|
71
|
+
export type ShapeOf<C> = C extends Contract<infer S> ? S : never;
|
|
72
|
+
|
|
73
|
+
/** Operation names in a contract. */
|
|
74
|
+
export type OperationName<C extends AnyContract> = keyof ShapeOf<C>['operations'] & string;
|
|
75
|
+
/** Stream names in a contract. */
|
|
76
|
+
export type StreamName<C extends AnyContract> = keyof ShapeOf<C>['streams'] & string;
|
|
77
|
+
|
|
78
|
+
/** The argument type of one operation. */
|
|
79
|
+
export type OperationInput<C extends AnyContract, K extends OperationName<C>> = Infer<
|
|
80
|
+
ShapeOf<C>['operations'][K]['input']
|
|
81
|
+
>;
|
|
82
|
+
/** The result type of one operation. */
|
|
83
|
+
export type OperationOutput<C extends AnyContract, K extends OperationName<C>> = Infer<
|
|
84
|
+
ShapeOf<C>['operations'][K]['output']
|
|
85
|
+
>;
|
|
86
|
+
/** The parameter type of one stream. */
|
|
87
|
+
export type StreamParams<C extends AnyContract, K extends StreamName<C>> = Infer<
|
|
88
|
+
ShapeOf<C>['streams'][K]['params']
|
|
89
|
+
>;
|
|
90
|
+
/** The event type of one stream. */
|
|
91
|
+
export type StreamEvent<C extends AnyContract, K extends StreamName<C>> = Infer<
|
|
92
|
+
ShapeOf<C>['streams'][K]['event']
|
|
93
|
+
>;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A route name is `group.member`. Brobridge resolves a unary call by splitting
|
|
97
|
+
* on the first `.` and looking the group up in its service registry, so both
|
|
98
|
+
* halves must be present and neither may itself contain a dot.
|
|
99
|
+
*/
|
|
100
|
+
const ROUTE_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/;
|
|
101
|
+
|
|
102
|
+
/** Split `"system.greet"` into its Brobridge service and method names. */
|
|
103
|
+
export function splitRoute(route: string): { group: string; member: string } {
|
|
104
|
+
const cut = route.indexOf('.');
|
|
105
|
+
return { group: route.slice(0, cut), member: route.slice(cut + 1) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Declare an application's host surface.
|
|
110
|
+
*
|
|
111
|
+
* Rejects a malformed route name at startup rather than at the first call,
|
|
112
|
+
* because a route that Brobridge cannot resolve is a programming error and
|
|
113
|
+
* should not wait for a user to find it.
|
|
114
|
+
*/
|
|
115
|
+
export function defineContract<const C extends ContractShape>(shape: C): Contract<C> {
|
|
116
|
+
const operations = Object.keys(shape.operations);
|
|
117
|
+
const streams = Object.keys(shape.streams);
|
|
118
|
+
for (const route of [...operations, ...streams]) {
|
|
119
|
+
if (!ROUTE_PATTERN.test(route)) {
|
|
120
|
+
throw new TypeError(
|
|
121
|
+
`route ${JSON.stringify(route)} must be "group.member", where each half is a JavaScript identifier`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const clash = operations.find((route) => streams.includes(route));
|
|
126
|
+
if (clash !== undefined) {
|
|
127
|
+
throw new TypeError(`route ${JSON.stringify(clash)} is declared as both an operation and a stream`);
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
operations: shape.operations,
|
|
131
|
+
streams: shape.streams,
|
|
132
|
+
routes: { operations, streams },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public/internal error boundary.
|
|
3
|
+
*
|
|
4
|
+
* A host operation runs with the invoking user's permissions and sees real
|
|
5
|
+
* paths, real database handles and real environment. A browser tab must not
|
|
6
|
+
* learn any of that from a failure, however that tab was authenticated. So
|
|
7
|
+
* Broapp draws one line: an error a handler raises deliberately with
|
|
8
|
+
* {@link PublicError} crosses to the browser with its message intact;
|
|
9
|
+
* everything else is logged on the host and reaches the browser as a fixed
|
|
10
|
+
* string.
|
|
11
|
+
*
|
|
12
|
+
* The transport is Brobridge's, unchanged. Brobridge already reduces any
|
|
13
|
+
* non-`BridgeError` throw to `INTERNAL_ERROR` with the message `"internal
|
|
14
|
+
* error"` (`services.ts`), which is exactly the behaviour wanted for the
|
|
15
|
+
* second case — so Broapp adds nothing there and simply lets it happen. For
|
|
16
|
+
* the first case Broapp throws a `BridgeError` whose message carries a short
|
|
17
|
+
* marker, because Brobridge's `ErrorCode` set is a protocol vocabulary and
|
|
18
|
+
* has no member for "this name is already taken".
|
|
19
|
+
*/
|
|
20
|
+
import { BridgeError, ErrorCode } from '@brobridgejs/core';
|
|
21
|
+
|
|
22
|
+
/** Machine-readable failure categories a browser may branch on. */
|
|
23
|
+
export type PublicErrorCode =
|
|
24
|
+
| 'invalid_input'
|
|
25
|
+
| 'not_found'
|
|
26
|
+
| 'conflict'
|
|
27
|
+
| 'unavailable'
|
|
28
|
+
| 'rejected'
|
|
29
|
+
| 'internal';
|
|
30
|
+
|
|
31
|
+
const PUBLIC_CODES: readonly PublicErrorCode[] = [
|
|
32
|
+
'invalid_input',
|
|
33
|
+
'not_found',
|
|
34
|
+
'conflict',
|
|
35
|
+
'unavailable',
|
|
36
|
+
'rejected',
|
|
37
|
+
'internal',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Protocol code carried alongside each public code.
|
|
42
|
+
*
|
|
43
|
+
* The mapping is lossy on purpose — several public codes share
|
|
44
|
+
* `INTERNAL_ERROR` — which is why the public code also travels in the
|
|
45
|
+
* message. A peer that only understands the protocol still sees a sensible
|
|
46
|
+
* code; a Broapp client sees both.
|
|
47
|
+
*/
|
|
48
|
+
const PROTOCOL_CODE: Record<PublicErrorCode, ErrorCode> = {
|
|
49
|
+
invalid_input: ErrorCode.PROTOCOL_VIOLATION,
|
|
50
|
+
not_found: ErrorCode.NOT_FOUND,
|
|
51
|
+
conflict: ErrorCode.INTERNAL_ERROR,
|
|
52
|
+
unavailable: ErrorCode.INTERNAL_ERROR,
|
|
53
|
+
rejected: ErrorCode.PERMISSION_DENIED,
|
|
54
|
+
internal: ErrorCode.INTERNAL_ERROR,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** The marker that tells a Broapp client the message is deliberately public. */
|
|
58
|
+
const MARKER = 'broapp/';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* An error whose message is safe for the browser to display.
|
|
62
|
+
*
|
|
63
|
+
* Raise it for a condition a user can act on: a name already taken, a path
|
|
64
|
+
* outside the configured root, a record that is not there. Do not put a
|
|
65
|
+
* filesystem path, a credential, or a driver message in it — the message is
|
|
66
|
+
* shown to the browser verbatim.
|
|
67
|
+
*/
|
|
68
|
+
export class PublicError extends Error {
|
|
69
|
+
readonly code: PublicErrorCode;
|
|
70
|
+
|
|
71
|
+
constructor(code: PublicErrorCode, message: string) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = 'PublicError';
|
|
74
|
+
this.code = code;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** As a `BridgeError`, which is what Brobridge forwards with its message intact. */
|
|
78
|
+
toBridgeError(): BridgeError {
|
|
79
|
+
return new BridgeError(PROTOCOL_CODE[this.code], `${MARKER}${this.code} ${this.message}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The message every unhandled host failure becomes, on both sides. */
|
|
84
|
+
export const INTERNAL_ERROR_MESSAGE = 'The application could not complete that operation.';
|
|
85
|
+
|
|
86
|
+
/** An operation or stream failure, as the browser sees it. */
|
|
87
|
+
export class BroappError extends Error {
|
|
88
|
+
readonly code: PublicErrorCode;
|
|
89
|
+
/** The underlying protocol error, when there was one. */
|
|
90
|
+
override readonly cause: unknown;
|
|
91
|
+
|
|
92
|
+
constructor(code: PublicErrorCode, message: string, cause?: unknown) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = 'BroappError';
|
|
95
|
+
this.code = code;
|
|
96
|
+
this.cause = cause;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Translate anything a call rejected with into a {@link BroappError}.
|
|
102
|
+
*
|
|
103
|
+
* A message without the marker is not shown: it came from the protocol layer
|
|
104
|
+
* or from Brobridge's own internal-error reduction, and neither is written
|
|
105
|
+
* for a user to read.
|
|
106
|
+
*/
|
|
107
|
+
export function fromTransportError(error: unknown): BroappError {
|
|
108
|
+
if (error instanceof BroappError) return error;
|
|
109
|
+
const message = error instanceof Error ? error.message : '';
|
|
110
|
+
if (message.startsWith(MARKER)) {
|
|
111
|
+
const space = message.indexOf(' ');
|
|
112
|
+
const code = space < 0 ? message.slice(MARKER.length) : message.slice(MARKER.length, space);
|
|
113
|
+
if ((PUBLIC_CODES as readonly string[]).includes(code)) {
|
|
114
|
+
return new BroappError(
|
|
115
|
+
code as PublicErrorCode,
|
|
116
|
+
space < 0 ? INTERNAL_ERROR_MESSAGE : message.slice(space + 1),
|
|
117
|
+
error,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (error instanceof BridgeError && error.code === ErrorCode.CANCELLED) {
|
|
122
|
+
return new BroappError('rejected', 'The operation was cancelled.', error);
|
|
123
|
+
}
|
|
124
|
+
return new BroappError('internal', INTERNAL_ERROR_MESSAGE, error);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Convenience constructors, so a handler reads as prose. */
|
|
128
|
+
export const publicError = {
|
|
129
|
+
invalidInput: (message: string): PublicError => new PublicError('invalid_input', message),
|
|
130
|
+
notFound: (message: string): PublicError => new PublicError('not_found', message),
|
|
131
|
+
conflict: (message: string): PublicError => new PublicError('conflict', message),
|
|
132
|
+
unavailable: (message: string): PublicError => new PublicError('unavailable', message),
|
|
133
|
+
rejected: (message: string): PublicError => new PublicError('rejected', message),
|
|
134
|
+
} as const;
|