ts-procedures 7.1.2 → 7.3.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 (41) hide show
  1. package/README.md +8 -0
  2. package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +35 -0
  3. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +40 -0
  4. package/agent_config/claude-code/skills/ts-procedures/patterns.md +14 -0
  5. package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +1 -0
  6. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/astro-catchall.md +23 -0
  7. package/agent_config/copilot/copilot-instructions.md +6 -0
  8. package/agent_config/cursor/cursorrules +6 -0
  9. package/build/implementations/http/astro/astro-context.d.ts +19 -0
  10. package/build/implementations/http/astro/astro-context.js +28 -0
  11. package/build/implementations/http/astro/astro-context.js.map +1 -0
  12. package/build/implementations/http/astro/create-handler.d.ts +26 -0
  13. package/build/implementations/http/astro/create-handler.js +28 -0
  14. package/build/implementations/http/astro/create-handler.js.map +1 -0
  15. package/build/implementations/http/astro/index.d.ts +3 -0
  16. package/build/implementations/http/astro/index.js +6 -0
  17. package/build/implementations/http/astro/index.js.map +1 -0
  18. package/build/implementations/http/astro/index.test.d.ts +1 -0
  19. package/build/implementations/http/astro/index.test.js +295 -0
  20. package/build/implementations/http/astro/index.test.js.map +1 -0
  21. package/build/implementations/http/astro/rewrite-request.d.ts +13 -0
  22. package/build/implementations/http/astro/rewrite-request.js +32 -0
  23. package/build/implementations/http/astro/rewrite-request.js.map +1 -0
  24. package/build/index.d.ts +10 -0
  25. package/build/index.js +12 -13
  26. package/build/index.js.map +1 -1
  27. package/build/index.test.js +107 -0
  28. package/build/index.test.js.map +1 -1
  29. package/docs/astro-adapter.md +227 -0
  30. package/docs/core.md +19 -0
  31. package/docs/superpowers/plans/2026-05-07-astro-adapter.md +1396 -0
  32. package/docs/superpowers/specs/2026-05-07-astro-adapter-design.md +254 -0
  33. package/package.json +8 -2
  34. package/src/implementations/http/astro/README.md +89 -0
  35. package/src/implementations/http/astro/astro-context.ts +34 -0
  36. package/src/implementations/http/astro/create-handler.ts +59 -0
  37. package/src/implementations/http/astro/index.test.ts +350 -0
  38. package/src/implementations/http/astro/index.ts +6 -0
  39. package/src/implementations/http/astro/rewrite-request.ts +31 -0
  40. package/src/index.test.ts +171 -0
  41. package/src/index.ts +27 -15
