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.
- package/build/client/index.js +5 -0
- package/build/client/index.js.map +1 -1
- package/build/client/stream.d.ts +25 -1
- package/build/client/stream.js +48 -5
- package/build/client/stream.js.map +1 -1
- package/build/client/stream.test.js +68 -1
- package/build/client/stream.test.js.map +1 -1
- package/build/codegen/bin/cli.js +0 -0
- package/build/implementations/http/doc-registry.js +14 -0
- package/build/implementations/http/doc-registry.js.map +1 -1
- package/build/implementations/http/doc-registry.test.js +37 -1
- package/build/implementations/http/doc-registry.test.js.map +1 -1
- package/build/implementations/http/hono-rpc/index.d.ts +11 -0
- package/build/implementations/http/hono-rpc/index.js +22 -1
- package/build/implementations/http/hono-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-rpc/index.test.js +25 -0
- package/build/implementations/http/hono-rpc/index.test.js.map +1 -1
- package/build/implementations/http/hono-stream/error-taxonomy.test.js +72 -0
- package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +1 -1
- package/build/implementations/http/hono-stream/index.d.ts +18 -4
- package/build/implementations/http/hono-stream/index.js +97 -18
- package/build/implementations/http/hono-stream/index.js.map +1 -1
- package/build/implementations/http/hono-stream/index.test.js +3 -3
- package/build/implementations/http/hono-stream/index.test.js.map +1 -1
- package/build/implementations/types.d.ts +10 -0
- package/build/index.js +22 -17
- package/build/index.js.map +1 -1
- package/build/index.test.js +36 -6
- package/build/index.test.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +6 -0
- package/src/client/stream.test.ts +82 -1
- package/src/client/stream.ts +67 -4
- package/src/implementations/http/doc-registry.test.ts +43 -1
- package/src/implementations/http/doc-registry.ts +19 -0
- package/src/implementations/http/hono-rpc/index.test.ts +32 -0
- package/src/implementations/http/hono-rpc/index.ts +27 -1
- package/src/implementations/http/hono-stream/error-taxonomy.test.ts +80 -0
- package/src/implementations/http/hono-stream/index.test.ts +3 -3
- package/src/implementations/http/hono-stream/index.ts +118 -22
- package/src/implementations/types.ts +7 -0
- package/src/index.test.ts +43 -6
- 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(['
|
|
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(['
|
|
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(['
|
|
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.
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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
|
-
//
|
|
359
|
-
|
|
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?.
|
|
362
|
-
|
|
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 ??
|
|
372
|
-
id: sseMeta?.id ?? String(eventId++),
|
|
373
|
-
...(sseMeta?.retry !== undefined && {
|
|
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
|
-
//
|
|
412
|
-
|
|
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?.
|
|
415
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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).
|
|
939
|
-
expect(e
|
|
940
|
-
expect(e.
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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>,
|