ts-procedures 5.15.0 → 5.16.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 (47) hide show
  1. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +1 -1
  2. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +57 -4
  3. package/agent_config/claude-code/skills/ts-procedures/patterns.md +102 -3
  4. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +33 -5
  5. package/agent_config/copilot/copilot-instructions.md +55 -7
  6. package/agent_config/cursor/cursorrules +55 -7
  7. package/build/client/call.d.ts +18 -9
  8. package/build/client/call.js +25 -19
  9. package/build/client/call.js.map +1 -1
  10. package/build/client/call.test.js +167 -17
  11. package/build/client/call.test.js.map +1 -1
  12. package/build/client/index.d.ts +1 -1
  13. package/build/client/index.js +18 -3
  14. package/build/client/index.js.map +1 -1
  15. package/build/client/index.test.js +104 -0
  16. package/build/client/index.test.js.map +1 -1
  17. package/build/client/resolve-options.d.ts +45 -0
  18. package/build/client/resolve-options.js +82 -0
  19. package/build/client/resolve-options.js.map +1 -0
  20. package/build/client/resolve-options.test.d.ts +1 -0
  21. package/build/client/resolve-options.test.js +158 -0
  22. package/build/client/resolve-options.test.js.map +1 -0
  23. package/build/client/stream.d.ts +18 -9
  24. package/build/client/stream.js +24 -19
  25. package/build/client/stream.js.map +1 -1
  26. package/build/client/stream.test.js +102 -46
  27. package/build/client/stream.test.js.map +1 -1
  28. package/build/client/types.d.ts +68 -1
  29. package/build/client/types.js +1 -1
  30. package/build/codegen/e2e.test.js +141 -0
  31. package/build/codegen/e2e.test.js.map +1 -1
  32. package/build/codegen/emit-client-runtime.js +3 -0
  33. package/build/codegen/emit-client-runtime.js.map +1 -1
  34. package/docs/client-and-codegen.md +123 -2
  35. package/package.json +1 -1
  36. package/src/client/call.test.ts +202 -29
  37. package/src/client/call.ts +41 -28
  38. package/src/client/index.test.ts +117 -0
  39. package/src/client/index.ts +25 -8
  40. package/src/client/resolve-options.test.ts +205 -0
  41. package/src/client/resolve-options.ts +113 -0
  42. package/src/client/stream.test.ts +132 -107
  43. package/src/client/stream.ts +40 -25
  44. package/src/client/types.ts +74 -2
  45. package/src/codegen/e2e.test.ts +151 -0
  46. package/src/codegen/emit-client-runtime.ts +3 -0
  47. package/src/implementations/http/README.md +9 -1
