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.
- package/README.md +60 -0
- package/agent_config/bin/postinstall.mjs +105 -0
- package/agent_config/bin/setup.mjs +286 -0
- package/agent_config/claude-code/.claude-plugin/plugin.json +5 -0
- package/agent_config/claude-code/agents/ts-procedures-architect.md +173 -0
- package/agent_config/claude-code/skills/guide/SKILL.md +142 -0
- package/agent_config/claude-code/skills/guide/anti-patterns.md +502 -0
- package/agent_config/claude-code/skills/guide/api-reference.md +550 -0
- package/agent_config/claude-code/skills/guide/patterns.md +572 -0
- package/agent_config/claude-code/skills/review/SKILL.md +53 -0
- package/agent_config/claude-code/skills/review/checklist.md +141 -0
- package/agent_config/claude-code/skills/scaffold/SKILL.md +54 -0
- package/agent_config/claude-code/skills/scaffold/templates/express-rpc.md +134 -0
- package/agent_config/claude-code/skills/scaffold/templates/hono-rpc.md +139 -0
- package/agent_config/claude-code/skills/scaffold/templates/hono-stream.md +134 -0
- package/agent_config/claude-code/skills/scaffold/templates/procedure.md +77 -0
- package/agent_config/claude-code/skills/scaffold/templates/stream-procedure.md +113 -0
- package/agent_config/copilot/copilot-instructions.md +255 -0
- package/agent_config/cursor/cursorrules +255 -0
- package/agent_config/lib/install-claude.mjs +109 -0
- package/package.json +7 -2
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# ts-procedures Review Checklist
|
|
2
|
+
|
|
3
|
+
## Procedure Definition Checks
|
|
4
|
+
|
|
5
|
+
### CRITICAL
|
|
6
|
+
- [ ] Uses `ctx.error()` for business logic errors, never throws raw `Error` instances
|
|
7
|
+
- [ ] Does not expect `schema.returnType` to be validated at runtime (it's documentation only)
|
|
8
|
+
- [ ] No duplicate procedure names within the same factory
|
|
9
|
+
- [ ] Schema uses TypeBox builders (`Type.Object(...)`, etc.), not plain JSON Schema objects
|
|
10
|
+
- [ ] Stream handlers check `ctx.signal.aborted` in loops to prevent infinite resource consumption
|
|
11
|
+
|
|
12
|
+
### WARNING
|
|
13
|
+
- [ ] Passes `ctx.signal` to all downstream async calls (fetch, database queries, etc.)
|
|
14
|
+
- [ ] Does not put manual validation logic in handler when `schema.params` handles it
|
|
15
|
+
- [ ] Does not swallow errors in try/catch without re-throwing (hides failures)
|
|
16
|
+
- [ ] Uses `validateYields: true` only when `schema.yieldType` is also defined
|
|
17
|
+
- [ ] Does not mix Create procedures and CreateStream procedures in confusing ways
|
|
18
|
+
|
|
19
|
+
### SUGGESTION
|
|
20
|
+
- [ ] Has `description` field set for documentation/introspection
|
|
21
|
+
- [ ] Uses meaningful procedure names (PascalCase, verb-noun: `GetUser`, `CreateOrder`)
|
|
22
|
+
- [ ] Destructures return value for clean exports: `const { GetUser, info } = Create(...)`
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Schema Checks
|
|
27
|
+
|
|
28
|
+
### CRITICAL
|
|
29
|
+
- [ ] Uses TypeBox (`Type.Object(...)`, `Type.String()`, etc.) — not plain objects
|
|
30
|
+
- [ ] Required fields use `Type.String()` directly; optional fields wrapped with `Type.Optional(...)`
|
|
31
|
+
|
|
32
|
+
### WARNING
|
|
33
|
+
- [ ] Understands AJV `coerceTypes: true` — no manual type parsing for query string values
|
|
34
|
+
- [ ] Understands AJV `removeAdditional: true` — extra fields in params are silently stripped
|
|
35
|
+
- [ ] Does not rely on params fields not in the schema (they get removed)
|
|
36
|
+
|
|
37
|
+
### SUGGESTION
|
|
38
|
+
- [ ] `returnType` schema provided for documentation generation
|
|
39
|
+
- [ ] `yieldType` schema provided for streaming procedures
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Context Checks
|
|
44
|
+
|
|
45
|
+
### CRITICAL
|
|
46
|
+
- [ ] Factory has explicit `TContext` type parameter when using HTTP builders
|
|
47
|
+
- [ ] Context factory function handles errors gracefully (async failures don't crash requests)
|
|
48
|
+
|
|
49
|
+
### WARNING
|
|
50
|
+
- [ ] Context type includes `signal?: AbortSignal` if procedures need cancellation support
|
|
51
|
+
- [ ] Context factory resolves only what handlers need (avoid expensive unused computations)
|
|
52
|
+
|
|
53
|
+
### SUGGESTION
|
|
54
|
+
- [ ] Context type is documented or self-descriptive
|
|
55
|
+
- [ ] Separate factories for different access levels (public vs authenticated)
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## HTTP Builder Checks (Express/Hono)
|
|
60
|
+
|
|
61
|
+
### CRITICAL
|
|
62
|
+
- [ ] Standard `Create` procedures registered with `ExpressRPCAppBuilder` or `HonoRPCAppBuilder`, NOT `HonoStreamAppBuilder`
|
|
63
|
+
- [ ] Stream `CreateStream` procedures registered with `HonoStreamAppBuilder`, NOT the RPC builders
|
|
64
|
+
- [ ] `onError` callback handles `ProcedureValidationError` and `ProcedureError` differently
|
|
65
|
+
|
|
66
|
+
### WARNING
|
|
67
|
+
- [ ] `pathPrefix` set consistently across builders
|
|
68
|
+
- [ ] `scope` and `version` set on all procedures when using `RPCConfig`
|
|
69
|
+
- [ ] Uses `extendProcedureDoc` for documentation generation instead of manual doc building
|
|
70
|
+
- [ ] `onRequestEnd` used for cleanup/logging, not business logic
|
|
71
|
+
|
|
72
|
+
### SUGGESTION
|
|
73
|
+
- [ ] Route documentation accessed via `builder.docs` for OpenAPI generation
|
|
74
|
+
- [ ] Lifecycle hooks used for observability (logging, metrics)
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Streaming Checks (HonoStreamAppBuilder)
|
|
79
|
+
|
|
80
|
+
### CRITICAL
|
|
81
|
+
- [ ] `onPreStreamError` handles validation errors before streaming starts (returns HTTP response)
|
|
82
|
+
- [ ] `onMidStreamError` handles runtime errors during streaming (yields error event)
|
|
83
|
+
- [ ] Stream handler does not assume `signal.reason` is always `'stream-completed'` — check for external abort
|
|
84
|
+
|
|
85
|
+
### WARNING
|
|
86
|
+
- [ ] Uses `sse()` helper for SSE metadata when custom event types or IDs are needed
|
|
87
|
+
- [ ] Correctly distinguishes stream modes: `'sse'` for EventSource clients, `'text'` for simple consumers
|
|
88
|
+
- [ ] Does not yield after `ctx.signal.aborted` is true
|
|
89
|
+
|
|
90
|
+
### SUGGESTION
|
|
91
|
+
- [ ] Stream mode documented in comments or config
|
|
92
|
+
- [ ] Error data shape matches `yieldType` schema for consistent client parsing
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Error Handling Checks
|
|
97
|
+
|
|
98
|
+
### CRITICAL
|
|
99
|
+
- [ ] Handler errors use `ctx.error(message, meta?)` — not `throw new Error()`
|
|
100
|
+
- [ ] Does not catch and swallow errors without re-throwing (hides failures from caller)
|
|
101
|
+
- [ ] HTTP builder has `onError` callback — default behavior may expose internal details
|
|
102
|
+
|
|
103
|
+
### WARNING
|
|
104
|
+
- [ ] `ProcedureValidationError` handled separately (400 status) from `ProcedureError` (4xx/5xx)
|
|
105
|
+
- [ ] Error `meta` object contains useful context (error codes, IDs) — not sensitive data
|
|
106
|
+
- [ ] Stack traces not exposed in production responses
|
|
107
|
+
|
|
108
|
+
### SUGGESTION
|
|
109
|
+
- [ ] Consistent error response shape across all procedures
|
|
110
|
+
- [ ] Error codes documented for API consumers
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Test File Checks
|
|
115
|
+
|
|
116
|
+
### CRITICAL
|
|
117
|
+
- [ ] Tests both valid and invalid param scenarios
|
|
118
|
+
- [ ] Tests `ProcedureValidationError` is thrown for schema violations
|
|
119
|
+
- [ ] Tests `ProcedureError` is thrown for business logic failures
|
|
120
|
+
|
|
121
|
+
### WARNING
|
|
122
|
+
- [ ] Tests pass context with required fields (doesn't rely on undefined context)
|
|
123
|
+
- [ ] HTTP builder tests verify response status codes and body shape
|
|
124
|
+
- [ ] Stream tests consume values with `for await...of` and break after expected count
|
|
125
|
+
|
|
126
|
+
### SUGGESTION
|
|
127
|
+
- [ ] Tests verify `info` metadata (name, schema, description)
|
|
128
|
+
- [ ] Tests verify `getProcedures()` returns expected registrations
|
|
129
|
+
- [ ] Tests verify error `meta` contains expected fields
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Extended Config Checks
|
|
134
|
+
|
|
135
|
+
### WARNING
|
|
136
|
+
- [ ] All procedures include required `TExtendedConfig` fields (e.g., `scope`, `version` for `RPCConfig`)
|
|
137
|
+
- [ ] Custom config fields used consistently across procedure group
|
|
138
|
+
|
|
139
|
+
### SUGGESTION
|
|
140
|
+
- [ ] Custom config fields documented
|
|
141
|
+
- [ ] `getProcedures()` used for config introspection (permissions audit, etc.)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: scaffold
|
|
3
|
+
description: "Scaffold ts-procedures code with correct patterns. Usage: /ts-procedures:scaffold <type> <Name>"
|
|
4
|
+
invocable_by:
|
|
5
|
+
- user
|
|
6
|
+
- model
|
|
7
|
+
user_instructions: |
|
|
8
|
+
Usage: /ts-procedures:scaffold <type> <Name>
|
|
9
|
+
|
|
10
|
+
Types: procedure, stream-procedure, express-rpc, hono-rpc, hono-stream
|
|
11
|
+
|
|
12
|
+
Examples:
|
|
13
|
+
/ts-procedures:scaffold procedure GetUser
|
|
14
|
+
/ts-procedures:scaffold stream-procedure StreamActivity
|
|
15
|
+
/ts-procedures:scaffold express-rpc UserApi
|
|
16
|
+
/ts-procedures:scaffold hono-rpc OrderApi
|
|
17
|
+
/ts-procedures:scaffold hono-stream LiveFeed
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
# Scaffold ts-procedures Code
|
|
21
|
+
|
|
22
|
+
Parse `$ARGUMENTS` as `<type> <Name>` (case-insensitive type, PascalCase Name).
|
|
23
|
+
|
|
24
|
+
## Instructions
|
|
25
|
+
|
|
26
|
+
1. Parse the arguments. If missing, ask the user for `<type>` and `<Name>`.
|
|
27
|
+
2. Derive placeholder variants from the provided PascalCase Name:
|
|
28
|
+
- `{{Name}}` — PascalCase as given (e.g., `UserProfile`)
|
|
29
|
+
- `{{name}}` — camelCase (e.g., `userProfile`)
|
|
30
|
+
- For URL scopes and file paths, use kebab-case (e.g., `user-profile`)
|
|
31
|
+
3. Read the template file from `templates/<type>.md` in this skill directory.
|
|
32
|
+
4. Replace all `{{Name}}` and `{{name}}` placeholders with the appropriate variants.
|
|
33
|
+
5. Generate the implementation file(s) following the template exactly.
|
|
34
|
+
6. Also generate a colocated test file following ts-procedures test conventions.
|
|
35
|
+
|
|
36
|
+
## Valid Types
|
|
37
|
+
|
|
38
|
+
| Type | Template | Files Generated |
|
|
39
|
+
|------|----------|----------------|
|
|
40
|
+
| `procedure` | `templates/procedure.md` | `{{Name}}.procedure.ts`, `{{Name}}.procedure.test.ts` |
|
|
41
|
+
| `stream-procedure` | `templates/stream-procedure.md` | `{{Name}}.stream.ts`, `{{Name}}.stream.test.ts` |
|
|
42
|
+
| `express-rpc` | `templates/express-rpc.md` | `{{Name}}.rpc.ts`, `{{Name}}.rpc.test.ts` |
|
|
43
|
+
| `hono-rpc` | `templates/hono-rpc.md` | `{{Name}}.rpc.ts`, `{{Name}}.rpc.test.ts` |
|
|
44
|
+
| `hono-stream` | `templates/hono-stream.md` | `{{Name}}.stream-rpc.ts`, `{{Name}}.stream-rpc.test.ts` |
|
|
45
|
+
|
|
46
|
+
## Rules
|
|
47
|
+
|
|
48
|
+
- Use `ctx.error()` for business logic errors, never throw raw `Error`.
|
|
49
|
+
- `schema.params` is validated at runtime; `schema.returnType` is documentation only.
|
|
50
|
+
- Pass `ctx.signal` to all downstream async calls.
|
|
51
|
+
- Stream handlers always have `ctx.signal` (guaranteed `AbortSignal`).
|
|
52
|
+
- Use TypeBox for schema definitions (`import { Type } from 'typebox'`).
|
|
53
|
+
- AJV config: `allErrors: true`, `coerceTypes: true`, `removeAdditional: true`.
|
|
54
|
+
- Test files use `describe`/`test` from vitest.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Express RPC Template: {{Name}}
|
|
2
|
+
|
|
3
|
+
## Implementation — `{{Name}}.rpc.ts`
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { Procedures, ProcedureError, ProcedureValidationError } from 'ts-procedures'
|
|
7
|
+
import { ExpressRPCAppBuilder } from 'ts-procedures/express-rpc'
|
|
8
|
+
import type { RPCConfig } from 'ts-procedures/http'
|
|
9
|
+
import { Type } from 'typebox'
|
|
10
|
+
|
|
11
|
+
// ─── Context ──────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
type {{Name}}Context = {
|
|
14
|
+
userId: string
|
|
15
|
+
requestId: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ─── Procedures ───────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const RPC = Procedures<{{Name}}Context, RPCConfig>()
|
|
21
|
+
|
|
22
|
+
export const { GetItem } = RPC.Create(
|
|
23
|
+
'GetItem',
|
|
24
|
+
{
|
|
25
|
+
scope: '{{name}}', // TODO: set URL scope
|
|
26
|
+
version: 1,
|
|
27
|
+
description: 'Fetch item by ID',
|
|
28
|
+
schema: {
|
|
29
|
+
params: Type.Object({
|
|
30
|
+
id: Type.String(),
|
|
31
|
+
}),
|
|
32
|
+
returnType: Type.Object({
|
|
33
|
+
id: Type.String(),
|
|
34
|
+
name: Type.String(),
|
|
35
|
+
}),
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
async (ctx, params) => {
|
|
39
|
+
// TODO: implement
|
|
40
|
+
return { id: params.id, name: 'Example' }
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
export const { ListItems } = RPC.Create(
|
|
45
|
+
'ListItems',
|
|
46
|
+
{
|
|
47
|
+
scope: '{{name}}',
|
|
48
|
+
version: 1,
|
|
49
|
+
description: 'List all items',
|
|
50
|
+
schema: {
|
|
51
|
+
params: Type.Object({
|
|
52
|
+
page: Type.Optional(Type.Number()),
|
|
53
|
+
limit: Type.Optional(Type.Number()),
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
async (ctx, params) => {
|
|
58
|
+
// TODO: implement
|
|
59
|
+
return { items: [], total: 0 }
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
// ─── Express App Builder ──────────────────────────────────
|
|
64
|
+
|
|
65
|
+
export const {{name}}App = new ExpressRPCAppBuilder({
|
|
66
|
+
pathPrefix: '/api',
|
|
67
|
+
onError: (procedure, req, res, error) => {
|
|
68
|
+
if (error instanceof ProcedureValidationError) {
|
|
69
|
+
res.status(400).json({
|
|
70
|
+
error: error.message,
|
|
71
|
+
details: error.errors,
|
|
72
|
+
procedure: error.procedureName,
|
|
73
|
+
})
|
|
74
|
+
} else if (error instanceof ProcedureError) {
|
|
75
|
+
res.status(422).json({
|
|
76
|
+
error: error.message,
|
|
77
|
+
meta: error.meta,
|
|
78
|
+
procedure: error.procedureName,
|
|
79
|
+
})
|
|
80
|
+
} else {
|
|
81
|
+
res.status(500).json({ error: 'Internal server error' })
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
.register(RPC, async (req) => ({
|
|
86
|
+
userId: req.headers['x-user-id'] as string || 'anonymous',
|
|
87
|
+
requestId: req.headers['x-request-id'] as string || crypto.randomUUID(),
|
|
88
|
+
}))
|
|
89
|
+
.build()
|
|
90
|
+
|
|
91
|
+
// Route map:
|
|
92
|
+
// POST /api/{{name}}/get-item/1
|
|
93
|
+
// POST /api/{{name}}/list-items/1
|
|
94
|
+
|
|
95
|
+
// Documentation: {{name}}App.docs
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Test — `{{Name}}.rpc.test.ts`
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
import { describe, test, expect } from 'vitest'
|
|
102
|
+
import supertest from 'supertest'
|
|
103
|
+
import { {{name}}App } from './{{Name}}.rpc'
|
|
104
|
+
|
|
105
|
+
describe('{{Name}} RPC', () => {
|
|
106
|
+
test('GET item returns expected result', async () => {
|
|
107
|
+
const res = await supertest({{name}}App)
|
|
108
|
+
.post('/api/{{name}}/get-item/1')
|
|
109
|
+
.send({ id: 'item-1' })
|
|
110
|
+
.expect(200)
|
|
111
|
+
|
|
112
|
+
expect(res.body).toEqual({ id: 'item-1', name: 'Example' })
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('returns 400 for invalid params', async () => {
|
|
116
|
+
const res = await supertest({{name}}App)
|
|
117
|
+
.post('/api/{{name}}/get-item/1')
|
|
118
|
+
.send({})
|
|
119
|
+
.expect(400)
|
|
120
|
+
|
|
121
|
+
expect(res.body.error).toBeDefined()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('LIST items returns array', async () => {
|
|
125
|
+
const res = await supertest({{name}}App)
|
|
126
|
+
.post('/api/{{name}}/list-items/1')
|
|
127
|
+
.send({ page: 1, limit: 10 })
|
|
128
|
+
.expect(200)
|
|
129
|
+
|
|
130
|
+
expect(res.body).toHaveProperty('items')
|
|
131
|
+
expect(res.body).toHaveProperty('total')
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
```
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Hono RPC Template: {{Name}}
|
|
2
|
+
|
|
3
|
+
## Implementation — `{{Name}}.rpc.ts`
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { Procedures, ProcedureError, ProcedureValidationError } from 'ts-procedures'
|
|
7
|
+
import { HonoRPCAppBuilder } from 'ts-procedures/hono-rpc'
|
|
8
|
+
import type { RPCConfig } from 'ts-procedures/http'
|
|
9
|
+
import { Type } from 'typebox'
|
|
10
|
+
|
|
11
|
+
// ─── Context ──────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
type {{Name}}Context = {
|
|
14
|
+
userId: string
|
|
15
|
+
requestId: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ─── Procedures ───────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const RPC = Procedures<{{Name}}Context, RPCConfig>()
|
|
21
|
+
|
|
22
|
+
export const { GetItem } = RPC.Create(
|
|
23
|
+
'GetItem',
|
|
24
|
+
{
|
|
25
|
+
scope: '{{name}}', // TODO: set URL scope
|
|
26
|
+
version: 1,
|
|
27
|
+
description: 'Fetch item by ID',
|
|
28
|
+
schema: {
|
|
29
|
+
params: Type.Object({
|
|
30
|
+
id: Type.String(),
|
|
31
|
+
}),
|
|
32
|
+
returnType: Type.Object({
|
|
33
|
+
id: Type.String(),
|
|
34
|
+
name: Type.String(),
|
|
35
|
+
}),
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
async (ctx, params) => {
|
|
39
|
+
// TODO: implement
|
|
40
|
+
return { id: params.id, name: 'Example' }
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
export const { ListItems } = RPC.Create(
|
|
45
|
+
'ListItems',
|
|
46
|
+
{
|
|
47
|
+
scope: '{{name}}',
|
|
48
|
+
version: 1,
|
|
49
|
+
description: 'List all items',
|
|
50
|
+
schema: {
|
|
51
|
+
params: Type.Object({
|
|
52
|
+
page: Type.Optional(Type.Number()),
|
|
53
|
+
limit: Type.Optional(Type.Number()),
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
async (ctx, params) => {
|
|
58
|
+
// TODO: implement
|
|
59
|
+
return { items: [], total: 0 }
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
// ─── Hono App Builder ─────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
export const {{name}}App = new HonoRPCAppBuilder({
|
|
66
|
+
pathPrefix: '/api',
|
|
67
|
+
onError: (procedure, c, error) => {
|
|
68
|
+
if (error instanceof ProcedureValidationError) {
|
|
69
|
+
return c.json({
|
|
70
|
+
error: error.message,
|
|
71
|
+
details: error.errors,
|
|
72
|
+
procedure: error.procedureName,
|
|
73
|
+
}, 400)
|
|
74
|
+
} else if (error instanceof ProcedureError) {
|
|
75
|
+
return c.json({
|
|
76
|
+
error: error.message,
|
|
77
|
+
meta: error.meta,
|
|
78
|
+
procedure: error.procedureName,
|
|
79
|
+
}, 422)
|
|
80
|
+
}
|
|
81
|
+
return c.json({ error: 'Internal server error' }, 500)
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
.register(RPC, (c) => ({
|
|
85
|
+
userId: c.req.header('x-user-id') || 'anonymous',
|
|
86
|
+
requestId: c.req.header('x-request-id') || crypto.randomUUID(),
|
|
87
|
+
}))
|
|
88
|
+
.build()
|
|
89
|
+
|
|
90
|
+
// Route map:
|
|
91
|
+
// POST /api/{{name}}/get-item/1
|
|
92
|
+
// POST /api/{{name}}/list-items/1
|
|
93
|
+
|
|
94
|
+
// Documentation: {{name}}App.docs
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Test — `{{Name}}.rpc.test.ts`
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
import { describe, test, expect } from 'vitest'
|
|
101
|
+
import { {{name}}App } from './{{Name}}.rpc'
|
|
102
|
+
|
|
103
|
+
describe('{{Name}} RPC', () => {
|
|
104
|
+
test('GET item returns expected result', async () => {
|
|
105
|
+
const res = await {{name}}App.request('/api/{{name}}/get-item/1', {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: { 'Content-Type': 'application/json' },
|
|
108
|
+
body: JSON.stringify({ id: 'item-1' }),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
expect(res.status).toBe(200)
|
|
112
|
+
const body = await res.json()
|
|
113
|
+
expect(body).toEqual({ id: 'item-1', name: 'Example' })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('returns 400 for invalid params', async () => {
|
|
117
|
+
const res = await {{name}}App.request('/api/{{name}}/get-item/1', {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
headers: { 'Content-Type': 'application/json' },
|
|
120
|
+
body: JSON.stringify({}),
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(res.status).toBe(400)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('LIST items returns array', async () => {
|
|
127
|
+
const res = await {{name}}App.request('/api/{{name}}/list-items/1', {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: { 'Content-Type': 'application/json' },
|
|
130
|
+
body: JSON.stringify({ page: 1, limit: 10 }),
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
expect(res.status).toBe(200)
|
|
134
|
+
const body = await res.json()
|
|
135
|
+
expect(body).toHaveProperty('items')
|
|
136
|
+
expect(body).toHaveProperty('total')
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
```
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Hono Stream Template: {{Name}}
|
|
2
|
+
|
|
3
|
+
## Implementation — `{{Name}}.stream-rpc.ts`
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { Procedures } from 'ts-procedures'
|
|
7
|
+
import { HonoStreamAppBuilder, sse } from 'ts-procedures/hono-stream'
|
|
8
|
+
import type { RPCConfig } from 'ts-procedures/http'
|
|
9
|
+
import { Type } from 'typebox'
|
|
10
|
+
|
|
11
|
+
// ─── Context ──────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
type {{Name}}Context = {
|
|
14
|
+
userId: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ─── Stream Procedures ────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const StreamRPC = Procedures<{{Name}}Context, RPCConfig>()
|
|
20
|
+
|
|
21
|
+
export const { {{Name}}Stream } = StreamRPC.CreateStream(
|
|
22
|
+
'{{Name}}Stream',
|
|
23
|
+
{
|
|
24
|
+
scope: '{{name}}', // TODO: set URL scope
|
|
25
|
+
version: 1,
|
|
26
|
+
description: 'TODO: describe what this streams',
|
|
27
|
+
schema: {
|
|
28
|
+
params: Type.Object({
|
|
29
|
+
// TODO: define input parameters
|
|
30
|
+
channel: Type.String(),
|
|
31
|
+
}),
|
|
32
|
+
yieldType: Type.Object({
|
|
33
|
+
// TODO: define shape of each yielded value
|
|
34
|
+
type: Type.String(),
|
|
35
|
+
payload: Type.Any(),
|
|
36
|
+
timestamp: Type.Number(),
|
|
37
|
+
}),
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
async function* (ctx, params) {
|
|
41
|
+
// ctx.signal is ALWAYS present in stream handlers
|
|
42
|
+
|
|
43
|
+
// TODO: implement streaming logic
|
|
44
|
+
let counter = 0
|
|
45
|
+
while (!ctx.signal.aborted) {
|
|
46
|
+
const event = await pollForEvents(params.channel, { signal: ctx.signal })
|
|
47
|
+
|
|
48
|
+
// Use sse() to attach SSE metadata (event type, id, retry)
|
|
49
|
+
yield sse(
|
|
50
|
+
{ type: event.type, payload: event.data, timestamp: Date.now() },
|
|
51
|
+
{ event: event.type, id: String(counter++) }
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
// Placeholder — replace with real event source
|
|
58
|
+
async function pollForEvents(channel: string, opts?: { signal?: AbortSignal }) {
|
|
59
|
+
await new Promise(r => setTimeout(r, 1000))
|
|
60
|
+
return { type: 'update', data: { value: Math.random() } }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Hono Stream App Builder ──────────────────────────────
|
|
64
|
+
|
|
65
|
+
export const {{name}}StreamApp = new HonoStreamAppBuilder({
|
|
66
|
+
pathPrefix: '/api',
|
|
67
|
+
defaultStreamMode: 'sse', // or 'text' for newline-delimited JSON
|
|
68
|
+
onPreStreamError: (procedure, c, error) => {
|
|
69
|
+
return c.json({ error: error.message }, 400)
|
|
70
|
+
},
|
|
71
|
+
onMidStreamError: (procedure, c, error) => ({
|
|
72
|
+
data: { type: 'error', payload: { message: error.message }, timestamp: Date.now() },
|
|
73
|
+
closeStream: true,
|
|
74
|
+
}),
|
|
75
|
+
})
|
|
76
|
+
.register(StreamRPC, (c) => ({
|
|
77
|
+
userId: c.req.header('x-user-id') || 'anonymous',
|
|
78
|
+
}))
|
|
79
|
+
.build()
|
|
80
|
+
|
|
81
|
+
// Routes:
|
|
82
|
+
// GET /api/{{name}}/{{name}}-stream/1?channel=test
|
|
83
|
+
// POST /api/{{name}}/{{name}}-stream/1 (body: { channel: "test" })
|
|
84
|
+
|
|
85
|
+
// SSE output per yield:
|
|
86
|
+
// event: {{Name}}Stream
|
|
87
|
+
// id: 0
|
|
88
|
+
// data: {"type":"update","payload":{...},"timestamp":1234567890}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Test — `{{Name}}.stream-rpc.test.ts`
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
import { describe, test, expect } from 'vitest'
|
|
95
|
+
import { {{name}}StreamApp } from './{{Name}}.stream-rpc'
|
|
96
|
+
|
|
97
|
+
describe('{{Name}} Stream RPC', () => {
|
|
98
|
+
test('GET returns SSE stream', async () => {
|
|
99
|
+
const res = await {{name}}StreamApp.request(
|
|
100
|
+
'/api/{{name}}/{{name}}-stream/1?channel=test'
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
expect(res.status).toBe(200)
|
|
104
|
+
expect(res.headers.get('content-type')).toContain('text/event-stream')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('POST returns SSE stream', async () => {
|
|
108
|
+
const res = await {{name}}StreamApp.request(
|
|
109
|
+
'/api/{{name}}/{{name}}-stream/1',
|
|
110
|
+
{
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'Content-Type': 'application/json' },
|
|
113
|
+
body: JSON.stringify({ channel: 'test' }),
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
expect(res.status).toBe(200)
|
|
118
|
+
expect(res.headers.get('content-type')).toContain('text/event-stream')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('returns 400 for invalid params', async () => {
|
|
122
|
+
const res = await {{name}}StreamApp.request(
|
|
123
|
+
'/api/{{name}}/{{name}}-stream/1',
|
|
124
|
+
{
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: { 'Content-Type': 'application/json' },
|
|
127
|
+
body: JSON.stringify({}),
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
expect(res.status).toBe(400)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
```
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Procedure Template: {{Name}}
|
|
2
|
+
|
|
3
|
+
## Implementation — `{{Name}}.procedure.ts`
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { Procedures } from 'ts-procedures'
|
|
7
|
+
import { Type } from 'typebox'
|
|
8
|
+
|
|
9
|
+
// Define context type (adjust to match your app's context)
|
|
10
|
+
type AppContext = {
|
|
11
|
+
userId: string
|
|
12
|
+
signal?: AbortSignal
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { Create } = Procedures<AppContext>()
|
|
16
|
+
|
|
17
|
+
export const { {{Name}}, procedure: {{Name}}Procedure, info: {{Name}}Info } = Create(
|
|
18
|
+
'{{Name}}',
|
|
19
|
+
{
|
|
20
|
+
description: 'TODO: describe what {{Name}} does',
|
|
21
|
+
schema: {
|
|
22
|
+
params: Type.Object({
|
|
23
|
+
// TODO: define input parameters
|
|
24
|
+
id: Type.String(),
|
|
25
|
+
}),
|
|
26
|
+
returnType: Type.Object({
|
|
27
|
+
// TODO: define return shape (documentation only, not validated at runtime)
|
|
28
|
+
id: Type.String(),
|
|
29
|
+
// ...fields
|
|
30
|
+
}),
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
async (ctx, params) => {
|
|
34
|
+
// params.id is guaranteed to be a string (validated by AJV)
|
|
35
|
+
|
|
36
|
+
// Use ctx.error() for business logic errors
|
|
37
|
+
// throw ctx.error('Not found', { code: 'NOT_FOUND', id: params.id })
|
|
38
|
+
|
|
39
|
+
// Pass ctx.signal to downstream async calls
|
|
40
|
+
// const data = await fetch(url, { signal: ctx.signal })
|
|
41
|
+
|
|
42
|
+
// TODO: implement handler logic
|
|
43
|
+
return { id: params.id }
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Test — `{{Name}}.procedure.test.ts`
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
import { describe, test, expect } from 'vitest'
|
|
52
|
+
import { ProcedureError, ProcedureValidationError } from 'ts-procedures'
|
|
53
|
+
import { {{Name}} } from './{{Name}}.procedure'
|
|
54
|
+
|
|
55
|
+
const mockContext = { userId: 'test-user' }
|
|
56
|
+
|
|
57
|
+
describe('{{Name}}', () => {
|
|
58
|
+
test('returns expected result for valid params', async () => {
|
|
59
|
+
const result = await {{Name}}(mockContext, { id: 'test-id' })
|
|
60
|
+
expect(result).toBeDefined()
|
|
61
|
+
expect(result.id).toBe('test-id')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('throws ProcedureValidationError for invalid params', async () => {
|
|
65
|
+
await expect(
|
|
66
|
+
{{Name}}(mockContext, {} as any)
|
|
67
|
+
).rejects.toThrow(ProcedureValidationError)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('throws ProcedureError for business logic failures', async () => {
|
|
71
|
+
// TODO: test ctx.error() scenarios
|
|
72
|
+
// await expect(
|
|
73
|
+
// {{Name}}(mockContext, { id: 'invalid' })
|
|
74
|
+
// ).rejects.toThrow(ProcedureError)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
```
|