@@ -0,0 +1,350 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { Hono } from 'hono'
3
+ import { Type } from 'typebox'
4
+ import { stripPrefix } from './rewrite-request.js'
5
+ import { setAstroContext, getAstroContext } from './astro-context.js'
6
+ import { createAstroHandler } from './create-handler.js'
7
+ import { Procedures } from '../../../index.js'
8
+ import { HonoAPIAppBuilder } from '../hono-api/index.js'
9
+ import { HonoStreamAppBuilder } from '../hono-stream/index.js'
10
+ import type { APIConfig, RPCConfig } from '../../types.js'
11
+
12
+ // Minimal stand-in for Astro's APIContext for unit tests.
13
+ function fakeApiContext(overrides: Record<string, unknown> = {}) {
14
+ return {
15
+ locals: { user: { id: 'u-1' } },
16
+ cookies: {},
17
+ params: {},
18
+ request: new Request('https://example.test/'),
19
+ url: new URL('https://example.test/'),
20
+ redirect: () => new Response(null, { status: 302 }),
21
+ ...overrides,
22
+ } as unknown as import('astro').APIContext
23
+ }
24
+
25
+ function buildSimpleUserApi() {
26
+ const API = Procedures<{ db: Map<string, { id: string; name: string }> }, APIConfig>()
27
+ API.Create(
28
+ 'GetUser',
29
+ {
30
+ path: '/users/:id',
31
+ method: 'get',
32
+ schema: {
33
+ input: { pathParams: Type.Object({ id: Type.String() }) },
34
+ returnType: Type.Object({ id: Type.String(), name: Type.String() }),
35
+ },
36
+ },
37
+ async (ctx, { pathParams }) => {
38
+ const u = ctx.db.get(pathParams.id)
39
+ if (!u) throw new Error('not found')
40
+ return u
41
+ }
42
+ )
43
+ const db = new Map([['1', { id: '1', name: 'Ada' }]])
44
+ return new HonoAPIAppBuilder().register(API, () => ({ db })).build()
45
+ }
46
+
47
+ describe('stripPrefix', () => {
48
+ test('returns original request when prefix is undefined', () => {
49
+ const req = new Request('https://example.test/api/users/1')
50
+ expect(stripPrefix(req, undefined)).toBe(req)
51
+ })
52
+
53
+ test('returns original request when normalized prefix is "/"', () => {
54
+ const req = new Request('https://example.test/users/1')
55
+ expect(stripPrefix(req, '/')).toBe(req)
56
+ expect(stripPrefix(req, '')).toBe(req)
57
+ })
58
+
59
+ test('strips a leading-slash prefix', () => {
60
+ const req = new Request('https://example.test/api/users/1')
61
+ const out = stripPrefix(req, '/api')!
62
+ expect(new URL(out.url).pathname).toBe('/users/1')
63
+ })
64
+
65
+ test('strips a no-slash prefix (normalized)', () => {
66
+ const req = new Request('https://example.test/api/users/1')
67
+ const out = stripPrefix(req, 'api')!
68
+ expect(new URL(out.url).pathname).toBe('/users/1')
69
+ })
70
+
71
+ test('strips a trailing-slash prefix (normalized)', () => {
72
+ const req = new Request('https://example.test/api/users/1')
73
+ const out = stripPrefix(req, '/api/')!
74
+ expect(new URL(out.url).pathname).toBe('/users/1')
75
+ })
76
+
77
+ test('exact-match prefix becomes "/"', () => {
78
+ const req = new Request('https://example.test/api')
79
+ const out = stripPrefix(req, '/api')!
80
+ expect(new URL(out.url).pathname).toBe('/')
81
+ })
82
+
83
+ test('returns null when path does not start with prefix (404 short-circuit)', () => {
84
+ const req = new Request('https://example.test/other/users/1')
85
+ expect(stripPrefix(req, '/api')).toBeNull()
86
+ })
87
+
88
+ test('returns null when path shares prefix characters but not a segment boundary', () => {
89
+ // /apikey starts with /api but is not under /api — must NOT rewrite.
90
+ expect(stripPrefix(new Request('https://example.test/apikey/secret'), '/api')).toBeNull()
91
+ expect(stripPrefix(new Request('https://example.test/api-v2/x'), '/api')).toBeNull()
92
+ expect(stripPrefix(new Request('https://example.test/api_internal'), '/api')).toBeNull()
93
+ })
94
+
95
+ test('preserves the query string through the rewrite', () => {
96
+ const req = new Request('https://example.test/api/users?limit=10&tag=a&tag=b')
97
+ const out = stripPrefix(req, '/api')!
98
+ const url = new URL(out.url)
99
+ expect(url.pathname).toBe('/users')
100
+ expect(url.search).toBe('?limit=10&tag=a&tag=b')
101
+ })
102
+
103
+ test('preserves the request method', () => {
104
+ const req = new Request('https://example.test/api/users', { method: 'POST' })
105
+ const out = stripPrefix(req, '/api')!
106
+ expect(out.method).toBe('POST')
107
+ })
108
+
109
+ test('preserves headers', () => {
110
+ const req = new Request('https://example.test/api/users', {
111
+ method: 'POST',
112
+ headers: { 'X-Trace-Id': 'abc-123', 'Content-Type': 'application/json' },
113
+ })
114
+ const out = stripPrefix(req, '/api')!
115
+ expect(out.headers.get('X-Trace-Id')).toBe('abc-123')
116
+ expect(out.headers.get('Content-Type')).toBe('application/json')
117
+ })
118
+ })
119
+
120
+ describe('astro-context', () => {
121
+ test('getAstroContext returns the APIContext stashed for the request seen by Hono', async () => {
122
+ const req = new Request('https://example.test/probe')
123
+ const apiContext = fakeApiContext()
124
+ setAstroContext(req, apiContext)
125
+
126
+ const app = new Hono()
127
+ let observed: import('astro').APIContext | null = null
128
+ app.get('/probe', (c) => {
129
+ observed = getAstroContext(c)
130
+ return c.json({ ok: true })
131
+ })
132
+
133
+ const res = await app.fetch(req)
134
+ expect(res.status).toBe(200)
135
+ expect(observed).toBe(apiContext)
136
+ })
137
+
138
+ test('getAstroContext throws when called outside an Astro request scope', async () => {
139
+ const app = new Hono()
140
+ let captured: unknown = null
141
+ app.get('/probe', (c) => {
142
+ try {
143
+ getAstroContext(c)
144
+ } catch (err) {
145
+ captured = err
146
+ }
147
+ return c.json({ ok: true })
148
+ })
149
+
150
+ await app.fetch(new Request('https://example.test/probe'))
151
+ expect(captured).toBeInstanceOf(Error)
152
+ expect((captured as Error).message).toMatch(/outside an Astro request scope/i)
153
+ })
154
+ })
155
+
156
+ describe('createAstroHandler — single app, no prefix', () => {
157
+ test('exports ALL plus the seven HTTP method handlers', () => {
158
+ const handlers = createAstroHandler({ apps: buildSimpleUserApi() })
159
+ expect(typeof handlers.ALL).toBe('function')
160
+ for (const m of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] as const) {
161
+ expect(typeof handlers[m]).toBe('function')
162
+ }
163
+ })
164
+
165
+ test('GET roundtrips through the underlying Hono app', async () => {
166
+ const { ALL } = createAstroHandler({ apps: buildSimpleUserApi() })
167
+ const apiContext = fakeApiContext({
168
+ request: new Request('https://example.test/users/1'),
169
+ url: new URL('https://example.test/users/1'),
170
+ })
171
+ const res = await ALL(apiContext)
172
+ expect(res.status).toBe(200)
173
+ expect(await res.json()).toEqual({ id: '1', name: 'Ada' })
174
+ })
175
+ })
176
+
177
+ describe('createAstroHandler — pathPrefix + getAstroContext', () => {
178
+ test('strips pathPrefix before delegating', async () => {
179
+ // Underlying Hono app has no /api in its routes.
180
+ const app = buildSimpleUserApi()
181
+ const { ALL } = createAstroHandler({ apps: app, pathPrefix: '/api' })
182
+ const apiContext = fakeApiContext({
183
+ request: new Request('https://example.test/api/users/1'),
184
+ url: new URL('https://example.test/api/users/1'),
185
+ })
186
+ const res = await ALL(apiContext)
187
+ expect(res.status).toBe(200)
188
+ expect(await res.json()).toEqual({ id: '1', name: 'Ada' })
189
+ })
190
+
191
+ test('returns 404 directly when request path is outside the prefix', async () => {
192
+ const { ALL } = createAstroHandler({ apps: buildSimpleUserApi(), pathPrefix: '/api' })
193
+ const apiContext = fakeApiContext({
194
+ request: new Request('https://example.test/somewhere-else'),
195
+ url: new URL('https://example.test/somewhere-else'),
196
+ })
197
+ const res = await ALL(apiContext)
198
+ expect(res.status).toBe(404)
199
+ })
200
+
201
+ test('factory closure can read APIContext via getAstroContext', async () => {
202
+ const API = Procedures<{ user: { id: string } | null }, APIConfig>()
203
+ API.Create(
204
+ 'WhoAmI',
205
+ { path: '/whoami', method: 'get', schema: { returnType: Type.Object({ userId: Type.Union([Type.String(), Type.Null()]) }) } },
206
+ async (ctx) => ({ userId: ctx.user?.id ?? null })
207
+ )
208
+
209
+ const app = new HonoAPIAppBuilder()
210
+ .register(API, (c) => {
211
+ const astro = getAstroContext(c)
212
+ return { user: (astro.locals as { user?: { id: string } }).user ?? null }
213
+ })
214
+ .build()
215
+
216
+ const { ALL } = createAstroHandler({ apps: app })
217
+ const apiContext = fakeApiContext({
218
+ locals: { user: { id: 'u-42' } },
219
+ request: new Request('https://example.test/whoami'),
220
+ url: new URL('https://example.test/whoami'),
221
+ })
222
+ const res = await ALL(apiContext)
223
+ expect(res.status).toBe(200)
224
+ expect(await res.json()).toEqual({ userId: 'u-42' })
225
+ })
226
+ })
227
+
228
+ describe('createAstroHandler — multi-app dispatch', () => {
229
+ function makeAppWithRoute(path: string, status: number, body: unknown) {
230
+ const app = new Hono()
231
+ app.all(path, (c) => c.json(body, status as any))
232
+ return app
233
+ }
234
+
235
+ test('first 404 falls through to the second app', async () => {
236
+ const a = makeAppWithRoute('/only-a', 200, { from: 'a' })
237
+ const b = makeAppWithRoute('/only-b', 200, { from: 'b' })
238
+ const { ALL } = createAstroHandler({ apps: [a, b] })
239
+
240
+ const apiContext = fakeApiContext({
241
+ request: new Request('https://example.test/only-b'),
242
+ url: new URL('https://example.test/only-b'),
243
+ })
244
+ const res = await ALL(apiContext)
245
+ expect(res.status).toBe(200)
246
+ expect(await res.json()).toEqual({ from: 'b' })
247
+ })
248
+
249
+ test('a non-404 response from the first app short-circuits dispatch', async () => {
250
+ const a = makeAppWithRoute('/error', 500, { boom: true })
251
+ const b = makeAppWithRoute('/error', 200, { from: 'b' })
252
+ const { ALL } = createAstroHandler({ apps: [a, b] })
253
+
254
+ const apiContext = fakeApiContext({
255
+ request: new Request('https://example.test/error'),
256
+ url: new URL('https://example.test/error'),
257
+ })
258
+ const res = await ALL(apiContext)
259
+ expect(res.status).toBe(500)
260
+ expect(await res.json()).toEqual({ boom: true })
261
+ })
262
+
263
+ test('all-404 returns the adapter\'s own 404 with empty body', async () => {
264
+ const a = makeAppWithRoute('/x', 200, { from: 'a' })
265
+ const b = makeAppWithRoute('/y', 200, { from: 'b' })
266
+ const { ALL } = createAstroHandler({ apps: [a, b] })
267
+
268
+ const apiContext = fakeApiContext({
269
+ request: new Request('https://example.test/nope'),
270
+ url: new URL('https://example.test/nope'),
271
+ })
272
+ const res = await ALL(apiContext)
273
+ expect(res.status).toBe(404)
274
+ expect(await res.text()).toBe('')
275
+ })
276
+ })
277
+
278
+ describe('createAstroHandler — streams', () => {
279
+ test('SSE events from a HonoStream builder pass through the adapter', async () => {
280
+ const STREAM = Procedures<{}, RPCConfig>()
281
+ STREAM.CreateStream(
282
+ 'Counter',
283
+ {
284
+ scope: 'counter',
285
+ version: 1,
286
+ schema: {
287
+ yieldType: Type.Object({ n: Type.Number() }),
288
+ },
289
+ },
290
+ async function* () {
291
+ yield { n: 1 }
292
+ yield { n: 2 }
293
+ yield { n: 3 }
294
+ }
295
+ )
296
+
297
+ const streamApp = new HonoStreamAppBuilder().register(STREAM, () => ({})).build()
298
+ const { ALL } = createAstroHandler({ apps: streamApp })
299
+
300
+ const apiContext = fakeApiContext({
301
+ request: new Request('https://example.test/counter/counter/1', { method: 'GET' }),
302
+ url: new URL('https://example.test/counter/counter/1'),
303
+ })
304
+ const res = await ALL(apiContext)
305
+ expect(res.status).toBe(200)
306
+ expect(res.headers.get('content-type')).toContain('text/event-stream')
307
+
308
+ // Drain the SSE body and assert all three events appear in order.
309
+ const text = await res.text()
310
+ expect(text).toContain('event: Counter')
311
+ expect(text).toContain('data: {"n":1}')
312
+ expect(text).toContain('data: {"n":2}')
313
+ expect(text).toContain('data: {"n":3}')
314
+ })
315
+ })
316
+
317
+ describe('createAstroHandler — abort signal', () => {
318
+ test("aborting the incoming request aborts the procedure handler's ctx.signal", async () => {
319
+ let observedSignal: AbortSignal | undefined
320
+ const aborted = new Promise<void>((resolve) => {
321
+ const API = Procedures<{}, APIConfig>()
322
+ API.Create(
323
+ 'Hang',
324
+ { path: '/hang', method: 'get', schema: { returnType: Type.Object({ ok: Type.Boolean() }) } },
325
+ async (ctx) => {
326
+ observedSignal = ctx.signal
327
+ ctx.signal?.addEventListener('abort', () => resolve(), { once: true })
328
+ // Wait for abort or a generous timeout.
329
+ await new Promise<void>((r) => setTimeout(r, 200))
330
+ return { ok: true }
331
+ }
332
+ )
333
+ const app = new HonoAPIAppBuilder().register(API, () => ({})).build()
334
+ const { ALL } = createAstroHandler({ apps: app })
335
+
336
+ const controller = new AbortController()
337
+ const apiContext = fakeApiContext({
338
+ request: new Request('https://example.test/hang', { signal: controller.signal }),
339
+ url: new URL('https://example.test/hang'),
340
+ })
341
+
342
+ // Fire ALL but don't await — abort while the handler is still running.
343
+ ;(ALL(apiContext) as Promise<Response>).catch(() => {})
344
+ setTimeout(() => controller.abort(), 20)
345
+ })
346
+
347
+ await aborted
348
+ expect(observedSignal?.aborted).toBe(true)
349
+ })
350
+ })
@@ -0,0 +1,6 @@
1
+ // Public surface for ts-procedures/astro.
2
+ // Note: setAstroContext is intentionally NOT re-exported — it's an
3
+ // internal helper used only by createAstroHandler.
4
+ export { createAstroHandler } from './create-handler.js'
5
+ export type { AstroAdapterConfig, AstroHandlers } from './create-handler.js'
6
+ export { getAstroContext } from './astro-context.js'
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Returns a Request with `prefix` stripped from the URL pathname.
3
+ *
4
+ * - `prefix` undefined or normalizing to '/' → returns the original request unchanged.
5
+ * - Path does not start with the normalized prefix at a segment boundary → returns `null`
6
+ * (caller short-circuits to 404). E.g. with prefix '/api', the request '/apikey'
7
+ * returns null because 'key' is not preceded by a path separator.
8
+ * - Otherwise → returns a new Request with the rewritten URL and method/headers/body/signal/duplex
9
+ * preserved via `new Request(url, init)`.
10
+ *
11
+ * Normalization: leading and trailing slashes are optional. '/api', 'api', and '/api/' are equivalent.
12
+ */
13
+ export function stripPrefix(request: Request, prefix: string | undefined): Request | null {
14
+ if (prefix === undefined) return request
15
+ const normalized = '/' + prefix.replace(/^\/|\/$/g, '')
16
+ if (normalized === '/') return request
17
+
18
+ const url = new URL(request.url)
19
+ if (!url.pathname.startsWith(normalized)) return null
20
+
21
+ // Segment-boundary guard: the character right after the prefix in the path
22
+ // MUST be either undefined (exact match) or '/' (real segment boundary).
23
+ // Without this, '/apikey' would falsely match prefix '/api'.
24
+ const nextChar = url.pathname[normalized.length]
25
+ if (nextChar !== undefined && nextChar !== '/') return null
26
+
27
+ const rest = url.pathname.slice(normalized.length)
28
+ url.pathname = rest === '' ? '/' : rest
29
+
30
+ return new Request(url, request)
31
+ }
package/src/index.test.ts CHANGED
@@ -1220,3 +1220,174 @@ describe('isPrevalidated context property', () => {
1220
1220
  })
