Skip to content

Commit 0d5964e

Browse files
committed
refactor(P060): bridge owns the parsing — adapter is a pure router
Each bridge method now safeParses its `unknown` input via the matching Zod schema before posting to the iframe. Bad input surfaces as `{ success: false, error: { code: 'bad_input', message } }` without a postMessage round-trip. The adapter (createClientTools switch terminus) collapses to one-line cases that just hand the LLM input to the bridge. - IframeBridge methods take `args: unknown` (uniform external surface). - bridge.ts: parseAndSend helper centralises the parse + sendRequest pair. - factory.ts: switch is pure routing, no schemas imported. - sendRequest's `data` parameter loosened to `unknown` (it JSON.stringifies and doesn't care about the shape). Adding a new bridge operation is now: schema in schemas.ts, method on IframeBridge, parseAndSend line in bridge.ts, switch arm in factory.ts. The schema is the single source of truth.
1 parent 2eb9a27 commit 0d5964e

3 files changed

Lines changed: 89 additions & 76 deletions

File tree

copilot/src/lib/embed-bridge-adapters/client-tools/factory.ts

Lines changed: 17 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,4 @@
1-
import {
2-
type BridgeResult,
3-
DeleteFieldsInput,
4-
DeletePagesInput,
5-
FocusFieldInput,
6-
GetDocumentContentInput,
7-
GoToInput,
8-
type IframeBridge,
9-
MovePageInput,
10-
RotatePageInput,
11-
SelectToolInput,
12-
SetFieldValueInput,
13-
} from '../../embed-bridge'
1+
import type { BridgeResult, IframeBridge } from '../../embed-bridge'
142
import { composeMiddleware, type ToolMiddleware } from './middleware'
153
import { type ClientToolName, isClientToolName } from './tools'
164

@@ -41,7 +29,7 @@ export type ClientTools = {
4129
// registered tools.
4230
execute: (toolName: ClientToolName, input: ToolInput) => Promise<BridgeResult<unknown>>
4331
// Type guard re-export so the consumer can branch on tool names without
44-
// importing `schemas.ts` separately.
32+
// importing `tools.ts` separately.
4533
isClientToolName: typeof isClientToolName
4634
}
4735

