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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +348 -0
  3. package/dist/client.d.ts +74 -0
  4. package/dist/client.js +216 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/errors.d.ts +111 -0
  7. package/dist/errors.js +152 -0
  8. package/dist/errors.js.map +1 -0
  9. package/dist/index.d.ts +8 -0
  10. package/dist/index.js +8 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/node/index.d.ts +28 -0
  13. package/dist/node/index.js +177 -0
  14. package/dist/node/index.js.map +1 -0
  15. package/dist/rpc.d.ts +37 -0
  16. package/dist/rpc.js +172 -0
  17. package/dist/rpc.js.map +1 -0
  18. package/dist/schema.d.ts +53 -0
  19. package/dist/schema.js +149 -0
  20. package/dist/schema.js.map +1 -0
  21. package/dist/session.d.ts +63 -0
  22. package/dist/session.js +202 -0
  23. package/dist/session.js.map +1 -0
  24. package/dist/tauri/index.d.ts +29 -0
  25. package/dist/tauri/index.js +80 -0
  26. package/dist/tauri/index.js.map +1 -0
  27. package/dist/tool.d.ts +39 -0
  28. package/dist/tool.js +48 -0
  29. package/dist/tool.js.map +1 -0
  30. package/dist/transport.d.ts +37 -0
  31. package/dist/transport.js +24 -0
  32. package/dist/transport.js.map +1 -0
  33. package/dist/types.d.ts +152 -0
  34. package/dist/types.js +2 -0
  35. package/dist/types.js.map +1 -0
  36. package/dist/zod/index.d.ts +21 -0
  37. package/dist/zod/index.js +44 -0
  38. package/dist/zod/index.js.map +1 -0
  39. package/package.json +79 -0
  40. package/src/client.ts +312 -0
  41. package/src/errors.ts +170 -0
  42. package/src/index.ts +22 -0
  43. package/src/node/index.ts +203 -0
  44. package/src/rpc.ts +204 -0
  45. package/src/schema.ts +206 -0
  46. package/src/session.ts +290 -0
  47. package/src/tauri/index.ts +100 -0
  48. package/src/tool.ts +80 -0
  49. package/src/transport.ts +60 -0
  50. package/src/types.ts +142 -0
  51. 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
+ }