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.
@@ -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;