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.
@@ -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
- /** Build the host side of a contract. */
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 [route, handler] of operations) {
247
+ for (const route of operations.keys()) {
160
248
  const { group, member } = splitRoute(route);
161
- const spec = contract.operations[route];
162
- if (spec === undefined) continue;
249
+ if (contract.operations[route] === undefined) continue;
163
250
  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
- };
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,
@@ -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(contract, options).then(
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;
@@ -2,6 +2,7 @@
2
2
  export {
3
3
  BroappProvider,
4
4
  useBroapp,
5
+ useBroappContract,
5
6
  useBroappReady,
6
7
  useConnection,
7
8
  useOperation,
@@ -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 first `.` and looking the group up in its service registry, so both
98
- * halves must be present and neither may itself contain a dot.
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
+ }
@@ -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),
@@ -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 { defineContract, splitRoute } from './contract.ts';
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,