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.
Files changed (36) hide show
  1. package/README.md +185 -0
  2. package/agent_config/claude-code/skills/guide/api-reference.md +200 -0
  3. package/agent_config/claude-code/skills/guide/patterns.md +108 -0
  4. package/agent_config/copilot/copilot-instructions.md +87 -0
  5. package/agent_config/cursor/cursorrules +87 -0
  6. package/build/implementations/http/doc-registry.test.js +27 -1
  7. package/build/implementations/http/doc-registry.test.js.map +1 -1
  8. package/build/implementations/http/express-rpc/index.js +1 -0
  9. package/build/implementations/http/express-rpc/index.js.map +1 -1
  10. package/build/implementations/http/express-rpc/index.test.js +1 -1
  11. package/build/implementations/http/express-rpc/index.test.js.map +1 -1
  12. package/build/implementations/http/hono-api/index.js +2 -0
  13. package/build/implementations/http/hono-api/index.js.map +1 -1
  14. package/build/implementations/http/hono-api/index.test.js +9 -0
  15. package/build/implementations/http/hono-api/index.test.js.map +1 -1
  16. package/build/implementations/http/hono-rpc/index.js +1 -0
  17. package/build/implementations/http/hono-rpc/index.js.map +1 -1
  18. package/build/implementations/http/hono-rpc/index.test.js +1 -1
  19. package/build/implementations/http/hono-rpc/index.test.js.map +1 -1
  20. package/build/implementations/http/hono-stream/index.js +17 -1
  21. package/build/implementations/http/hono-stream/index.js.map +1 -1
  22. package/build/implementations/http/hono-stream/index.test.js +61 -0
  23. package/build/implementations/http/hono-stream/index.test.js.map +1 -1
  24. package/build/implementations/http/hono-stream/types.d.ts +4 -13
  25. package/build/implementations/types.d.ts +5 -0
  26. package/build/index.js +8 -1
  27. package/build/index.js.map +1 -1
  28. package/package.json +21 -3
  29. package/src/client/call.ts +74 -0
  30. package/src/client/errors.ts +43 -0
  31. package/src/client/fetch-adapter.ts +191 -0
  32. package/src/client/hooks.ts +65 -0
  33. package/src/client/index.ts +121 -0
  34. package/src/client/request-builder.ts +73 -0
  35. package/src/client/stream.ts +164 -0
  36. 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
+ }