@@ -50,45 +38,43 @@ export const createClientTools = ({
5038
systemPrompt,
5139
middleware = [],
5240
}: CreateClientToolsArgs): ClientTools => {
53-
// One arm per tool. Each arm parses the LLM-supplied input via the
54-
// bridge schema (single source of truth, lives in
55-
// embed-bridge/schemas.ts) and forwards the typed payload to the matching
56-
// bridge method. `satisfies never` keeps the switch exhaustive over
57-
// ClientToolName at compile time.
58-
const composed = composeMiddleware(middleware, async ({ toolName, input }) => {
41+
// Pure router. Each arm just hands the LLM input to the matching bridge
42+
// method; the bridge owns parsing + validation. `satisfies never` keeps
43+
// the switch exhaustive over ClientToolName at compile time.
44+
const composed = composeMiddleware(middleware, ({ toolName, input }) => {
5945
switch (toolName) {
6046
case 'get_fields':
6147
return bridge.getFields()
6248
case 'get_document_content':
63-
return bridge.getDocumentContent(GetDocumentContentInput.parse(input))
49+
return bridge.getDocumentContent(input)
6450
case 'detect_fields':
6551
return bridge.detectFields()
6652
case 'delete_fields':
67-
return bridge.deleteFields(DeleteFieldsInput.parse(input))
53+
return bridge.deleteFields(input)
6854
case 'select_tool':
69-
return bridge.selectTool(SelectToolInput.parse(input))
55+
return bridge.selectTool(input)
7056
case 'set_field_value':
71-
return bridge.setFieldValue(SetFieldValueInput.parse(input))
57+
return bridge.setFieldValue(input)
7258
case 'focus_field':
73-
return bridge.focusField(FocusFieldInput.parse(input))
59+
return bridge.focusField(input)
7460
case 'go_to_page':
75-
return bridge.goTo(GoToInput.parse(input))
61+
return bridge.goTo(input)
7662
case 'move_page':
77-
return bridge.movePage(MovePageInput.parse(input))
63+
return bridge.movePage(input)
7864
case 'delete_pages':
79-
return bridge.deletePages(DeletePagesInput.parse(input))
65+
return bridge.deletePages(input)
8066
case 'rotate_page':
81-
return bridge.rotatePage(RotatePageInput.parse(input))
67+
return bridge.rotatePage(input)
8268
case 'submit':
8369
return bridge.submit({ download_copy: false })
8470
case 'download':
8571
return bridge.download()
8672
default:
8773
toolName satisfies never
88-
return {
74+
return Promise.resolve({
8975
success: false,
9076
error: { code: 'unknown_tool', message: `Unknown tool: ${String(toolName)}` },
91-
}
77+
})
9278
}
9379
})
9480
return {

copilot/src/lib/embed-bridge/bridge.ts

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
1+
import type { z } from 'zod'
12
import { type BridgeLogger, NOOP_LOGGER } from './logger'
3+
import {
4+
DeleteFieldsInput,
5+
DeletePagesInput,
6+
FocusFieldInput,
7+
GetDocumentContentInput,
8+
GoToInput,
9+
LoadDocumentInput,
10+
MovePageInput,
11+
RotatePageInput,
12+
SelectToolInput,
13+
SetFieldValueInput,
14+
SubmitInput,
15+
} from './schemas'
216
import {
317
type BridgeRequestType,
418
type BridgeResult,
519
type BridgeState,
620
type DocumentContentResult,
721
type FieldRecord,
22+
type FocusFieldResult,
823
type IframeBridge,
924
isBridgeResultLike,
1025
} from './types'
@@ -109,7 +124,7 @@ export const createBridge = ({
109124

110125
const sendRequest = <TData>(
111126
type: BridgeRequestType,
112-
data: Record<string, unknown>,
127+
data: unknown,
113128
): Promise<BridgeResult<TData>> =>
114129
new Promise((resolve) => {
115130
const iframe = getIframe()
@@ -368,24 +383,49 @@ export const createBridge = ({
368383

369384
window.addEventListener('message', onMessage)
370385

371-
// Each method is a one-line pass-through to sendRequest. The args shape
372-
// already matches the iframe's snake_case payload (see schemas.ts), so
373-
// no key conversion happens at this layer.
386+
// The bridge OWNS validation: each method validates its `unknown` input
387+
// against the schema in schemas.ts before posting to the iframe. The
388+
// adapter layer (LLM dispatcher, React SDK, etc.) is therefore a pure
389+
// router — no parse, no narrowing. Adding a new method = add a schema in
390+
// schemas.ts, add a method on IframeBridge, and add a `parseAndSend` line
391+
// here.
392+
const parseAndSend = <TSchema extends z.ZodType, TData = null>(
393+
schema: TSchema,
394+
type: BridgeRequestType,
395+
args: unknown,
396+
): Promise<BridgeResult<TData>> => {
397+
const parsed = schema.safeParse(args)
398+
if (!parsed.success) {
399+
return Promise.resolve({
400+
success: false,
401+
error: { code: 'bad_input', message: parsed.error.message },
402+
})
403+
}
404+
return sendRequest<TData>(type, parsed.data)
405+
}
374406
const bridge: IframeBridge = {
375407
getState: () => state,
376-
loadDocument: (args) => sendRequest('LOAD_DOCUMENT', args),
408+
loadDocument: (args) => parseAndSend(LoadDocumentInput, 'LOAD_DOCUMENT', args),
377409
getFields: () => sendRequest<{ fields: FieldRecord[] }>('GET_FIELDS', {}),
378-
getDocumentContent: (args) => sendRequest<DocumentContentResult>('GET_DOCUMENT_CONTENT', args),
410+
getDocumentContent: (args) => parseAndSend<typeof GetDocumentContentInput, DocumentContentResult>(
411+
GetDocumentContentInput,
412+
'GET_DOCUMENT_CONTENT',
413+
args,
414+
),
379415
detectFields: () => sendRequest('DETECT_FIELDS', {}),
380-
deleteFields: (args) => sendRequest('DELETE_FIELDS', args),
381-
selectTool: (args) => sendRequest('SELECT_TOOL', args),
382-
setFieldValue: (args) => sendRequest('SET_FIELD_VALUE', args),
383-
focusField: (args) => sendRequest('FOCUS_FIELD', args),
384-
goTo: (args) => sendRequest('GO_TO', args),
385-
movePage: (args) => sendRequest('MOVE_PAGE', args),
386-
deletePages: (args) => sendRequest('DELETE_PAGES', args),
387-
rotatePage: (args) => sendRequest('ROTATE_PAGE', args),
388-
submit: (args) => sendRequest('SUBMIT', args),
416+
deleteFields: (args) => parseAndSend<typeof DeleteFieldsInput, { deleted_count: number }>(
417+
DeleteFieldsInput,
418+
'DELETE_FIELDS',
419+
args,
420+
),
421+
selectTool: (args) => parseAndSend(SelectToolInput, 'SELECT_TOOL', args),
422+
setFieldValue: (args) => parseAndSend(SetFieldValueInput, 'SET_FIELD_VALUE', args),
423+
focusField: (args) => parseAndSend<typeof FocusFieldInput, FocusFieldResult>(FocusFieldInput, 'FOCUS_FIELD', args),
424+
goTo: (args) => parseAndSend(GoToInput, 'GO_TO', args),
425+
movePage: (args) => parseAndSend(MovePageInput, 'MOVE_PAGE', args),
426+
deletePages: (args) => parseAndSend(DeletePagesInput, 'DELETE_PAGES', args),
427+
rotatePage: (args) => parseAndSend(RotatePageInput, 'ROTATE_PAGE', args),
428+
submit: (args) => parseAndSend(SubmitInput, 'SUBMIT', args),
389429
download: () => sendRequest('DOWNLOAD', {}),
390430
}
391431

copilot/src/lib/embed-bridge/types.ts

Lines changed: 17 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,6 @@
11
// Shared types for the SimplePDF embed bridge. Pure TypeScript, no
22
// framework dependencies.
33

4-
import type { z } from 'zod'
5-
import type {
6-
DeleteFieldsInput,
7-
DeletePagesInput,
8-
FocusFieldInput,
9-
GetDocumentContentInput,
10-
GoToInput,
11-
LoadDocumentInput,
12-
MovePageInput,
13-
RotatePageInput,
14-
SelectToolInput,
15-
SetFieldValueInput,
16-
SubmitInput,
17-
} from './schemas'
18-
194
export type BridgeResult<TData = null> =
205
| { success: true; data: TData }
216
| { success: false; error: { code: string; message: string } }
@@ -104,26 +89,28 @@ export type BridgeRequestType =
10489
| 'DELETE_PAGES'
10590
| 'ROTATE_PAGE'
10691

107-
// Each method's input is typed via z.infer from its bridge schema in
108-
// schemas.ts — that file is the single source of truth for the iframe
109-
// contract shape. Output types (BridgeResult<...>) stay explicit since
110-
// they describe the iframe response, not the request input.
11192
export type FocusFieldResult = { hint: { type: 'user_action_expected'; message: string } } | null
11293

94+
// The bridge owns the contract. Each method takes `unknown` (raw, from any
95+
// caller — the LLM dispatcher, direct UI code, etc.) and validates it
96+
// internally against the matching Zod schema in schemas.ts before posting
97+
// to the iframe. Bad input surfaces as `{ success: false, error: { code:
98+
// 'bad_input', ... } }` without a postMessage round-trip. Adapters do not
99+
// re-validate.
113100
export type IframeBridge = {
114101
getState: () => BridgeState
115-
loadDocument: (args: z.infer<typeof LoadDocumentInput>) => Promise<BridgeResult>
102+
loadDocument: (args: unknown) => Promise<BridgeResult>
116103
getFields: () => Promise<BridgeResult<{ fields: FieldRecord[] }>>
117-
getDocumentContent: (args: z.infer<typeof GetDocumentContentInput>) => Promise<BridgeResult<DocumentContentResult>>
104+
getDocumentContent: (args: unknown) => Promise<BridgeResult<DocumentContentResult>>
118105
detectFields: () => Promise<BridgeResult<{ detected_count: number }>>
119-
deleteFields: (args: z.infer<typeof DeleteFieldsInput>) => Promise<BridgeResult<{ deleted_count: number }>>
120-
selectTool: (args: z.infer<typeof SelectToolInput>) => Promise<BridgeResult>
121-
setFieldValue: (args: z.infer<typeof SetFieldValueInput>) => Promise<BridgeResult>
122-
focusField: (args: z.infer<typeof FocusFieldInput>) => Promise<BridgeResult<FocusFieldResult>>
123-
goTo: (args: z.infer<typeof GoToInput>) => Promise<BridgeResult>
124-
movePage: (args: z.infer<typeof MovePageInput>) => Promise<BridgeResult>
125-
deletePages: (args: z.infer<typeof DeletePagesInput>) => Promise<BridgeResult>
126-
rotatePage: (args: z.infer<typeof RotatePageInput>) => Promise<BridgeResult>
127-
submit: (args: z.infer<typeof SubmitInput>) => Promise<BridgeResult>
106+
deleteFields: (args: unknown) => Promise<BridgeResult<{ deleted_count: number }>>
107+
selectTool: (args: unknown) => Promise<BridgeResult>
108+
setFieldValue: (args: unknown) => Promise<BridgeResult>
109+
focusField: (args: unknown) => Promise<BridgeResult<FocusFieldResult>>
110+
goTo: (args: unknown) => Promise<BridgeResult>
111+
movePage: (args: unknown) => Promise<BridgeResult>
112+
deletePages: (args: unknown) => Promise<BridgeResult>
113+
rotatePage: (args: unknown) => Promise<BridgeResult>
114+
submit: (args: unknown) => Promise<BridgeResult>
128115
download: () => Promise<BridgeResult>
129116
}

0 commit comments

Comments
 (0)