ts-procedures 10.1.2 → 10.2.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 (257) hide show
  1. package/CHANGELOG.md +26 -91
  2. package/README.md +2 -0
  3. package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +8 -0
  4. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +25 -22
  5. package/agent_config/claude-code/skills/ts-procedures/patterns.md +24 -13
  6. package/agent_config/copilot/copilot-instructions.md +1 -1
  7. package/agent_config/cursor/cursorrules +1 -1
  8. package/build/adapters/hono/envelope-parity.test.js +6 -17
  9. package/build/adapters/hono/envelope-parity.test.js.map +1 -1
  10. package/build/adapters/hono/handlers/http-stream.d.ts +2 -1
  11. package/build/adapters/hono/handlers/http-stream.js +9 -2
  12. package/build/adapters/hono/handlers/http-stream.js.map +1 -1
  13. package/build/adapters/hono/handlers/http.d.ts +2 -1
  14. package/build/adapters/hono/handlers/http.js +9 -2
  15. package/build/adapters/hono/handlers/http.js.map +1 -1
  16. package/build/adapters/hono/handlers/rpc.d.ts +2 -1
  17. package/build/adapters/hono/handlers/rpc.js +9 -2
  18. package/build/adapters/hono/handlers/rpc.js.map +1 -1
  19. package/build/adapters/hono/handlers/stream.d.ts +2 -1
  20. package/build/adapters/hono/handlers/stream.js +9 -2
  21. package/build/adapters/hono/handlers/stream.js.map +1 -1
  22. package/build/adapters/hono/index.d.ts +14 -7
  23. package/build/adapters/hono/index.js +45 -5
  24. package/build/adapters/hono/index.js.map +1 -1
  25. package/build/adapters/hono/index.test.js +55 -0
  26. package/build/adapters/hono/index.test.js.map +1 -1
  27. package/build/adapters/hono/request.d.ts +7 -0
  28. package/build/adapters/hono/request.js +15 -0
  29. package/build/adapters/hono/request.js.map +1 -1
  30. package/build/adapters/hono/types.d.ts +31 -0
  31. package/build/adapters/hono/types.js.map +1 -1
  32. package/build/codegen/bin/cli.js +1 -1
  33. package/build/codegen/bin/cli.js.map +1 -1
  34. package/build/codegen/constants.js +2 -2
  35. package/build/codegen/constants.js.map +1 -1
  36. package/build/codegen/emit/api-route.js +1 -1
  37. package/build/codegen/emit/api-route.js.map +1 -1
  38. package/build/codegen/emit/scope-file.js +1 -1
  39. package/build/codegen/emit/scope-file.js.map +1 -1
  40. package/build/codegen/emit-errors.integration.test.js +2 -2
  41. package/build/codegen/emit-errors.integration.test.js.map +1 -1
  42. package/build/codegen/resolve-envelope.js +1 -1
  43. package/build/codegen/resolve-envelope.js.map +1 -1
  44. package/build/codegen/test-helpers/run-tsc.js +1 -1
  45. package/build/codegen/test-helpers/run-tsc.js.map +1 -1
  46. package/build/core/create-http-stream.d.ts +3 -14
  47. package/build/core/create-http-stream.js +4 -2
  48. package/build/core/create-http-stream.js.map +1 -1
  49. package/build/core/create-http.d.ts +2 -9
  50. package/build/core/create-http.js +3 -2
  51. package/build/core/create-http.js.map +1 -1
  52. package/build/core/create-http.test-d.js +104 -0
  53. package/build/core/create-http.test-d.js.map +1 -0
  54. package/build/core/create-stream.js +6 -2
  55. package/build/core/create-stream.js.map +1 -1
  56. package/build/core/create.js +5 -4
  57. package/build/core/create.js.map +1 -1
  58. package/build/core/create.test.js +63 -0
  59. package/build/core/create.test.js.map +1 -1
  60. package/build/core/internal.d.ts +16 -0
  61. package/build/core/internal.js +22 -0
  62. package/build/core/internal.js.map +1 -1
  63. package/build/core/procedures.d.ts +9 -16
  64. package/build/core/procedures.js +4 -0
  65. package/build/core/procedures.js.map +1 -1
  66. package/build/core/types.d.ts +85 -4
  67. package/build/errors.d.ts +33 -0
  68. package/build/errors.js +91 -0
  69. package/build/errors.js.map +1 -0
  70. package/build/errors.test.d.ts +1 -0
  71. package/build/errors.test.js +122 -0
  72. package/build/errors.test.js.map +1 -0
  73. package/build/exports.d.ts +1 -2
  74. package/build/exports.js +0 -4
  75. package/build/exports.js.map +1 -1
  76. package/build/implementations/http/doc-registry.d.ts +28 -0
  77. package/build/implementations/http/doc-registry.js +96 -0
  78. package/build/implementations/http/doc-registry.js.map +1 -0
  79. package/build/implementations/http/doc-registry.test.d.ts +1 -0
  80. package/build/implementations/http/doc-registry.test.js +516 -0
  81. package/build/implementations/http/doc-registry.test.js.map +1 -0
  82. package/build/implementations/http/error-taxonomy.d.ts +249 -0
  83. package/build/implementations/http/error-taxonomy.js +252 -0
  84. package/build/implementations/http/error-taxonomy.js.map +1 -0
  85. package/build/implementations/http/error-taxonomy.test.d.ts +1 -0
  86. package/build/implementations/http/error-taxonomy.test.js +399 -0
  87. package/build/implementations/http/error-taxonomy.test.js.map +1 -0
  88. package/build/implementations/http/express-rpc/error-taxonomy.test.d.ts +1 -0
  89. package/build/implementations/http/express-rpc/error-taxonomy.test.js +83 -0
  90. package/build/implementations/http/express-rpc/error-taxonomy.test.js.map +1 -0
  91. package/build/implementations/http/express-rpc/index.d.ts +125 -0
  92. package/build/implementations/http/express-rpc/index.js +216 -0
  93. package/build/implementations/http/express-rpc/index.js.map +1 -0
  94. package/build/implementations/http/express-rpc/index.test.d.ts +1 -0
  95. package/build/implementations/http/express-rpc/index.test.js +684 -0
  96. package/build/implementations/http/express-rpc/index.test.js.map +1 -0
  97. package/build/implementations/http/express-rpc/types.d.ts +11 -0
  98. package/build/implementations/http/express-rpc/types.js +2 -0
  99. package/build/implementations/http/express-rpc/types.js.map +1 -0
  100. package/build/implementations/http/hono-api/error-taxonomy.test.d.ts +1 -0
  101. package/build/implementations/http/hono-api/error-taxonomy.test.js +137 -0
  102. package/build/implementations/http/hono-api/error-taxonomy.test.js.map +1 -0
  103. package/build/implementations/http/hono-api/index.d.ts +151 -0
  104. package/build/implementations/http/hono-api/index.js +344 -0
  105. package/build/implementations/http/hono-api/index.js.map +1 -0
  106. package/build/implementations/http/hono-api/index.test.d.ts +1 -0
  107. package/build/implementations/http/hono-api/index.test.js +992 -0
  108. package/build/implementations/http/hono-api/index.test.js.map +1 -0
  109. package/build/implementations/http/hono-api/types.d.ts +13 -0
  110. package/build/implementations/http/hono-api/types.js +2 -0
  111. package/build/implementations/http/hono-api/types.js.map +1 -0
  112. package/build/implementations/http/hono-rpc/error-taxonomy.test.d.ts +1 -0
  113. package/build/implementations/http/hono-rpc/error-taxonomy.test.js +64 -0
  114. package/build/implementations/http/hono-rpc/error-taxonomy.test.js.map +1 -0
  115. package/build/implementations/http/hono-rpc/index.d.ts +130 -0
  116. package/build/implementations/http/hono-rpc/index.js +209 -0
  117. package/build/implementations/http/hono-rpc/index.js.map +1 -0
  118. package/build/implementations/http/hono-rpc/index.test.d.ts +1 -0
  119. package/build/implementations/http/hono-rpc/index.test.js +828 -0
  120. package/build/implementations/http/hono-rpc/index.test.js.map +1 -0
  121. package/build/implementations/http/hono-rpc/types.d.ts +11 -0
  122. package/build/implementations/http/hono-rpc/types.js +2 -0
  123. package/build/implementations/http/hono-rpc/types.js.map +1 -0
  124. package/build/implementations/http/hono-stream/error-taxonomy.test.d.ts +1 -0
  125. package/build/implementations/http/hono-stream/error-taxonomy.test.js +159 -0
  126. package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +1 -0
  127. package/build/implementations/http/hono-stream/index.d.ts +171 -0
  128. package/build/implementations/http/hono-stream/index.js +415 -0
  129. package/build/implementations/http/hono-stream/index.js.map +1 -0
  130. package/build/implementations/http/hono-stream/index.test.d.ts +1 -0
  131. package/build/implementations/http/hono-stream/index.test.js +1383 -0
  132. package/build/implementations/http/hono-stream/index.test.js.map +1 -0
  133. package/build/implementations/http/hono-stream/types.d.ts +15 -0
  134. package/build/implementations/http/hono-stream/types.js +2 -0
  135. package/build/implementations/http/hono-stream/types.js.map +1 -0
  136. package/build/implementations/http/on-request-error.test.d.ts +1 -0
  137. package/build/implementations/http/on-request-error.test.js +173 -0
  138. package/build/implementations/http/on-request-error.test.js.map +1 -0
  139. package/build/implementations/http/route-errors.test.d.ts +1 -0
  140. package/build/implementations/http/route-errors.test.js +139 -0
  141. package/build/implementations/http/route-errors.test.js.map +1 -0
  142. package/build/implementations/types.d.ts +192 -0
  143. package/build/implementations/types.js +2 -0
  144. package/build/implementations/types.js.map +1 -0
  145. package/build/index.d.ts +165 -0
  146. package/build/index.js +258 -0
  147. package/build/index.js.map +1 -0
  148. package/build/index.test.d.ts +1 -0
  149. package/build/index.test.js +920 -0
  150. package/build/index.test.js.map +1 -0
  151. package/build/schema/compute-schema.js +1 -5
  152. package/build/schema/compute-schema.js.map +1 -1
  153. package/build/schema/compute-schema.test.js +0 -13
  154. package/build/schema/compute-schema.test.js.map +1 -1
  155. package/build/schema/extract-json-schema.d.ts +2 -0
  156. package/build/schema/extract-json-schema.js +12 -0
  157. package/build/schema/extract-json-schema.js.map +1 -0
  158. package/build/schema/extract-json-schema.test.d.ts +1 -0
  159. package/build/schema/extract-json-schema.test.js +23 -0
  160. package/build/schema/extract-json-schema.test.js.map +1 -0
  161. package/build/schema/parser.d.ts +28 -0
  162. package/build/schema/parser.js +170 -0
  163. package/build/schema/parser.js.map +1 -0
  164. package/build/schema/parser.test.d.ts +1 -0
  165. package/build/schema/parser.test.js +120 -0
  166. package/build/schema/parser.test.js.map +1 -0
  167. package/build/schema/resolve-schema-lib.d.ts +12 -0
  168. package/build/schema/resolve-schema-lib.js +11 -0
  169. package/build/schema/resolve-schema-lib.js.map +1 -0
  170. package/build/schema/resolve-schema-lib.test.d.ts +1 -0
  171. package/build/schema/resolve-schema-lib.test.js +17 -0
  172. package/build/schema/resolve-schema-lib.test.js.map +1 -0
  173. package/build/schema/typebox.d.ts +1 -1
  174. package/build/schema/typebox.js +2 -20
  175. package/build/schema/typebox.js.map +1 -1
  176. package/build/schema/typebox.test.js +0 -10
  177. package/build/schema/typebox.test.js.map +1 -1
  178. package/build/schema/types.d.ts +8 -0
  179. package/build/schema/types.js +2 -0
  180. package/build/schema/types.js.map +1 -0
  181. package/build/server/context.d.ts +12 -2
  182. package/build/server/context.js +49 -4
  183. package/build/server/context.js.map +1 -1
  184. package/build/server/context.test.js +32 -1
  185. package/build/server/context.test.js.map +1 -1
  186. package/build/server/doc-registry.js +0 -3
  187. package/build/server/doc-registry.js.map +1 -1
  188. package/build/server/doc-registry.test.js +0 -31
  189. package/build/server/doc-registry.test.js.map +1 -1
  190. package/build/server/index.d.ts +0 -1
  191. package/build/server/index.js +0 -1
  192. package/build/server/index.js.map +1 -1
  193. package/build/server/types.d.ts +6 -27
  194. package/build/stack-utils.d.ts +25 -0
  195. package/build/stack-utils.js +95 -0
  196. package/build/stack-utils.js.map +1 -0
  197. package/build/stack-utils.test.d.ts +1 -0
  198. package/build/stack-utils.test.js +80 -0
  199. package/build/stack-utils.test.js.map +1 -0
  200. package/docs/core.md +8 -0
  201. package/docs/http-integrations.md +41 -27
  202. package/package.json +8 -8
  203. package/src/adapters/astro/README.md +2 -0
  204. package/src/adapters/hono/envelope-parity.test.ts +6 -18
  205. package/src/adapters/hono/handlers/http-stream.ts +12 -3
  206. package/src/adapters/hono/handlers/http.ts +12 -3
  207. package/src/adapters/hono/handlers/rpc.ts +12 -3
  208. package/src/adapters/hono/handlers/stream.ts +12 -3
  209. package/src/adapters/hono/index.test.ts +70 -0
  210. package/src/adapters/hono/index.ts +53 -11
  211. package/src/adapters/hono/request.ts +18 -0
  212. package/src/adapters/hono/types.ts +37 -0
  213. package/src/codegen/bin/cli.ts +1 -1
  214. package/src/codegen/constants.ts +2 -2
  215. package/src/codegen/emit/api-route.ts +1 -1
  216. package/src/codegen/emit/scope-file.ts +1 -2
  217. package/src/codegen/emit-errors.integration.test.ts +2 -2
  218. package/src/codegen/resolve-envelope.ts +1 -1
  219. package/src/codegen/test-helpers/run-tsc.ts +0 -1
  220. package/src/core/create-http-stream.ts +20 -13
  221. package/src/core/create-http.test-d.ts +154 -0
  222. package/src/core/create-http.ts +19 -8
  223. package/src/core/create-stream.ts +12 -4
  224. package/src/core/create.test.ts +74 -0
  225. package/src/core/create.ts +10 -5
  226. package/src/core/internal.ts +35 -0
  227. package/src/core/procedures.ts +4 -0
  228. package/src/core/types.ts +99 -4
  229. package/src/exports.ts +6 -4
  230. package/src/schema/compute-schema.test.ts +0 -16
  231. package/src/schema/compute-schema.ts +1 -5
  232. package/src/schema/typebox.test.ts +0 -12
  233. package/src/schema/typebox.ts +2 -21
  234. package/src/server/context.test.ts +39 -1
  235. package/src/server/context.ts +54 -4
  236. package/src/server/doc-registry.test.ts +0 -34
  237. package/src/server/doc-registry.ts +0 -3
  238. package/src/server/index.ts +0 -1
  239. package/src/server/types.ts +8 -27
  240. package/build/schema/strip-internal-keywords.d.ts +0 -18
  241. package/build/schema/strip-internal-keywords.js +0 -33
  242. package/build/schema/strip-internal-keywords.js.map +0 -1
  243. package/build/schema/strip-internal-keywords.test.js +0 -38
  244. package/build/schema/strip-internal-keywords.test.js.map +0 -1
  245. package/build/server/spec-version.d.ts +0 -24
  246. package/build/server/spec-version.js +0 -26
  247. package/build/server/spec-version.js.map +0 -1
  248. package/build/version.d.ts +0 -9
  249. package/build/version.js +0 -11
  250. package/build/version.js.map +0 -1
  251. package/docs/doc-envelope-spec-v1.md +0 -296
  252. package/docs/doc-envelope-v1.schema.json +0 -253
  253. package/src/schema/strip-internal-keywords.test.ts +0 -43
  254. package/src/schema/strip-internal-keywords.ts +0 -36
  255. package/src/server/spec-version.ts +0 -27
  256. package/src/version.ts +0 -11
  257. /package/build/{schema/strip-internal-keywords.test.d.ts → core/create-http.test-d.d.ts} +0 -0
