ts-procedures 5.7.0 → 5.7.1
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 +185 -0
- package/agent_config/claude-code/skills/guide/api-reference.md +200 -0
- package/agent_config/claude-code/skills/guide/patterns.md +108 -0
- package/agent_config/copilot/copilot-instructions.md +87 -0
- package/agent_config/cursor/cursorrules +87 -0
- package/build/implementations/http/doc-registry.test.js +27 -1
- package/build/implementations/http/doc-registry.test.js.map +1 -1
- package/build/implementations/http/express-rpc/index.js +1 -0
- package/build/implementations/http/express-rpc/index.js.map +1 -1
- package/build/implementations/http/express-rpc/index.test.js +1 -1
- package/build/implementations/http/express-rpc/index.test.js.map +1 -1
- package/build/implementations/http/hono-api/index.js +2 -0
- package/build/implementations/http/hono-api/index.js.map +1 -1
- package/build/implementations/http/hono-api/index.test.js +9 -0
- package/build/implementations/http/hono-api/index.test.js.map +1 -1
- package/build/implementations/http/hono-rpc/index.js +1 -0
- package/build/implementations/http/hono-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-rpc/index.test.js +1 -1
- package/build/implementations/http/hono-rpc/index.test.js.map +1 -1
- package/build/implementations/http/hono-stream/index.js +17 -1
- package/build/implementations/http/hono-stream/index.js.map +1 -1
- package/build/implementations/http/hono-stream/index.test.js +61 -0
- package/build/implementations/http/hono-stream/index.test.js.map +1 -1
- package/build/implementations/http/hono-stream/types.d.ts +4 -13
- package/build/implementations/types.d.ts +5 -0
- package/build/index.js +8 -1
- package/build/index.js.map +1 -1
- package/package.json +21 -3
- package/src/client/call.ts +74 -0
- package/src/client/errors.ts +43 -0
- package/src/client/fetch-adapter.ts +191 -0
- package/src/client/hooks.ts +65 -0
- package/src/client/index.ts +121 -0
- package/src/client/request-builder.ts +73 -0
- package/src/client/stream.ts +164 -0
- package/src/client/types.ts +103 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { ClientAdapter, AdapterRequest, AdapterResponse, AdapterStreamResponse } from './types.js'
|
|
2
|
+
|
|
3
|
+
// ── Config ────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export interface FetchAdapterConfig {
|
|
6
|
+
headers?: Record<string, string>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// ── SSE parser ────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
interface SSEEvent {
|
|
12
|
+
data: unknown
|
|
13
|
+
event?: string
|
|
14
|
+
id?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parses an SSE message block (the text between double-newlines).
|
|
19
|
+
* Returns null if there is no data field (e.g., comment-only blocks).
|
|
20
|
+
*/
|
|
21
|
+
function parseSSEBlock(block: string): SSEEvent | null {
|
|
22
|
+
const lines = block.split('\n')
|
|
23
|
+
let event: string | undefined
|
|
24
|
+
let id: string | undefined
|
|
25
|
+
const dataParts: string[] = []
|
|
26
|
+
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
if (line.startsWith('event:')) {
|
|
29
|
+
event = line.slice('event:'.length).trim()
|
|
30
|
+
} else if (line.startsWith('data:')) {
|
|
31
|
+
dataParts.push(line.slice('data:'.length).trimStart())
|
|
32
|
+
} else if (line.startsWith('id:')) {
|
|
33
|
+
id = line.slice('id:'.length).trim()
|
|
34
|
+
}
|
|
35
|
+
// Lines starting with ':' are comments — skip them
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (dataParts.length === 0) {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const dataStr = dataParts.join('\n')
|
|
43
|
+
let data: unknown
|
|
44
|
+
try {
|
|
45
|
+
data = JSON.parse(dataStr)
|
|
46
|
+
} catch {
|
|
47
|
+
data = dataStr
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { data, event, id }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Async generator that reads a ReadableStream<Uint8Array>, buffers text,
|
|
55
|
+
* splits on double-newline SSE boundaries, and yields parsed SSE events.
|
|
56
|
+
*/
|
|
57
|
+
async function* parseSseStream(
|
|
58
|
+
readableStream: ReadableStream<Uint8Array>
|
|
59
|
+
): AsyncGenerator<SSEEvent> {
|
|
60
|
+
const reader = readableStream.getReader()
|
|
61
|
+
const decoder = new TextDecoder()
|
|
62
|
+
let buffer = ''
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
while (true) {
|
|
66
|
+
const { done, value } = await reader.read()
|
|
67
|
+
|
|
68
|
+
if (value) {
|
|
69
|
+
buffer += decoder.decode(value, { stream: !done })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Process all complete SSE message blocks (split on \n\n)
|
|
73
|
+
let boundary: number
|
|
74
|
+
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
|
|
75
|
+
const block = buffer.slice(0, boundary).trim()
|
|
76
|
+
buffer = buffer.slice(boundary + 2)
|
|
77
|
+
|
|
78
|
+
if (block.length > 0) {
|
|
79
|
+
const event = parseSSEBlock(block)
|
|
80
|
+
if (event !== null) {
|
|
81
|
+
yield event
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (done) break
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Handle any remaining buffer content (no trailing \n\n)
|
|
90
|
+
const remaining = buffer.trim()
|
|
91
|
+
if (remaining.length > 0) {
|
|
92
|
+
const event = parseSSEBlock(remaining)
|
|
93
|
+
if (event !== null) {
|
|
94
|
+
yield event
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
reader.releaseLock()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Adapter ───────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Extracts response headers as a plain Record<string, string>.
|
|
106
|
+
*/
|
|
107
|
+
function extractHeaders(response: Response): Record<string, string> {
|
|
108
|
+
const headers: Record<string, string> = {}
|
|
109
|
+
response.headers.forEach((value, key) => {
|
|
110
|
+
headers[key] = value
|
|
111
|
+
})
|
|
112
|
+
return headers
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Attempts to parse the response body as JSON, then as text, then returns null.
|
|
117
|
+
*/
|
|
118
|
+
async function parseResponseBody(response: Response): Promise<unknown> {
|
|
119
|
+
// Clone so we can attempt multiple reads
|
|
120
|
+
const clone = response.clone()
|
|
121
|
+
try {
|
|
122
|
+
return await clone.json()
|
|
123
|
+
} catch {
|
|
124
|
+
try {
|
|
125
|
+
const text = await response.text()
|
|
126
|
+
return text || null
|
|
127
|
+
} catch {
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Creates a fetch-based ClientAdapter.
|
|
135
|
+
*
|
|
136
|
+
* - `config.headers` are default headers applied to every request.
|
|
137
|
+
* - Per-request headers override config headers (spread order).
|
|
138
|
+
* - Works in Node.js 18+ and browsers (uses standard fetch + ReadableStream).
|
|
139
|
+
*/
|
|
140
|
+
export function createFetchAdapter(config?: FetchAdapterConfig): ClientAdapter {
|
|
141
|
+
const configHeaders = config?.headers ?? {}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
async request(req: AdapterRequest): Promise<AdapterResponse> {
|
|
145
|
+
const mergedHeaders: Record<string, string> = {
|
|
146
|
+
...configHeaders,
|
|
147
|
+
...(req.headers ?? {}),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const response = await fetch(req.url, {
|
|
151
|
+
method: req.method,
|
|
152
|
+
headers: mergedHeaders,
|
|
153
|
+
body: req.body !== undefined ? JSON.stringify(req.body) : undefined,
|
|
154
|
+
signal: req.signal,
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const headers = extractHeaders(response)
|
|
158
|
+
const body = await parseResponseBody(response)
|
|
159
|
+
|
|
160
|
+
return { status: response.status, headers, body }
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
async stream(req: AdapterRequest): Promise<AdapterStreamResponse> {
|
|
164
|
+
const mergedHeaders: Record<string, string> = {
|
|
165
|
+
...configHeaders,
|
|
166
|
+
...(req.headers ?? {}),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const response = await fetch(req.url, {
|
|
170
|
+
method: req.method,
|
|
171
|
+
headers: mergedHeaders,
|
|
172
|
+
body: req.body !== undefined ? JSON.stringify(req.body) : undefined,
|
|
173
|
+
signal: req.signal,
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const headers = extractHeaders(response)
|
|
177
|
+
|
|
178
|
+
if (!response.body) {
|
|
179
|
+
// No body — return an empty async iterable
|
|
180
|
+
const emptyBody: AsyncIterable<unknown> = {
|
|
181
|
+
[Symbol.asyncIterator]: async function* () {},
|
|
182
|
+
}
|
|
183
|
+
return { status: response.status, headers, body: emptyBody }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const body = parseSseStream(response.body as ReadableStream<Uint8Array>)
|
|
187
|
+
|
|
188
|
+
return { status: response.status, headers, body }
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BeforeRequestContext,
|
|
3
|
+
AfterResponseContext,
|
|
4
|
+
ErrorContext,
|
|
5
|
+
ClientHooks,
|
|
6
|
+
} from './types.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Runs `onBeforeRequest` hooks: global first, then per-procedure.
|
|
10
|
+
* Each hook receives the (possibly mutated) context from the previous hook.
|
|
11
|
+
* Returns the final context.
|
|
12
|
+
*/
|
|
13
|
+
export async function runBeforeRequest(
|
|
14
|
+
ctx: BeforeRequestContext,
|
|
15
|
+
globalHooks: ClientHooks,
|
|
16
|
+
localHooks: ClientHooks | undefined
|
|
17
|
+
): Promise<BeforeRequestContext> {
|
|
18
|
+
let current = ctx
|
|
19
|
+
|
|
20
|
+
if (globalHooks.onBeforeRequest) {
|
|
21
|
+
current = await globalHooks.onBeforeRequest(current)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (localHooks?.onBeforeRequest) {
|
|
25
|
+
current = await localHooks.onBeforeRequest(current)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return current
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Runs `onAfterResponse` hooks: global first, then per-procedure.
|
|
33
|
+
* Returns void.
|
|
34
|
+
*/
|
|
35
|
+
export async function runAfterResponse(
|
|
36
|
+
ctx: AfterResponseContext,
|
|
37
|
+
globalHooks: ClientHooks,
|
|
38
|
+
localHooks: ClientHooks | undefined
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
if (globalHooks.onAfterResponse) {
|
|
41
|
+
await globalHooks.onAfterResponse(ctx)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (localHooks?.onAfterResponse) {
|
|
45
|
+
await localHooks.onAfterResponse(ctx)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Runs `onError` hooks: global first, then per-procedure.
|
|
51
|
+
* Returns void.
|
|
52
|
+
*/
|
|
53
|
+
export async function runOnError(
|
|
54
|
+
ctx: ErrorContext,
|
|
55
|
+
globalHooks: ClientHooks,
|
|
56
|
+
localHooks: ClientHooks | undefined
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
if (globalHooks.onError) {
|
|
59
|
+
await globalHooks.onError(ctx)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (localHooks?.onError) {
|
|
63
|
+
await localHooks.onError(ctx)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { executeCall } from './call.js'
|
|
2
|
+
import { executeStream, createTypedStream } from './stream.js'
|
|
3
|
+
import type {
|
|
4
|
+
CreateClientConfig,
|
|
5
|
+
ClientInstance,
|
|
6
|
+
CallDescriptor,
|
|
7
|
+
StreamDescriptor,
|
|
8
|
+
ProcedureCallOptions,
|
|
9
|
+
TypedStream,
|
|
10
|
+
} from './types.js'
|
|
11
|
+
|
|
12
|
+
// ── createClient ──────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Creates a typed client from a config object.
|
|
16
|
+
*
|
|
17
|
+
* The `scopes` callback receives a `ClientInstance` and returns the typed
|
|
18
|
+
* scope bindings (e.g., `{ users: { getUser, createUser }, posts: { ... } }`).
|
|
19
|
+
* The return value of `createClient` is the scopes object.
|
|
20
|
+
*
|
|
21
|
+
* `client.stream()` must return `TypedStream` synchronously even though
|
|
22
|
+
* `executeStream` is async. We achieve this by creating a deferred TypedStream:
|
|
23
|
+
* - A deferred async generator awaits `executeStream` internally, then forwards
|
|
24
|
+
* yields from the inner stream.
|
|
25
|
+
* - The outer `.result` is wired up to the inner stream's `.result`.
|
|
26
|
+
*/
|
|
27
|
+
export function createClient<TScopes>(config: CreateClientConfig<TScopes>): TScopes {
|
|
28
|
+
const { adapter, basePath, hooks: globalHooks = {}, scopes } = config
|
|
29
|
+
|
|
30
|
+
const instance: ClientInstance = {
|
|
31
|
+
basePath,
|
|
32
|
+
adapter,
|
|
33
|
+
hooks: globalHooks,
|
|
34
|
+
|
|
35
|
+
call<TResponse>(
|
|
36
|
+
descriptor: CallDescriptor,
|
|
37
|
+
options?: ProcedureCallOptions
|
|
38
|
+
): Promise<TResponse> {
|
|
39
|
+
return executeCall<TResponse>(descriptor, basePath, adapter, globalHooks, options)
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
stream<TYield, TReturn>(
|
|
43
|
+
descriptor: StreamDescriptor,
|
|
44
|
+
options?: ProcedureCallOptions
|
|
45
|
+
): TypedStream<TYield, TReturn> {
|
|
46
|
+
// executeStream is async but stream() must be synchronous.
|
|
47
|
+
// Create a deferred TypedStream that wraps the async executeStream call.
|
|
48
|
+
|
|
49
|
+
let resolveResult: (value: TReturn) => void
|
|
50
|
+
let rejectResult: (reason: unknown) => void
|
|
51
|
+
|
|
52
|
+
const resultPromise = new Promise<TReturn>((resolve, reject) => {
|
|
53
|
+
resolveResult = resolve
|
|
54
|
+
rejectResult = reject
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// The deferred async generator: awaits executeStream, then forwards
|
|
58
|
+
async function* deferredGenerator(): AsyncGenerator<TYield> {
|
|
59
|
+
let innerStream: TypedStream<TYield, TReturn>
|
|
60
|
+
try {
|
|
61
|
+
innerStream = await executeStream<TYield, TReturn>(
|
|
62
|
+
descriptor,
|
|
63
|
+
basePath,
|
|
64
|
+
adapter,
|
|
65
|
+
globalHooks,
|
|
66
|
+
options
|
|
67
|
+
)
|
|
68
|
+
} catch (err) {
|
|
69
|
+
rejectResult(err)
|
|
70
|
+
throw err
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Wire up .result from the inner stream
|
|
74
|
+
innerStream.result.then(resolveResult, rejectResult)
|
|
75
|
+
|
|
76
|
+
for await (const item of innerStream) {
|
|
77
|
+
yield item
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const iterator = deferredGenerator()
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
[Symbol.asyncIterator]() {
|
|
85
|
+
return iterator
|
|
86
|
+
},
|
|
87
|
+
result: resultPromise,
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return scopes(instance)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Barrel exports ────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export type {
|
|
98
|
+
ClientAdapter,
|
|
99
|
+
AdapterRequest,
|
|
100
|
+
AdapterResponse,
|
|
101
|
+
AdapterStreamResponse,
|
|
102
|
+
ClientHooks,
|
|
103
|
+
BeforeRequestContext,
|
|
104
|
+
AfterResponseContext,
|
|
105
|
+
ErrorContext,
|
|
106
|
+
CallDescriptor,
|
|
107
|
+
StreamDescriptor,
|
|
108
|
+
TypedStream,
|
|
109
|
+
ClientInstance,
|
|
110
|
+
ProcedureCallOptions,
|
|
111
|
+
CreateClientConfig,
|
|
112
|
+
} from './types.js'
|
|
113
|
+
|
|
114
|
+
export { ClientRequestError, ClientPathParamError, ClientStreamError } from './errors.js'
|
|
115
|
+
|
|
116
|
+
export { createTypedStream } from './stream.js'
|
|
117
|
+
export { executeCall } from './call.js'
|
|
118
|
+
export { executeStream } from './stream.js'
|
|
119
|
+
|
|
120
|
+
export { createFetchAdapter } from './fetch-adapter.js'
|
|
121
|
+
export type { FetchAdapterConfig } from './fetch-adapter.js'
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ClientPathParamError } from './errors.js'
|
|
2
|
+
import type { AdapterRequest, CallDescriptor } from './types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Replaces `:paramName` segments in `path` with URI-encoded values from `params`.
|
|
6
|
+
* Throws `ClientPathParamError` if a required segment is missing from `params`.
|
|
7
|
+
*/
|
|
8
|
+
export function interpolatePath(
|
|
9
|
+
path: string,
|
|
10
|
+
params: Record<string, unknown>,
|
|
11
|
+
procedureName: string
|
|
12
|
+
): string {
|
|
13
|
+
return path.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (_match, key: string) => {
|
|
14
|
+
const value = params[key]
|
|
15
|
+
if (value === undefined || value === null) {
|
|
16
|
+
throw new ClientPathParamError(key, path, procedureName)
|
|
17
|
+
}
|
|
18
|
+
return encodeURIComponent(String(value))
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Builds an `AdapterRequest` from a `CallDescriptor` and a base URL.
|
|
24
|
+
*
|
|
25
|
+
* - `kind === 'rpc'` or `kind === 'stream'`: params are flat — sent as the JSON body.
|
|
26
|
+
* - `kind === 'api'`: params are structured channels — `pathParams`, `query`, `body`, `headers`.
|
|
27
|
+
*/
|
|
28
|
+
export function buildAdapterRequest(descriptor: CallDescriptor, basePath: string): AdapterRequest {
|
|
29
|
+
const { name, path, method, kind, params } = descriptor
|
|
30
|
+
|
|
31
|
+
if (kind === 'rpc' || kind === 'stream') {
|
|
32
|
+
return {
|
|
33
|
+
url: `${basePath}${path}`,
|
|
34
|
+
method,
|
|
35
|
+
body: params,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// kind === 'api' — params are structured channels
|
|
40
|
+
const structured = (params ?? {}) as {
|
|
41
|
+
pathParams?: Record<string, unknown>
|
|
42
|
+
query?: Record<string, unknown>
|
|
43
|
+
body?: unknown
|
|
44
|
+
headers?: Record<string, string>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Interpolate path params
|
|
48
|
+
const interpolatedPath = structured.pathParams
|
|
49
|
+
? interpolatePath(path, structured.pathParams, name)
|
|
50
|
+
: path
|
|
51
|
+
|
|
52
|
+
// Build query string
|
|
53
|
+
let url = `${basePath}${interpolatedPath}`
|
|
54
|
+
if (structured.query && Object.keys(structured.query).length > 0) {
|
|
55
|
+
const searchParams = new URLSearchParams(
|
|
56
|
+
Object.entries(structured.query).map(([k, v]) => [k, String(v)])
|
|
57
|
+
)
|
|
58
|
+
url = `${url}?${searchParams.toString()}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Build headers
|
|
62
|
+
const headers =
|
|
63
|
+
structured.headers && Object.keys(structured.headers).length > 0
|
|
64
|
+
? structured.headers
|
|
65
|
+
: undefined
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
url,
|
|
69
|
+
method,
|
|
70
|
+
headers,
|
|
71
|
+
body: structured.body,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { buildAdapterRequest } from './request-builder.js'
|
|
2
|
+
import { runBeforeRequest, runAfterResponse, runOnError } from './hooks.js'
|
|
3
|
+
import { ClientRequestError } from './errors.js'
|
|
4
|
+
import type {
|
|
5
|
+
ClientAdapter,
|
|
6
|
+
ClientHooks,
|
|
7
|
+
StreamDescriptor,
|
|
8
|
+
TypedStream,
|
|
9
|
+
AdapterResponse,
|
|
10
|
+
} from './types.js'
|
|
11
|
+
|
|
12
|
+
// ── SSE item shape ────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
interface SSEItem {
|
|
15
|
+
data: unknown
|
|
16
|
+
event?: string
|
|
17
|
+
id?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── createTypedStream ─────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Wraps an AsyncIterable into a TypedStream.
|
|
24
|
+
*
|
|
25
|
+
* SSE mode: each item is `{ data, event?, id? }`.
|
|
26
|
+
* - If `event === 'return'`, the data resolves `.result` and is NOT yielded.
|
|
27
|
+
* - Otherwise, `data` is yielded.
|
|
28
|
+
*
|
|
29
|
+
* Text mode: each item is yielded as-is.
|
|
30
|
+
* - `.result` resolves to `void` on completion.
|
|
31
|
+
*
|
|
32
|
+
* On error: `.result` rejects and the error is re-thrown from the async iterator.
|
|
33
|
+
*/
|
|
34
|
+
export function createTypedStream<TYield, TReturn = void>(
|
|
35
|
+
source: AsyncIterable<unknown>,
|
|
36
|
+
streamMode: 'sse' | 'text'
|
|
37
|
+
): TypedStream<TYield, TReturn> {
|
|
38
|
+
let resolveResult: (value: TReturn) => void
|
|
39
|
+
let rejectResult: (reason: unknown) => void
|
|
40
|
+
|
|
41
|
+
const resultPromise = new Promise<TReturn>((resolve, reject) => {
|
|
42
|
+
resolveResult = resolve
|
|
43
|
+
rejectResult = reject
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
async function* generate(): AsyncGenerator<TYield> {
|
|
47
|
+
try {
|
|
48
|
+
if (streamMode === 'sse') {
|
|
49
|
+
let returnValue: TReturn | undefined
|
|
50
|
+
let hasReturn = false
|
|
51
|
+
|
|
52
|
+
for await (const item of source) {
|
|
53
|
+
const sseItem = item as SSEItem
|
|
54
|
+
if (sseItem.event === 'return') {
|
|
55
|
+
returnValue = sseItem.data as TReturn
|
|
56
|
+
hasReturn = true
|
|
57
|
+
} else {
|
|
58
|
+
yield sseItem.data as TYield
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Resolve result after iteration completes
|
|
63
|
+
if (hasReturn) {
|
|
64
|
+
resolveResult(returnValue as TReturn)
|
|
65
|
+
} else {
|
|
66
|
+
resolveResult(undefined as TReturn)
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
// text mode: yield each item as-is
|
|
70
|
+
for await (const item of source) {
|
|
71
|
+
yield item as TYield
|
|
72
|
+
}
|
|
73
|
+
resolveResult(undefined as TReturn)
|
|
74
|
+
}
|
|
75
|
+
} catch (err) {
|
|
76
|
+
rejectResult(err)
|
|
77
|
+
throw err
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const iterator = generate()
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
[Symbol.asyncIterator]() {
|
|
85
|
+
return iterator
|
|
86
|
+
},
|
|
87
|
+
result: resultPromise,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── executeStream ─────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Executes a streaming procedure call through the adapter.
|
|
95
|
+
*
|
|
96
|
+
* Flow:
|
|
97
|
+
* 1. Build AdapterRequest from descriptor
|
|
98
|
+
* 2. Run onBeforeRequest hooks
|
|
99
|
+
* 3. Call adapter.stream()
|
|
100
|
+
* 4. On adapter error: run onError hooks, re-throw
|
|
101
|
+
* 5. Run onAfterResponse immediately (before iteration), body is null
|
|
102
|
+
* 6. If non-2xx: throw ClientRequestError
|
|
103
|
+
* 7. Return createTypedStream(streamResponse.body, descriptor.streamMode)
|
|
104
|
+
*/
|
|
105
|
+
export async function executeStream<TYield, TReturn = void>(
|
|
106
|
+
descriptor: StreamDescriptor,
|
|
107
|
+
basePath: string,
|
|
108
|
+
adapter: ClientAdapter,
|
|
109
|
+
globalHooks: ClientHooks,
|
|
110
|
+
localHooks: ClientHooks | undefined
|
|
111
|
+
): Promise<TypedStream<TYield, TReturn>> {
|
|
112
|
+
// 1. Build the initial request
|
|
113
|
+
let request = buildAdapterRequest(descriptor, basePath)
|
|
114
|
+
|
|
115
|
+
// 2. Run before-request hooks
|
|
116
|
+
const beforeCtx = await runBeforeRequest(
|
|
117
|
+
{ procedureName: descriptor.name, scope: descriptor.scope, request },
|
|
118
|
+
globalHooks,
|
|
119
|
+
localHooks
|
|
120
|
+
)
|
|
121
|
+
request = beforeCtx.request
|
|
122
|
+
|
|
123
|
+
// 3. Call the adapter
|
|
124
|
+
let streamResponse
|
|
125
|
+
try {
|
|
126
|
+
streamResponse = await adapter.stream(request)
|
|
127
|
+
} catch (err) {
|
|
128
|
+
// 4. On adapter error: run error hooks, re-throw
|
|
129
|
+
await runOnError(
|
|
130
|
+
{ procedureName: descriptor.name, scope: descriptor.scope, request, error: err },
|
|
131
|
+
globalHooks,
|
|
132
|
+
localHooks
|
|
133
|
+
)
|
|
134
|
+
throw err
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Build an AdapterResponse shape for the hooks (body is null for streams at this point)
|
|
138
|
+
const responseForHooks: AdapterResponse = {
|
|
139
|
+
status: streamResponse.status,
|
|
140
|
+
headers: streamResponse.headers,
|
|
141
|
+
body: null,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 5. Run after-response hooks immediately (before iteration)
|
|
145
|
+
await runAfterResponse(
|
|
146
|
+
{ procedureName: descriptor.name, scope: descriptor.scope, request, response: responseForHooks },
|
|
147
|
+
globalHooks,
|
|
148
|
+
localHooks
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
// 6. Check status after hooks (hooks may mutate responseForHooks.status)
|
|
152
|
+
if (responseForHooks.status < 200 || responseForHooks.status >= 300) {
|
|
153
|
+
throw new ClientRequestError({
|
|
154
|
+
status: responseForHooks.status,
|
|
155
|
+
headers: responseForHooks.headers,
|
|
156
|
+
body: responseForHooks.body,
|
|
157
|
+
procedureName: descriptor.name,
|
|
158
|
+
scope: descriptor.scope,
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 7. Return the typed stream
|
|
163
|
+
return createTypedStream<TYield, TReturn>(streamResponse.body, descriptor.streamMode)
|
|
164
|
+
}
|