@@ -0,0 +1,205 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import {
3
+ applyRequestOptions,
4
+ resolveBasePath,
5
+ resolveHeaders,
6
+ resolveMeta,
7
+ resolveSignal,
8
+ } from './resolve-options.js'
9
+ import type { AdapterRequest, ProcedureCallDefaults, ProcedureCallOptions } from './types.js'
10
+
11
+ // ── resolveBasePath ───────────────────────────────────────
12
+
13
+ describe('resolveBasePath', () => {
14
+ it('uses fallback when nothing is set', () => {
15
+ expect(resolveBasePath(undefined, undefined, 'https://api.example.com')).toBe(
16
+ 'https://api.example.com',
17
+ )
18
+ })
19
+
20
+ it('uses default when only defaults.basePath is set', () => {
21
+ expect(
22
+ resolveBasePath({ basePath: 'https://default.example.com' }, undefined, 'https://fallback'),
23
+ ).toBe('https://default.example.com')
24
+ })
25
+
26
+ it('per-call basePath overrides default', () => {
27
+ expect(
28
+ resolveBasePath(
29
+ { basePath: 'https://default.example.com' },
30
+ { basePath: 'https://percall.example.com' },
31
+ 'https://fallback',
32
+ ),
33
+ ).toBe('https://percall.example.com')
34
+ })
35
+ })
36
+
37
+ // ── resolveHeaders ────────────────────────────────────────
38
+
39
+ describe('resolveHeaders', () => {
40
+ it('returns undefined when neither side sets headers', () => {
41
+ expect(resolveHeaders(undefined, undefined)).toBeUndefined()
42
+ })
43
+
44
+ it('returns default headers when only defaults set', () => {
45
+ expect(resolveHeaders({ headers: { 'x-a': '1' } }, undefined)).toEqual({ 'x-a': '1' })
46
+ })
47
+
48
+ it('per-call keys override default keys', () => {
49
+ const defaults: ProcedureCallDefaults = { headers: { 'x-a': 'default', 'x-b': 'keep' } }
50
+ const options: ProcedureCallOptions = { headers: { 'x-a': 'override' } }
51
+ expect(resolveHeaders(defaults, options)).toEqual({
52
+ 'x-a': 'override',
53
+ 'x-b': 'keep',
54
+ })
55
+ })
56
+ })
57
+
58
+ // ── resolveMeta ───────────────────────────────────────────
59
+
60
+ describe('resolveMeta', () => {
61
+ it('returns undefined when neither side sets meta', () => {
62
+ expect(resolveMeta(undefined, undefined)).toBeUndefined()
63
+ })
64
+
65
+ it('merges default + per-call meta (shallow), per-call keys win', () => {
66
+ const defaults: ProcedureCallDefaults = { meta: { a: 1, b: 2 } as never }
67
+ const options: ProcedureCallOptions = { meta: { b: 99, c: 3 } as never }
68
+ expect(resolveMeta(defaults, options)).toEqual({ a: 1, b: 99, c: 3 })
69
+ })
70
+ })
71
+
72
+ // ── resolveSignal ─────────────────────────────────────────
73
+
74
+ describe('resolveSignal', () => {
75
+ it('returns undefined when nothing is set', () => {
76
+ expect(resolveSignal(undefined, undefined)).toBeUndefined()
77
+ })
78
+
79
+ it('returns the default signal when no timeout and no per-call signal', () => {
80
+ const controller = new AbortController()
81
+ expect(resolveSignal({ signal: controller.signal }, undefined)).toBe(controller.signal)
82
+ })
83
+
84
+ it('returns the per-call signal when no default and no timeout', () => {
85
+ const controller = new AbortController()
86
+ expect(resolveSignal(undefined, { signal: controller.signal })).toBe(controller.signal)
87
+ })
88
+
89
+ it('combines default + per-call signals — default aborts combined signal', () => {
90
+ const defaultCtrl = new AbortController()
91
+ const callCtrl = new AbortController()
92
+ const signal = resolveSignal({ signal: defaultCtrl.signal }, { signal: callCtrl.signal })!
93
+
94
+ expect(signal.aborted).toBe(false)
95
+ defaultCtrl.abort(new Error('default-abort'))
96
+ expect(signal.aborted).toBe(true)
97
+ })
98
+
99
+ it('combines default + per-call signals — per-call aborts combined signal', () => {
100
+ const defaultCtrl = new AbortController()
101
+ const callCtrl = new AbortController()
102
+ const signal = resolveSignal({ signal: defaultCtrl.signal }, { signal: callCtrl.signal })!
103
+
104
+ callCtrl.abort(new Error('call-abort'))
105
+ expect(signal.aborted).toBe(true)
106
+ })
107
+
108
+ it('applies timeout via AbortSignal.timeout', () => {
109
+ const spy = vi.spyOn(AbortSignal, 'timeout')
110
+ try {
111
+ const signal = resolveSignal(undefined, { timeout: 100 })
112
+ expect(spy).toHaveBeenCalledWith(100)
113
+ expect(signal).toBeDefined()
114
+ } finally {
115
+ spy.mockRestore()
116
+ }
117
+ })
118
+
119
+ it('per-call timeout overrides default timeout', () => {
120
+ const spy = vi.spyOn(AbortSignal, 'timeout')
121
+ try {
122
+ resolveSignal({ timeout: 10_000 }, { timeout: 100 })
123
+ expect(spy).toHaveBeenCalledWith(100)
124
+ expect(spy).not.toHaveBeenCalledWith(10_000)
125
+ } finally {
126
+ spy.mockRestore()
127
+ }
128
+ })
129
+
130
+ it('combines signal + timeout — signal aborts first', () => {
131
+ const controller = new AbortController()
132
+ const signal = resolveSignal(undefined, { signal: controller.signal, timeout: 10_000 })!
133
+ expect(signal.aborted).toBe(false)
134
+ controller.abort()
135
+ expect(signal.aborted).toBe(true)
136
+ })
137
+
138
+ it('per-call timeout: 0 disables default timeout', () => {
139
+ const signal = resolveSignal({ timeout: 1000 }, { timeout: 0 })
140
+ expect(signal).toBeUndefined()
141
+ })
142
+ })
143
+
144
+ // ── applyRequestOptions ───────────────────────────────────
145
+
146
+ describe('applyRequestOptions', () => {
147
+ const baseRequest: AdapterRequest = {
148
+ url: 'https://api.example.com/foo',
149
+ method: 'POST',
150
+ body: { hello: 'world' },
151
+ }
152
+
153
+ it('returns the request unchanged when nothing is provided', () => {
154
+ const result = applyRequestOptions(baseRequest, undefined, undefined)
155
+ expect(result.url).toBe(baseRequest.url)
156
+ expect(result.body).toEqual({ hello: 'world' })
157
+ expect(result.headers).toBeUndefined()
158
+ expect(result.signal).toBeUndefined()
159
+ expect(result.meta).toBeUndefined()
160
+ })
161
+
162
+ it('merges default + per-call headers, preserving route-declared headers', () => {
163
+ const reqWithHeaders: AdapterRequest = {
164
+ ...baseRequest,
165
+ headers: { 'content-type': 'application/json', 'x-route': 'declared' },
166
+ }
167
+ const result = applyRequestOptions(
168
+ reqWithHeaders,
169
+ { headers: { 'x-default': 'd', 'x-route': 'from-default' } },
170
+ { headers: { 'x-call': 'c', 'x-route': 'from-call' } },
171
+ )
172
+ expect(result.headers).toEqual({
173
+ 'x-default': 'd',
174
+ 'x-call': 'c',
175
+ // Route-declared headers WIN over resolved options (typed contract)
176
+ 'content-type': 'application/json',
177
+ 'x-route': 'declared',
178
+ })
179
+ })
180
+
181
+ it('attaches meta to the request when provided', () => {
182
+ const result = applyRequestOptions(baseRequest, undefined, {
183
+ meta: { traceId: 'abc' } as never,
184
+ })
185
+ expect(result.meta).toEqual({ traceId: 'abc' })
186
+ })
187
+
188
+ it('passes per-call signal through', () => {
189
+ const controller = new AbortController()
190
+ const result = applyRequestOptions(baseRequest, undefined, { signal: controller.signal })
191
+ expect(result.signal).toBe(controller.signal)
192
+ })
193
+
194
+ it('attaches a signal when per-call timeout is set', () => {
195
+ const spy = vi.spyOn(AbortSignal, 'timeout')
196
+ try {
197
+ const result = applyRequestOptions(baseRequest, undefined, { timeout: 100 })
198
+ expect(spy).toHaveBeenCalledWith(100)
199
+ expect(result.signal).toBeDefined()
200
+ expect(result.signal?.aborted).toBe(false)
201
+ } finally {
202
+ spy.mockRestore()
203
+ }
204
+ })
205
+ })
@@ -0,0 +1,113 @@
1
+ import type {
2
+ AdapterRequest,
3
+ ProcedureCallDefaults,
4
+ ProcedureCallOptions,
5
+ RequestMeta,
6
+ } from './types.js'
7
+
8
+ /**
9
+ * Resolves the effective base path:
10
+ * per-call `basePath` > default `basePath` > config `basePath` (fallback).
11
+ */
12
+ export function resolveBasePath(
13
+ defaults: ProcedureCallDefaults | undefined,
14
+ options: ProcedureCallOptions | undefined,
15
+ fallback: string,
16
+ ): string {
17
+ return options?.basePath ?? defaults?.basePath ?? fallback
18
+ }
19
+
20
+ /**
21
+ * Resolves the effective AbortSignal by combining (via `AbortSignal.any`):
22
+ * - default signal (if any)
23
+ * - per-call signal (if any)
24
+ * - timeout signal (if resolved timeout > 0)
25
+ *
26
+ * Returns undefined when none apply. Per-call `timeout: 0` disables an
27
+ * inherited default timeout.
28
+ */
29
+ export function resolveSignal(
30
+ defaults: ProcedureCallDefaults | undefined,
31
+ options: ProcedureCallOptions | undefined,
32
+ ): AbortSignal | undefined {
33
+ const signals: AbortSignal[] = []
34
+
35
+ if (defaults?.signal) signals.push(defaults.signal)
36
+ if (options?.signal) signals.push(options.signal)
37
+
38
+ const timeout = options?.timeout ?? defaults?.timeout
39
+ if (timeout != null && timeout > 0) {
40
+ signals.push(AbortSignal.timeout(timeout))
41
+ }
42
+
43
+ if (signals.length === 0) return undefined
44
+ if (signals.length === 1) return signals[0]
45
+ return AbortSignal.any(signals)
46
+ }
47
+
48
+ /**
49
+ * Merges headers with precedence: default < per-call. Returns undefined if
50
+ * no headers would be set.
51
+ */
52
+ export function resolveHeaders(
53
+ defaults: ProcedureCallDefaults | undefined,
54
+ options: ProcedureCallOptions | undefined,
55
+ ): Record<string, string> | undefined {
56
+ const defaultHeaders = defaults?.headers
57
+ const callHeaders = options?.headers
58
+
59
+ if (!defaultHeaders && !callHeaders) return undefined
60
+
61
+ return { ...defaultHeaders, ...callHeaders }
62
+ }
63
+
64
+ /**
65
+ * Merges meta with precedence: default < per-call. Returns undefined if
66
+ * no meta fields would be set.
67
+ *
68
+ * The cast is load-bearing: when a developer augments `RequestMeta` with
69
+ * required fields, spread of two `RequestMeta | undefined` values widens to
70
+ * a partial shape, which TypeScript can't prove satisfies `RequestMeta`.
71
+ * At runtime, the merged object carries whichever keys the caller supplied —
72
+ * the contract is "if you declare required fields in RequestMeta, supply them
73
+ * somewhere (defaults or per-call)."
74
+ */
75
+ export function resolveMeta(
76
+ defaults: ProcedureCallDefaults | undefined,
77
+ options: ProcedureCallOptions | undefined,
78
+ ): RequestMeta | undefined {
79
+ const defaultMeta = defaults?.meta
80
+ const callMeta = options?.meta
81
+
82
+ if (!defaultMeta && !callMeta) return undefined
83
+
84
+ return { ...defaultMeta, ...callMeta } as RequestMeta
85
+ }
86
+
87
+ /**
88
+ * Applies resolved default + per-call options to an AdapterRequest.
89
+ *
90
+ * Runs before hooks, so `onBeforeRequest` observes the merged request and can
91
+ * still override any field.
92
+ *
93
+ * Headers produced by the request builder (e.g., `schema.input.headers` for
94
+ * API routes) are preserved; resolved headers merge underneath them so the
95
+ * route-declared headers win, matching the adapter.config → defaults → call
96
+ * → route-declared → hooks precedence chain documented in the types.
97
+ */
98
+ export function applyRequestOptions(
99
+ request: AdapterRequest,
100
+ defaults: ProcedureCallDefaults | undefined,
101
+ options: ProcedureCallOptions | undefined,
102
+ ): AdapterRequest {
103
+ const signal = resolveSignal(defaults, options)
104
+ const resolvedHeaders = resolveHeaders(defaults, options)
105
+ const meta = resolveMeta(defaults, options)
106
+
107
+ const headers =
108
+ resolvedHeaders || request.headers
109
+ ? { ...resolvedHeaders, ...request.headers }
110
+ : undefined
111
+
112
+ return { ...request, headers, signal, meta }
113
+ }