ts-procedures 4.0.1 → 5.1.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.
@@ -18,7 +18,7 @@ npm install hono
18
18
 
19
19
  ```typescript
20
20
  import { Procedures } from 'ts-procedures'
21
- import { HonoStreamAppBuilder } from 'ts-procedures/hono-stream'
21
+ import { HonoStreamAppBuilder, sse } from 'ts-procedures/hono-stream'
22
22
 
23
23
  // Define your context and config types
24
24
  type StreamContext = { userId: string }
@@ -30,7 +30,7 @@ interface RPCConfig {
30
30
  // Create a procedures factory
31
31
  const StreamRPC = Procedures<StreamContext, RPCConfig>()
32
32
 
33
- // Create a streaming procedure
33
+ // Create a streaming procedure — yield domain data directly
34
34
  StreamRPC.CreateStream(
35
35
  'WatchNotifications',
36
36
  {
@@ -42,8 +42,8 @@ StreamRPC.CreateStream(
42
42
  },
43
43
  async function* (ctx) {
44
44
  for (let i = 1; i <= 10; i++) {
45
- // SSE mode: yield { data, event?, id?, retry? }
46
- yield { data: { id: i, message: `Notification ${i}` }, event: 'notification' }
45
+ // Yield domain data directly matches yieldType schema
46
+ yield { id: i, message: `Notification ${i}` }
47
47
  await new Promise((r) => setTimeout(r, 1000))
48
48
  }
49
49
  }
@@ -73,41 +73,52 @@ POST /{pathPrefix}/{scope...}/{procedureName}/{version} (JSON body)
73
73
  - **GET**: For EventSource/SSE clients, params passed via query string
74
74
  - **POST**: For clients needing complex JSON body params
75
75
 
76
- ## SSE Yield Shape
76
+ ## Yielding in SSE Mode
77
77
 
78
- In SSE mode, generators yield `SSEYield` objects that control the SSE wire format directly:
78
+ Procedures yield **domain data directly** — the same shape as your `yieldType` schema. `HonoStreamAppBuilder` handles the SSE envelope (`event:`, `id:`, `data:`) automatically.
79
79
 
80
80
  ```typescript
