ts-procedures 5.3.0 → 5.4.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 (38) hide show
  1. package/README.md +90 -0
  2. package/agent_config/claude-code/agents/ts-procedures-architect.md +15 -0
  3. package/agent_config/claude-code/skills/guide/anti-patterns.md +106 -0
  4. package/agent_config/claude-code/skills/guide/api-reference.md +150 -4
  5. package/agent_config/claude-code/skills/guide/patterns.md +155 -0
  6. package/agent_config/claude-code/skills/review/checklist.md +22 -0
  7. package/agent_config/claude-code/skills/scaffold/SKILL.md +3 -1
  8. package/agent_config/claude-code/skills/scaffold/templates/hono-api.md +169 -0
  9. package/agent_config/copilot/copilot-instructions.md +35 -0
  10. package/agent_config/cursor/cursorrules +35 -0
  11. package/build/implementations/http/hono-api/index.d.ts +102 -0
  12. package/build/implementations/http/hono-api/index.js +339 -0
  13. package/build/implementations/http/hono-api/index.js.map +1 -0
  14. package/build/implementations/http/hono-api/index.test.d.ts +1 -0
  15. package/build/implementations/http/hono-api/index.test.js +983 -0
  16. package/build/implementations/http/hono-api/index.test.js.map +1 -0
  17. package/build/implementations/http/hono-api/types.d.ts +13 -0
  18. package/build/implementations/http/hono-api/types.js +2 -0
  19. package/build/implementations/http/hono-api/types.js.map +1 -0
  20. package/build/implementations/types.d.ts +44 -0
  21. package/build/index.d.ts +28 -6
  22. package/build/index.js +28 -0
  23. package/build/index.js.map +1 -1
  24. package/build/schema/compute-schema.d.ts +5 -0
  25. package/build/schema/compute-schema.js +8 -1
  26. package/build/schema/compute-schema.js.map +1 -1
  27. package/build/schema/parser.d.ts +6 -5
  28. package/build/schema/parser.js +54 -0
  29. package/build/schema/parser.js.map +1 -1
  30. package/package.json +8 -3
  31. package/src/implementations/http/README.md +45 -2
  32. package/src/implementations/http/hono-api/index.test.ts +1328 -0
  33. package/src/implementations/http/hono-api/index.ts +461 -0
  34. package/src/implementations/http/hono-api/types.ts +16 -0
  35. package/src/implementations/types.ts +52 -0
  36. package/src/index.ts +87 -10
  37. package/src/schema/compute-schema.ts +23 -2
  38. package/src/schema/parser.ts +70 -3
