ts-procedures 7.1.0 → 7.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/build/client/index.js +5 -0
  2. package/build/client/index.js.map +1 -1
  3. package/build/client/stream.d.ts +25 -1
  4. package/build/client/stream.js +48 -5
  5. package/build/client/stream.js.map +1 -1
  6. package/build/client/stream.test.js +68 -1
  7. package/build/client/stream.test.js.map +1 -1
  8. package/build/codegen/bin/cli.js +0 -0
  9. package/build/implementations/http/doc-registry.js +14 -0
  10. package/build/implementations/http/doc-registry.js.map +1 -1
  11. package/build/implementations/http/doc-registry.test.js +37 -1
  12. package/build/implementations/http/doc-registry.test.js.map +1 -1
  13. package/build/implementations/http/hono-rpc/index.d.ts +11 -0
  14. package/build/implementations/http/hono-rpc/index.js +22 -1
  15. package/build/implementations/http/hono-rpc/index.js.map +1 -1
  16. package/build/implementations/http/hono-rpc/index.test.js +25 -0
  17. package/build/implementations/http/hono-rpc/index.test.js.map +1 -1
  18. package/build/implementations/http/hono-stream/error-taxonomy.test.js +72 -0
  19. package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +1 -1
  20. package/build/implementations/http/hono-stream/index.d.ts +18 -4
  21. package/build/implementations/http/hono-stream/index.js +97 -18
  22. package/build/implementations/http/hono-stream/index.js.map +1 -1
  23. package/build/implementations/http/hono-stream/index.test.js +3 -3
  24. package/build/implementations/http/hono-stream/index.test.js.map +1 -1
  25. package/build/implementations/types.d.ts +10 -0
  26. package/build/index.js +22 -17
  27. package/build/index.js.map +1 -1
  28. package/build/index.test.js +36 -6
  29. package/build/index.test.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/client/index.ts +6 -0
  32. package/src/client/stream.test.ts +82 -1
  33. package/src/client/stream.ts +67 -4
  34. package/src/implementations/http/doc-registry.test.ts +43 -1
  35. package/src/implementations/http/doc-registry.ts +19 -0
  36. package/src/implementations/http/hono-rpc/index.test.ts +32 -0
  37. package/src/implementations/http/hono-rpc/index.ts +27 -1
  38. package/src/implementations/http/hono-stream/error-taxonomy.test.ts +80 -0
  39. package/src/implementations/http/hono-stream/index.test.ts +3 -3
  40. package/src/implementations/http/hono-stream/index.ts +118 -22
  41. package/src/implementations/types.ts +7 -0
  42. package/src/index.test.ts +43 -6
  43. package/src/index.ts +23 -20
@@ -96,3 +96,83 @@ describe('HonoStreamAppBuilder — error taxonomy (pre-stream)', () => {
96
96
  expect(await res.json()).toEqual({ name: 'ServiceUnavailable' })
97
97
  })
98
98
  })