1221
1221
  })
1222
1222
  })
1223
+
1224
+ describe('builder.config.noRuntimeValidation', () => {
1225
+ test('Create skips params validation when noRuntimeValidation is true', async () => {
1226
+ const { Create } = Procedures({ config: { noRuntimeValidation: true } })
1227
+
1228
+ const { SkipParams } = Create(
1229
+ 'SkipParams',
1230
+ {
1231
+ schema: {
1232
+ params: Type.Object({ name: Type.String() }),
1233
+ },
1234
+ },
1235
+ async (_ctx, params) => params
1236
+ )
1237
+
1238
+ // Missing required `name` would normally throw ProcedureValidationError
1239
+ const result = await SkipParams({}, {} as any)
1240
+ expect(result).toEqual({})
1241
+ })
1242
+
1243
+ test('Create skips input channel validation when noRuntimeValidation is true', async () => {
1244
+ const { Create } = Procedures({ config: { noRuntimeValidation: true } })
1245
+
1246
+ const { SkipInput } = Create(
1247
+ 'SkipInput',
1248
+ {
1249
+ schema: {
1250
+ input: {
1251
+ pathParams: Type.Object({ id: Type.String() }),
1252
+ body: Type.Object({ name: Type.String() }),
1253
+ },
1254
+ },
1255
+ },
1256
+ async (_ctx, params) => params
1257
+ )
1258
+
1259
+ // Both channels missing required fields — normally throws on the first one
1260
+ const result = await SkipInput(
1261
+ {},
1262
+ { pathParams: {}, body: {} } as any
1263
+ )
1264
+ expect(result).toEqual({ pathParams: {}, body: {} })
1265
+ })
1266
+
1267
+ test('Create still validates when noRuntimeValidation is omitted', async () => {
1268
+ const { Create } = Procedures({ config: {} })
1269
+
1270
+ const { ValidateDefault } = Create(
1271
+ 'ValidateDefault',
1272
+ {
1273
+ schema: {
1274
+ params: Type.Object({ name: Type.String() }),
1275
+ },
1276
+ },
1277
+ async (_ctx, params) => params
1278
+ )
1279
+
1280
+ await expect(ValidateDefault({}, {} as any)).rejects.toBeInstanceOf(ProcedureValidationError)
1281
+ })
1282
+
1283
+ test('Create validates when builder is omitted entirely', async () => {
1284
+ const { Create } = Procedures()
1285
+
1286
+ const { ValidateNoBuilder } = Create(
1287
+ 'ValidateNoBuilder',
1288
+ {
1289
+ schema: {
1290
+ params: Type.Object({ name: Type.String() }),
1291
+ },
1292
+ },
1293
+ async (_ctx, params) => params
1294
+ )
1295
+
1296
+ await expect(ValidateNoBuilder({}, {} as any)).rejects.toBeInstanceOf(
1297
+ ProcedureValidationError
1298
+ )
1299
+ })
1300
+
1301
+ test('CreateStream skips params validation when noRuntimeValidation is true', async () => {
1302
+ const { CreateStream } = Procedures({ config: { noRuntimeValidation: true } })
1303
+
1304
+ const { StreamSkipParams } = CreateStream(
1305
+ 'StreamSkipParams',
1306
+ {
1307
+ schema: {
1308
+ params: Type.Object({ count: Type.Number() }),
1309
+ },
1310
+ },
1311
+ async function* (_ctx, params) {
1312
+ yield { received: params }
1313
+ }
1314
+ )
1315
+
1316
+ const values: any[] = []
1317
+ for await (const val of StreamSkipParams({}, {} as any)) {
1318
+ values.push(val)
1319
+ }
1320
+
1321
+ expect(values).toEqual([{ received: {} }])
1322
+ })
1323
+
1324
+ test('CreateStream skips input channel validation when noRuntimeValidation is true', async () => {
1325
+ const { CreateStream } = Procedures({ config: { noRuntimeValidation: true } })
1326
+
1327
+ const { StreamSkipInput } = CreateStream(
1328
+ 'StreamSkipInput',
1329
+ {
1330
+ schema: {
1331
+ input: {
1332
+ pathParams: Type.Object({ id: Type.String() }),
1333
+ },
1334
+ },
1335
+ },
1336
+ async function* (_ctx, params) {
1337
+ yield params
1338
+ }
1339
+ )
1340
+
1341
+ const values: any[] = []
1342
+ for await (const val of StreamSkipInput({}, { pathParams: {} } as any)) {
1343
+ values.push(val)
1344
+ }
1345
+
1346
+ expect(values).toEqual([{ pathParams: {} }])
1347
+ })
1348
+
1349
+ test('CreateStream still validates when noRuntimeValidation is omitted', async () => {
1350
+ const { CreateStream } = Procedures({ config: {} })
1351
+
1352
+ const { StreamValidateDefault } = CreateStream(
1353
+ 'StreamValidateDefault',
1354
+ {
1355
+ schema: {
1356
+ params: Type.Object({ count: Type.Number() }),
1357
+ },
1358
+ },
1359
+ async function* (_ctx, params) {
1360
+ yield params
1361
+ }
1362
+ )
1363
+
1364
+ await expect(async () => {
1365
+ for await (const _v of StreamValidateDefault({}, {} as any)) {
1366
+ // consume
1367
+ }
1368
+ }).rejects.toBeInstanceOf(ProcedureValidationError)
1369
+ })
1370
+
1371
+ test('noRuntimeValidation does not interfere with onCreate callback', async () => {
1372
+ const seen: string[] = []
1373
+ const { Create } = Procedures({
1374
+ config: { noRuntimeValidation: true },
1375
+ onCreate: (proc) => {
1376
+ seen.push(proc.name)
1377
+ },
1378
+ })
1379
+
1380
+ Create(
1381
+ 'Registered',
1382
+ { schema: { params: Type.Object({ id: Type.String() }) } },
1383
+ async (_ctx, params) => params
1384
+ )
1385
+
1386
+ expect(seen).toEqual(['Registered'])
1387
+ })
1388
+
1389
+ test('noRuntimeValidation: false is rejected by the type system', () => {
1390
+ // @ts-expect-error - only `true` (or omitted) is allowed for noRuntimeValidation
1391
+ void Procedures({ config: { noRuntimeValidation: false } })
1392
+ })
1393
+ })
package/src/index.ts CHANGED
@@ -58,11 +58,22 @@ export type TStreamProcedureRegistration<TContext = unknown, TExtendedConfig = u
58
58
  handler: (ctx: TContext, params?: any) => AsyncGenerator<any, any, unknown>