@@ -0,0 +1,461 @@
1
+ import { Hono, Context } from 'hono'
2
+ import { TProcedureRegistration } from '../../../index.js'
3
+ import {
4
+ ExtractConfig,
5
+ ExtractContext,
6
+ ProceduresFactory,
7
+ APIConfig,
8
+ APIHttpRouteDoc,
9
+ APIInput,
10
+ HttpMethod,
11
+ } from '../../types.js'
12
+ import { HonoAPIFactoryItem } from './types.js'
13
+
14
+ export type { APIConfig, APIHttpRouteDoc, APIInput, HttpMethod }
15
+
16
+ // ================
17
+ // Query string parsing
18
+ // ================
19
+
20
+ export type QueryParser = (queryString: string) => Record<string, unknown>
21
+
22
+ /** Lazy-loaded qs module (optional peer dependency) */
23
+ let _qsModule: { parse: (str: string, opts?: any) => any } | false | undefined
24
+
25
+ async function loadQs(): Promise<{ parse: (str: string, opts?: any) => any } | undefined> {
26
+ if (_qsModule === undefined) {
27
+ try {
28
+ const mod = await import('qs')
29
+ _qsModule = mod.default ?? mod
30
+ } catch {
31
+ _qsModule = false
32
+ }
33
+ }
34
+ return _qsModule || undefined
35
+ }
36
+
37
+ /** Fallback query parser using native URLSearchParams */
38
+ function parseQueryNative(queryString: string): Record<string, unknown> {
39
+ const searchParams = new URLSearchParams(queryString)
40
+ const result: Record<string, unknown> = {}
41
+ for (const key of new Set(searchParams.keys())) {
42
+ const values = searchParams.getAll(key)
43
+ result[key] = values.length > 1 ? values : values[0]
44
+ }
45
+ return result
46
+ }
47
+
48
+ /**
49
+ * Resolves the query parser once. Called during build() so handlers use a sync parser.
50
+ * Priority: custom queryParser > qs (optional peer dep) > native URLSearchParams
51
+ */
52
+ async function resolveQueryParser(custom?: QueryParser): Promise<QueryParser> {
53
+ if (custom) return custom
54
+
55
+ const qs = await loadQs()
56
+ if (qs) {
57
+ return (raw: string) => qs.parse(raw) as Record<string, unknown>
58
+ }
59
+
60
+ return parseQueryNative
61
+ }
62
+
63
+ /** Extract path parameter names from a route pattern (e.g., '/users/:id' → ['id']) */
64
+ function extractPathParamNames(path: string): string[] {
65
+ const matches = path.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g)
66
+ return matches ? matches.map((m) => m.slice(1)) : []
67
+ }
68
+
69
+ /** Default success status codes by HTTP method */
70
+ function defaultSuccessStatus(method: HttpMethod): number {
71
+ switch (method) {
72
+ case 'post':
73
+ return 201
74
+ case 'delete':
75
+ return 204
76
+ default:
77
+ return 200
78
+ }
79
+ }
80
+
81
+ /** HTTP methods that typically carry a request body */
82
+ const BODY_METHODS: HttpMethod[] = ['post', 'put', 'patch']
83
+
84
+ /** Extracts the raw query string from a URL and runs it through the resolved parser. */
85
+ function extractQuery(url: string, queryParser: QueryParser): Record<string, unknown> {
86
+ const questionMark = url.indexOf('?')
87
+ if (questionMark === -1) return {}
88
+ const rawQuery = url.slice(questionMark + 1)
89
+ if (!rawQuery) return {}
90
+ return queryParser(rawQuery)
91
+ }
92
+
93
+ // ================
94
+ // Builder
95
+ // ================
96
+
97
+ export type HonoAPIAppBuilderConfig = {
98
+ /**
99
+ * An existing Hono application instance to use.
100
+ * If not provided, a new instance will be created.
101
+ */
102
+ app?: Hono
103
+ /** Optional path prefix for all API routes. */
104
+ pathPrefix?: string
105
+ /**
106
+ * Custom query string parser. Receives the raw query string (without '?').
107
+ * Default: uses `qs` (optional peer dependency) if available, otherwise native URLSearchParams.
108
+ */
109
+ queryParser?: QueryParser
110
+ onRequestStart?: (c: Context) => void
111
+ onRequestEnd?: (c: Context) => void
112
+ onSuccess?: (procedure: TProcedureRegistration, c: Context) => void
113
+ /**
114
+ * Error handler called when a procedure throws an error.
115
+ */
116
+ onError?: (
117
+ procedure: TProcedureRegistration,
118
+ c: Context,
119
+ error: Error
120
+ ) => Response | Promise<Response>
121
+ }
122
+
123
+ /**
124
+ * Builder class for creating a Hono application with REST-style API routes.
125
+ *
126
+ * Uses `schema.input` for per-channel type safety:
127
+ * - `input.pathParams` → validated path parameters
128
+ * - `input.query` → validated query string parameters
129
+ * - `input.body` → validated request body
130
+ * - `input.headers` → validated request headers
131
+ *
132
+ * Usage:
133
+ * const API = Procedures<MyContext, APIConfig>()
134
+ *
135
+ * API.Create('GetUser', {
136
+ * path: '/users/:id',
137
+ * method: 'get',
138
+ * schema: {
139
+ * input: {
140
+ * pathParams: Type.Object({ id: Type.String() }),
141
+ * query: Type.Object({ include: Type.Optional(Type.String()) }),
142
+ * },
143
+ * returnType: Type.Object({ id: Type.String(), name: Type.String() }),
144
+ * }
145
+ * }, async (ctx, { pathParams, query }) => {
146
+ * return { id: pathParams.id, name: 'John' }
147
+ * })
148
+ *
149
+ * const apiApp = new HonoAPIAppBuilder()
150
+ * .register(API, (c) => ({ ... }))
151
+ * .build()
152
+ */
153
+ export class HonoAPIAppBuilder {
154
+ constructor(readonly config?: HonoAPIAppBuilderConfig) {
155
+ if (config?.app) {
156
+ this._app = config.app
157
+ }
158
+
159
+ if (config?.onRequestStart) {
160
+ this._app.use('*', async (c, next) => {
161
+ config.onRequestStart!(c)
162
+ await next()
163
+ })
164
+ }
165
+
166
+ if (config?.onRequestEnd) {
167
+ this._app.use('*', async (c, next) => {
168
+ await next()
169
+ config.onRequestEnd!(c)
170
+ })
171
+ }
172
+ }
173
+
174
+ private factories: HonoAPIFactoryItem<any>[] = []
175
+
176
+ private _app: Hono = new Hono()
177
+ private _docs: (APIHttpRouteDoc & object)[] = []
178
+
179
+ get app(): Hono {
180
+ return this._app
181
+ }
182
+
183
+ get docs(): APIHttpRouteDoc[] {
184
+ return this._docs
185
+ }
186
+
187
+ /**
188
+ * Registers a procedure factory with its context.
189
+ * @param factory - The procedure factory created by Procedures<Context, APIConfig>()
190
+ * @param factoryContext - Context for handlers. Direct value, sync function, or async function.
191
+ * @param extendProcedureDoc - Custom function to extend the generated route documentation.
192
+ */
193
+ register<TFactory extends ProceduresFactory>(
194
+ factory: TFactory,
195
+ factoryContext:
196
+ | ExtractContext<TFactory>
197
+ | ((c: Context) => ExtractContext<TFactory> | Promise<ExtractContext<TFactory>>),
198
+ extendProcedureDoc?: (params: {
199
+ base: APIHttpRouteDoc
200
+ procedure: TProcedureRegistration<any, ExtractConfig<TFactory>>
201
+ }) => Record<string, any>
202
+ ): this {
203
+ this.factories.push({ factory, factoryContext, extendProcedureDoc } as HonoAPIFactoryItem<any>)
204
+ return this
205
+ }
206
+
207
+ /**
208
+ * Resolves the full path for a route, combining pathPrefix and the procedure's path.
209
+ */
210
+ private resolveFullPath(procedurePath: string): string {
211
+ const prefix = this.config?.pathPrefix
212
+ const normalizedPrefix = prefix ? (prefix.startsWith('/') ? prefix : `/${prefix}`) : ''
213
+ const normalizedPath = procedurePath.startsWith('/') ? procedurePath : `/${procedurePath}`
214
+ return `${normalizedPrefix}${normalizedPath}`
215
+ }
216
+
217
+ /**
218
+ * Builds and returns the Hono application with registered API routes.
219
+ * Async because it resolves the query parser (qs optional peer dep) once at build time.
220
+ */
221
+ async build(): Promise<Hono> {
222
+ // Resolve query parser once so handlers use it synchronously
223
+ const queryParser = await resolveQueryParser(this.config?.queryParser)
224
+
225
+ this.factories.forEach(({ factory, factoryContext, extendProcedureDoc }) => {
226
+ factory.getProcedures().map((procedure: TProcedureRegistration<any, APIConfig>) => {
227
+ const { config } = procedure
228
+ const fullPath = this.resolveFullPath(config.path)
229
+ const inputSchema = procedure.config.schema?.input
230
+
231
+ // Validate consistency: path params in path template must match schema.input.pathParams
232
+ this.validatePathParamConsistency(procedure.name, config.path, inputSchema)
233
+
234
+ const route = this.buildApiHttpRouteDoc(procedure, fullPath, extendProcedureDoc)
235
+ this._docs.push(route)
236
+
237
+ const method = config.method as HttpMethod
238
+ const handler = this.createRouteHandler(procedure, factoryContext, config, inputSchema, queryParser)
239
+
240
+ this._app.on(method.toUpperCase(), fullPath, handler)
241
+ })
242
+ })
243
+
244
+ return this._app
245
+ }
246
+
247
+ /**
248
+ * Validates that path parameter names in the path template match the schema.input.pathParams declaration.
249
+ */
250
+ private validatePathParamConsistency(
251
+ procedureName: string,
252
+ path: string,
253
+ inputSchema?: Record<string, unknown>
254
+ ): void {
255
+ // Only validate when schema.input is used; schema.params (flat mode) skips this check
256
+ if (!inputSchema) return
257
+
258
+ const pathParamNames = extractPathParamNames(path)
259
+
260
+ if (pathParamNames.length > 0 && !inputSchema.pathParams) {
261
+ throw new Error(
262
+ `Path "${path}" has path parameters [${pathParamNames.join(', ')}] but schema.input.pathParams is not defined for procedure "${procedureName}". ` +
263
+ `Define schema.input.pathParams to validate path parameters.`
264
+ )
265
+ }
266
+
267
+ if (inputSchema.pathParams && pathParamNames.length === 0) {
268
+ throw new Error(
269
+ `schema.input.pathParams is defined for procedure "${procedureName}" but path "${path}" has no path parameters. ` +
270
+ `Remove schema.input.pathParams or add path parameters to the path.`
271
+ )
272
+ }
273
+
274
+ // Deep validation: verify schema property names match the :param names in the path
275
+ if (inputSchema.pathParams && pathParamNames.length > 0) {
276
+ const pathParamsJsonSchema = inputSchema.pathParams as Record<string, unknown>
277
+ const schemaProperties = pathParamsJsonSchema.properties as Record<string, unknown> | undefined
278
+
279
+ if (schemaProperties) {
280
+ const schemaKeys = Object.keys(schemaProperties)
281
+ const missingInSchema = pathParamNames.filter((p) => !schemaKeys.includes(p))
282
+ const extraInSchema = schemaKeys.filter((k) => !pathParamNames.includes(k))
283
+
284
+ if (missingInSchema.length > 0 || extraInSchema.length > 0) {
285
+ const parts: string[] = []
286
+ if (missingInSchema.length > 0) {
287
+ parts.push(`path has [${missingInSchema.join(', ')}] but they are missing from schema`)
288
+ }
289
+ if (extraInSchema.length > 0) {
290
+ parts.push(`schema has [${extraInSchema.join(', ')}] but they are not in the path`)
291
+ }
292
+ throw new Error(
293
+ `Path param mismatch for procedure "${procedureName}": ${parts.join('; ')}. ` +
294
+ `Path "${path}" expects [${pathParamNames.join(', ')}], schema defines [${schemaKeys.join(', ')}].`
295
+ )
296
+ }
297
+ }
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Creates the async route handler for a procedure.
303
+ */
304
+ private createRouteHandler(
305
+ procedure: TProcedureRegistration<any, APIConfig>,
306
+ factoryContext: HonoAPIFactoryItem['factoryContext'],
307
+ config: APIConfig,
308
+ inputSchema: Record<string, unknown> | undefined,
309
+ queryParser: QueryParser
310
+ ) {
311
+ const successStatus = config.successStatus ?? defaultSuccessStatus(config.method)
312
+
313
+ return async (c: Context) => {
314
+ try {
315
+ const context =
316
+ typeof factoryContext === 'function'
317
+ ? await factoryContext(c)
318
+ : (factoryContext as any)
319
+
320
+ let params: unknown
321
+
322
+ if (inputSchema) {
323
+ // schema.input mode: build structured params from HTTP sources
324
+ params = await this.extractInputParams(c, config.method, inputSchema, queryParser)
325
+ } else if (procedure.config.schema?.params) {
326
+ // schema.params mode: extract from body or query based on method
327
+ if (BODY_METHODS.includes(config.method)) {
328
+ params = await c.req.json().catch(() => ({}))
329
+ } else {
330
+ params = extractQuery(c.req.url, queryParser)
331
+ }
332
+ } else {
333
+ params = await c.req.json().catch(() => undefined)
334
+ }
335
+
336
+ const result = await procedure.handler({ ...context, signal: c.req.raw.signal }, params)
337
+
338
+ if (this.config?.onSuccess) {
339
+ this.config.onSuccess(procedure, c)
340
+ }
341
+
342
+ // 204 No Content returns no body
343
+ if (successStatus === 204) {
344
+ return c.body(null, 204)
345
+ }
346
+
347
+ return c.json(result, successStatus as any)
348
+ } catch (error) {
349
+ if (this.config?.onError) {
350
+ return this.config.onError(procedure, c, error as Error)
351
+ }
352
+ return c.json({ error: (error as Error).message }, 500)
353
+ }
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Extracts and assembles structured input params from HTTP request sources.
359
+ * Each channel (pathParams, query, body, headers) is extracted from its HTTP source.
360
+ * The core validates each channel independently via schema.input validators.
361
+ */
362
+ private async extractInputParams(
363
+ c: Context,
364
+ method: HttpMethod,
365
+ inputSchema: Record<string, unknown>,
366
+ queryParser: QueryParser
367
+ ): Promise<Record<string, unknown>> {
368
+ const params: Record<string, unknown> = {}
369
+ const channelNames = Object.keys(inputSchema)
370
+
371
+ for (const channel of channelNames) {
372
+ switch (channel) {
373
+ case 'pathParams':
374
+ params.pathParams = c.req.param()
375
+ break
376
+
377
+ case 'query':
378
+ params.query = extractQuery(c.req.url, queryParser)
379
+ break
380
+
381
+ case 'body':
382
+ if (BODY_METHODS.includes(method)) {
383
+ params.body = await c.req.json().catch(() => ({}))
384
+ }
385
+ break
386
+
387
+ case 'headers': {
388
+ // Pass all request headers; AJV's removeAdditional strips non-declared keys
389
+ const headersObj: Record<string, string> = {}
390
+ c.req.raw.headers.forEach((value, key) => {
391
+ headersObj[key] = value
392
+ })
393
+ params.headers = headersObj
394
+ break
395
+ }
396
+
397
+ default:
398
+ // Unknown channel — pass through empty. Core validation will catch mismatches.
399
+ params[channel] = undefined
400
+ break
401
+ }
402
+ }
403
+
404
+ return params
405
+ }
406
+
407
+ /**
408
+ * Generates the API HTTP route documentation for the given procedure.
409
+ */
410
+ private buildApiHttpRouteDoc(
411
+ procedure: TProcedureRegistration<any, APIConfig>,
412
+ fullPath: string,
413
+ extendProcedureDoc?: HonoAPIFactoryItem['extendProcedureDoc']
414
+ ): APIHttpRouteDoc {
415
+ const { config } = procedure
416
+ const inputSchema = config.schema?.input
417
+ const jsonSchema: APIHttpRouteDoc['jsonSchema'] = {}
418
+
419
+ // Populate per-channel JSON schemas from schema.input
420
+ if (inputSchema) {
421
+ if (inputSchema.pathParams) jsonSchema.pathParams = inputSchema.pathParams as Record<string, unknown>
422
+ if (inputSchema.query) jsonSchema.query = inputSchema.query as Record<string, unknown>
423
+ if (inputSchema.body) jsonSchema.body = inputSchema.body as Record<string, unknown>
424
+ if (inputSchema.headers) jsonSchema.headers = inputSchema.headers as Record<string, unknown>
425
+ } else if (config.schema?.params) {
426
+ // Fallback: schema.params treated as body/query depending on method
427
+ if (BODY_METHODS.includes(config.method)) {
428
+ jsonSchema.body = config.schema.params
429
+ } else {
430
+ jsonSchema.query = config.schema.params
431
+ }
432
+ }
433
+
434
+ if (config.schema?.returnType) {
435
+ jsonSchema.response = config.schema.returnType
436
+ }
437
+
438
+ const base: APIHttpRouteDoc = {
439
+ name: procedure.name,
440
+ path: config.path,
441
+ method: config.method,
442
+ fullPath,
443
+ jsonSchema,
444
+ }
445
+
446
+ if (config.successStatus) {
447
+ base.successStatus = config.successStatus
448
+ }
449
+
450
+ let extendedDoc: object = {}
451
+
452
+ if (extendProcedureDoc) {
453
+ extendedDoc = extendProcedureDoc({ base, procedure })
454
+ }
455
+
456
+ return {
457
+ ...extendedDoc,
458
+ ...base,
459
+ }
460
+ }
461
+ }
@@ -0,0 +1,16 @@
1
+ import { ExtractConfig, ExtractContext, APIConfig, APIHttpRouteDoc } from '../../types.js'
2
+ import { Procedures, TProcedureRegistration } from '../../../index.js'
3
+ import { Context } from 'hono'
4
+
5
+ export type HonoAPIFactoryItem<TFactory = ReturnType<typeof Procedures<any, APIConfig>>> = {
6
+ factory: TFactory
7
+ factoryContext:
8
+ | ExtractContext<TFactory>
9
+ | ((c: Context) => ExtractContext<TFactory> | Promise<ExtractContext<TFactory>>)
10
+ extendProcedureDoc?: (params: {
11
+ /** API App builder base http route doc */
12
+ base: APIHttpRouteDoc
13
+ /** Procedure registration */
14
+ procedure: TProcedureRegistration<any, ExtractConfig<TFactory>>
15
+ }) => Record<string, any>
16
+ }
@@ -23,6 +23,58 @@ export interface RPCHttpRouteDoc extends RPCConfig {
23
23
 
24
24
  export type StreamMode = 'sse' | 'text'
25
25
 
26
+ // ================
27
+ // API (REST-style) types
28
+ // ================
29
+
30
+ export type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head'
31
+
32
+ export interface APIConfig {
33
+ /** HTTP route path (supports Hono path params, e.g., '/users/:id') */
34
+ path: string
35
+ /** HTTP method for this endpoint */
36
+ method: HttpMethod
37
+ /** HTTP status code on success. Defaults: POST→201, DELETE→204, others→200 */
38
+ successStatus?: number
39
+ }
40
+
41
+ export interface APIHttpRouteDoc extends APIConfig {
42
+ name: string
43
+ /** Full resolved path including pathPrefix */
44
+ fullPath: string
45
+ jsonSchema: {
46
+ pathParams?: Record<string, unknown>
47
+ query?: Record<string, unknown>
48
+ body?: Record<string, unknown>
49
+ headers?: Record<string, unknown>
50
+ response?: Record<string, unknown>
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Constrains schema.input channel names to valid HTTP input sources.
56
+ * Use with `satisfies` or as a type annotation to catch typos at compile time:
57
+ *
58
+ * @example
59
+ * schema: {
60
+ * input: {
61
+ * pathParams: Type.Object({ id: Type.String() }),
62
+ * qurey: Type.Object({ ... }), // TS error: 'qurey' not in APIInput
63
+ * } satisfies APIInput
64
+ * }
65
+ */
66
+ export type APIInput<T extends {
67
+ pathParams?: unknown
68
+ query?: unknown
69
+ body?: unknown
70
+ headers?: unknown
71
+ } = {
72
+ pathParams?: unknown
73
+ query?: unknown
74
+ body?: unknown
75
+ headers?: unknown
76
+ }> = T
77
+
26
78
  export interface StreamHttpRouteDoc extends RPCConfig {
27
79
  name: string // procedure name
28
80
  path: string