ts-procedures 7.1.2 → 7.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 +8 -0
- package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +35 -0
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +40 -0
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +14 -0
- package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +1 -0
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/astro-catchall.md +23 -0
- package/agent_config/copilot/copilot-instructions.md +6 -0
- package/agent_config/cursor/cursorrules +6 -0
- package/build/implementations/http/astro/astro-context.d.ts +19 -0
- package/build/implementations/http/astro/astro-context.js +28 -0
- package/build/implementations/http/astro/astro-context.js.map +1 -0
- package/build/implementations/http/astro/create-handler.d.ts +26 -0
- package/build/implementations/http/astro/create-handler.js +28 -0
- package/build/implementations/http/astro/create-handler.js.map +1 -0
- package/build/implementations/http/astro/index.d.ts +3 -0
- package/build/implementations/http/astro/index.js +6 -0
- package/build/implementations/http/astro/index.js.map +1 -0
- package/build/implementations/http/astro/index.test.d.ts +1 -0
- package/build/implementations/http/astro/index.test.js +295 -0
- package/build/implementations/http/astro/index.test.js.map +1 -0
- package/build/implementations/http/astro/rewrite-request.d.ts +13 -0
- package/build/implementations/http/astro/rewrite-request.js +32 -0
- package/build/implementations/http/astro/rewrite-request.js.map +1 -0
- package/build/index.d.ts +10 -0
- package/build/index.js +12 -13
- package/build/index.js.map +1 -1
- package/build/index.test.js +107 -0
- package/build/index.test.js.map +1 -1
- package/docs/astro-adapter.md +227 -0
- package/docs/core.md +19 -0
- package/docs/superpowers/plans/2026-05-07-astro-adapter.md +1396 -0
- package/docs/superpowers/specs/2026-05-07-astro-adapter-design.md +254 -0
- package/package.json +8 -2
- package/src/implementations/http/astro/README.md +89 -0
- package/src/implementations/http/astro/astro-context.ts +34 -0
- package/src/implementations/http/astro/create-handler.ts +59 -0
- package/src/implementations/http/astro/index.test.ts +350 -0
- package/src/implementations/http/astro/index.ts +6 -0
- package/src/implementations/http/astro/rewrite-request.ts +31 -0
- package/src/index.test.ts +171 -0
- package/src/index.ts +27 -15
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# Astro adapter walkthrough
|
|
2
|
+
|
|
3
|
+
This walkthrough builds an Astro app that serves ts-procedures handlers from a single catch-all endpoint.
|
|
4
|
+
|
|
5
|
+
> **Requires SSR.** Your Astro project must use `output: 'server'` or `output: 'hybrid'` with `export const prerender = false` in the catch-all file. Static-prerender mode bypasses live endpoints.
|
|
6
|
+
|
|
7
|
+
## Project shape
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
my-astro-app/
|
|
11
|
+
├── astro.config.mjs
|
|
12
|
+
├── package.json
|
|
13
|
+
└── src/
|
|
14
|
+
├── server/
|
|
15
|
+
│ ├── db.ts
|
|
16
|
+
│ ├── procedures/
|
|
17
|
+
│ │ └── users.ts
|
|
18
|
+
│ └── api.ts
|
|
19
|
+
└── pages/
|
|
20
|
+
└── api/
|
|
21
|
+
└── [...rest].ts
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 1. Define procedures
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
// src/server/procedures/users.ts
|
|
28
|
+
import { Procedures } from 'ts-procedures'
|
|
29
|
+
import type { APIConfig } from 'ts-procedures/http'
|
|
30
|
+
import { Type } from 'typebox'
|
|
31
|
+
|
|
32
|
+
type UserContext = {
|
|
33
|
+
db: { findUser(id: string): Promise<{ id: string; name: string } | null> }
|
|
34
|
+
currentUser: { id: string } | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const usersAPI = Procedures<UserContext, APIConfig>()
|
|
38
|
+
|
|
39
|
+
usersAPI.Create(
|
|
40
|
+
'GetUser',
|
|
41
|
+
{
|
|
42
|
+
path: '/users/:id',
|
|
43
|
+
method: 'get',
|
|
44
|
+
scope: 'users', // drives the generated client namespace: api.users.GetUser(...)
|
|
45
|
+
schema: {
|
|
46
|
+
input: { pathParams: Type.Object({ id: Type.String() }) },
|
|
47
|
+
returnType: Type.Object({ id: Type.String(), name: Type.String() }),
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
async (ctx, { pathParams }) => {
|
|
51
|
+
const u = await ctx.db.findUser(pathParams.id)
|
|
52
|
+
if (!u) throw new Error('not found')
|
|
53
|
+
return u
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 2. Build the Hono app once
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// src/server/api.ts
|
|
62
|
+
import { HonoAPIAppBuilder } from 'ts-procedures/hono-api'
|
|
63
|
+
import { getAstroContext } from 'ts-procedures/astro'
|
|
64
|
+
import { usersAPI } from './procedures/users'
|
|
65
|
+
import { db } from './db'
|
|
66
|
+
|
|
67
|
+
export const apiApp = new HonoAPIAppBuilder()
|
|
68
|
+
.register(usersAPI, (c) => {
|
|
69
|
+
const astro = getAstroContext(c)
|
|
70
|
+
return {
|
|
71
|
+
db,
|
|
72
|
+
currentUser: astro.locals.user ?? null,
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
.build()
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## 3. Mount the catch-all
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// src/pages/api/[...rest].ts
|
|
82
|
+
import { createAstroHandler } from 'ts-procedures/astro'
|
|
83
|
+
import { apiApp } from '../../server/api'
|
|
84
|
+
|
|
85
|
+
export const { ALL } = createAstroHandler({
|
|
86
|
+
apps: apiApp,
|
|
87
|
+
pathPrefix: '/api',
|
|
88
|
+
})
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
That's it — `GET /api/users/123` runs through `usersAPI.GetUser`. The factory closure sees `astro.locals.user` populated from your Astro middleware.
|
|
92
|
+
|
|
93
|
+
### Troubleshooting: routes 404
|
|
94
|
+
|
|
95
|
+
The most common cause is a `pathPrefix` mismatch. The prefix MUST match the directory the catch-all file lives in:
|
|
96
|
+
|
|
97
|
+
- `src/pages/api/[...rest].ts` → `pathPrefix: '/api'`
|
|
98
|
+
- `src/pages/v1/[...rest].ts` → `pathPrefix: '/v1'`
|
|
99
|
+
- `src/pages/[...rest].ts` (root) → omit `pathPrefix`
|
|
100
|
+
|
|
101
|
+
If you mount at `/api` but forget `pathPrefix`, the inner Hono app sees the full path including `/api/...`, so routes registered as `/users/:id` won't match `/api/users/123`.
|
|
102
|
+
|
|
103
|
+
## Where do `Astro.locals.user` come from?
|
|
104
|
+
|
|
105
|
+
A typical pattern: an Astro middleware that reads a session cookie and sets `locals.user`:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// src/middleware.ts
|
|
109
|
+
import { defineMiddleware } from 'astro:middleware'
|
|
110
|
+
import { db } from './server/db'
|
|
111
|
+
|
|
112
|
+
export const onRequest = defineMiddleware(async (context, next) => {
|
|
113
|
+
const sessionId = context.cookies.get('sid')?.value
|
|
114
|
+
if (sessionId) {
|
|
115
|
+
context.locals.user = await db.findUserBySession(sessionId)
|
|
116
|
+
}
|
|
117
|
+
return next()
|
|
118
|
+
})
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The Astro runtime invokes this before your endpoint. The adapter then forwards the same `APIContext` (with `locals.user` populated) into the WeakMap, so `getAstroContext(c)` returns it inside the procedure factory.
|
|
122
|
+
|
|
123
|
+
## Client codegen — where does it go?
|
|
124
|
+
|
|
125
|
+
The adapter does NOT couple to `DocRegistry`. Wire codegen separately, against the same builders. The cleanest DX is a one-line `npm run gen:api` that emits an envelope from your procedures and runs the codegen CLI in a single step.
|
|
126
|
+
|
|
127
|
+
### Emit an envelope from your procedures
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
// scripts/build-docs.ts
|
|
131
|
+
import { writeFileSync } from 'node:fs'
|
|
132
|
+
import { DocRegistry } from 'ts-procedures/http-docs'
|
|
133
|
+
import { HonoAPIAppBuilder } from 'ts-procedures/hono-api'
|
|
134
|
+
import { usersAPI } from '../src/server/procedures/users'
|
|
135
|
+
|
|
136
|
+
// Use the SAME builders as your runtime app — types can't drift, because
|
|
137
|
+
// both call sites import the exact same `Procedures<...>()` registration.
|
|
138
|
+
const builder = new HonoAPIAppBuilder().register(usersAPI, () => ({} as never))
|
|
139
|
+
builder.build()
|
|
140
|
+
|
|
141
|
+
const envelope = new DocRegistry().from(builder).toEnvelope()
|
|
142
|
+
writeFileSync('envelope.json', JSON.stringify(envelope, null, 2))
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Wire it as an npm script
|
|
146
|
+
|
|
147
|
+
```jsonc
|
|
148
|
+
// package.json
|
|
149
|
+
{
|
|
150
|
+
"scripts": {
|
|
151
|
+
"gen:api": "tsx scripts/build-docs.ts && ts-procedures-codegen --file envelope.json --out src/generated/api --service-name Api"
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
CLI defaults are already optimal: `--self-contained` (no runtime dependency on `ts-procedures/client`), `--namespace-types` (clean `Api.Users.GetUser.Params`), and `--jsdoc` are all on. The default `--service-name` is `Api`, which gives you `createApiClient`, `createApiBindings`, and an `ApiErrors` namespace.
|
|
157
|
+
|
|
158
|
+
> **Tip:** add `src/generated/api` to `.gitignore` and run `npm run gen:api` in CI / pre-commit. The output is a pure function of `envelope.json`.
|
|
159
|
+
|
|
160
|
+
## Use the typed client in pages
|
|
161
|
+
|
|
162
|
+
Generated client is fully typed end-to-end — params, response, and (via `.safe()`) typed errors.
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
---
|
|
166
|
+
// src/pages/users/[id].astro
|
|
167
|
+
import { createApiClient } from '../../generated/api'
|
|
168
|
+
|
|
169
|
+
const api = createApiClient({ basePath: '/api' })
|
|
170
|
+
|
|
171
|
+
// Throwing form: typed Response, typed Params (with structured channels)
|
|
172
|
+
const user = await api.users.GetUser({ pathParams: { id: Astro.params.id! } })
|
|
173
|
+
|
|
174
|
+
// Result form: typed errors via the route's declared `errors`
|
|
175
|
+
const result = await api.users.GetUser.safe({ pathParams: { id: Astro.params.id! } })
|
|
176
|
+
if (!result.ok) {
|
|
177
|
+
if (result.kind === 'typed') {
|
|
178
|
+
// result.error is narrowed to the route's declared error union
|
|
179
|
+
return Astro.redirect('/login')
|
|
180
|
+
}
|
|
181
|
+
// result.kind === 'transport' — network/parse/timeout/abort
|
|
182
|
+
throw result.error
|
|
183
|
+
}
|
|
184
|
+
---
|
|
185
|
+
<h1>{user.name}</h1>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
For client-side islands (React/Svelte/Vue components), the same `createApiClient` works in the browser — pass an absolute `basePath` if calling cross-origin, or omit it to use relative paths.
|
|
189
|
+
|
|
190
|
+
### Per-call options
|
|
191
|
+
|
|
192
|
+
Every callable accepts a second arg with `signal`, `timeout`, `headers`, `basePath`, `meta`, and per-call hooks:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
const controller = new AbortController()
|
|
196
|
+
await api.users.GetUser(
|
|
197
|
+
{ pathParams: { id: '123' } },
|
|
198
|
+
{ signal: controller.signal, timeout: 5000, headers: { 'X-Trace-Id': 'abc' } },
|
|
199
|
+
)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`signal`s combine via `AbortSignal.any`; `timeout: 0` disables an inherited default. Set client-wide defaults via `createApiClient({ basePath, defaults: { timeout, headers, meta } })`.
|
|
203
|
+
|
|
204
|
+
## Multi-app composition
|
|
205
|
+
|
|
206
|
+
For larger projects with separate API/RPC/Stream surfaces, pass an array:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
export const { ALL } = createAstroHandler({
|
|
210
|
+
apps: [apiApp, rpcApp, streamsApp],
|
|
211
|
+
pathPrefix: '/api',
|
|
212
|
+
})
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Order matters — first non-404 wins. See the module README for full dispatch rules.
|
|
216
|
+
|
|
217
|
+
## DX summary — what makes this streamlined
|
|
218
|
+
|
|
219
|
+
- **One file mounts everything.** `src/pages/api/[...rest].ts` is 4 lines; all routing happens inside the Hono app(s) you already built.
|
|
220
|
+
- **Same builders power runtime AND codegen.** Your procedure registration is the single source of truth — types can't drift between server and client.
|
|
221
|
+
- **`getAstroContext(c)` inside the factory closure.** Full `APIContext` (locals, cookies, redirect, params) is available without leaking Astro into your procedure handlers.
|
|
222
|
+
- **Generated client is self-contained.** No runtime dependency on `ts-procedures/client`; ships as plain `.ts` files you can read and audit.
|
|
223
|
+
- **`.safe()` on every callable.** Opt into `Result<T, TypedErrors>` per call site instead of try/catch — typed against the route's declared `errors`.
|
|
224
|
+
- **Streams just work.** `HonoStreamAppBuilder` returns a `Response` with a `ReadableStream` body; Astro SSR forwards it verbatim, and client disconnects abort `ctx.signal`.
|
|
225
|
+
- **Multi-app composition.** Mix API + RPC + Stream surfaces under one catch-all with first-match dispatch.
|
|
226
|
+
|
|
227
|
+
The single biggest footgun: `pathPrefix` MUST match the catch-all directory. Mismatch produces silent 404s. See the troubleshooting section above.
|
package/docs/core.md
CHANGED
|
@@ -10,6 +10,7 @@ The `Procedures()` function creates a factory for defining procedures. It accept
|
|
|
10
10
|
|
|
11
11
|
```typescript
|
|
12
12
|
Procedures<TContext, TExtendedConfig>(builder?: {
|
|
13
|
+
config?: { noRuntimeValidation?: true }
|
|
13
14
|
onCreate?: (procedure: TProcedureRegistration<TContext, TExtendedConfig>) => void
|
|
14
15
|
})
|
|
15
16
|
```
|
|
@@ -18,6 +19,7 @@ Procedures<TContext, TExtendedConfig>(builder?: {
|
|
|
18
19
|
|-----------|----------------------------------------------------------------------------|
|
|
19
20
|
| `TContext` | The base context type passed to all handlers as the first parameter |
|
|
20
21
|
| `TExtendedConfig` | Additional configuration properties for all procedures `config` properties |
|
|
22
|
+
| `builder.config.noRuntimeValidation` | When `true`, every procedure created by this factory skips AJV validation of `schema.params` and `schema.input` at call time. Applies to both `Create` and `CreateStream`. Default: validation runs. |
|
|
21
23
|
| `builder.onCreate` | Optional callback invoked when each procedure is registered (runtime) |
|
|
22
24
|
|
|
23
25
|
## Create Function
|
|
@@ -221,6 +223,22 @@ AJV is configured with:
|
|
|
221
223
|
|
|
222
224
|
**Note:** `schema.params` is validated at runtime. `schema.returnType` is for documentation/introspection only.
|
|
223
225
|
|
|
226
|
+
### Disabling Runtime Validation
|
|
227
|
+
|
|
228
|
+
For trusted internal callers — for example, a back-of-house factory whose inputs are already type-checked at build time and whose handlers are never invoked from public HTTP — pass `config.noRuntimeValidation: true` when constructing the factory:
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
const { Create, CreateStream } = Procedures({
|
|
232
|
+
config: { noRuntimeValidation: true },
|
|
233
|
+
})
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
When set, every procedure registered by this factory skips AJV validation of `schema.params` and `schema.input` on every call. JSON Schema is still computed at registration time (so `info.schema` and codegen are unaffected) — only the per-call validator runs are bypassed.
|
|
237
|
+
|
|
238
|
+
**Use sparingly.** Do not enable this for procedures that accept input from public clients, untyped JSON bodies, or anything you do not control end-to-end. The flag is shaped as `noRuntimeValidation?: true` (only `true` is accepted) to make the opt-out explicit at the call site.
|
|
239
|
+
|
|
240
|
+
This is independent of the per-call `ctx.isPrevalidated` escape hatch used by HTTP builders that have already validated upstream — both paths short-circuit the same validators.
|
|
241
|
+
|
|
224
242
|
## Error Handling
|
|
225
243
|
|
|
226
244
|
### Using ctx.error()
|
|
@@ -419,6 +437,7 @@ describe('GetUser', () => {
|
|
|
419
437
|
Creates a procedure factory.
|
|
420
438
|
|
|
421
439
|
**Parameters:**
|
|
440
|
+
- `builder.config.noRuntimeValidation` - When `true`, every procedure registered through this factory skips AJV validation of `schema.params` and `schema.input` at call time. Default: validation runs. See [Disabling Runtime Validation](#disabling-runtime-validation).
|
|
422
441
|
- `builder.onCreate` - Callback invoked when each procedure is registered
|
|
423
442
|
|
|
424
443
|
**Returns:**
|