ts-procedures 5.2.0 → 5.3.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.
@@ -0,0 +1,113 @@
1
+ # Stream Procedure Template: {{Name}}
2
+
3
+ ## Implementation — `{{Name}}.stream.ts`
4
+
5
+ ```typescript
6
+ import { Procedures } from 'ts-procedures'
7
+ import { Type } from 'typebox'
8
+
9
+ type AppContext = {
10
+ userId: string
11
+ signal?: AbortSignal
12
+ }
13
+
14
+ const { CreateStream } = Procedures<AppContext>()
15
+
16
+ export const { {{Name}}, procedure: {{Name}}Procedure, info: {{Name}}Info } = CreateStream(
17
+ '{{Name}}',
18
+ {
19
+ description: 'TODO: describe what {{Name}} streams',
20
+ schema: {
21
+ params: Type.Object({
22
+ // TODO: define input parameters
23
+ channel: Type.String(),
24
+ }),
25
+ yieldType: Type.Object({
26
+ // TODO: define shape of each yielded value
27
+ id: Type.String(),
28
+ data: Type.Any(),
29
+ timestamp: Type.Number(),
30
+ }),
31
+ },
32
+ // Set to true to validate each yielded value against yieldType schema
33
+ // validateYields: false,
34
+ },
35
+ async function* (ctx, params) {
36
+ // ctx.signal is ALWAYS present in stream handlers (guaranteed AbortSignal)
37
+
38
+ // TODO: implement streaming logic
39
+ // Example: poll-based streaming
40
+ let counter = 0
41
+ while (!ctx.signal.aborted) {
42
+ const data = await fetchLatestData(params.channel, { signal: ctx.signal })
43
+
44
+ yield {
45
+ id: String(counter++),
46
+ data,
47
+ timestamp: Date.now(),
48
+ }
49
+
50
+ // Wait between polls (pass signal for cancellation)
51
+ await new Promise((resolve, reject) => {
52
+ const timeout = setTimeout(resolve, 1000)
53
+ ctx.signal.addEventListener('abort', () => {
54
+ clearTimeout(timeout)
55
+ resolve(undefined)
56
+ }, { once: true })
57
+ })
58
+ }
59
+
60
+ // After loop exits, signal.reason tells you why:
61
+ // - 'stream-completed': consumer finished reading (normal)
62
+ // - other: client disconnected or external abort
63
+ }
64
+ )
65
+
66
+ // Placeholder — replace with real data fetching
67
+ async function fetchLatestData(channel: string, opts?: { signal?: AbortSignal }) {
68
+ return { value: Math.random() }
69
+ }
70
+ ```
71
+
72
+ ## Test — `{{Name}}.stream.test.ts`
73
+
74
+ ```typescript
75
+ import { describe, test, expect } from 'vitest'
76
+ import { ProcedureValidationError } from 'ts-procedures'
77
+ import { {{Name}} } from './{{Name}}.stream'
78
+
79
+ const mockContext = { userId: 'test-user' }
80
+
81
+ describe('{{Name}}', () => {
82
+ test('yields expected values', async () => {
83
+ const values = []
84
+ for await (const val of {{Name}}(mockContext, { channel: 'test' })) {
85
+ values.push(val)
86
+ if (values.length >= 3) break // Don't consume indefinitely
87
+ }
88
+
89
+ expect(values).toHaveLength(3)
90
+ expect(values[0]).toHaveProperty('id')
91
+ expect(values[0]).toHaveProperty('data')
92
+ expect(values[0]).toHaveProperty('timestamp')
93
+ })
94
+
95
+ test('throws ProcedureValidationError for invalid params', async () => {
96
+ const gen = {{Name}}(mockContext, {} as any)
97
+ await expect(gen.next()).rejects.toThrow(ProcedureValidationError)
98
+ })
99
+
100
+ test('respects signal abort', async () => {
101
+ const ac = new AbortController()
102
+ const values = []
103
+
104
+ setTimeout(() => ac.abort(), 100)
105
+
106
+ for await (const val of {{Name}}({ ...mockContext, signal: ac.signal }, { channel: 'test' })) {
107
+ values.push(val)
108
+ }
109
+
110
+ expect(values.length).toBeGreaterThanOrEqual(0)
111
+ })
112
+ })
113
+ ```
@@ -0,0 +1,255 @@
1
+ # ts-procedures Framework Reference
2
+
3
+ You are assisting a developer using **ts-procedures**, a TypeScript RPC framework that creates type-safe, schema-validated procedure calls with a single function definition.
4
+
5
+ ## Core Flow
6
+
7
+ ```
8
+ Procedures<TContext, TExtendedConfig>(builder?)
9
+
10
+ Create(name, config, handler) → Standard async procedure
11
+ CreateStream(name, config, handler) → Streaming async generator
12
+
13
+ Returns: { [name]: handler, procedure: handler, info: metadata }
14
+ ```
15
+
16
+ ## Imports
17
+
18
+ ```typescript
19
+ // Core
20
+ import { Procedures } from 'ts-procedures'
21
+ import type { TLocalContext, TStreamContext, TProcedureRegistration, TStreamProcedureRegistration } from 'ts-procedures'
22
+
23
+ // Errors
24
+ import { ProcedureError, ProcedureValidationError, ProcedureYieldValidationError, ProcedureRegistrationError } from 'ts-procedures'
25
+
26
+ // HTTP types
27
+ import type { RPCConfig, RPCHttpRouteDoc, StreamHttpRouteDoc, StreamMode } from 'ts-procedures/http'
28
+
29
+ // Express RPC
30
+ import { ExpressRPCAppBuilder } from 'ts-procedures/express-rpc'
31
+
32
+ // Hono RPC
33
+ import { HonoRPCAppBuilder } from 'ts-procedures/hono-rpc'
34
+
35
+ // Hono Streaming
36
+ import { HonoStreamAppBuilder, sse } from 'ts-procedures/hono-stream'
37
+ ```
38
+
39
+ ## Architecture Rules
40
+
41
+ 1. **schema.params is validated at runtime; schema.returnType is documentation only.** Never expect returnType to be validated. Use TypeScript for return type safety.
42
+
43
+ 2. **Use ctx.error() for business logic errors.** Never throw raw `Error` instances. `ctx.error(message, meta?)` creates `ProcedureError` with procedure name, metadata, and enhanced stack trace.
44
+
45
+ 3. **Pass ctx.signal to all downstream async calls.** HTTP implementations inject AbortSignal automatically. Pass it to fetch, database queries, and other async operations for cancellation support.
46
+
47
+ 4. **Stream handlers always have ctx.signal (guaranteed AbortSignal).** Standard handlers get it when HTTP implementations provide it. Check `signal.reason === 'stream-completed'` to distinguish normal completion from client disconnect.
48
+
49
+ 5. **Use TypeBox for schemas** (`import { Type } from 'typebox'`). Plain JSON Schema objects are not recognized. AJV config: `allErrors: true`, `coerceTypes: true`, `removeAdditional: true`.
50
+
51
+ 6. **One factory per access level.** Separate `Procedures()` factories for public, authenticated, admin contexts.
52
+
53
+ 7. **onCreate callback enables framework integration.** Use for route registration, OpenAPI generation, logging.
54
+
55
+ 8. **getProcedures() for introspection.** Returns all registered procedures with metadata.
56
+
57
+ ## Procedure Pattern
58
+
59
+ ```typescript
60
+ import { Procedures } from 'ts-procedures'
61
+ import { Type } from 'typebox'
62
+
63
+ type AppContext = { userId: string; signal?: AbortSignal }
64
+
65
+ const { Create, CreateStream } = Procedures<AppContext>()
66
+
67
+ // Standard procedure
68
+ const { GetUser } = Create(
69
+ 'GetUser',
70
+ {
71
+ description: 'Fetch user by ID',
72
+ schema: {
73
+ params: Type.Object({ id: Type.String() }),
74
+ returnType: Type.Object({ id: Type.String(), name: Type.String() }),
75
+ },
76
+ },
77
+ async (ctx, params) => {
78
+ // params.id guaranteed string (AJV validated)
79
+ const user = await fetchUser(params.id, { signal: ctx.signal })
80
+ if (!user) throw ctx.error('Not found', { code: 'NOT_FOUND' })
81
+ return user
82
+ }
83
+ )
84
+ ```
85
+
86
+ ## Stream Procedure Pattern
87
+
88
+ ```typescript
89
+ const { StreamEvents } = CreateStream(
90
+ 'StreamEvents',
91
+ {
92
+ schema: {
93
+ params: Type.Object({ channel: Type.String() }),
94
+ yieldType: Type.Object({ type: Type.String(), data: Type.Any() }),
95
+ },
96
+ },
97
+ async function* (ctx, params) {
98
+ // ctx.signal always present in streams
99
+ while (!ctx.signal.aborted) {
100
+ const event = await pollEvents(params.channel, { signal: ctx.signal })
101
+ yield event
102
+ }
103
+ }
104
+ )
105
+ ```
106
+
107
+ ## Express RPC Pattern
108
+
109
+ ```typescript
110
+ import { ExpressRPCAppBuilder } from 'ts-procedures/express-rpc'
111
+ import type { RPCConfig } from 'ts-procedures/http'
112
+
113
+ const RPC = Procedures<AppContext, RPCConfig>()
114
+
115
+ RPC.Create('GetUser', {
116
+ scope: 'users', version: 1,
117
+ schema: { params: Type.Object({ id: Type.String() }) },
118
+ }, async (ctx, params) => fetchUser(params.id))
119
+
120
+ const app = new ExpressRPCAppBuilder({ pathPrefix: '/api' })
121
+ .register(RPC, async (req) => ({ userId: await authenticate(req) }))
122
+ .build()
123
+ // POST /api/users/get-user/1
124
+ ```
125
+
126
+ ## Hono RPC Pattern
127
+
128
+ ```typescript
129
+ import { HonoRPCAppBuilder } from 'ts-procedures/hono-rpc'
130
+
131
+ const app = new HonoRPCAppBuilder({ pathPrefix: '/api' })
132
+ .register(RPC, (c) => ({ userId: c.req.header('x-user-id') }))
133
+ .build()
134
+ ```
135
+
136
+ ## Hono Streaming Pattern
137
+
138
+ ```typescript
139
+ import { HonoStreamAppBuilder, sse } from 'ts-procedures/hono-stream'
140
+
141
+ const StreamRPC = Procedures<AppContext, RPCConfig>()
142
+
143
+ StreamRPC.CreateStream('Feed', {
144
+ scope: 'events', version: 1,
145
+ schema: { params: Type.Object({ channel: Type.String() }) },
146
+ }, async function* (ctx, params) {
147
+ while (!ctx.signal.aborted) {
148
+ const event = await poll({ signal: ctx.signal })
149
+ yield sse(event, { event: 'update' })
150
+ }
151
+ })
152
+
153
+ const app = new HonoStreamAppBuilder({ defaultStreamMode: 'sse' })
154
+ .register(StreamRPC, (c) => ({ userId: c.req.header('x-user-id') }))
155
+ .build()
156
+ // GET|POST /events/feed/1
157
+ ```
158
+
159
+ ## Error Handling
160
+
161
+ | Error Class | Trigger | HTTP Status |
162
+ |-------------|---------|-------------|
163
+ | `ProcedureValidationError` | Schema params validation failure | 400 |
164
+ | `ProcedureError` | `ctx.error()` or unhandled handler exception | 422/500 |
165
+ | `ProcedureYieldValidationError` | Yield validation failure (validateYields: true) | N/A (mid-stream) |
166
+ | `ProcedureRegistrationError` | Invalid schema or duplicate name at registration | N/A (startup) |
167
+
168
+ ```typescript
169
+ // In HTTP builder
170
+ onError: (procedure, req, res, error) => {
171
+ if (error instanceof ProcedureValidationError) {
172
+ res.status(400).json({ error: error.message, details: error.errors })
173
+ } else if (error instanceof ProcedureError) {
174
+ res.status(422).json({ error: error.message, meta: error.meta })
175
+ } else {
176
+ res.status(500).json({ error: 'Internal server error' })
177
+ }
178
+ }
179
+ ```
180
+
181
+ ## Lifecycle Hook Order
182
+
183
+ ### Standard RPC
184
+ ```
185
+ onRequestStart → factoryContext() → handler() → onSuccess → onRequestEnd
186
+ → onError → onRequestEnd
187
+ ```
188
+
189
+ ### Streaming
190
+ ```
191
+ onRequestStart → factoryContext() → validation → onStreamStart → handler yields → onStreamEnd → onRequestEnd
192
+ → onPreStreamError → onRequestEnd
193
+ → onMidStreamError → onStreamEnd → onRequestEnd
194
+ ```
195
+
196
+ ## Decision Framework
197
+
198
+ **Which procedure type?**
199
+ - Single response → `Create`
200
+ - Multiple values over time → `CreateStream`
201
+
202
+ **Which schema library?**
203
+ - **TypeBox** (`import { Type } from 'typebox'`)
204
+
205
+ **Which HTTP implementation?**
206
+ - Express → `ExpressRPCAppBuilder`
207
+ - Hono (standard) → `HonoRPCAppBuilder`
208
+ - Hono (streaming) → `HonoStreamAppBuilder`
209
+
210
+ **Stream mode?**
211
+ - Browser EventSource → `'sse'` (default)
212
+ - Simple HTTP client → `'text'` (newline-delimited JSON)
213
+
214
+ ## Anti-Patterns — NEVER Do These
215
+
216
+ 1. **Never throw raw Error** — use `ctx.error(message, meta?)`
217
+ 2. **Never expect returnType runtime validation** — it's docs only
218
+ 3. **Never put validation logic in handler** — use `schema.params`
219
+ 4. **Never ignore ctx.signal** — pass to all async calls
220
+ 5. **Never skip signal.aborted check in stream loops** — causes resource leaks
221
+ 6. **Never register Create procedures with HonoStreamAppBuilder** — they're silently ignored
222
+ 7. **Never use plain JSON Schema objects** — use TypeBox builders
223
+ 8. **Never swallow errors without re-throwing** — hides failures
224
+ 9. **Never assume extra params fields survive** — `removeAdditional: true` strips them
225
+ 10. **Never manually parse types AJV coerces** — `coerceTypes: true` handles it
226
+
227
+ ## Testing
228
+
229
+ ```typescript
230
+ import { describe, test, expect } from 'vitest'
231
+ import { ProcedureError, ProcedureValidationError } from 'ts-procedures'
232
+
233
+ describe('GetUser', () => {
234
+ test('valid params', async () => {
235
+ const result = await GetUser(mockCtx, { id: 'user-1' })
236
+ expect(result.id).toBe('user-1')
237
+ })
238
+
239
+ test('invalid params', async () => {
240
+ await expect(GetUser(mockCtx, {})).rejects.toThrow(ProcedureValidationError)
241
+ })
242
+
243
+ test('business error', async () => {
244
+ await expect(GetUser(mockCtx, { id: 'missing' })).rejects.toThrow(ProcedureError)
245
+ })
246
+ })
247
+ ```
248
+
249
+ ## HTTP Route Path Format
250
+
251
+ `{pathPrefix}/{scope}/{kebab-case-name}/{version}`
252
+
253
+ - `scope` can be string or string[] (joined as path segments)
254
+ - Procedure name auto-converted to kebab-case
255
+ - Stream routes support both GET (query params) and POST (JSON body)
@@ -0,0 +1,255 @@
1
+ # ts-procedures Framework Reference
2
+
3
+ You are assisting a developer using **ts-procedures**, a TypeScript RPC framework that creates type-safe, schema-validated procedure calls with a single function definition.
4
+
5
+ ## Core Flow
6
+
7
+ ```
8
+ Procedures<TContext, TExtendedConfig>(builder?)
9
+
10
+ Create(name, config, handler) → Standard async procedure
11
+ CreateStream(name, config, handler) → Streaming async generator
12
+
13
+ Returns: { [name]: handler, procedure: handler, info: metadata }
14
+ ```
15
+
16
+ ## Imports
17
+
18
+ ```typescript
19
+ // Core
20
+ import { Procedures } from 'ts-procedures'
21
+ import type { TLocalContext, TStreamContext, TProcedureRegistration, TStreamProcedureRegistration } from 'ts-procedures'
22
+
23
+ // Errors
24
+ import { ProcedureError, ProcedureValidationError, ProcedureYieldValidationError, ProcedureRegistrationError } from 'ts-procedures'
25
+
26
+ // HTTP types
27
+ import type { RPCConfig, RPCHttpRouteDoc, StreamHttpRouteDoc, StreamMode } from 'ts-procedures/http'
28
+
29
+ // Express RPC
30
+ import { ExpressRPCAppBuilder } from 'ts-procedures/express-rpc'
31
+
32
+ // Hono RPC
33
+ import { HonoRPCAppBuilder } from 'ts-procedures/hono-rpc'
34
+
35
+ // Hono Streaming
36
+ import { HonoStreamAppBuilder, sse } from 'ts-procedures/hono-stream'
37
+ ```
38
+
39
+ ## Architecture Rules
40
+
41
+ 1. **schema.params is validated at runtime; schema.returnType is documentation only.** Never expect returnType to be validated. Use TypeScript for return type safety.
42
+
43
+ 2. **Use ctx.error() for business logic errors.** Never throw raw `Error` instances. `ctx.error(message, meta?)` creates `ProcedureError` with procedure name, metadata, and enhanced stack trace.
44
+
45
+ 3. **Pass ctx.signal to all downstream async calls.** HTTP implementations inject AbortSignal automatically. Pass it to fetch, database queries, and other async operations for cancellation support.
46
+
47
+ 4. **Stream handlers always have ctx.signal (guaranteed AbortSignal).** Standard handlers get it when HTTP implementations provide it. Check `signal.reason === 'stream-completed'` to distinguish normal completion from client disconnect.
48
+
49
+ 5. **Use TypeBox for schemas** (`import { Type } from 'typebox'`). Plain JSON Schema objects are not recognized. AJV config: `allErrors: true`, `coerceTypes: true`, `removeAdditional: true`.
50
+
51
+ 6. **One factory per access level.** Separate `Procedures()` factories for public, authenticated, admin contexts.
52
+
53
+ 7. **onCreate callback enables framework integration.** Use for route registration, OpenAPI generation, logging.
54
+
55
+ 8. **getProcedures() for introspection.** Returns all registered procedures with metadata.
56
+
57
+ ## Procedure Pattern
58
+
59
+ ```typescript
60
+ import { Procedures } from 'ts-procedures'
61
+ import { Type } from 'typebox'
62
+
63
+ type AppContext = { userId: string; signal?: AbortSignal }
64
+
65
+ const { Create, CreateStream } = Procedures<AppContext>()
66
+
67
+ // Standard procedure
68
+ const { GetUser } = Create(
69
+ 'GetUser',
70
+ {
71
+ description: 'Fetch user by ID',
72
+ schema: {
73
+ params: Type.Object({ id: Type.String() }),
74
+ returnType: Type.Object({ id: Type.String(), name: Type.String() }),
75
+ },
76
+ },
77
+ async (ctx, params) => {
78
+ // params.id guaranteed string (AJV validated)
79
+ const user = await fetchUser(params.id, { signal: ctx.signal })
80
+ if (!user) throw ctx.error('Not found', { code: 'NOT_FOUND' })
81
+ return user
82
+ }
83
+ )
84
+ ```
85
+
86
+ ## Stream Procedure Pattern
87
+
88
+ ```typescript
89
+ const { StreamEvents } = CreateStream(
90
+ 'StreamEvents',
91
+ {
92
+ schema: {
93
+ params: Type.Object({ channel: Type.String() }),
94
+ yieldType: Type.Object({ type: Type.String(), data: Type.Any() }),
95
+ },
96
+ },
97
+ async function* (ctx, params) {
98
+ // ctx.signal always present in streams
99
+ while (!ctx.signal.aborted) {
100
+ const event = await pollEvents(params.channel, { signal: ctx.signal })
101
+ yield event
102
+ }
103
+ }
104
+ )
105
+ ```
106
+
107
+ ## Express RPC Pattern
108
+
109
+ ```typescript
110
+ import { ExpressRPCAppBuilder } from 'ts-procedures/express-rpc'
111
+ import type { RPCConfig } from 'ts-procedures/http'
112
+
113
+ const RPC = Procedures<AppContext, RPCConfig>()
114
+
115
+ RPC.Create('GetUser', {
116
+ scope: 'users', version: 1,
117
+ schema: { params: Type.Object({ id: Type.String() }) },
118
+ }, async (ctx, params) => fetchUser(params.id))
119
+
120
+ const app = new ExpressRPCAppBuilder({ pathPrefix: '/api' })
121
+ .register(RPC, async (req) => ({ userId: await authenticate(req) }))
122
+ .build()
123
+ // POST /api/users/get-user/1
124
+ ```
125
+
126
+ ## Hono RPC Pattern
127
+
128
+ ```typescript
129
+ import { HonoRPCAppBuilder } from 'ts-procedures/hono-rpc'
130
+
131
+ const app = new HonoRPCAppBuilder({ pathPrefix: '/api' })
132
+ .register(RPC, (c) => ({ userId: c.req.header('x-user-id') }))
133
+ .build()
134
+ ```
135
+
136
+ ## Hono Streaming Pattern
137
+
138
+ ```typescript
139
+ import { HonoStreamAppBuilder, sse } from 'ts-procedures/hono-stream'
140
+
141
+ const StreamRPC = Procedures<AppContext, RPCConfig>()
142
+
143
+ StreamRPC.CreateStream('Feed', {
144
+ scope: 'events', version: 1,
145
+ schema: { params: Type.Object({ channel: Type.String() }) },
146
+ }, async function* (ctx, params) {
147
+ while (!ctx.signal.aborted) {
148
+ const event = await poll({ signal: ctx.signal })
149
+ yield sse(event, { event: 'update' })
150
+ }
151
+ })
152
+
153
+ const app = new HonoStreamAppBuilder({ defaultStreamMode: 'sse' })
154
+ .register(StreamRPC, (c) => ({ userId: c.req.header('x-user-id') }))
155
+ .build()
156
+ // GET|POST /events/feed/1
157
+ ```
158
+
159
+ ## Error Handling
160
+
161
+ | Error Class | Trigger | HTTP Status |
162
+ |-------------|---------|-------------|
163
+ | `ProcedureValidationError` | Schema params validation failure | 400 |
164
+ | `ProcedureError` | `ctx.error()` or unhandled handler exception | 422/500 |
165
+ | `ProcedureYieldValidationError` | Yield validation failure (validateYields: true) | N/A (mid-stream) |
166
+ | `ProcedureRegistrationError` | Invalid schema or duplicate name at registration | N/A (startup) |
167
+
168
+ ```typescript
169
+ // In HTTP builder
170
+ onError: (procedure, req, res, error) => {
171
+ if (error instanceof ProcedureValidationError) {
172
+ res.status(400).json({ error: error.message, details: error.errors })
173
+ } else if (error instanceof ProcedureError) {
174
+ res.status(422).json({ error: error.message, meta: error.meta })
175
+ } else {
176
+ res.status(500).json({ error: 'Internal server error' })
177
+ }
178
+ }
179
+ ```
180
+
181
+ ## Lifecycle Hook Order
182
+
183
+ ### Standard RPC
184
+ ```
185
+ onRequestStart → factoryContext() → handler() → onSuccess → onRequestEnd
186
+ → onError → onRequestEnd
187
+ ```
188
+
189
+ ### Streaming
190
+ ```
191
+ onRequestStart → factoryContext() → validation → onStreamStart → handler yields → onStreamEnd → onRequestEnd
192
+ → onPreStreamError → onRequestEnd
193
+ → onMidStreamError → onStreamEnd → onRequestEnd
194
+ ```
195
+
196
+ ## Decision Framework
197
+
198
+ **Which procedure type?**
199
+ - Single response → `Create`
200
+ - Multiple values over time → `CreateStream`
201
+
202
+ **Which schema library?**
203
+ - **TypeBox** (`import { Type } from 'typebox'`)
204
+
205
+ **Which HTTP implementation?**
206
+ - Express → `ExpressRPCAppBuilder`
207
+ - Hono (standard) → `HonoRPCAppBuilder`
208
+ - Hono (streaming) → `HonoStreamAppBuilder`
209
+
210
+ **Stream mode?**
211
+ - Browser EventSource → `'sse'` (default)
212
+ - Simple HTTP client → `'text'` (newline-delimited JSON)
213
+
214
+ ## Anti-Patterns — NEVER Do These
215
+
216
+ 1. **Never throw raw Error** — use `ctx.error(message, meta?)`
217
+ 2. **Never expect returnType runtime validation** — it's docs only
218
+ 3. **Never put validation logic in handler** — use `schema.params`
219
+ 4. **Never ignore ctx.signal** — pass to all async calls
220
+ 5. **Never skip signal.aborted check in stream loops** — causes resource leaks
221
+ 6. **Never register Create procedures with HonoStreamAppBuilder** — they're silently ignored
222
+ 7. **Never use plain JSON Schema objects** — use TypeBox builders
223
+ 8. **Never swallow errors without re-throwing** — hides failures
224
+ 9. **Never assume extra params fields survive** — `removeAdditional: true` strips them
225
+ 10. **Never manually parse types AJV coerces** — `coerceTypes: true` handles it
226
+
227
+ ## Testing
228
+
229
+ ```typescript
230
+ import { describe, test, expect } from 'vitest'
231
+ import { ProcedureError, ProcedureValidationError } from 'ts-procedures'
232
+
233
+ describe('GetUser', () => {
234
+ test('valid params', async () => {
235
+ const result = await GetUser(mockCtx, { id: 'user-1' })
236
+ expect(result.id).toBe('user-1')
237
+ })
238
+
239
+ test('invalid params', async () => {
240
+ await expect(GetUser(mockCtx, {})).rejects.toThrow(ProcedureValidationError)
241
+ })
242
+
243
+ test('business error', async () => {
244
+ await expect(GetUser(mockCtx, { id: 'missing' })).rejects.toThrow(ProcedureError)
245
+ })
246
+ })
247
+ ```
248
+
249
+ ## HTTP Route Path Format
250
+
251
+ `{pathPrefix}/{scope}/{kebab-case-name}/{version}`
252
+
253
+ - `scope` can be string or string[] (joined as path segments)
254
+ - Procedure name auto-converted to kebab-case
255
+ - Stream routes support both GET (query params) and POST (JSON body)