99
+
100
+ describe('HonoStreamAppBuilder — error taxonomy (mid-stream)', () => {
101
+ test('taxonomy resolves the body for a typed error thrown mid-stream (SSE)', async () => {
102
+ const errors = defineErrorTaxonomy({
103
+ AuthError: {
104
+ class: AuthError,
105
+ statusCode: 403,
106
+ toResponse: (err) => ({ name: 'AuthError', reason: err.reason }),
107
+ },
108
+ })
109
+ const RPC = Procedures<{}, RPCConfig>()
110
+ RPC.CreateStream('Stream', { scope: 'test', version: 1 }, async function* () {
111
+ yield { msg: 'first' }
112
+ throw new AuthError('forbidden')
113
+ })
114
+
115
+ const app = new HonoStreamAppBuilder({ errors })
116
+ .register(RPC, () => ({}))
117
+ .build()
118
+
119
+ const res = await app.request('/test/stream/1', { method: 'POST' })
120
+ expect(res.status).toBe(200) // status committed before error
121
+ const text = await res.text()
122
+ // Last event is the error event with the resolved body shape
123
+ expect(text).toContain('event: error')
124
+ expect(text).toContain('"name":"AuthError"')
125
+ expect(text).toContain('"reason":"forbidden"')
126
+ })
127
+
128
+ test('onMidStreamError still runs as fallback when taxonomy does not match', async () => {
129
+ const RPC = Procedures<{}, RPCConfig>()
130
+ RPC.CreateStream('Stream', { scope: 'test', version: 1 }, async function* () {
131
+ yield { msg: 'first' }
132
+ throw new TypeError('mid-stream boom')
133
+ })
134
+
135
+ const app = new HonoStreamAppBuilder({
136
+ errors: {
137
+ AuthError: {
138
+ class: AuthError,
139
+ statusCode: 403,
140
+ toResponse: () => ({ name: 'AuthError', reason: 'forbidden' }),
141
+ },
142
+ },
143
+ onMidStreamError: (_p, _c, err) => ({ data: { name: 'FallbackError', message: err.message } }),
144
+ })
145
+ .register(RPC, () => ({}))
146
+ .build()
147
+
148
+ const res = await app.request('/test/stream/1', { method: 'POST' })
149
+ const text = await res.text()
150
+ expect(text).toContain('"name":"FallbackError"')
151
+ expect(text).toContain('mid-stream boom')
152
+ })
153
+
154
+ test('mid-stream typed body falls through to text mode', async () => {
155
+ const RPC = Procedures<{}, RPCConfig>()
156
+ RPC.CreateStream('Stream', { scope: 'test', version: 1 }, async function* () {
157
+ yield { msg: 'first' }
158
+ throw new AuthError('forbidden')
159
+ })
160
+ const app = new HonoStreamAppBuilder({
161
+ defaultStreamMode: 'text',
162
+ errors: {
163
+ AuthError: {
164
+ class: AuthError,
165
+ statusCode: 403,
166
+ toResponse: () => ({ name: 'AuthError', reason: 'forbidden' }),
167
+ },
168
+ },
169
+ })
170
+ .register(RPC, () => ({}))
171
+ .build()
172
+
173
+ const res = await app.request('/test/stream/1', { method: 'POST' })
174
+ const text = await res.text()
175
+ expect(text).toContain('"name":"AuthError"')
176
+ expect(text).toContain('"reason":"forbidden"')
177
+ })
178
+ })
@@ -890,7 +890,7 @@ describe('HonoStreamAppBuilder', () => {
890
890
 
891
891
  const doc = builder.docs[0]!
892
892
  expect(doc.path).toBe('/messages/stream-messages/1')
893
- expect(doc.methods).toEqual(['get', 'post'])
893
+ expect(doc.methods).toEqual(['post', 'get'])
894
894
  expect(doc.streamMode).toBe('sse')
895
895
  expect(doc.jsonSchema.params).toBeDefined()
896
896
  expect(doc.jsonSchema.returnType).toBeDefined()
@@ -1111,7 +1111,7 @@ describe('HonoStreamAppBuilder', () => {
1111
1111
  // Base properties should NOT be overridden
1112
1112
  expect(doc.name).toBe('Test')
1113
1113
  expect(doc.path).toBe('/test/test/1')
1114
- expect(doc.methods).toEqual(['get', 'post'])
1114
+ expect(doc.methods).toEqual(['post', 'get'])
1115
1115
  // Custom field should be present
1116
1116
  expect(doc).toHaveProperty('customField', 'custom-value')
1117
1117
  })
@@ -1779,7 +1779,7 @@ describe('HonoStreamAppBuilder', () => {
1779
1779
  // Only streaming procedure should be registered
1780
1780
  expect(builder.docs).toHaveLength(1)
1781
1781
  expect(builder.docs[0]!.name).toBe('WatchNotifications')
1782
- expect(builder.docs[0]!.methods).toEqual(['get', 'post'])
1782
+ expect(builder.docs[0]!.methods).toEqual(['post', 'get'])
1783
1783
 
1784
1784
  // Test streaming
1785
1785
  const res = await app.request('/user/notifications/watch-notifications/1?limit=2', {
@@ -75,10 +75,13 @@ export type HonoStreamAppBuilderConfig<TErrorData = unknown> = {
75
75
  onStreamEnd?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
76
76
  /**
77
77
  * Declarative error-to-response mapping (one of the two peer error modes).
78
- * Thrown error classes map to status codes + bodies declaratively. Mid-stream
79
- * errors still go through `onMidStreamError` the HTTP status is already
80
- * committed once streaming starts. See hono-api for the full taxonomy
81
- * contract.
78
+ * Thrown error classes map to status codes + bodies declaratively. The
79
+ * taxonomy applies to BOTH pre-stream errors (where the status code is
80
+ * honored) AND mid-stream errors (where only the body shape is honored —
81
+ * the HTTP status is already committed once streaming starts; the body is
82
+ * written as the SSE `event: 'error'` data, or a JSON line in text mode,
83
+ * for the client's error registry to dispatch). See hono-api for the full
84
+ * taxonomy contract.
82
85
  */
83
86
  errors?: ErrorTaxonomy
84
87
  /**
@@ -185,6 +188,7 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
185
188
 
186
189
  private _app: Hono = new Hono()
187
190
  private _docs: (StreamHttpRouteDoc & object)[] = []
191
+ private _skipped: { name: string; reason: string }[] = []
188
192
 
189
193
  get app(): Hono {
190
194
  return this._app
@@ -194,6 +198,16 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
194
198
  return this._docs
195
199
  }
196
200
 
201
+ /**
202
+ * Procedures that were skipped at `build()` time because they don't fit
203
+ * this builder (e.g. non-streaming procedures registered against the
204
+ * stream builder). Surfaced via `DocSource.skippedProcedures` so
205
+ * DocRegistry can warn about coverage gaps.
206
+ */
207
+ get skippedProcedures(): { name: string; reason: string }[] {
208
+ return this._skipped
209
+ }
210
+
197
211
  /**
198
212
  * Registers a procedure factory with its context.
199
213
  * Only streaming procedures (created with CreateStream) will be registered.
@@ -355,24 +369,61 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
355
369
  })
356
370
  }
357
371
  } catch (error) {
358
- // Get error yield value from callback (onMidStreamError)
359
- let errorResult: MidStreamErrorResult<TErrorData> | undefined
372
+ // Dispatch order mirrors hono-rpc: taxonomy onMidStreamError → default.
373
+ // The HTTP status is already committed (200 OK headers were sent the
374
+ // moment streaming started), so the taxonomy here only drives the
375
+ // wire-protocol body shape — clients dispatch through the same error
376
+ // registry as RPC/API responses by reading `data.name`.
377
+ let errorData: unknown
378
+ let sseEventOverride: string | undefined
379
+ let sseIdOverride: string | undefined
380
+ let sseRetryOverride: number | undefined
381
+ let runOnCatch: (() => Promise<void>) | undefined
360
382
 
361
- if (this.config?.onMidStreamError) {
362
- errorResult = this.config.onMidStreamError(procedure, c, error as Error)
383
+ if (this.config?.errors || this.config?.unknownError) {
384
+ const resolved = resolveErrorResponse({
385
+ err: error,
386
+ userTaxonomy: this.config.errors,
387
+ unknownError: this.config.unknownError,
388
+ procedure,
389
+ raw: c,
390
+ })
391
+ if (resolved) {
392
+ errorData = resolved.body
393
+ sseEventOverride = 'error'
394
+ runOnCatch = resolved.runOnCatch
395
+ }
396
+ }
397
+
398
+ if (errorData === undefined && this.config?.onMidStreamError) {
399
+ const errorResult: MidStreamErrorResult<TErrorData> | undefined =
400
+ this.config.onMidStreamError(procedure, c, error as Error)
401
+ if (errorResult?.data !== undefined) {
402
+ errorData = errorResult.data
403
+ sseEventOverride = procedure.name
404
+ }
405
+ }
406
+
407
+ if (errorData === undefined) {
408
+ errorData = { error: (error as Error).message }
409
+ sseEventOverride = 'error'
363
410
  }
364
411
 
365
- // Write error value to stream
366
- const errorData = errorResult?.data ?? { error: (error as Error).message }
367
412
  const sseMeta = getSSEMeta(errorData)
368
413
 
369
414
  await stream.writeSSE({
370
415
  data: typeof errorData === 'string' ? errorData : JSON.stringify(errorData),
371
- event: sseMeta?.event ?? (errorResult?.data !== undefined ? procedure.name : 'error'),
372
- id: sseMeta?.id ?? String(eventId++),
373
- ...(sseMeta?.retry !== undefined && { retry: sseMeta.retry }),
416
+ event: sseMeta?.event ?? sseEventOverride ?? 'error',
417
+ id: sseMeta?.id ?? sseIdOverride ?? String(eventId++),
418
+ ...((sseMeta?.retry ?? sseRetryOverride) !== undefined && {
419
+ retry: (sseMeta?.retry ?? sseRetryOverride) as number,
420
+ }),
374
421
  })
375
422
 
423
+ if (runOnCatch) {
424
+ await runOnCatch()
425
+ }
426
+
376
427
  // closeStream defaults to true if not specified
377
428
  // (stream closes naturally after this handler completes)
378
429
  } finally {
@@ -408,16 +459,43 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
408
459
  await stream.writeln(JSON.stringify(value))
409
460
  }
410
461
  } catch (error) {
411
- // Get error yield value from callback (onMidStreamError)
412
- let errorResult: MidStreamErrorResult<TErrorData> | undefined
462
+ // Same dispatch order as SSE taxonomy first, onMidStreamError next,
463
+ // hard default last. Text streams have no event/id metadata, so we
464
+ // only forward the body bytes.
465
+ let errorData: unknown
466
+ let runOnCatch: (() => Promise<void>) | undefined
413
467
 
414
- if (this.config?.onMidStreamError) {
415
- errorResult = this.config.onMidStreamError(procedure, c, error as Error)
468
+ if (this.config?.errors || this.config?.unknownError) {
469
+ const resolved = resolveErrorResponse({
470
+ err: error,
471
+ userTaxonomy: this.config.errors,
472
+ unknownError: this.config.unknownError,
473
+ procedure,
474
+ raw: c,
475
+ })
476
+ if (resolved) {
477
+ errorData = resolved.body
478
+ runOnCatch = resolved.runOnCatch
479
+ }
480
+ }
481
+
482
+ if (errorData === undefined && this.config?.onMidStreamError) {
483
+ const errorResult: MidStreamErrorResult<TErrorData> | undefined =
484
+ this.config.onMidStreamError(procedure, c, error as Error)
485
+ if (errorResult?.data !== undefined) {
486
+ errorData = errorResult.data
487
+ }
488
+ }
489
+
490
+ if (errorData === undefined) {
491
+ errorData = { error: (error as Error).message }
416
492
  }
417
493
 
418
- // Write error value to stream
419
- const errorData = errorResult?.data ?? { error: (error as Error).message }
420
494
  await stream.writeln(JSON.stringify(errorData))
495
+
496
+ if (runOnCatch) {
497
+ await runOnCatch()
498
+ }
421
499
  } finally {
422
500
  if (this.config?.onStreamEnd) {
423
501
  this.config.onStreamEnd(procedure, c, 'text')
@@ -436,8 +514,23 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
436
514
  this.factories.forEach(({ factory, factoryContext, streamMode, extendProcedureDoc }) => {
437
515
  const mode = streamMode ?? this.config?.defaultStreamMode ?? 'sse'
438
516
 
439
- factory
440
- .getProcedures()
517
+ const procedures = factory.getProcedures()
518
+
519
+ // Track non-streaming procedures so DocRegistry can warn about coverage
520
+ // gaps (e.g. a regular procedure registered with this builder will get
521
+ // dropped here and needs to be registered with HonoRPCAppBuilder).
522
+ for (const p of procedures as { name: string; isStream?: boolean }[]) {
523
+ if (p.isStream !== true) {
524
+ const reason =
525
+ 'Non-streaming procedure registered with HonoStreamAppBuilder — register it with HonoRPCAppBuilder (or HonoApiAppBuilder) instead.'
526
+ this._skipped.push({ name: p.name, reason })
527
+ console.warn(
528
+ `[ts-procedures hono-stream] Skipping procedure "${p.name}": ${reason}`
529
+ )
530
+ }
531
+ }
532
+
533
+ procedures
441
534
  .filter(
442
535
  (p: { isStream?: boolean }): p is TStreamProcedureRegistration => p.isStream === true
443
536
  )
@@ -471,7 +564,10 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
471
564
  config,
472
565
  prefix: this.config?.pathPrefix,
473
566
  })
474
- const methods = ['get', 'post'] as const
567
+ // POST first so codegen (which uses `methods[0]`) defaults to POST. POST is
568
+ // the canonical method for streaming procedures because it can carry a body
569
+ // for params; GET is the supplementary method for query-string callers.
570
+ const methods = ['post', 'get'] as const
475
571
  const jsonSchema: { params?: Record<string, unknown>; yieldType?: Record<string, unknown>; returnType?: Record<string, unknown> } = {}
476
572
 
477
573
  if (config.schema?.params) {
@@ -168,6 +168,13 @@ export type AnyHttpRouteDoc = RPCHttpRouteDoc | APIHttpRouteDoc | StreamHttpRout
168
168
 
169
169
  export interface DocSource<T = AnyHttpRouteDoc> {
170
170
  readonly docs: T[]
171
+ /**
172
+ * Optional list of procedures that were registered with this builder but
173
+ * couldn't be served by it (e.g. a streaming procedure registered with an
174
+ * RPC builder). DocRegistry aggregates these across sources and warns at
175
+ * `toJSON()` time so silently-dropped procedures don't slip through.
176
+ */
177
+ readonly skippedProcedures?: { name: string; reason: string }[]
171
178
  }
172
179
 
173
180
  export interface HeaderDoc {
package/src/index.test.ts CHANGED
@@ -915,10 +915,19 @@ describe('Streaming Procedures - CreateStream', () => {
915
915
  expect(values).toEqual(['user-123'])
916
916
  })
917
917
 
918
- test('CreateStream wrapped errors preserve cause', async () => {
918
+ test('CreateStream rethrows the original error preserving class identity', async () => {
919
+ // The streaming wrapper must NOT box user errors inside ProcedureError —
920
+ // doing so would defeat route-declared typed-error dispatch on the client
921
+ // (the HTTP builder's taxonomy would see `ProcedureError` instead of the
922
+ // user's class). Stack annotation is added in place; class identity and
923
+ // custom properties are preserved.
924
+ class MyDomainError extends Error {
925
+ readonly name = 'MyDomainError'
926
+ readonly code = 'STREAM_FAIL'
927
+ }
928
+
919
929
  const { CreateStream } = Procedures()
920
- const originalError = new Error('Stream underlying error')
921
- ;(originalError as any).code = 'STREAM_FAIL'
930
+ const originalError = new MyDomainError('Stream underlying error')
922
931
 
923
932
  const { StreamCause } = CreateStream(
924
933
  'StreamCause',
@@ -935,12 +944,40 @@ describe('Streaming Procedures - CreateStream', () => {
935
944
  }
936
945
  expect.fail('Should have thrown')
937
946
  } catch (e: any) {
938
- expect(e).toBeInstanceOf(ProcedureError)
939
- expect(e.cause).toBe(originalError)
940
- expect(e.cause.code).toBe('STREAM_FAIL')
947
+ expect(e).toBe(originalError)
948
+ expect(e).toBeInstanceOf(MyDomainError)
949
+ expect(e.code).toBe('STREAM_FAIL')
941
950
  }
942
951
  })
943
952
 
953
+ test('CreateStream propagates .return() to the user generator', async () => {
954
+ // Consumers that close a stream early (via `iterator.return()` or breaking
955
+ // out of for-await) must trigger the user generator's `finally` block so
956
+ // cleanup (db handles, subscriptions, signal-driven teardown) runs.
957
+ const { CreateStream } = Procedures()
958
+ let finallyRan = false
959
+
960
+ const { EarlyClose } = CreateStream(
961
+ 'EarlyClose',
962
+ {},
963
+ async function* () {
964
+ try {
965
+ yield 1
966
+ yield 2
967
+ yield 3
968
+ } finally {
969
+ finallyRan = true
970
+ }
971
+ }
972
+ )
973
+
974
+ const iter = EarlyClose({}, {})
975
+ const first = await iter.next()
976
+ expect(first.value).toBe(1)
977
+ await iter.return!(undefined)
978
+ expect(finallyRan).toBe(true)
979
+ })
980
+
944
981
  test('CreateStream with extended config', () => {
945
982
  interface ExtConfig {
946
983
  scope: string
package/src/index.ts CHANGED
@@ -383,8 +383,8 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
383
383
  params as any
384
384
  )
385
385
 
386
+ const userIterator = userGenerator[Symbol.asyncIterator]()
386
387
  try {
387
- const userIterator = userGenerator[Symbol.asyncIterator]()
388
388
  let userIterResult = await userIterator.next()
389
389
 
390
390
  while (!userIterResult.done) {
@@ -411,27 +411,30 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
411
411
  // can send it as a special 'return' SSE event
412
412
  return userIterResult.value
413
413
  } catch (error: any) {
414
- if (error instanceof ProcedureError) {
415
- throw error
416
- } else {
417
- const err = new ProcedureError(
418
- name,
419
- `Error in streaming handler for ${name} - ${error?.message}`,
420
- undefined,
421
- definitionInfo
422
- )
423
- err.cause = error
424
- if (error.stack && definitionInfo.definedAt) {
425
- const { file, line, column } = definitionInfo.definedAt
426
- err.stack =
427
- error.stack +
428
- `\n--- Procedure "${name}" defined at ---\n at ${file}:${line}:${column}`
429
- } else if (error.stack) {
430
- err.stack = error.stack
431
- }
432
- throw err
414
+ // Preserve the original error class so HTTP builders' taxonomies and
415
+ // `onMidStreamError` callbacks see the actual thrown type — boxing
416
+ // user-defined errors inside ProcedureError defeats route-declared
417
+ // typed-error dispatch on the client. Augment the stack trace in
418
+ // place with the procedure's definition site when available.
419
+ if (
420
+ definitionInfo.definedAt &&
421
+ error &&
422
+ typeof error.stack === 'string'
423
+ ) {
424
+ const { file, line, column } = definitionInfo.definedAt
425
+ error.stack =
426
+ `${error.stack}\n--- Procedure "${name}" defined at ---\n at ${file}:${line}:${column}`
433
427
  }
428
+ throw error
434
429
  } finally {
430
+ // Propagate `.return()` to the user generator so its `finally`
431
+ // blocks (and any `signal`-driven cleanup) run when the consumer
432
+ // closes the stream early. No-op when iteration already completed.
433
+ try {
434
+ await userIterator.return?.(undefined)
435
+ } catch {
436
+ // Swallow — cleanup must not mask the primary error path
437
+ }
435
438
  abortController.abort('stream-completed')
436
439
  }
437
440
  } as (ctx: TContext, params?: any) => AsyncGenerator<any, any, unknown>,