@@ -364,6 +364,80 @@ describe('Procedures', () => {
364
364
  })
365
365
  })
366
366
 
367
+ describe('Procedures({ middleware }) - factory level, ctx + input aware', () => {
368
+ test('middleware runs with same ctx the handler sees and can inspect input', async () => {
369
+ const calls: any[] = []
370
+
371
+ const { Create } = Procedures<{ userId: string }>({
372
+ middleware: [
373
+ ({ ctx, input, next }) => {
374
+ calls.push({ userId: ctx.userId, input })
375
+ // "parse the incoming call" using ctx + input
376
+ if ((input as any)?.orgId === 'evil') {
377
+ throw ctx.error('tenant violation')
378
+ }
379
+ return next()
380
+ },
381
+ ],
382
+ })
383
+
384
+ const { GetThing } = Create('GetThing', { schema: { params: Type.Object({ orgId: Type.String() }) } }, async (ctx, p) => {
385
+ return { seenByHandler: ctx.userId, org: (p as any).orgId }
386
+ })
387
+
388
+ await GetThing({ userId: 'u1' }, { orgId: 'acme' })
389
+ expect(calls).toHaveLength(1)
390
+ expect(calls[0]).toEqual({ userId: 'u1', input: { orgId: 'acme' } })
391
+ })
392
+
393
+ test('middleware can augment ctx and transform input for the handler', async () => {
394
+ const { Create } = Procedures<{ base: string }>({
395
+ middleware: [
396
+ ({ ctx, input, next }) => {
397
+ return next({
398
+ ctx: { ...ctx, extra: 'added' },
399
+ input: { ...(input as any), injected: true },
400
+ })
401
+ },
402
+ ],
403
+ })
404
+
405
+ const { Echo } = Create('Echo', { schema: { params: Type.Object({ n: Type.Number() }) } }, async (ctx, p: any) => {
406
+ return { base: ctx.base, extra: (ctx as any).extra, received: p }
407
+ })
408
+
409
+ const result = await Echo({ base: 'b' }, { n: 42 })
410
+ expect(result).toEqual({
411
+ base: 'b',
412
+ extra: 'added',
413
+ received: { n: 42, injected: true },
414
+ })
415
+ })
416
+
417
+ test('middleware error uses the factory ctx.error and is wrapped as ProcedureError', async () => {
418
+ const { Create } = Procedures<{}>({
419
+ middleware: [({ ctx }) => { throw ctx.error('from factory mw') }],
420
+ })
421
+
422
+ const { Fail } = Create('Fail', {}, async () => 'no')
423
+ await expect(Fail({}, {})).rejects.toThrow('from factory mw')
424
+ })
425
+
426
+ test('multiple factory middlewares compose (order + next)', async () => {
427
+ const order: string[] = []
428
+ const { Create } = Procedures({
429
+ middleware: [
430
+ async ({ next }) => { order.push('mw1'); return next() },
431
+ async ({ next }) => { order.push('mw2'); return next() },
432
+ ],
433
+ })
434
+
435
+ const { Ok } = Create('Ok', {}, async () => 'ok')
436
+ await Ok({}, {})
437
+ expect(order).toEqual(['mw1', 'mw2'])
438
+ })
439
+ })
440
+
367
441
  describe('Procedures - Definition Location in Errors', () => {
368
442
  test('ProcedureValidationError includes definition location', async () => {
369
443
  const { Create } = Procedures()
@@ -5,6 +5,7 @@ import { captureDefinitionInfo } from './definition-site.js'
5
5
  import {
6
6
  assertNoHttpFields,
7
7
  assertNotDuplicate,
8
+ runWithMiddlewares,
8
9
  shouldSkipValidation,
9
10
  toProcedureError,
10
11
  validateParams,
@@ -76,11 +77,15 @@ export function makeCreate<TContext, TExtendedConfig>(runtime: FactoryRuntime<TC
76
77
  error: errorFactory,
77
78
  }
78
79
 
79
- // params is correctly typed at the public API boundary;
80
- // cast here because TS cannot narrow generics inside implementations
81
- return await handler(
82
- { ...ctx, ...localCtx } as Prettify<TContext & TLocalContext>,
83
- params as any,
80
+ const fullCtx = { ...ctx, ...localCtx } as Prettify<TContext & TLocalContext>
81
+
82
+ // Factory middleware sees the exact same ctx + validated input as the
83
+ // user handler. Supports next({ ctx?, input? }) augmentation.
84
+ return await runWithMiddlewares(
85
+ runtime.middlewares,
86
+ fullCtx,
87
+ params,
88
+ (c, p) => handler(c as any, p as any)
84
89
  )
85
90
  } catch (error: any) {
86
91
  throw toProcedureError(name, definitionInfo, error)
@@ -20,6 +20,8 @@ export type FactoryRuntime<TContext, TExtendedConfig> = {
20
20
  adapters: readonly SchemaAdapter[]
21
21
  compile: ValidatorCompiler
22
22
  httpDefaults?: { pathPrefix?: string; scope?: string }
23
+ /** Factory-level middleware applied to every procedure from this factory. */
24
+ middlewares: Array<(opts: { ctx: any; input: unknown; next: (o?: any) => any }) => any>
23
25
  }
24
26
 
25
27
  const HTTP_FIELDS = ['path', 'method', 'req', 'res', 'successStatus'] as const
@@ -142,6 +144,39 @@ export function combineSignals(incoming: AbortSignal | undefined, own: AbortSign
142
144
  return incoming ? AbortSignal.any([incoming, own]) : own
143
145
  }
144
146
 
147
+ /**
148
+ * Apply factory-defined middlewares around a core invocation.
149
+ * Middlewares run from first to last; each sees the (possibly augmented) ctx + input.
150
+ * Supports both unary results and async generators (for streams).
151
+ */
152
+ export function runWithMiddlewares(
153
+ middlewares: Array<(opts: { ctx: any; input: unknown; next: (o?: any) => any }) => any>,
154
+ ctx: any,
155
+ input: unknown,
156
+ core: (c: any, i: unknown) => any
157
+ ): any {
158
+ if (!middlewares.length) {
159
+ return core(ctx, input)
160
+ }
161
+
162
+ // Build the chain from the inside out
163
+ let current = core
164
+
165
+ for (let i = middlewares.length - 1; i >= 0; i--) {
166
+ const mw = middlewares[i]!
167
+ const previous = current
168
+ current = (c: any, i: unknown) =>
169
+ mw({
170
+ ctx: c,
171
+ input: i,
172
+ next: (o: any = {}) => previous(o.ctx ?? c, o.input ?? i),
173
+ })
174
+ }
175
+
176
+ return current(ctx, input)
177
+ }
178
+
179
+
145
180
  /**
146
181
  * Drives a user stream iterator with the framework guarantees shared by
147
182
  * CreateStream and CreateHttpStream:
@@ -18,6 +18,7 @@ import type { AnyProcedureRegistration, ProceduresOptions, TNoContextProvided }
18
18
  * schema: { adapters: [myZodAdapter] }, // TypeBox is built in
19
19
  * http: { pathPrefix: '/v1', scope: 'billing' }, // CreateHttp* defaults
20
20
  * onCreate: (procedure) => log(procedure.kind, procedure.name),
21
+ * // middleware: [ myFactoryGuard ], // runs with final ctx + input for every proc
21
22
  * })
22
23
  * ```
23
24
  *
@@ -26,6 +27,8 @@ import type { AnyProcedureRegistration, ProceduresOptions, TNoContextProvided }
26
27
  * - `TExtendedConfig` — extra per-procedure config fields (e.g. `scope`,
27
28
  * `version`, auth flags) carried through to registrations, `info`, and
28
29
  * `onCreate`.
30
+ * - `middleware` — factory-level middleware with access to the final ctx + input
31
+ * ("parse the incoming call") for authz, enrichment, etc. See `FactoryMiddleware`.
29
32
  */
30
33
  export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unknown>(
31
34
  options?: ProceduresOptions<TContext, TExtendedConfig>,
@@ -39,6 +42,7 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
39
42
  adapters: [...(options?.schema?.adapters ?? []), typeboxAdapter],
40
43
  compile: createValidatorCompiler(options?.validation === false ? undefined : options?.validation),
41
44
  httpDefaults: options?.http,
45
+ middlewares: options?.middleware ?? [],
42
46
  }
43
47
 
44
48
  const Create = makeCreate<TContext, TExtendedConfig>(runtime)
package/src/core/types.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type * as AJV from 'ajv'
2
2
  import type { ProcedureError } from './errors.js'
3
3
  import type { SchemaAdapter } from '../schema/adapter.js'
4
- import type { TJSONSchema } from '../schema/json-schema.js'
4
+ import type { Infer, Prettify, TJSONSchema } from '../schema/json-schema.js'
5
5
  import type { Validate } from '../schema/compile.js'
6
6
 
7
7
  export type TNoContextProvided = unknown
@@ -69,7 +69,64 @@ export type TStreamProcedureRegistration<TContext = unknown, TExtendedConfig = u
69
69
 
70
70
  export type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head'
71
71
 
72
- export type TCreateHttpConfig<TReq, TRes, TErrorKey extends string = string> = {
72
+ /**
73
+ * Canonical shape for `schema.req` channels.
74
+ * Used both to constrain generics and to provide autocomplete when writing
75
+ * `CreateHttp` / `CreateHttpStream` config objects.
76
+ *
77
+ * Note: RPC `Create` / `CreateStream` use a flatter `schema: { params?, yieldType?, returnType? }`
78
+ * directly (no extra container generic), so they don't need an equivalent named input shape.
79
+ */
80
+ export type THttpReqInput = {
81
+ pathParams?: unknown
82
+ query?: unknown
83
+ body?: unknown
84
+ headers?: unknown
85
+ }
86
+
87
+ /**
88
+ * Canonical shape for `schema.res` on CreateHttp (CreateHttpStream only supports `headers`).
89
+ */
90
+ export type THttpResInput = {
91
+ body?: unknown
92
+ headers?: unknown
93
+ }
94
+
95
+ /**
96
+ * @internal Helper to surface literal channel keys for autocomplete
97
+ * while still threading the user's concrete generic schemas through for inference.
98
+ */
99
+ export type HttpSchemaInput<Base, T> = Base & (T extends undefined ? {} : T)
100
+
101
+ /**
102
+ * @internal Shared helper for typing the destructured `req` argument passed to
103
+ * CreateHttp / CreateHttpStream handlers. Mirrors the per-channel Infer.
104
+ */
105
+ export type HttpReq<TReq> = TReq extends Record<string, unknown>
106
+ ? Prettify<{ [K in keyof TReq]: Infer<TReq[K]> }>
107
+ : undefined
108
+
109
+ /**
110
+ * Shape used for the `schema` option of `CreateHttpStream`.
111
+ * Reuses the same req input shape as CreateHttp for autocomplete consistency.
112
+ */
113
+ export type THttpStreamSchema<
114
+ TReq extends THttpReqInput | undefined = undefined,
115
+ TYield = unknown,
116
+ TReturn = void,
117
+ TResHeaders = undefined,
118
+ > = {
119
+ req?: HttpSchemaInput<THttpReqInput, TReq>
120
+ yield?: TYield
121
+ returnType?: TReturn
122
+ res?: TResHeaders extends undefined ? undefined : { headers: TResHeaders }
123
+ }
124
+
125
+ export type TCreateHttpConfig<
126
+ TReq extends THttpReqInput | undefined = undefined,
127
+ TRes extends THttpResInput | undefined = undefined,
128
+ TErrorKey extends string = string,
129
+ > = {
73
130
  path: string
74
131
  method: HttpMethod
75
132
  successStatus?: number
@@ -78,8 +135,10 @@ export type TCreateHttpConfig<TReq, TRes, TErrorKey extends string = string> = {
78
135
  description?: string
79
136
  /** Optional — a no-input, no-output route (e.g. a 204 logout) may omit it. */
80
137
  schema?: {
81
- req?: TReq
82
- res?: TRes
138
+ // HttpSchemaInput surfaces the channel keys for IDE autocomplete / excess-property checking
139
+ // while the generic part carries the caller's concrete schema (for Infer + handler typing).
140
+ req?: HttpSchemaInput<THttpReqInput, TReq>
141
+ res?: HttpSchemaInput<THttpResInput, TRes>
83
142
  }
84
143
  }
85
144
 
@@ -193,4 +252,40 @@ export type ProceduresOptions<TContext = unknown, TExtendedConfig = unknown> = {
193
252
  pathPrefix?: string
194
253
  scope?: string
195
254
  }
255
+
256
+ /**
257
+ * Optional middleware that runs for *every* procedure defined via this factory.
258
+ * Runs after schema validation but before the user handler, with the *same*
259
+ * context the handler receives (including injected `error()` and `signal`).
260
+ *
261
+ * Use this for cross-cutting logic that needs the resolved `TContext` + the
262
+ * incoming call data ("parse the incoming call"):
263
+ * - Authorization / tenancy checks using `ctx.user` + `input`
264
+ * - Enriching ctx for all procedures on the factory
265
+ * - Auditing, feature flags, etc.
266
+ *
267
+ * The middleware receives a `next` function. Call `await next({ ctx?, input? })`
268
+ * to continue (optionally with augmented ctx or transformed input). You may
269
+ * also return/throw early.
270
+ *
271
+ * Works for both direct calls and all adapters. Complements (runs after)
272
+ * builder-level middleware on HonoAppBuilder.
273
+ */
274
+ middleware?: FactoryMiddleware<TContext>[]
196
275
  }
276
+
277
+ /**
278
+ * Middleware defined on a Procedures factory.
279
+ *
280
+ * - `ctx` is the fully resolved context (your TContext + `error()` + `signal`).
281
+ * - `input` is the (already schema-validated) call argument:
282
+ * - For Create / CreateStream: the `params` object
283
+ * - For CreateHttp / CreateHttpStream: the `{ pathParams?, query?, body?, headers? }`
284
+ * - Call `next()` (or `next({ ctx: augmented, input: transformed })`) to proceed.
285
+ */
286
+ export type FactoryMiddleware<TContext = unknown> = (opts: {
287
+ ctx: Prettify<TContext & TLocalContext>
288
+ input: unknown
289
+ next: (opts?: { ctx?: any; input?: unknown }) => Promise<any> | AsyncGenerator<any, any, unknown>
290
+ }) => Promise<any> | AsyncGenerator<any, any, unknown> | void
291
+
package/src/exports.ts CHANGED
@@ -23,10 +23,16 @@ export type {
23
23
  THttpProcedureRegistration,
24
24
  THttpStreamProcedureRegistration,
25
25
  TCreateHttpConfig,
26
+ THttpReqInput,
27
+ THttpResInput,
28
+ THttpStreamSchema,
29
+ HttpSchemaInput,
30
+ HttpReq,
26
31
  TLocalContext,
27
32
  TStreamContext,
28
33
  TNoContextProvided,
29
34
  HttpMethod,
35
+ FactoryMiddleware,
30
36
  } from './core/types.js'
31
37
  export type { HttpReturn } from './core/create-http.js'
32
38
  export {
@@ -56,9 +62,5 @@ export type { Infer, TSchemaLib, TJSONSchema, Prettify } from './schema/json-sch
56
62
 
57
63
  // Doc envelope — offline codegen input
58
64
  export { writeDocEnvelope } from './server/doc-envelope.js'
59
- // `generatorVersion` metadata lives on `ts-procedures/server`
60
- // (DOC_ENVELOPE_GENERATOR_VERSION) — it's niche telemetry. The spec version is
61
- // the one consumers branch on, so it gets the prominent top-level export.
62
- export { DOC_ENVELOPE_SPEC_VERSION } from './server/spec-version.js'
63
65
  export type { DocEnvelopeSource } from './server/doc-envelope.js'
64
66
  export type { DocEnvelope } from './server/types.js'
@@ -245,22 +245,6 @@ describe('computeSchema', () => {
245
245
  ).toThrow(/Error extracting json schema schema\.req\.query/)
246
246
  })
247
247
 
248
- it('handles Type.Optional(Type.Unsafe(...)) without leaking ~markers into strict AJV or output', () => {
249
- // typebox >= 1.2.0: this composition emits `~unsafe`/`~optional` as
250
- // enumerable props, which crashed strict AJV and polluted the JSON Schema.
251
- const schema = {
252
- params: Type.Object({ x: Type.Optional(Type.Unsafe({ type: 'string' })) }),
253
- }
254
-
255
- const result = computeSchema('test-procedure', schema, options)
256
-
257
- // No registration crash, and the stored JSON Schema is clean.
258
- expect(JSON.stringify(result.jsonSchema.params)).not.toMatch(/~/)
259
- expect((result.jsonSchema.params as any).properties.x).toEqual({ type: 'string' })
260
- // Validator works against the cleaned schema.
261
- expect(result.validations.params!({ x: 'hello' })).toEqual({})
262
- })
263
-
264
248
  it('throws a compile error when the schema is recognized but invalid JSON Schema', () => {
265
249
  // Looks like a TypeBox schema (has ~kind) but AJV rejects it at compile time
266
250
  const recognizedButInvalid = { '~kind': 'Object', type: 'not-a-real-type' }
@@ -2,7 +2,6 @@ import { ProcedureRegistrationError } from '../core/errors.js'
2
2
  import type { DefinitionInfo } from '../core/definition-site.js'
3
3
  import { extractJsonSchema } from './adapter.js'
4
4
  import type { SchemaAdapter } from './adapter.js'
5
- import { stripInternalKeywords } from './strip-internal-keywords.js'
6
5
  import type { TJSONSchema } from './json-schema.js'
7
6
  import type { TSchemaValidationError, Validate, ValidatorCompiler } from './compile.js'
8
7
 
@@ -68,10 +67,7 @@ function extractChannel(ctx: ComputeContext, channel: string, raw: unknown): TJS
68
67
  `Error extracting json schema ${channel} - it might be empty or not recognized by any schema adapter (registered: ${known})`,
69
68
  )
70
69
  }
71
- // Strip TypeBox internal markers (`~unsafe`/`~optional`/…) that some
72
- // compositions leak as enumerable properties — they break strict AJV and
73
- // pollute serialized docs/codegen. See strip-internal-keywords.ts.
74
- return stripInternalKeywords(extracted)
70
+ return extracted
75
71
  }
76
72
 
77
73
  /** Extracts and compiles one validated channel. */
@@ -9,18 +9,6 @@ describe('typebox schema detection', () => {
9
9
  expect(isTypeboxSchema(typebox)).toBe(true)
10
10
  })
11
11
 
12
- test('it recognizes a top-level Type.Unsafe schema (~unsafe marker, no ~kind)', () => {
13
- // typebox >= 1.2.0: Type.Unsafe carries `~unsafe` INSTEAD of `~kind`.
14
- expect(isTypeboxSchema(Type.Unsafe({ type: 'string' }))).toBe(true)
15
- })
16
-
17
- test('it recognizes any future ~-prefixed marker (convention, not a name list)', () => {
18
- // Detection keys off TypeBox's `~` namespace convention, so a marker that
19
- // doesn't exist yet is still recognized without a code change.
20
- const futureMarker = { '~somethingNew': true, type: 'object' }
21
- expect(isTypeboxSchema(futureMarker)).toBe(true)
22
- })
23
-
24
12
  test('it rejects non-TypeBox values', () => {
25
13
  expect(isTypeboxSchema({ type: 'object' })).toBe(false)
26
14
  expect(isTypeboxSchema('string value')).toBe(false)
@@ -3,29 +3,10 @@ import type { TJSONSchema } from './json-schema.js'
3
3
 
4
4
  const TYPEBOX_KIND = Symbol.for('TypeBox.Kind')
5
5
 
6
- /** TypeBox v1 prefixes every internal marker key with `~`. */
7
- const TILDE = '~'.charCodeAt(0)
8
-
9
- /**
10
- * TypeBox v1 namespaces all of its internal markers under a `~` prefix
11
- * (`~kind`, `~unsafe`, `~optional`, ...) — non-enumerable own properties that
12
- * stay out of the serialized JSON Schema. Detecting the *convention* rather
13
- * than specific marker names means new markers (e.g. `~unsafe`, added in
14
- * typebox 1.2.0 and used by `Type.Unsafe(...)` *instead of* `~kind`) are
15
- * recognized without a code change. Reflect.ownKeys is required because these
16
- * markers are non-enumerable.
17
- */
18
- function hasTypeBoxV1Marker(schema: object): boolean {
19
- for (const key of Reflect.ownKeys(schema)) {
20
- if (typeof key === 'string' && key.charCodeAt(0) === TILDE) return true
21
- }
22
- return false
23
- }
24
-
25
6
  /**
26
7
  * Built-in adapter for TypeBox (`import { Type } from 'typebox'`).
27
8
  *
28
- * Detection covers both typebox v1 (any `~`-prefixed marker) and legacy
9
+ * Detection covers both typebox v1 (`~kind` property) and legacy
29
10
  * `@sinclair/typebox` 0.3x (`Symbol.for('TypeBox.Kind')`). TypeBox schemas
30
11
  * already ARE JSON Schema, so conversion is the identity.
31
12
  */
@@ -33,7 +14,7 @@ export const typeboxAdapter: SchemaAdapter = {
33
14
  name: 'typebox',
34
15
  detect(schema: unknown): boolean {
35
16
  if (typeof schema !== 'object' || schema === null) return false
36
- return hasTypeBoxV1Marker(schema) || TYPEBOX_KIND in schema
17
+ return '~kind' in schema || TYPEBOX_KIND in schema
37
18
  },
38
19
  toJsonSchema(schema: unknown): TJSONSchema {
39
20
  return schema as TJSONSchema
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { resolveFactoryContext } from './context.js'
2
+ import { resolveFactoryContext, composeContextInits } from './context.js'
3
3
 
4
4
  describe('resolveFactoryContext', () => {
5
5
  it('returns a plain value as-is', async () => {
@@ -19,4 +19,42 @@ describe('resolveFactoryContext', () => {
19
19
  resolveFactoryContext(async () => ({ ready: true }), null),
20
20
  ).resolves.toEqual({ ready: true })
21
21
  })
22
+
23
+ it('resolves an array of inits with shallow merge (later wins)', async () => {
24
+ const raw = { id: 'r1' }
25
+ const composed = [
26
+ { a: 1 },
27
+ (r: any) => ({ b: r.id }),
28
+ async () => ({ c: 3 }),
29
+ ]
30
+ await expect(resolveFactoryContext(composed as any, raw)).resolves.toEqual({
31
+ a: 1,
32
+ b: 'r1',
33
+ c: 3,
34
+ })
35
+ })
36
+
37
+ it('later layers override earlier keys', async () => {
38
+ const layers = [{ tenant: 't1' }, { tenant: 't2', extra: true }]
39
+ await expect(resolveFactoryContext(layers as any, {})).resolves.toEqual({
40
+ tenant: 't2',
41
+ extra: true,
42
+ })
43
+ })
44
+ })
45
+
46
+ describe('composeContextInits', () => {
47
+ it('returns a reusable composed initializer', async () => {
48
+ const init = composeContextInits<{ token: string | undefined }, { token?: string }>(
49
+ { base: true } as any,
50
+ (r: { token?: string }) => ({ token: r.token }),
51
+ )
52
+ const out = await resolveFactoryContext(init, { token: 'abc' })
53
+ expect(out).toEqual({ base: true, token: 'abc' })
54
+ })
55
+
56
+ it('handles empty compose as empty object', async () => {
57
+ const init = composeContextInits()
58
+ await expect(resolveFactoryContext(init, {})).resolves.toEqual({})
59
+ })
22
60
  })
@@ -2,17 +2,67 @@
2
2
  * A factory context as accepted by server adapters' `register()`: either the
3
3
  * context value itself, or a function deriving it from the adapter's request
4
4
  * context per request (sync or async).
5
+ *
6
+ * Arrays are supported for composition (resolved left-to-right with shallow
7
+ * merge of object results). Use `composeContextInits` for a reusable composed
8
+ * initializer.
5
9
  */
6
10
  export type FactoryContextInit<TContext, TRaw> =
7
11
  | TContext
8
12
  | ((raw: TRaw) => TContext | Promise<TContext>)
13
+ | Array<FactoryContextInit<TContext, TRaw>>
9
14
 
10
- /** Resolves a {@link FactoryContextInit} for one request. */
15
+ /**
16
+ * Compose multiple context initializers (values or functions) into one.
17
+ * Later initializers shallow-merge over earlier results (object case).
18
+ * Functions are resolved in order against the raw + the accumulating context.
19
+ */
20
+ export function composeContextInits<TContext, TRaw>(
21
+ ...inits: FactoryContextInit<TContext, TRaw>[]
22
+ ): FactoryContextInit<TContext, TRaw> {
23
+ const flat = inits.flat() as FactoryContextInit<TContext, TRaw>[]
24
+ if (flat.length === 0) return {} as TContext
25
+ if (flat.length === 1) return flat[0]!
26
+
27
+ return async (raw: TRaw): Promise<TContext> => {
28
+ let acc: any = {}
29
+ for (const init of flat) {
30
+ const part = typeof init === 'function'
31
+ ? await (init as (r: TRaw) => TContext | Promise<TContext>)(raw)
32
+ : init
33
+ if (part && typeof part === 'object') {
34
+ acc = { ...acc, ...part }
35
+ } else if (part !== undefined) {
36
+ // Non-object last-wins (rare, but supported)
37
+ acc = part
38
+ }
39
+ }
40
+ return acc as TContext
41
+ }
42
+ }
43
+
44
+ /** Resolves a {@link FactoryContextInit} (or array) for one request. */
11
45
  export async function resolveFactoryContext<TContext, TRaw>(
12
46
  init: FactoryContextInit<TContext, TRaw>,
13
47
  raw: TRaw,
14
48
  ): Promise<TContext> {
15
- return typeof init === 'function'
16
- ? await (init as (raw: TRaw) => TContext | Promise<TContext>)(raw)
17
- : init
49
+ if (Array.isArray(init)) {
50
+ // Use the same merge loop here to avoid calling a potentially non-fn value.
51
+ let acc: any = {}
52
+ for (const i of init) {
53
+ const part = typeof i === 'function'
54
+ ? await (i as (r: TRaw) => TContext | Promise<TContext>)(raw)
55
+ : i
56
+ if (part && typeof part === 'object') {
57
+ acc = { ...acc, ...part }
58
+ } else if (part !== undefined) {
59
+ acc = part
60
+ }
61
+ }
62
+ return acc as TContext
63
+ }
64
+ if (typeof init === 'function') {
65
+ return await (init as (raw: TRaw) => TContext | Promise<TContext>)(raw)
66
+ }
67
+ return init
18
68
  }
@@ -5,9 +5,7 @@ import type {
5
5
  THttpStreamProcedureRegistration,
6
6
  TProcedureRegistration,
7
7
  } from '../core/types.js'
8
- import pkg from '../../package.json' with { type: 'json' }
9
8
  import { DocRegistry } from './doc-registry.js'
10
- import { DOC_ENVELOPE_SPEC_VERSION, DOC_ENVELOPE_GENERATOR_VERSION } from './spec-version.js'
11
9
  import { defineErrorTaxonomy } from './errors/taxonomy.js'
12
10
  import { buildRpcRouteDoc } from './docs/rpc-doc.js'
13
11
  import { buildHttpStreamRouteDoc } from './docs/http-stream-doc.js'
@@ -203,38 +201,6 @@ describe('DocRegistry', () => {
203
201
  })
204
202
  })
205
203
 
206
- // --------------------------------------------------------------------------
207
- // toJSON() spec metadata
208
- // --------------------------------------------------------------------------
209
- describe('toJSON() spec metadata', () => {
210
- test('emits specVersion (the constant, a number) and generatorVersion (the package version)', () => {
211
- const out = new DocRegistry().toJSON()
212
- expect(out.specVersion).toBe(DOC_ENVELOPE_SPEC_VERSION)
213
- expect(typeof out.specVersion).toBe('number')
214
- expect(out.generatorVersion).toBe(pkg.version)
215
- // the exported constant must track the real package version
216
- expect(DOC_ENVELOPE_GENERATOR_VERSION).toBe(pkg.version)
217
- })
218
-
219
- test('metadata is present even on an otherwise-empty envelope', () => {
220
- const out = new DocRegistry({ includeDefaults: false }).toJSON()
221
- expect(out).toMatchObject({
222
- specVersion: DOC_ENVELOPE_SPEC_VERSION,
223
- generatorVersion: pkg.version,
224
- })
225
- })
226
-
227
- test('transform receives the metadata on its envelope argument', () => {
228
- const seen = new DocRegistry().toJSON({
229
- transform: ({ specVersion, generatorVersion }) => ({ specVersion, generatorVersion }),
230
- })
231
- expect(seen).toEqual({
232
- specVersion: DOC_ENVELOPE_SPEC_VERSION,
233
- generatorVersion: pkg.version,
234
- })
235
- })
236
- })
237
-
238
204
  // --------------------------------------------------------------------------
239
205
  // toJSON() filter
240
206
  // --------------------------------------------------------------------------
@@ -13,7 +13,6 @@ import {
13
13
  taxonomyToErrorDocs,
14
14
  type ErrorTaxonomy,
15
15
  } from './errors/taxonomy.js'
16
- import { DOC_ENVELOPE_SPEC_VERSION, DOC_ENVELOPE_GENERATOR_VERSION } from './spec-version.js'
17
16
 
18
17
  export type {
19
18
  AnyHttpRouteDoc,
@@ -113,8 +112,6 @@ export class DocRegistry {
113
112
  }
114
113
 
115
114
  const envelope: DocEnvelope = {
116
- specVersion: DOC_ENVELOPE_SPEC_VERSION,
117
- generatorVersion: DOC_ENVELOPE_GENERATOR_VERSION,
118
115
  basePath: this.basePath,
119
116
  headers: [...this.headers],
120
117
  errors: [...this.errors],
@@ -25,6 +25,5 @@ export { buildStreamRouteDoc } from './docs/stream-doc.js'
25
25
  export { buildHttpRouteDoc } from './docs/http-doc.js'
26
26
  export { buildHttpStreamRouteDoc } from './docs/http-stream-doc.js'
27
27
  export { DocRegistry } from './doc-registry.js'
28
- export { DOC_ENVELOPE_SPEC_VERSION, DOC_ENVELOPE_GENERATOR_VERSION } from './spec-version.js'
29
28
  export { writeDocEnvelope } from './doc-envelope.js'
30
29
  export type { DocEnvelopeSource } from './doc-envelope.js'