broapp 0.1.0 → 0.2.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/README.md +13 -4
- package/package.json +10 -2
- package/src/ai/host/adapter.ts +106 -0
- package/src/ai/host/create-ai.ts +200 -0
- package/src/ai/host/fake.ts +223 -0
- package/src/ai/host/from-contract.ts +85 -0
- package/src/ai/host/index.ts +32 -0
- package/src/ai/host/registry.ts +221 -0
- package/src/ai/host/run-types.ts +14 -0
- package/src/ai/host/run.ts +320 -0
- package/src/ai/host/secrets.ts +118 -0
- package/src/ai/host/settings.ts +83 -0
- package/src/ai/host/tool.ts +89 -0
- package/src/ai/react/AiChat.tsx +194 -0
- package/src/ai/react/AiSettings.tsx +222 -0
- package/src/ai/react/ai.css +152 -0
- package/src/ai/react/index.tsx +35 -0
- package/src/ai/react/provider.tsx +97 -0
- package/src/ai/react/use-ai-chat.ts +309 -0
- package/src/ai/react/use-ai-models.ts +70 -0
- package/src/ai/react/use-ai-settings.ts +105 -0
- package/src/ai/shared/contract.ts +138 -0
- package/src/ai/shared/index.ts +16 -0
- package/src/ai/shared/types.check.ts +43 -0
- package/src/ai/shared/types.ts +87 -0
- package/src/host/app.ts +94 -30
- package/src/host/index.ts +5 -0
- package/src/react/hooks.tsx +37 -3
- package/src/react/index.ts +1 -0
- package/src/shared/contract.ts +56 -2
- package/src/shared/errors.ts +10 -0
- package/src/shared/index.ts +8 -2
- package/src/shared/schema.ts +125 -28
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The types the browser sees.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is shared code: it names what the AI layer exchanges over
|
|
5
|
+
* the bridge and nothing about how a provider is reached. No file in this
|
|
6
|
+
* directory may import the AI SDK packages — the browser bundle follows these
|
|
7
|
+
* imports, and the page's CSP forbids it from talking to a provider anyway.
|
|
8
|
+
*
|
|
9
|
+
* The fields are not marked `readonly`. These interfaces must be *identical*
|
|
10
|
+
* to what `Infer` derives from the contract in `contract.ts`, which
|
|
11
|
+
* `types.check.ts` asserts at compile time; a `readonly` here would make the
|
|
12
|
+
* two types merely compatible instead, and the drift check would stop
|
|
13
|
+
* catching drift.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A model a provider offers, as the browser sees it. */
|
|
17
|
+
export interface BroappModel {
|
|
18
|
+
provider: string;
|
|
19
|
+
modelId: string;
|
|
20
|
+
label: string;
|
|
21
|
+
capabilities: {
|
|
22
|
+
tools: boolean;
|
|
23
|
+
vision: boolean;
|
|
24
|
+
structuredOutput: boolean;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A provider compiled into this application, as the browser sees it. */
|
|
29
|
+
export interface ProviderInfo {
|
|
30
|
+
id: string;
|
|
31
|
+
label: string;
|
|
32
|
+
/** True when requests stay on this machine with the current settings. */
|
|
33
|
+
local: boolean;
|
|
34
|
+
needs: { apiKey: boolean; baseUrl: 'required' | 'optional' | 'none' };
|
|
35
|
+
defaultBaseUrl: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What the settings route returns. Never contains the key itself. */
|
|
39
|
+
export interface AiSettings {
|
|
40
|
+
provider: string | null;
|
|
41
|
+
modelId: string | null;
|
|
42
|
+
baseUrl: string | null;
|
|
43
|
+
hasKey: boolean;
|
|
44
|
+
/** Last four characters of the key, for the UI to show which key is set. */
|
|
45
|
+
keyHint: string | null;
|
|
46
|
+
/** False means the key is held in memory only and forgotten on exit. */
|
|
47
|
+
remember: boolean;
|
|
48
|
+
/** True when provider and model are both set and the provider's needs are met. */
|
|
49
|
+
configured: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** How much ceremony a tool call needs before it runs. */
|
|
53
|
+
export type ToolPermission = 'read' | 'confirm';
|
|
54
|
+
|
|
55
|
+
/** One turn of prior conversation the browser sends back with each message. */
|
|
56
|
+
export interface ChatTurn {
|
|
57
|
+
role: 'user' | 'assistant';
|
|
58
|
+
content: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One event on the `ai.chat` stream. Flat on purpose: the `s` validator has
|
|
63
|
+
* no unions, so the discriminant is `type` and the other fields are
|
|
64
|
+
* optional. Which fields are present for which type:
|
|
65
|
+
*
|
|
66
|
+
* text text
|
|
67
|
+
* tool-call callId, tool, input, permission
|
|
68
|
+
* confirm callId, tool, input (waits for ai.chat.confirm)
|
|
69
|
+
* tool-result callId, tool, output, denied?
|
|
70
|
+
* usage inputTokens, outputTokens
|
|
71
|
+
* done —
|
|
72
|
+
* error code, message
|
|
73
|
+
*/
|
|
74
|
+
export interface ChatEvent {
|
|
75
|
+
type: 'text' | 'tool-call' | 'confirm' | 'tool-result' | 'usage' | 'done' | 'error';
|
|
76
|
+
text?: string;
|
|
77
|
+
callId?: string;
|
|
78
|
+
tool?: string;
|
|
79
|
+
input?: unknown;
|
|
80
|
+
output?: unknown;
|
|
81
|
+
denied?: boolean;
|
|
82
|
+
permission?: ToolPermission;
|
|
83
|
+
inputTokens?: number;
|
|
84
|
+
outputTokens?: number;
|
|
85
|
+
code?: string;
|
|
86
|
+
message?: string;
|
|
87
|
+
}
|
package/src/host/app.ts
CHANGED
|
@@ -23,8 +23,8 @@ import type {
|
|
|
23
23
|
StreamName,
|
|
24
24
|
StreamParams,
|
|
25
25
|
} from '../shared/contract.ts';
|
|
26
|
-
import { splitRoute } from '../shared/contract.ts';
|
|
27
|
-
import { PublicError } from '../shared/errors.ts';
|
|
26
|
+
import { assertNoReservedRoutes, splitRoute } from '../shared/contract.ts';
|
|
27
|
+
import { INTERNAL_ERROR_MESSAGE, isPublicBridgeError, PublicError } from '../shared/errors.ts';
|
|
28
28
|
import { encodeEvent } from '../shared/ndjson.ts';
|
|
29
29
|
import { ValidationError } from '../shared/schema.ts';
|
|
30
30
|
|
|
@@ -97,16 +97,48 @@ export interface HostApp<C extends AnyContract> {
|
|
|
97
97
|
* a developer is present to see it.
|
|
98
98
|
*/
|
|
99
99
|
mount(bridge: Bridge): void;
|
|
100
|
+
/**
|
|
101
|
+
* Run one operation directly, without the bridge. Input is validated and
|
|
102
|
+
* output is checked exactly as for a call from the browser, and the same
|
|
103
|
+
* error boundary applies. This is how the AI layer lets a model call an
|
|
104
|
+
* application's operations as tools.
|
|
105
|
+
*
|
|
106
|
+
* A route that is a stream, or one no handler implements, is a programming
|
|
107
|
+
* error rather than a call failure, so it throws `TypeError` at the call
|
|
108
|
+
* site instead of rejecting.
|
|
109
|
+
*/
|
|
110
|
+
invoke<K extends OperationName<C>>(name: K, input: unknown): Promise<OperationOutput<C, K>>;
|
|
100
111
|
/** Abort every stream this app currently has open. Called during shutdown. */
|
|
101
112
|
abortAll(reason: string): void;
|
|
102
113
|
/** How many streams are running right now. */
|
|
103
114
|
readonly activeStreams: number;
|
|
104
115
|
}
|
|
105
116
|
|
|
106
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Build the host side of an application's contract.
|
|
119
|
+
*
|
|
120
|
+
* The reserved-group check lives here rather than in `defineContract` because
|
|
121
|
+
* Broapp's own AI contract is built with `defineContract` and legitimately
|
|
122
|
+
* uses the group. An application that declares `ai.*` would collide with the
|
|
123
|
+
* AI layer on the same bridge, so it is refused while a developer is watching.
|
|
124
|
+
*/
|
|
107
125
|
export function createHostApp<C extends AnyContract>(
|
|
108
126
|
contract: C,
|
|
109
127
|
options: HostAppOptions = {},
|
|
128
|
+
): HostApp<C> {
|
|
129
|
+
assertNoReservedRoutes(contract);
|
|
130
|
+
return createReservedHostApp(contract, options);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Build a host app without the reserved-group check.
|
|
135
|
+
*
|
|
136
|
+
* Not for applications. `broapp/ai/host` uses it to mount the AI contract,
|
|
137
|
+
* which is the one contract allowed to own the `ai` group.
|
|
138
|
+
*/
|
|
139
|
+
export function createReservedHostApp<C extends AnyContract>(
|
|
140
|
+
contract: C,
|
|
141
|
+
options: HostAppOptions = {},
|
|
110
142
|
): HostApp<C> {
|
|
111
143
|
const logger: HostLogger = options.logger ?? console;
|
|
112
144
|
const operations = new Map<string, OperationHandler<C, never>>();
|
|
@@ -120,6 +152,40 @@ export function createHostApp<C extends AnyContract>(
|
|
|
120
152
|
}
|
|
121
153
|
}
|
|
122
154
|
|
|
155
|
+
/**
|
|
156
|
+
* One operation call, from the bridge or from {@link HostApp.invoke}.
|
|
157
|
+
*
|
|
158
|
+
* Both paths must validate the same way and fail the same way — an AI tool
|
|
159
|
+
* call is not more trusted than a browser call just because it originates
|
|
160
|
+
* inside the host — so there is one implementation and two callers.
|
|
161
|
+
*/
|
|
162
|
+
async function runOperation(route: string, raw: unknown): Promise<unknown> {
|
|
163
|
+
const spec = contract.operations[route];
|
|
164
|
+
const handler = operations.get(route);
|
|
165
|
+
if (spec === undefined || handler === undefined) {
|
|
166
|
+
throw new TypeError(`operation ${JSON.stringify(route)} has no implementation`);
|
|
167
|
+
}
|
|
168
|
+
const context: CallContext = { route };
|
|
169
|
+
let input: unknown;
|
|
170
|
+
try {
|
|
171
|
+
input = spec.input.parse(raw);
|
|
172
|
+
} catch (cause) {
|
|
173
|
+
// A validation message names a field and a constraint from the contract
|
|
174
|
+
// the browser already has. It carries nothing the caller did not send,
|
|
175
|
+
// so it is safe to return and useful to see.
|
|
176
|
+
throw new PublicError(
|
|
177
|
+
'invalid_input',
|
|
178
|
+
cause instanceof ValidationError ? cause.message : 'invalid input',
|
|
179
|
+
).toBridgeError();
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const output = await handler(input as never, context);
|
|
183
|
+
return spec.output.parse(output);
|
|
184
|
+
} catch (cause) {
|
|
185
|
+
throw wrap(cause, route, logger);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
123
189
|
const app: HostApp<C> = {
|
|
124
190
|
operation(name, handler) {
|
|
125
191
|
known('operation', name);
|
|
@@ -135,6 +201,28 @@ export function createHostApp<C extends AnyContract>(
|
|
|
135
201
|
return app;
|
|
136
202
|
},
|
|
137
203
|
|
|
204
|
+
invoke(name, input) {
|
|
205
|
+
// Structural mistakes surface synchronously: a stream is not invokable
|
|
206
|
+
// and a missing handler is a bug, and neither should look like a failed
|
|
207
|
+
// call to whatever is awaiting the result.
|
|
208
|
+
if (Object.prototype.hasOwnProperty.call(contract.streams, name)) {
|
|
209
|
+
throw new TypeError(`route ${JSON.stringify(name)} is a stream, which cannot be invoked`);
|
|
210
|
+
}
|
|
211
|
+
if (!Object.prototype.hasOwnProperty.call(contract.operations, name)) {
|
|
212
|
+
throw new TypeError(`operation ${JSON.stringify(name)} is not declared in the contract`);
|
|
213
|
+
}
|
|
214
|
+
if (!operations.has(name)) {
|
|
215
|
+
throw new TypeError(`operation ${JSON.stringify(name)} has no implementation`);
|
|
216
|
+
}
|
|
217
|
+
return runOperation(name, input).catch((cause: unknown) => {
|
|
218
|
+
// On the bridge, Brobridge reduces an unexpected failure to a fixed
|
|
219
|
+
// sentence on the way out. `invoke` has no transport to do that, and
|
|
220
|
+
// its caller is the AI layer, which may put what it is given into a
|
|
221
|
+
// transcript — so the same reduction is applied here.
|
|
222
|
+
throw isPublicBridgeError(cause) ? cause : new Error(INTERNAL_ERROR_MESSAGE);
|
|
223
|
+
}) as Promise<never>;
|
|
224
|
+
},
|
|
225
|
+
|
|
138
226
|
get activeStreams() {
|
|
139
227
|
return running.size;
|
|
140
228
|
},
|
|
@@ -156,35 +244,11 @@ export function createHostApp<C extends AnyContract>(
|
|
|
156
244
|
// dotted routes are collected back into groups here. This is the only
|
|
157
245
|
// place the two namings meet.
|
|
158
246
|
const groups = new Map<string, Record<string, unknown>>();
|
|
159
|
-
for (const
|
|
247
|
+
for (const route of operations.keys()) {
|
|
160
248
|
const { group, member } = splitRoute(route);
|
|
161
|
-
|
|
162
|
-
if (spec === undefined) continue;
|
|
249
|
+
if (contract.operations[route] === undefined) continue;
|
|
163
250
|
const service = groups.get(group) ?? {};
|
|
164
|
-
service[member] =
|
|
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
|
-
};
|
|
251
|
+
service[member] = (raw: unknown): Promise<unknown> => runOperation(route, raw);
|
|
188
252
|
groups.set(group, service);
|
|
189
253
|
}
|
|
190
254
|
for (const [group, service] of groups) bridge.expose(group, service);
|
package/src/host/index.ts
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* the import would fail loudly — which is the intended outcome.
|
|
7
7
|
*/
|
|
8
8
|
export { createHostApp } from './app.ts';
|
|
9
|
+
/**
|
|
10
|
+
* Not for applications: it skips the check that refuses the reserved `ai`
|
|
11
|
+
* route group. Only `broapp/ai/host` should use it.
|
|
12
|
+
*/
|
|
13
|
+
export { createReservedHostApp } from './app.ts';
|
|
9
14
|
export type {
|
|
10
15
|
CallContext,
|
|
11
16
|
HostApp,
|
package/src/react/hooks.tsx
CHANGED
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
StreamName,
|
|
24
24
|
StreamParams,
|
|
25
25
|
} from '../shared/contract.ts';
|
|
26
|
+
import { mergeContracts } from '../shared/contract.ts';
|
|
26
27
|
import { BroappError } from '../shared/errors.ts';
|
|
27
28
|
import type { BridgeState, BroappClient, CreateClientOptions, Subscription } from '../client/client.ts';
|
|
28
29
|
import { createClient } from '../client/client.ts';
|
|
@@ -72,6 +73,14 @@ interface ContextValue<C extends AnyContract> {
|
|
|
72
73
|
*/
|
|
73
74
|
readonly ready: Promise<BroappClient<C>>;
|
|
74
75
|
readonly status: ConnectionStatus;
|
|
76
|
+
/**
|
|
77
|
+
* The contract actually spoken, application plus extensions.
|
|
78
|
+
*
|
|
79
|
+
* Components below the provider read routes from here rather than from the
|
|
80
|
+
* contract they were handed, because an extension's routes exist only after
|
|
81
|
+
* the merge.
|
|
82
|
+
*/
|
|
83
|
+
readonly contract: AnyContract;
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
const BroappContext = React.createContext<ContextValue<AnyContract> | null>(null);
|
|
@@ -79,6 +88,8 @@ const BroappContext = React.createContext<ContextValue<AnyContract> | null>(null
|
|
|
79
88
|
/** Props for {@link BroappProvider}. */
|
|
80
89
|
export interface BroappProviderProps<C extends AnyContract> {
|
|
81
90
|
readonly contract: C;
|
|
91
|
+
/** Extra contracts to speak over the same connection, e.g. Broapp's `aiContract`. */
|
|
92
|
+
readonly extensions?: readonly AnyContract[];
|
|
82
93
|
readonly options?: CreateClientOptions;
|
|
83
94
|
/**
|
|
84
95
|
* How long the host retains a protocol session after a disconnect. Must
|
|
@@ -106,10 +117,22 @@ function toStatus(state: BridgeState, since: number, now: number, ttlMs: number)
|
|
|
106
117
|
/** Owns the connection for everything below it. */
|
|
107
118
|
export function BroappProvider<C extends AnyContract>({
|
|
108
119
|
contract,
|
|
120
|
+
extensions,
|
|
109
121
|
options,
|
|
110
122
|
sessionTtlMs = 60_000,
|
|
111
123
|
children,
|
|
112
124
|
}: BroappProviderProps<C>): React.ReactElement {
|
|
125
|
+
// Merged once and kept. Both contracts are module-level constants, and a
|
|
126
|
+
// second merge would build a second client and drop the connection.
|
|
127
|
+
const mergedRef = React.useRef<AnyContract | null>(null);
|
|
128
|
+
if (mergedRef.current === null) {
|
|
129
|
+
mergedRef.current = (extensions ?? []).reduce<AnyContract>(
|
|
130
|
+
(left, right) => mergeContracts(left, right),
|
|
131
|
+
contract,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const merged = mergedRef.current as C;
|
|
135
|
+
|
|
113
136
|
const [client, setClient] = React.useState<BroappClient<C> | null>(null);
|
|
114
137
|
const [status, setStatus] = React.useState<ConnectionStatus>({ phase: 'connecting' });
|
|
115
138
|
// Created before the effect runs, so the very first render already has
|
|
@@ -143,7 +166,7 @@ export function BroappProvider<C extends AnyContract>({
|
|
|
143
166
|
// transport event.
|
|
144
167
|
let tick: ReturnType<typeof setInterval> | undefined;
|
|
145
168
|
|
|
146
|
-
void createClient(
|
|
169
|
+
void createClient(merged, options).then(
|
|
147
170
|
(next) => {
|
|
148
171
|
if (!live) {
|
|
149
172
|
void next.close();
|
|
@@ -191,8 +214,8 @@ export function BroappProvider<C extends AnyContract>({
|
|
|
191
214
|
}, []);
|
|
192
215
|
|
|
193
216
|
const value = React.useMemo<ContextValue<C>>(
|
|
194
|
-
() => ({ client, ready, status }),
|
|
195
|
-
[client, ready, status],
|
|
217
|
+
() => ({ client, ready, status, contract: merged }),
|
|
218
|
+
[client, ready, status, merged],
|
|
196
219
|
);
|
|
197
220
|
return (
|
|
198
221
|
<BroappContext.Provider value={value as ContextValue<AnyContract>}>
|
|
@@ -212,6 +235,17 @@ export function useConnection(): ConnectionStatus {
|
|
|
212
235
|
return useContextValue().status;
|
|
213
236
|
}
|
|
214
237
|
|
|
238
|
+
/**
|
|
239
|
+
* The contract actually spoken over this connection, extensions included.
|
|
240
|
+
*
|
|
241
|
+
* An extension's own hooks use it to check that they were installed — asking
|
|
242
|
+
* for a route that is not there fails at the first call otherwise, which is
|
|
243
|
+
* later and further from the mistake.
|
|
244
|
+
*/
|
|
245
|
+
export function useBroappContract(): AnyContract {
|
|
246
|
+
return useContextValue().contract;
|
|
247
|
+
}
|
|
248
|
+
|
|
215
249
|
/** The client, or `null` until the first connection settles. */
|
|
216
250
|
export function useBroapp<C extends AnyContract>(): BroappClient<C> | null {
|
|
217
251
|
return useContextValue<C>().client;
|
package/src/react/index.ts
CHANGED
package/src/shared/contract.ts
CHANGED
|
@@ -94,8 +94,9 @@ export type StreamEvent<C extends AnyContract, K extends StreamName<C>> = Infer<
|
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
96
|
* A route name is `group.member`. Brobridge resolves a unary call by splitting
|
|
97
|
-
* on the
|
|
98
|
-
*
|
|
97
|
+
* on the *last* `.` and looking the group up in its service registry, and it
|
|
98
|
+
* refuses to expose a service whose name contains a dot — so both halves must
|
|
99
|
+
* be present and neither may itself contain one.
|
|
99
100
|
*/
|
|
100
101
|
const ROUTE_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/;
|
|
101
102
|
|
|
@@ -132,3 +133,56 @@ export function defineContract<const C extends ContractShape>(shape: C): Contrac
|
|
|
132
133
|
routes: { operations, streams },
|
|
133
134
|
};
|
|
134
135
|
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Combine two contracts into one. Used in the browser so one client can
|
|
139
|
+
* speak an application's contract and Broapp's AI contract over one
|
|
140
|
+
* connection. Throws if any route name appears in both.
|
|
141
|
+
*/
|
|
142
|
+
export function mergeContracts<A extends AnyContract, B extends AnyContract>(
|
|
143
|
+
a: A,
|
|
144
|
+
b: B,
|
|
145
|
+
): Contract<{
|
|
146
|
+
operations: ShapeOf<A>['operations'] & ShapeOf<B>['operations'];
|
|
147
|
+
streams: ShapeOf<A>['streams'] & ShapeOf<B>['streams'];
|
|
148
|
+
}> {
|
|
149
|
+
// A clash is checked across all four tables, not table by table: a name that
|
|
150
|
+
// is an operation on one side and a stream on the other is just as
|
|
151
|
+
// unresolvable as a duplicate operation, because Brobridge dispatches on the
|
|
152
|
+
// route name alone.
|
|
153
|
+
const names = new Set<string>([...a.routes.operations, ...a.routes.streams]);
|
|
154
|
+
for (const route of [...b.routes.operations, ...b.routes.streams]) {
|
|
155
|
+
if (names.has(route)) throw new TypeError(`route ${JSON.stringify(route)} is declared by both contracts`);
|
|
156
|
+
}
|
|
157
|
+
const operations = { ...a.operations, ...b.operations };
|
|
158
|
+
const streams = { ...a.streams, ...b.streams };
|
|
159
|
+
return {
|
|
160
|
+
operations,
|
|
161
|
+
streams,
|
|
162
|
+
routes: { operations: Object.keys(operations), streams: Object.keys(streams) },
|
|
163
|
+
} as Contract<{
|
|
164
|
+
operations: ShapeOf<A>['operations'] & ShapeOf<B>['operations'];
|
|
165
|
+
streams: ShapeOf<A>['streams'] & ShapeOf<B>['streams'];
|
|
166
|
+
}>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The route group Broapp reserves for its AI layer. */
|
|
170
|
+
export const RESERVED_GROUPS: readonly string[] = ['ai'];
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Throws if a contract declares a route in a reserved group.
|
|
174
|
+
*
|
|
175
|
+
* This is not checked in `defineContract`, because Broapp's own AI contract is
|
|
176
|
+
* built with `defineContract` and has to be allowed the group. It is checked
|
|
177
|
+
* where an *application* contract enters the host instead.
|
|
178
|
+
*/
|
|
179
|
+
export function assertNoReservedRoutes(contract: AnyContract): void {
|
|
180
|
+
for (const route of [...contract.routes.operations, ...contract.routes.streams]) {
|
|
181
|
+
const { group } = splitRoute(route);
|
|
182
|
+
if (RESERVED_GROUPS.includes(group)) {
|
|
183
|
+
throw new TypeError(
|
|
184
|
+
`route ${JSON.stringify(route)} uses the group ${JSON.stringify(group)}, which is reserved for Broapp's AI layer`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
package/src/shared/errors.ts
CHANGED
|
@@ -124,6 +124,16 @@ export function fromTransportError(error: unknown): BroappError {
|
|
|
124
124
|
return new BroappError('internal', INTERNAL_ERROR_MESSAGE, error);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* True when an error already carries a message written for the browser.
|
|
129
|
+
*
|
|
130
|
+
* Anything else is a host-side failure whose message may name a path, a query
|
|
131
|
+
* or a token, and must be reduced before it leaves the host.
|
|
132
|
+
*/
|
|
133
|
+
export function isPublicBridgeError(error: unknown): boolean {
|
|
134
|
+
return error instanceof Error && error.message.startsWith(MARKER);
|
|
135
|
+
}
|
|
136
|
+
|
|
127
137
|
/** Convenience constructors, so a handler reads as prose. */
|
|
128
138
|
export const publicError = {
|
|
129
139
|
invalidInput: (message: string): PublicError => new PublicError('invalid_input', message),
|
package/src/shared/index.ts
CHANGED
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
* description and the types derived from it. That is what makes it safe for
|
|
6
6
|
* the browser bundle to follow.
|
|
7
7
|
*/
|
|
8
|
-
export {
|
|
8
|
+
export {
|
|
9
|
+
assertNoReservedRoutes,
|
|
10
|
+
defineContract,
|
|
11
|
+
mergeContracts,
|
|
12
|
+
RESERVED_GROUPS,
|
|
13
|
+
splitRoute,
|
|
14
|
+
} from './contract.ts';
|
|
9
15
|
export type {
|
|
10
16
|
AnyContract,
|
|
11
17
|
Contract,
|
|
@@ -22,7 +28,7 @@ export type {
|
|
|
22
28
|
} from './contract.ts';
|
|
23
29
|
|
|
24
30
|
export { s, ValidationError } from './schema.ts';
|
|
25
|
-
export type { Infer, Issue, Result, Schema } from './schema.ts';
|
|
31
|
+
export type { Infer, InferObject, Issue, JsonSchema, Result, Schema } from './schema.ts';
|
|
26
32
|
|
|
27
33
|
export {
|
|
28
34
|
BroappError,
|