81
- type SSEYield = {
82
- data: string | unknown // Required. Strings pass through as-is; objects are JSON.stringify'd
83
- event?: string // SSE event name. Defaults to procedure name
84
- id?: string // SSE event id. Auto-incremented if not provided
85
- retry?: number // SSE retry interval in ms. Passed through if present
81
+ async function* (ctx) {
82
+ // Domain objects are JSON.stringify'd into the SSE data: field
83
+ yield { count: 1 }
84
+
85
+ // Strings are passed through as-is (no double-stringify)
86
+ yield 'raw string value'
86
87
  }
87
88
  ```
88
89
 
89
- This gives procedure authors full control over SSE event names, IDs, and data formatting enabling native AI SDK streams, OpenAI-compatible streams, or any custom SSE protocol without frontend adapters.
90
+ By default, SSE events use the procedure name as the `event:` field and auto-increment the `id:` field.
91
+
92
+ ### `sse()` Helper
93
+
94
+ To override SSE metadata (event name, id, retry) for individual yields, use the `sse()` helper:
90
95
 
91
96
  ```typescript
92
- async function* (ctx) {
93
- // Minimal: only data required (event defaults to procedure name, id auto-increments)
94
- yield { data: { count: 1 } }
97
+ import { sse } from 'ts-procedures/hono-stream'
95
98
 
99
+ async function* (ctx) {
96
100
  // Custom event name
97
- yield { data: { type: 'heartbeat' }, event: 'ping' }
101
+ yield sse({ type: 'heartbeat' }, { event: 'ping' })
98
102
 
99
103
  // Custom id
100
- yield { data: { msg: 'hello' }, id: 'msg-001' }
104
+ yield sse({ msg: 'hello' }, { id: 'msg-001' })
101
105
 
102
- // String data (no double-stringify)
103
- yield { data: 'data: raw SSE line' }
106
+ // All SSE options
107
+ yield sse({ done: true }, { event: 'complete', id: 'final', retry: 5000 })
104
108
 
105
- // All fields
106
- yield { data: { done: true }, event: 'complete', id: 'final', retry: 5000 }
109
+ // sse() with no options — same as plain yield (uses defaults)
110
+ yield sse({ count: 1 })
107
111
  }
108
112
  ```
109
113
 
110
- **Text mode is unaffected** text-mode generators still yield plain domain objects.
114
+ `sse()` attaches metadata via a `WeakMap` side-channel, so:
115
+ - The domain object passes through core validation (`validateYields`) unmodified
116
+ - `JSON.stringify` only sees domain properties
117
+ - AJV's `removeAdditional: true` doesn't affect the metadata
118
+
119
+ > **Note:** `sse()` requires object values (used as `WeakMap` keys). Strings and other primitives cannot carry SSE metadata — they use the default event name (procedure name) and auto-incremented id.
120
+
121
+ **Text mode is unaffected** — both plain objects and `sse()`-tagged objects are JSON-stringified identically. SSE metadata is ignored in text mode.
111
122
 
112
123
  ## Stream Modes
113
124
 
@@ -125,7 +136,7 @@ event: WatchNotifications
125
136
  data: {"id":1,"message":"Notification 1"}
126
137
  id: 0
127
138
 
128
- event: notification
139
+ event: WatchNotifications
129
140
  data: {"id":2,"message":"Notification 2"}
130
141
  id: 1
131
142
  ```
@@ -138,7 +149,7 @@ eventSource.addEventListener('WatchNotifications', (event) => {
138
149
  const data = JSON.parse(event.data)
139
150
  console.log(data)
140
151
  })
141
- // Listen for custom events
152
+ // Listen for custom events (when using sse() helper)
142
153
  eventSource.addEventListener('notification', (event) => {
143
154
  const data = JSON.parse(event.data)
144
155
  console.log(data)
@@ -180,18 +191,18 @@ while (true) {
180
191
  ### Builder Config
181
192
 
182
193
  ```typescript
183
- interface HonoStreamAppBuilderConfig {
194
+ interface HonoStreamAppBuilderConfig<TErrorData = unknown> {
184
195
  app?: Hono // Use existing Hono instance
185
196
  pathPrefix?: string // Prefix for all routes
186
197
  defaultStreamMode?: 'sse' | 'text' // Default: 'sse'
187
198
  onRequestStart?: (c: Context) => void
188
199
  onRequestEnd?: (c: Context) => void
189
- onStreamStart?: (procedure: TStreamProcedureRegistration, c: Context) => void
190
- onStreamEnd?: (procedure: TStreamProcedureRegistration, c: Context) => void
200
+ onStreamStart?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
201
+ onStreamEnd?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
191
202
 
192
203
  // Error handling callbacks (see Error Handling section)
193
- onPreStreamError?: (procedure, c, error) => Response | Promise<Response>
194
- onMidStreamError?: (procedure, c, error) => { data: unknown; event?: string; id?: string; closeStream?: boolean } | undefined
204
+ onPreStreamError?: (procedure, c, error: ProcedureValidationError | Error) => Response | Promise<Response>
205
+ onMidStreamError?: (procedure, c, error) => MidStreamErrorResult<TErrorData> | undefined
195
206
  }
196
207
  ```
197
208
 
@@ -212,15 +223,17 @@ builder.register(factory, context, {
212
223
  Hooks execute in the following order:
213
224
 
214
225
  ```
215
- onRequestStart → [validation] → onStreamStart → [yields...] → onStreamEnd → onRequestEnd
216
-
217
- (validation error) (stream error)
218
-
219
- HTTP 400 response error sent in stream
220
-
221
- onStreamEnd
226
+ onRequestStart → [validation] → onStreamStart(proc, c, streamMode) → [yields...] → onStreamEnd(proc, c, streamMode) → onRequestEnd
227
+
228
+ (validation error) (stream error)
229
+
230
+ HTTP 400 response error sent in stream
231
+
232
+ onStreamEnd
222
233
  ```
223
234
 
235
+ `onStreamStart` and `onStreamEnd` receive the resolved `streamMode` (`'sse'` or `'text'`) as their third argument.
236
+
224
237
  ## Validation
225
238
 
226
239
  ### Pre-validation
@@ -248,7 +261,7 @@ Streaming has two distinct error phases with different semantics:
248
261
 
249
262
  ### Pre-Stream Errors (`onPreStreamError`)
250
263
 
251
- Errors that occur **before** the stream starts (validation failures, context resolution errors). The return value **IS** used as the HTTP response:
264
+ Errors that occur **before** the stream starts (validation failures, context resolution errors). The `error` parameter is typed as `ProcedureValidationError | Error`, allowing `instanceof` narrowing. The return value **IS** used as the HTTP response:
252
265
 
253
266
  ```typescript
254
267
  const builder = new HonoStreamAppBuilder({
@@ -271,23 +284,22 @@ const builder = new HonoStreamAppBuilder({
271
284
 
272
285
  ### Mid-Stream Errors (`onMidStreamError`)
273
286
 
274
- Errors that occur **during** streaming (generator throws). Since the stream is already open, HTTP status cannot change. Instead, return a value to write to the stream:
287
+ Errors that occur **during** streaming (generator throws). Since the stream is already open, HTTP status cannot change. Return `{ data, closeStream? }` — the `data` value is written as the SSE `data:` field content.
288
+
289
+ To control SSE metadata (event name, id, retry) on the error yield, wrap the data object with `sse()`:
275
290
 
276
291
  ```typescript
277
292
  const builder = new HonoStreamAppBuilder({
278
293
  onMidStreamError: (procedure, c, error) => {
279
294
  return {
280
- // Data to write to stream (should match your yieldType schema)
281
- data: {
295
+ // Data written as the SSE data: field (should match your yieldType schema)
296
+ // Use sse() to attach custom event/id/retry metadata
297
+ data: sse({
282
298
  type: 'error',
283
299
  code: 'STREAM_ERROR',
284
300
  message: error.message,
285
301
  retryable: false,
286
- },
287
- // Optional SSE event name (defaults to procedure name if data provided, 'error' otherwise)
288
- event: 'error',
289
- // Optional SSE event id (auto-incremented if not provided)
290
- id: 'err-001',
302
+ }, { event: 'error', id: 'err-001' }),
291
303
  // Optional: whether to close stream after (default: true)
292
304
  closeStream: true,
293
305
  }
@@ -296,11 +308,36 @@ const builder = new HonoStreamAppBuilder({
296
308
  ```
297
309
 
298
310
  **Return values:**
299
- - `{ data, event?, id?, closeStream? }` - Write `data` to stream with optional SSE fields, optionally keep stream open
311
+ - `{ data, closeStream? }` - Write `data` to stream. Use `sse(data, opts)` for custom SSE event/id/retry
300
312
  - `undefined` - Use default behavior: `{ error: message }` with `event: 'error'`
301
313
 
314
+ Without `sse()`, the event defaults to the procedure name when custom data is provided, or `'error'` when using the default error format. The id auto-increments.
315
+
302
316
  ### Type-Safe Error Handling with Union Types
303
317
 
318
+ Use the generic `TErrorData` parameter on `HonoStreamAppBuilder` to constrain the `onMidStreamError` return type:
319
+
320
+ ```typescript
321
+ type ErrorPayload = {
322
+ type: 'error'
323
+ code: string
324
+ message: string
325
+ retryable: boolean
326
+ }
327
+
328
+ // TErrorData constrains what onMidStreamError can return
329
+ const builder = new HonoStreamAppBuilder<ErrorPayload>({
330
+ onMidStreamError: (proc, c, error) => ({
331
+ data: {
332
+ type: 'error',
333
+ code: 'STREAM_ERROR',
334
+ message: error.message,
335
+ retryable: false,
336
+ }
337
+ })
338
+ })
339
+ ```
340
+
304
341
  For type-safe error handling, define your `yieldType` as a discriminated union:
305
342
 
306
343
  ```typescript
@@ -325,21 +362,10 @@ StreamRPC.CreateStream('WatchEvents', {
325
362
  ])
326
363
  }
327
364
  }, async function* (ctx, params) {
328
- yield { data: { type: 'event', eventType: 'user_joined', data: { userId: ctx.userId } } }
365
+ // Yield domain data directly matches yieldType schema
366
+ yield { type: 'event', eventType: 'user_joined', data: { userId: ctx.userId } }
329
367
  // On error, the procedure throws and onMidStreamError handles it
330
368
  })
331
-
332
- // Configure error callback to return union-compatible error value
333
- const builder = new HonoStreamAppBuilder({
334
- onMidStreamError: (proc, c, error) => ({
335
- data: {
336
- type: 'error',
337
- code: 'STREAM_ERROR',
338
- message: error.message,
339
- retryable: false,
340
- }
341
- })
342
- })
343
369
  ```
344
370
 
345
371
  **Output (SSE mode):**
@@ -379,9 +405,25 @@ interface StreamHttpRouteDoc {
379
405
  scope: string | string[]
380
406
  version: number
381
407
  jsonSchema: {
382
- params?: object // From schema.params
383
- yieldType?: object // From schema.yieldType
384
- returnType?: object // From schema.returnType
408
+ params?: Record<string, unknown> // From schema.params
409
+ yieldType?: Record<string, unknown> // SSE: envelope with data/event/id/retry; Text: from schema.yieldType
410
+ returnType?: Record<string, unknown> // From schema.returnType
411
+ }
412
+ }
413
+ ```
414
+
415
+ In SSE mode, the documented `yieldType` is an SSE envelope schema with the user's `yieldType` nested under the `data` property:
416
+
417
+ ```json
418
+ {
419
+ "type": "object",
420
+ "description": "SSE message envelope. The data field contains the procedure yield value.",
421
+ "required": ["data", "event", "id"],
422
+ "properties": {
423
+ "data": { /* user's yieldType schema */ },
424
+ "event": { "type": "string" },
425
+ "id": { "type": "string" },
426
+ "retry": { "type": "number" }
385
427
  }
386
428
  }
387
429
  ```
@@ -398,7 +440,7 @@ RPC.Create('GetUser', config, async (ctx) => ({ user: 'data' }))
398
440
 
399
441
  // This WILL be registered
400
442
  RPC.CreateStream('WatchUser', config, async function* (ctx) {
401
- yield { data: { update: 'data' } }
443
+ yield { update: 'data' }
402
444
  })
403
445
  ```
404
446
 
@@ -409,7 +451,7 @@ When a client disconnects, the stream's `onAbort` handler is triggered, which ca
409
451
  ```typescript
410
452
  RPC.CreateStream('LongStream', config, async function* (ctx) {
411
453
  while (!ctx.signal.aborted) {
412
- yield { data: { tick: Date.now() } }
454
+ yield { tick: Date.now() }
413
455
  await new Promise((r) => setTimeout(r, 1000))
414
456
  }
415
457
  })
@@ -420,10 +462,52 @@ RPC.CreateStream('LongStream', config, async function* (ctx) {
420
462
  ```typescript
421
463
  import {
422
464
  HonoStreamAppBuilder,
423
- HonoStreamAppBuilderConfig,
465
+ HonoStreamAppBuilderConfig, // Generic: HonoStreamAppBuilderConfig<TErrorData = unknown>
424
466
  StreamHttpRouteDoc,
425
467
  StreamMode,
426
- SSEYield, // Yield shape for SSE mode generators
427
- MidStreamErrorResult, // Return type for onMidStreamError callback
468
+ sse, // Helper to attach SSE metadata (event/id/retry) to yielded objects
469
+ SSEOptions, // Type for sse() options: { event?, id?, retry? }
470
+ MidStreamErrorResult, // Generic: MidStreamErrorResult<TErrorData = unknown>
428
471
  } from 'ts-procedures/hono-stream'
429
472
  ```
473
+
474
+ ## Migration Notes
475
+
476
+ ### Migrating from v5
477
+
478
+ **Breaking: `MidStreamErrorResult` no longer has `event` and `id` fields.** Use `sse()` to attach SSE metadata to error data instead:
479
+
480
+ ```typescript
481
+ // Before (v5)
482
+ onMidStreamError: (proc, c, error) => ({
483
+ data: { type: 'error', message: error.message },
484
+ event: 'custom-error',
485
+ id: 'err-1',
486
+ })
487
+
488
+ // After (v6)
489
+ onMidStreamError: (proc, c, error) => ({
490
+ data: sse(
491
+ { type: 'error', message: error.message },
492
+ { event: 'custom-error', id: 'err-1' }
493
+ ),
494
+ })
495
+ ```
496
+
497
+ **Breaking: `onStreamStart` / `onStreamEnd` now receive `streamMode` as a third parameter.**
498
+
499
+ ```typescript
500
+ // Before (v5)
501
+ onStreamStart: (procedure, c) => { ... }
502
+ onStreamEnd: (procedure, c) => { ... }
503
+
504
+ // After (v6)
505
+ onStreamStart: (procedure, c, streamMode) => { ... }
506
+ onStreamEnd: (procedure, c, streamMode) => { ... }
507
+ ```
508
+
509
+ **Breaking: `StreamHttpRouteDoc.jsonSchema` fields narrowed from `object` to `Record<string, unknown>`.**
510
+
511
+ **New: Generic `TErrorData` parameter** on `HonoStreamAppBuilder` and `MidStreamErrorResult` for type-safe `onMidStreamError` callbacks.
512
+
513
+ **New: `onPreStreamError` error parameter** typed as `ProcedureValidationError | Error` for `instanceof` narrowing.