afm-bridge 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 +348 -0
- package/dist/client.d.ts +74 -0
- package/dist/client.js +216 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +111 -0
- package/dist/errors.js +152 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/node/index.d.ts +28 -0
- package/dist/node/index.js +177 -0
- package/dist/node/index.js.map +1 -0
- package/dist/rpc.d.ts +37 -0
- package/dist/rpc.js +172 -0
- package/dist/rpc.js.map +1 -0
- package/dist/schema.d.ts +53 -0
- package/dist/schema.js +149 -0
- package/dist/schema.js.map +1 -0
- package/dist/session.d.ts +63 -0
- package/dist/session.js +202 -0
- package/dist/session.js.map +1 -0
- package/dist/tauri/index.d.ts +29 -0
- package/dist/tauri/index.js +80 -0
- package/dist/tauri/index.js.map +1 -0
- package/dist/tool.d.ts +39 -0
- package/dist/tool.js +48 -0
- package/dist/tool.js.map +1 -0
- package/dist/transport.d.ts +37 -0
- package/dist/transport.js +24 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +152 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/zod/index.d.ts +21 -0
- package/dist/zod/index.js +44 -0
- package/dist/zod/index.js.map +1 -0
- package/package.json +79 -0
- package/src/client.ts +312 -0
- package/src/errors.ts +170 -0
- package/src/index.ts +22 -0
- package/src/node/index.ts +203 -0
- package/src/rpc.ts +204 -0
- package/src/schema.ts +206 -0
- package/src/session.ts +290 -0
- package/src/tauri/index.ts +100 -0
- package/src/tool.ts +80 -0
- package/src/transport.ts +60 -0
- package/src/types.ts +142 -0
- package/src/zod/index.ts +53 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from 'node:child_process'
|
|
2
|
+
import { accessSync, chmodSync, constants, existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { ModelUnavailableError, TransportError } from '../errors.js'
|
|
6
|
+
import { type Connection, createLineSplitter, type Transport, type TransportHandlers } from '../transport.js'
|
|
7
|
+
|
|
8
|
+
export interface NodeStdioTransportOptions {
|
|
9
|
+
/** Path to `afm-bridge-server`. Default: `resolveSidecarPath()`. */
|
|
10
|
+
binaryPath?: string
|
|
11
|
+
args?: string[]
|
|
12
|
+
/** Extra environment variables for the sidecar. */
|
|
13
|
+
env?: Record<string, string | undefined>
|
|
14
|
+
/** Sidecar log level (stderr). Default `warn`. */
|
|
15
|
+
logLevel?: 'error' | 'warn' | 'info' | 'debug'
|
|
16
|
+
/** How long `close()` waits for a graceful exit before killing. Default 2000 ms. */
|
|
17
|
+
shutdownTimeoutMs?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Runs the sidecar as a child process and talks over stdin/stdout.
|
|
22
|
+
* Works in Electron's main process, plain Node and CLI tools.
|
|
23
|
+
*/
|
|
24
|
+
export class NodeStdioTransport implements Transport {
|
|
25
|
+
constructor(private readonly options: NodeStdioTransportOptions = {}) {}
|
|
26
|
+
|
|
27
|
+
async connect(handlers: TransportHandlers): Promise<Connection> {
|
|
28
|
+
assertSupportedPlatform()
|
|
29
|
+
const binary = this.options.binaryPath ?? resolveSidecarPath()
|
|
30
|
+
const child = spawn(binary, this.options.args ?? [], {
|
|
31
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
32
|
+
env: { ...process.env, AFM_BRIDGE_LOG: this.options.logLevel ?? 'warn', ...this.options.env },
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
await new Promise<void>((resolve, reject) => {
|
|
36
|
+
child.once('spawn', resolve)
|
|
37
|
+
child.once('error', (error) =>
|
|
38
|
+
reject(
|
|
39
|
+
new TransportError(`Could not start afm-bridge-server at ${binary}: ${error.message}`, {
|
|
40
|
+
type: 'transportError',
|
|
41
|
+
cause: error,
|
|
42
|
+
}),
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
let exited = false
|
|
48
|
+
const exit = new Promise<void>((resolve) => {
|
|
49
|
+
child.once('close', (code, signal) => {
|
|
50
|
+
exited = true
|
|
51
|
+
stdout.flush()
|
|
52
|
+
stderr.flush()
|
|
53
|
+
handlers.onClose({ code, signal })
|
|
54
|
+
resolve()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
// Errors after spawn (e.g. EPIPE on a dead process) surface through 'close'.
|
|
58
|
+
child.on('error', () => {})
|
|
59
|
+
child.stdin?.on('error', () => {})
|
|
60
|
+
|
|
61
|
+
const stdout = createLineSplitter((line) => handlers.onMessage(line))
|
|
62
|
+
const stderr = createLineSplitter((line) => handlers.onLog?.(line))
|
|
63
|
+
child.stdout?.setEncoding('utf8').on('data', (chunk: string) => stdout.push(chunk))
|
|
64
|
+
child.stderr?.setEncoding('utf8').on('data', (chunk: string) => stderr.push(chunk))
|
|
65
|
+
|
|
66
|
+
const shutdownTimeoutMs = this.options.shutdownTimeoutMs ?? 2000
|
|
67
|
+
return {
|
|
68
|
+
send(line) {
|
|
69
|
+
if (!exited && child.stdin?.writable) child.stdin.write(`${line}\n`)
|
|
70
|
+
},
|
|
71
|
+
async close() {
|
|
72
|
+
if (exited) return
|
|
73
|
+
setActive(child, true)
|
|
74
|
+
child.stdin?.end()
|
|
75
|
+
if (await settlesWithin(exit, shutdownTimeoutMs)) return
|
|
76
|
+
child.kill('SIGTERM')
|
|
77
|
+
if (await settlesWithin(exit, 1000)) return
|
|
78
|
+
child.kill('SIGKILL')
|
|
79
|
+
await exit
|
|
80
|
+
},
|
|
81
|
+
setActive(active) {
|
|
82
|
+
if (!exited) setActive(child, active)
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Finds the `afm-bridge-server` binary. Order:
|
|
90
|
+
* 1. `AFM_BRIDGE_BINARY` environment variable
|
|
91
|
+
* 2. Packaged Electron app: `<app>/Contents/Resources/afm-bridge-server` (`process.resourcesPath`)
|
|
92
|
+
* 3. The `afm-bridge-darwin-arm64` package (with `app.asar` rewritten to `app.asar.unpacked`)
|
|
93
|
+
*/
|
|
94
|
+
export function resolveSidecarPath(): string {
|
|
95
|
+
const tried: string[] = []
|
|
96
|
+
const usable = (candidate: string) => {
|
|
97
|
+
tried.push(candidate)
|
|
98
|
+
try {
|
|
99
|
+
accessSync(candidate, constants.X_OK)
|
|
100
|
+
return true
|
|
101
|
+
} catch {
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const fromEnv = process.env.AFM_BRIDGE_BINARY
|
|
107
|
+
if (fromEnv) {
|
|
108
|
+
if (usable(fromEnv)) return fromEnv
|
|
109
|
+
throw notFound(tried, 'AFM_BRIDGE_BINARY points to a file that does not exist or is not executable.')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const resourcesPath = (process as { resourcesPath?: string }).resourcesPath
|
|
113
|
+
if (resourcesPath) {
|
|
114
|
+
const packaged = join(resourcesPath, 'afm-bridge-server')
|
|
115
|
+
if (usable(packaged)) return packaged
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const fromPackage = createRequire(import.meta.url).resolve(
|
|
120
|
+
'afm-bridge-darwin-arm64/bin/afm-bridge-server',
|
|
121
|
+
)
|
|
122
|
+
const unpacked = fromPackage.replace(/app\.asar([\\/])/, 'app.asar.unpacked$1')
|
|
123
|
+
if (unpacked !== fromPackage && usable(unpacked)) return unpacked
|
|
124
|
+
if (usable(fromPackage)) return fromPackage
|
|
125
|
+
// Some package managers drop the executable bit when installing; restore it.
|
|
126
|
+
if (unpacked === fromPackage && existsSync(fromPackage)) {
|
|
127
|
+
try {
|
|
128
|
+
chmodSync(fromPackage, 0o755)
|
|
129
|
+
if (usable(fromPackage)) return fromPackage
|
|
130
|
+
} catch {
|
|
131
|
+
// Read-only location; fall through to the error below.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// Package not installed (not on macOS arm64, or optional dependencies were skipped).
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw notFound(tried)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function notFound(tried: string[], hint?: string): TransportError {
|
|
142
|
+
return new TransportError(
|
|
143
|
+
[
|
|
144
|
+
'afm-bridge-server binary not found.',
|
|
145
|
+
hint,
|
|
146
|
+
tried.length > 0 ? `Tried: ${tried.join(', ')}.` : undefined,
|
|
147
|
+
'Fix: install afm-bridge-darwin-arm64 (npm adds it automatically on Apple silicon), build it with `swift build -c release` and pass `binaryPath`, or set AFM_BRIDGE_BINARY.',
|
|
148
|
+
]
|
|
149
|
+
.filter(Boolean)
|
|
150
|
+
.join(' '),
|
|
151
|
+
{ type: 'transportError' },
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Throws `ModelUnavailableError` on platforms where the sidecar cannot run. */
|
|
156
|
+
function assertSupportedPlatform() {
|
|
157
|
+
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
|
|
158
|
+
throw new ModelUnavailableError(
|
|
159
|
+
`Apple Foundation Models need macOS on Apple silicon (this is ${process.platform}/${process.arch})`,
|
|
160
|
+
{ type: 'modelUnavailable', data: { model: 'on-device', reason: 'unsupportedPlatform' } },
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
const version = macOSVersion()
|
|
164
|
+
if (version !== undefined && version < 26) {
|
|
165
|
+
throw new ModelUnavailableError(
|
|
166
|
+
`Apple Foundation Models need macOS 26 or newer (this is macOS ${version})`,
|
|
167
|
+
{
|
|
168
|
+
type: 'modelUnavailable',
|
|
169
|
+
data: { model: 'on-device', reason: 'unsupportedOS' },
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function macOSVersion(): number | undefined {
|
|
176
|
+
try {
|
|
177
|
+
const plist = readFileSync('/System/Library/CoreServices/SystemVersion.plist', 'utf8')
|
|
178
|
+
const match = /<key>ProductVersion<\/key>\s*<string>(\d+)/.exec(plist)
|
|
179
|
+
return match?.[1] ? Number(match[1]) : undefined
|
|
180
|
+
} catch {
|
|
181
|
+
return undefined
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function setActive(child: ChildProcess, active: boolean) {
|
|
186
|
+
for (const handle of [child, child.stdin, child.stdout, child.stderr] as Array<{
|
|
187
|
+
ref?: () => void
|
|
188
|
+
unref?: () => void
|
|
189
|
+
} | null>) {
|
|
190
|
+
if (active) handle?.ref?.()
|
|
191
|
+
else handle?.unref?.()
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function settlesWithin(promise: Promise<void>, ms: number): Promise<boolean> {
|
|
196
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
197
|
+
const timeout = new Promise<boolean>((resolve) => {
|
|
198
|
+
timer = setTimeout(() => resolve(false), ms)
|
|
199
|
+
})
|
|
200
|
+
const result = await Promise.race([promise.then(() => true), timeout])
|
|
201
|
+
clearTimeout(timer)
|
|
202
|
+
return result
|
|
203
|
+
}
|
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AbortError,
|
|
3
|
+
AfmError,
|
|
4
|
+
errorFromRpc,
|
|
5
|
+
ProtocolError,
|
|
6
|
+
RequestTimeoutError,
|
|
7
|
+
type RpcErrorPayload,
|
|
8
|
+
} from './errors.js'
|
|
9
|
+
import type { Connection } from './transport.js'
|
|
10
|
+
|
|
11
|
+
export interface RequestOptions {
|
|
12
|
+
signal?: AbortSignal | undefined
|
|
13
|
+
timeoutMs?: number | undefined
|
|
14
|
+
/** Called with the request id right before the request is written. */
|
|
15
|
+
onStart?: (id: number) => void
|
|
16
|
+
/** How long to wait for the peer to confirm a cancel before rejecting anyway. Default 5000 ms. */
|
|
17
|
+
cancelGraceMs?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type IncomingRequestHandler = (method: string, params: unknown, signal: AbortSignal) => Promise<unknown>
|
|
21
|
+
|
|
22
|
+
interface Pending {
|
|
23
|
+
resolve(value: unknown): void
|
|
24
|
+
reject(error: unknown): void
|
|
25
|
+
cleanup(): void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_CANCEL_GRACE_MS = 5000
|
|
29
|
+
|
|
30
|
+
/** JSON-RPC 2.0 peer over one connection. Both sides can send requests. */
|
|
31
|
+
export class RpcPeer {
|
|
32
|
+
private nextId = 1
|
|
33
|
+
private readonly pending = new Map<number, Pending>()
|
|
34
|
+
private readonly incoming = new Map<string | number, AbortController>()
|
|
35
|
+
private closedError: Error | undefined
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
private readonly connection: Connection,
|
|
39
|
+
private readonly handlers: {
|
|
40
|
+
onNotification(method: string, params: unknown): void
|
|
41
|
+
onRequest?: IncomingRequestHandler
|
|
42
|
+
onPendingChange?(count: number): void
|
|
43
|
+
onProtocolError?(error: Error): void
|
|
44
|
+
},
|
|
45
|
+
) {}
|
|
46
|
+
|
|
47
|
+
get pendingCount(): number {
|
|
48
|
+
return this.pending.size
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
request<T>(method: string, params: unknown, options: RequestOptions = {}): Promise<T> {
|
|
52
|
+
if (this.closedError) return Promise.reject(this.closedError)
|
|
53
|
+
const { signal, timeoutMs } = options
|
|
54
|
+
if (signal?.aborted) return Promise.reject(new AbortError())
|
|
55
|
+
|
|
56
|
+
const id = this.nextId++
|
|
57
|
+
return new Promise<T>((resolve, reject) => {
|
|
58
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
59
|
+
let graceTimer: ReturnType<typeof setTimeout> | undefined
|
|
60
|
+
let cancelError: AfmError | undefined
|
|
61
|
+
|
|
62
|
+
const cancel = (error: AfmError) => {
|
|
63
|
+
if (cancelError || !this.pending.has(id)) return
|
|
64
|
+
cancelError = error
|
|
65
|
+
this.notify('cancel', { id })
|
|
66
|
+
// Wait for the sidecar's answer so the session is free again when we reject,
|
|
67
|
+
// but never hang if the sidecar does not answer.
|
|
68
|
+
const grace = options.cancelGraceMs ?? DEFAULT_CANCEL_GRACE_MS
|
|
69
|
+
if (grace <= 0) this.settle(id, { error })
|
|
70
|
+
else graceTimer = setTimeout(() => this.settle(id, { error }), grace)
|
|
71
|
+
}
|
|
72
|
+
const onAbort = () => cancel(new AbortError())
|
|
73
|
+
|
|
74
|
+
this.pending.set(id, {
|
|
75
|
+
resolve: (value) => resolve(value as T),
|
|
76
|
+
// A cancelled request is answered with `cancelled`; report the reason we cancelled for.
|
|
77
|
+
reject: (error) =>
|
|
78
|
+
reject(
|
|
79
|
+
cancelError && error instanceof AfmError && error.type === 'cancelled' ? cancelError : error,
|
|
80
|
+
),
|
|
81
|
+
cleanup: () => {
|
|
82
|
+
if (timer) clearTimeout(timer)
|
|
83
|
+
if (graceTimer) clearTimeout(graceTimer)
|
|
84
|
+
signal?.removeEventListener('abort', onAbort)
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
this.handlers.onPendingChange?.(this.pending.size)
|
|
88
|
+
|
|
89
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
90
|
+
if (timeoutMs !== undefined) {
|
|
91
|
+
timer = setTimeout(
|
|
92
|
+
() => cancel(new RequestTimeoutError(`Request '${method}' timed out after ${timeoutMs} ms`)),
|
|
93
|
+
timeoutMs,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
options.onStart?.(id)
|
|
97
|
+
this.write({ jsonrpc: '2.0', id, method, params })
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
notify(method: string, params: unknown): void {
|
|
102
|
+
if (this.closedError) return
|
|
103
|
+
this.write({ jsonrpc: '2.0', method, params })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Feeds one line received from the connection. */
|
|
107
|
+
handleLine(line: string): void {
|
|
108
|
+
let message: Record<string, unknown>
|
|
109
|
+
try {
|
|
110
|
+
message = JSON.parse(line)
|
|
111
|
+
} catch {
|
|
112
|
+
this.handlers.onProtocolError?.(new ProtocolError(`Sidecar sent invalid JSON: ${line.slice(0, 200)}`))
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (typeof message !== 'object' || message === null || message.jsonrpc !== '2.0') {
|
|
116
|
+
this.handlers.onProtocolError?.(
|
|
117
|
+
new ProtocolError(`Sidecar sent a non JSON-RPC message: ${line.slice(0, 200)}`),
|
|
118
|
+
)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
const { id, method } = message
|
|
122
|
+
if (typeof method === 'string') {
|
|
123
|
+
if (id === undefined) this.handleNotification(method, message.params)
|
|
124
|
+
else this.handleIncomingRequest(id as string | number, method, message.params)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
if (typeof id === 'number') {
|
|
128
|
+
if ('error' in message) this.settle(id, { error: errorFromRpc(message.error as RpcErrorPayload) })
|
|
129
|
+
else this.settle(id, { result: message.result })
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
// Error without id: the sidecar could not parse something we sent.
|
|
133
|
+
if ('error' in message) this.handlers.onProtocolError?.(errorFromRpc(message.error as RpcErrorPayload))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Rejects everything in flight; later calls reject with the same error. */
|
|
137
|
+
close(error: Error): void {
|
|
138
|
+
if (this.closedError) return
|
|
139
|
+
this.closedError = error
|
|
140
|
+
for (const id of [...this.pending.keys()]) this.settle(id, { error })
|
|
141
|
+
for (const controller of this.incoming.values()) controller.abort()
|
|
142
|
+
this.incoming.clear()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private handleNotification(method: string, params: unknown) {
|
|
146
|
+
if (method === 'cancel') {
|
|
147
|
+
const id = (params as { id?: string | number } | undefined)?.id
|
|
148
|
+
if (id !== undefined) this.incoming.get(id)?.abort()
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
this.handlers.onNotification(method, params)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private handleIncomingRequest(id: string | number, method: string, params: unknown) {
|
|
155
|
+
const handler = this.handlers.onRequest
|
|
156
|
+
if (!handler) {
|
|
157
|
+
this.write({
|
|
158
|
+
jsonrpc: '2.0',
|
|
159
|
+
id,
|
|
160
|
+
error: { code: -32601, message: `Method not found: ${method}`, data: { type: 'methodNotFound' } },
|
|
161
|
+
})
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
const controller = new AbortController()
|
|
165
|
+
this.incoming.set(id, controller)
|
|
166
|
+
handler(method, params, controller.signal).then(
|
|
167
|
+
(result) => {
|
|
168
|
+
this.incoming.delete(id)
|
|
169
|
+
this.write({ jsonrpc: '2.0', id, result })
|
|
170
|
+
},
|
|
171
|
+
(error: unknown) => {
|
|
172
|
+
this.incoming.delete(id)
|
|
173
|
+
const payload =
|
|
174
|
+
error instanceof AfmError && error.code !== undefined
|
|
175
|
+
? { code: error.code, message: error.message, data: { ...error.data, type: error.type } }
|
|
176
|
+
: {
|
|
177
|
+
code: -32000,
|
|
178
|
+
message: error instanceof Error ? error.message : String(error),
|
|
179
|
+
data: { type: 'handlerError' },
|
|
180
|
+
}
|
|
181
|
+
this.write({ jsonrpc: '2.0', id, error: payload })
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private settle(id: number, outcome: { result?: unknown; error?: unknown }) {
|
|
187
|
+
const entry = this.pending.get(id)
|
|
188
|
+
if (!entry) return
|
|
189
|
+
this.pending.delete(id)
|
|
190
|
+
entry.cleanup()
|
|
191
|
+
this.handlers.onPendingChange?.(this.pending.size)
|
|
192
|
+
if ('error' in outcome && outcome.error !== undefined) entry.reject(outcome.error)
|
|
193
|
+
else entry.resolve(outcome.result)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private write(message: unknown) {
|
|
197
|
+
if (this.closedError) return
|
|
198
|
+
try {
|
|
199
|
+
this.connection.send(JSON.stringify(message))
|
|
200
|
+
} catch (error) {
|
|
201
|
+
this.handlers.onProtocolError?.(error instanceof Error ? error : new Error(String(error)))
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { DecodingError } from './errors.js'
|
|
2
|
+
|
|
3
|
+
/** The JSON Schema subset afm-bridge supports (PROTOCOL.md section 8). */
|
|
4
|
+
export interface JSONSchema {
|
|
5
|
+
type?: 'object' | 'array' | 'string' | 'integer' | 'number' | 'boolean' | 'null' | Array<string>
|
|
6
|
+
description?: string
|
|
7
|
+
properties?: Record<string, JSONSchema>
|
|
8
|
+
required?: string[]
|
|
9
|
+
additionalProperties?: false
|
|
10
|
+
items?: JSONSchema
|
|
11
|
+
minItems?: number
|
|
12
|
+
maxItems?: number
|
|
13
|
+
minimum?: number
|
|
14
|
+
maximum?: number
|
|
15
|
+
enum?: Array<string | null>
|
|
16
|
+
const?: string
|
|
17
|
+
pattern?: string
|
|
18
|
+
anyOf?: JSONSchema[]
|
|
19
|
+
oneOf?: JSONSchema[]
|
|
20
|
+
$ref?: string
|
|
21
|
+
$defs?: Record<string, JSONSchema>
|
|
22
|
+
definitions?: Record<string, JSONSchema>
|
|
23
|
+
[annotation: string]: unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const structured = Symbol.for('afm-bridge.schema')
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A schema plus a parser that turns the generated JSON into `T`. Create one with
|
|
30
|
+
* `jsonSchema()` or `zodSchema()` from `afm-bridge/zod`.
|
|
31
|
+
*/
|
|
32
|
+
export interface StructuredSchema<T> {
|
|
33
|
+
readonly [structured]: true
|
|
34
|
+
readonly jsonSchema: JSONSchema
|
|
35
|
+
/** Validates and converts a generated value. Throws `DecodingError` when it does not match. */
|
|
36
|
+
parse(value: unknown): T
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A plain JSON Schema object or a `StructuredSchema`. */
|
|
40
|
+
export type SchemaInput<T = unknown> = JSONSchema | StructuredSchema<T>
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Wraps a JSON Schema. Without `parse` the result is checked by the built-in validator
|
|
44
|
+
* for the supported subset and typed as `T`.
|
|
45
|
+
*/
|
|
46
|
+
export function jsonSchema<T = unknown>(
|
|
47
|
+
schema: JSONSchema,
|
|
48
|
+
parse?: (value: unknown) => T,
|
|
49
|
+
): StructuredSchema<T> {
|
|
50
|
+
return {
|
|
51
|
+
[structured]: true,
|
|
52
|
+
jsonSchema: schema,
|
|
53
|
+
parse:
|
|
54
|
+
parse ??
|
|
55
|
+
((value: unknown) => {
|
|
56
|
+
const issue = validate(schema, value)
|
|
57
|
+
if (issue) {
|
|
58
|
+
throw new DecodingError(
|
|
59
|
+
`Generated value does not match the schema at '${issue.path}': ${issue.message}`,
|
|
60
|
+
{
|
|
61
|
+
type: 'decodingFailure',
|
|
62
|
+
data: { path: issue.path, rawContent: JSON.stringify(value) },
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
return value as T
|
|
67
|
+
}),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isStructuredSchema(value: unknown): value is StructuredSchema<unknown> {
|
|
72
|
+
return typeof value === 'object' && value !== null && structured in value
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function toStructuredSchema<T>(input: SchemaInput<T>): StructuredSchema<T> {
|
|
76
|
+
return isStructuredSchema(input) ? (input as StructuredSchema<T>) : jsonSchema<T>(input as JSONSchema)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Recursively optional, for partial values while a structured response streams in. */
|
|
80
|
+
export type DeepPartial<T> =
|
|
81
|
+
T extends Array<infer U>
|
|
82
|
+
? Array<DeepPartial<U>>
|
|
83
|
+
: T extends object
|
|
84
|
+
? { [K in keyof T]?: DeepPartial<T[K]> }
|
|
85
|
+
: T
|
|
86
|
+
|
|
87
|
+
// MARK: - Validation of the supported subset
|
|
88
|
+
|
|
89
|
+
interface Issue {
|
|
90
|
+
path: string
|
|
91
|
+
message: string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Returns the first mismatch between `value` and `schema`, or undefined. `format` is not checked. */
|
|
95
|
+
export function validate(schema: JSONSchema, value: unknown): Issue | undefined {
|
|
96
|
+
return check(schema, value, '#', schema, 0)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function check(
|
|
100
|
+
schema: JSONSchema,
|
|
101
|
+
value: unknown,
|
|
102
|
+
path: string,
|
|
103
|
+
root: JSONSchema,
|
|
104
|
+
depth: number,
|
|
105
|
+
): Issue | undefined {
|
|
106
|
+
if (depth > 64) return undefined
|
|
107
|
+
const fail = (message: string): Issue => ({ path, message })
|
|
108
|
+
|
|
109
|
+
if (schema.$ref !== undefined) {
|
|
110
|
+
const target = resolveRef(schema.$ref, root)
|
|
111
|
+
if (!target) return fail(`cannot resolve ${schema.$ref}`)
|
|
112
|
+
return check(target, value, path, root, depth + 1)
|
|
113
|
+
}
|
|
114
|
+
const branches = schema.anyOf ?? schema.oneOf
|
|
115
|
+
if (branches) {
|
|
116
|
+
const ok = branches.some((b) => !check(b, value, path, root, depth + 1))
|
|
117
|
+
return ok ? undefined : fail('does not match any of the allowed schemas')
|
|
118
|
+
}
|
|
119
|
+
if (schema.enum) {
|
|
120
|
+
return schema.enum.includes(value as string | null)
|
|
121
|
+
? undefined
|
|
122
|
+
: fail(`must be one of ${JSON.stringify(schema.enum)}`)
|
|
123
|
+
}
|
|
124
|
+
if (schema.const !== undefined) {
|
|
125
|
+
return value === schema.const ? undefined : fail(`must be ${JSON.stringify(schema.const)}`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const types = schema.type === undefined ? [] : Array.isArray(schema.type) ? schema.type : [schema.type]
|
|
129
|
+
if (value === null) {
|
|
130
|
+
return types.includes('null') ? undefined : fail('must not be null')
|
|
131
|
+
}
|
|
132
|
+
const type = types.find((t) => t !== 'null')
|
|
133
|
+
switch (type) {
|
|
134
|
+
case 'string': {
|
|
135
|
+
if (typeof value !== 'string') return fail('must be a string')
|
|
136
|
+
// A pattern that is valid for Swift but not for JavaScript is skipped, not failed.
|
|
137
|
+
const pattern = schema.pattern === undefined ? undefined : safeRegExp(schema.pattern)
|
|
138
|
+
if (pattern && !pattern.test(value)) return fail(`must match /${schema.pattern}/`)
|
|
139
|
+
return undefined
|
|
140
|
+
}
|
|
141
|
+
case 'integer':
|
|
142
|
+
case 'number':
|
|
143
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return fail(`must be a ${type}`)
|
|
144
|
+
if (type === 'integer' && !Number.isInteger(value)) return fail('must be an integer')
|
|
145
|
+
if (schema.minimum !== undefined && value < schema.minimum) return fail(`must be >= ${schema.minimum}`)
|
|
146
|
+
if (schema.maximum !== undefined && value > schema.maximum) return fail(`must be <= ${schema.maximum}`)
|
|
147
|
+
return undefined
|
|
148
|
+
case 'boolean':
|
|
149
|
+
return typeof value === 'boolean' ? undefined : fail('must be a boolean')
|
|
150
|
+
case 'array': {
|
|
151
|
+
if (!Array.isArray(value)) return fail('must be an array')
|
|
152
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) {
|
|
153
|
+
return fail(`must have at least ${schema.minItems} items`)
|
|
154
|
+
}
|
|
155
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems) {
|
|
156
|
+
return fail(`must have at most ${schema.maxItems} items`)
|
|
157
|
+
}
|
|
158
|
+
if (!schema.items) return undefined
|
|
159
|
+
for (const [i, item] of value.entries()) {
|
|
160
|
+
const issue = check(schema.items, item, `${path}/${i}`, root, depth + 1)
|
|
161
|
+
if (issue) return issue
|
|
162
|
+
}
|
|
163
|
+
return undefined
|
|
164
|
+
}
|
|
165
|
+
case 'object': {
|
|
166
|
+
if (typeof value !== 'object' || Array.isArray(value)) return fail('must be an object')
|
|
167
|
+
const record = value as Record<string, unknown>
|
|
168
|
+
const properties = schema.properties ?? {}
|
|
169
|
+
for (const key of schema.required ?? []) {
|
|
170
|
+
if (!(key in record)) return fail(`is missing required property '${key}'`)
|
|
171
|
+
}
|
|
172
|
+
for (const [key, v] of Object.entries(record)) {
|
|
173
|
+
const sub = properties[key]
|
|
174
|
+
if (!sub) {
|
|
175
|
+
if (schema.additionalProperties === false) return fail(`has unexpected property '${key}'`)
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
const issue = check(sub, v, `${path}/${escapePointer(key)}`, root, depth + 1)
|
|
179
|
+
if (issue) return issue
|
|
180
|
+
}
|
|
181
|
+
return undefined
|
|
182
|
+
}
|
|
183
|
+
default:
|
|
184
|
+
return undefined
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function resolveRef(ref: string, root: JSONSchema): JSONSchema | undefined {
|
|
189
|
+
if (ref === '#') return root
|
|
190
|
+
const match = /^#\/(\$defs|definitions)\/(.+)$/.exec(ref)
|
|
191
|
+
if (!match?.[1] || !match[2]) return undefined
|
|
192
|
+
const defs = (match[1] === '$defs' ? root.$defs : root.definitions) ?? {}
|
|
193
|
+
return defs[match[2].replace(/~1/g, '/').replace(/~0/g, '~')]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function escapePointer(key: string): string {
|
|
197
|
+
return key.replace(/~/g, '~0').replace(/\//g, '~1')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function safeRegExp(pattern: string): RegExp | undefined {
|
|
201
|
+
try {
|
|
202
|
+
return new RegExp(pattern, 'u')
|
|
203
|
+
} catch {
|
|
204
|
+
return undefined
|
|
205
|
+
}
|
|
206
|
+
}
|