59
59
  }
60
60
 
61
+ export type TBuilderConfig = {
62
+ /**
63
+ * The default is to validate schema inputs at runtime, to disable pass `true` for this property.
64
+ *
65
+ * @Note: this is not recommended for public unknown data, only use for internal procedure
66
+ * factories with type-checking build steps.
67
+ */
68
+ noRuntimeValidation?: true
69
+ }
70
+
61
71
  export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unknown>(
62
72
  /**
63
73
  * Optionally provided builder to register Procedures
64
74
  */
65
75
  builder?: {
76
+ config?: TBuilderConfig
66
77
  onCreate?: (
67
78
  procedure: Prettify<{
68
79
  name: string
@@ -148,9 +159,12 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
148
159
 
149
160
  handler: async (ctx: Prettify<TContext>, params: TSchemaLib<TParams>) => {
150
161
  try {
151
- // Skip validation if caller has already validated (e.g., HonoStreamAppBuilder)
152
- const isPrevalidated = (ctx as { isPrevalidated?: boolean }).isPrevalidated
153
- if (validations?.params && !isPrevalidated) {
162
+ // Skip validation if caller has already validated (e.g., HonoStreamAppBuilder) or if builder config dictates noRuntimeValidation
163
+ const skipValidation =
164
+ (ctx as { isPrevalidated?: boolean }).isPrevalidated ||
165
+ builder?.config?.noRuntimeValidation
166
+
167
+ if (validations?.params && !skipValidation) {
154
168
  const { errors } = validations.params(params)
155
169
 
156
170
  if (errors) {
@@ -166,7 +180,7 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
166
180
  // Validate each input channel independently for better error messages.
167
181
  // Double validation (per-channel + merged) is intentional for developer experience;
168
182
  // the cost is negligible — revisit if validation becomes a performance bottleneck.
169
- if (validations?.input && !isPrevalidated) {
183
+ if (validations?.input && !skipValidation) {
170
184
  for (const [channel, validator] of Object.entries(validations.input)) {
171
185
  const channelValue = (params as Record<string, unknown>)?.[channel]
172
186
  const { errors } = validator(channelValue)
@@ -330,9 +344,12 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
330
344
  // Create abort controller for this stream
331
345
  const abortController = new AbortController()
332
346
 
333
- // Skip validation if caller has already validated (e.g., HonoStreamAppBuilder)
334
- const isPrevalidated = (ctx as { isPrevalidated?: boolean }).isPrevalidated
335
- if (validations?.params && !isPrevalidated) {
347
+ // Skip validation if caller has already validated (e.g., HonoStreamAppBuilder) or if builder config dictates noRuntimeValidation
348
+ const skipValidation =
349
+ (ctx as { isPrevalidated?: boolean }).isPrevalidated ||
350
+ builder?.config?.noRuntimeValidation
351
+
352
+ if (validations?.params && !skipValidation) {
336
353
  const { errors } = validations.params(params)
337
354
 
338
355
  if (errors) {
@@ -346,7 +363,7 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
346
363
  }
347
364
 
348
365
  // Validate each input channel independently (see Create for rationale)
349
- if (validations?.input && !isPrevalidated) {
366
+ if (validations?.input && !skipValidation) {
350
367
  for (const [channel, validator] of Object.entries(validations.input)) {
351
368
  const channelValue = (params as Record<string, unknown>)?.[channel]
352
369
  const { errors } = validator(channelValue)
@@ -416,14 +433,9 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
416
433
  // user-defined errors inside ProcedureError defeats route-declared
417
434
  // typed-error dispatch on the client. Augment the stack trace in
418
435
  // place with the procedure's definition site when available.
419
- if (
420
- definitionInfo.definedAt &&
421
- error &&
422
- typeof error.stack === 'string'
423
- ) {
436
+ if (definitionInfo.definedAt && error && typeof error.stack === 'string') {
424
437
  const { file, line, column } = definitionInfo.definedAt
425
- error.stack =
426
- `${error.stack}\n--- Procedure "${name}" defined at ---\n at ${file}:${line}:${column}`
438
+ error.stack = `${error.stack}\n--- Procedure "${name}" defined at ---\n at ${file}:${line}:${column}`
427
439
  }
428
440
  throw error
429
441
  } finally {