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.
Files changed (41) hide show
  1. package/README.md +8 -0
  2. package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +35 -0
  3. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +40 -0
  4. package/agent_config/claude-code/skills/ts-procedures/patterns.md +14 -0
  5. package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +1 -0
  6. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/astro-catchall.md +23 -0
  7. package/agent_config/copilot/copilot-instructions.md +6 -0
  8. package/agent_config/cursor/cursorrules +6 -0
  9. package/build/implementations/http/astro/astro-context.d.ts +19 -0
  10. package/build/implementations/http/astro/astro-context.js +28 -0
  11. package/build/implementations/http/astro/astro-context.js.map +1 -0
  12. package/build/implementations/http/astro/create-handler.d.ts +26 -0
  13. package/build/implementations/http/astro/create-handler.js +28 -0
  14. package/build/implementations/http/astro/create-handler.js.map +1 -0
  15. package/build/implementations/http/astro/index.d.ts +3 -0
  16. package/build/implementations/http/astro/index.js +6 -0
  17. package/build/implementations/http/astro/index.js.map +1 -0
  18. package/build/implementations/http/astro/index.test.d.ts +1 -0
  19. package/build/implementations/http/astro/index.test.js +295 -0
  20. package/build/implementations/http/astro/index.test.js.map +1 -0
  21. package/build/implementations/http/astro/rewrite-request.d.ts +13 -0
  22. package/build/implementations/http/astro/rewrite-request.js +32 -0
  23. package/build/implementations/http/astro/rewrite-request.js.map +1 -0
  24. package/build/index.d.ts +10 -0
  25. package/build/index.js +12 -13
  26. package/build/index.js.map +1 -1
  27. package/build/index.test.js +107 -0
  28. package/build/index.test.js.map +1 -1
  29. package/docs/astro-adapter.md +227 -0
  30. package/docs/core.md +19 -0
  31. package/docs/superpowers/plans/2026-05-07-astro-adapter.md +1396 -0
  32. package/docs/superpowers/specs/2026-05-07-astro-adapter-design.md +254 -0
  33. package/package.json +8 -2
  34. package/src/implementations/http/astro/README.md +89 -0
  35. package/src/implementations/http/astro/astro-context.ts +34 -0
  36. package/src/implementations/http/astro/create-handler.ts +59 -0
  37. package/src/implementations/http/astro/index.test.ts +350 -0
  38. package/src/implementations/http/astro/index.ts +6 -0
  39. package/src/implementations/http/astro/rewrite-request.ts +31 -0
  40. package/src/index.test.ts +171 -0
  41. package/src/index.ts +27 -15
@@ -0,0 +1,1396 @@
1
+ # Astro Adapter Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Ship a new `ts-procedures/astro` subpath that lets a downstream developer drop one or more pre-built Hono apps (from `HonoAPIAppBuilder`, `HonoRPCAppBuilder`, `HonoStreamAppBuilder`) into a single catch-all Astro endpoint file (`src/pages/api/[...rest].ts`) and have every procedure call route correctly with full access to Astro's per-request data inside factory-context closures.
6
+
7
+ **Architecture:** Web-fetch delegator — accept already-built Hono apps via `app.fetch(request)`. The adapter (a) optionally strips a configured `pathPrefix` from the incoming `Request` URL, (b) stashes Astro's `APIContext` in a `WeakMap` keyed by the rewritten Request, (c) dispatches across the registered apps with first-non-404-wins, (d) returns the response. A second exported helper, `getAstroContext(c)`, lets factory closures read the stashed `APIContext` from the Hono `Context` (via `c.req.raw`). No Hono builder is modified; Astro is types-only and listed under `optionalDependencies`.
8
+
9
+ **Tech Stack:** TypeScript (ESM, NodeNext), vitest, Hono (already a dev/optional dep), Astro 5 (new `optionalDependencies` + `devDependencies` entry, `import type` only).
10
+
11
+ **Source spec:** `docs/superpowers/specs/2026-05-07-astro-adapter-design.md`
12
+
13
+ ---
14
+
15
+ ## File Structure
16
+
17
+ **New files:**
18
+ - `src/implementations/http/astro/astro-context.ts` — module-private `WeakMap<Request, APIContext>`, internal `setAstroContext(req, ctx)`, public `getAstroContext(c)` reading via `c.req.raw`. Throws `Error` when the request is not in scope.
19
+ - `src/implementations/http/astro/rewrite-request.ts` — pure function `stripPrefix(request, prefix)`: returns the original request when no prefix, the rewritten request when prefix matches, or `null` (404 short-circuit) when it doesn't match.
20
+ - `src/implementations/http/astro/create-handler.ts` — `createAstroHandler({ apps, pathPrefix })` returning the per-method `APIRoute` map. Internal multi-app dispatch with first-non-404 short-circuit.
21
+ - `src/implementations/http/astro/index.ts` — public barrel: re-exports `createAstroHandler`, `getAstroContext`, and the `AstroAdapterConfig` type.
22
+ - `src/implementations/http/astro/index.test.ts` — vitest suite with all unit + integration tests in one file (matches the convention used by `hono-api/index.test.ts`).
23
+ - `src/implementations/http/astro/README.md` — usage docs, prefix semantics, examples.
24
+ - `docs/astro-adapter.md` — end-to-end walkthrough.
25
+ - `agent_config/claude-code/skills/ts-procedures-scaffold/templates/astro-catchall.ts` — scaffold template.
26
+
27
+ **Modified files:**
28
+ - `package.json` — add `./astro` to `exports`, add `astro` to `optionalDependencies` and `devDependencies`.
29
+ - `agent_config/claude-code/skills/ts-procedures/api-reference.md` — add Astro adapter section.
30
+ - `agent_config/claude-code/skills/ts-procedures/patterns.md` — add Astro pattern.
31
+ - `agent_config/copilot/copilot-instructions.md` and `agent_config/cursor/cursorrules` — add the same Astro pattern inside `<!-- BEGIN/END ts-procedures -->` markers (these two files are kept identical per CLAUDE.md).
32
+
33
+ ---
34
+
35
+ ## Task 1: Module skeleton + package.json wiring
36
+
37
+ **Files:**
38
+ - Create: `src/implementations/http/astro/index.ts`
39
+ - Create: `src/implementations/http/astro/astro-context.ts`
40
+ - Create: `src/implementations/http/astro/rewrite-request.ts`
41
+ - Create: `src/implementations/http/astro/create-handler.ts`
42
+ - Create: `src/implementations/http/astro/index.test.ts`
43
+ - Modify: `package.json`
44
+
45
+ - [ ] **Step 1: Install Astro as a devDependency**
46
+
47
+ Run from the project root:
48
+
49
+ ```bash
50
+ npm install --save-dev --save-optional astro@^5.0.0
51
+ ```
52
+
53
+ Expected: `astro` appears in both `devDependencies` and `optionalDependencies`. (npm will write `optionalDependencies` because of `--save-optional`; verify it also lands in `devDependencies` — if not, manually add it to `devDependencies` so types resolve at build time.)
54
+
55
+ - [ ] **Step 2: Verify package.json now has astro in both sections**
56
+
57
+ Open `package.json` and confirm both:
58
+
59
+ ```json
60
+ "optionalDependencies": {
61
+ "ajsc": "7.2.0",
62
+ "astro": "^5.0.0",
63
+ "express": "^5.2.1",
64
+ "hono": "^4.7.4"
65
+ },
66
+ "devDependencies": {
67
+ "...": "...",
68
+ "astro": "^5.0.0"
69
+ }
70
+ ```
71
+
72
+ If `astro` is missing from `devDependencies`, add it manually with the same version range.
73
+
74
+ - [ ] **Step 3: Add the `./astro` subpath export**
75
+
76
+ In `package.json`, add to the `exports` object (alphabetical placement — between `.` and `./client` is fine):
77
+
78
+ ```json
79
+ "./astro": {
80
+ "types": "./build/implementations/http/astro/index.d.ts",
81
+ "import": "./build/implementations/http/astro/index.js"
82
+ }
83
+ ```
84
+
85
+ - [ ] **Step 4: Create empty source files**
86
+
87
+ Create the following with minimal placeholder content so TypeScript resolves them:
88
+
89
+ ```ts
90
+ // src/implementations/http/astro/astro-context.ts
91
+ export {}
92
+ ```
93
+
94
+ ```ts
95
+ // src/implementations/http/astro/rewrite-request.ts
96
+ export {}
97
+ ```
98
+
99
+ ```ts
100
+ // src/implementations/http/astro/create-handler.ts
101
+ export {}
102
+ ```
103
+
104
+ ```ts
105
+ // src/implementations/http/astro/index.ts
106
+ export {}
107
+ ```
108
+
109
+ ```ts
110
+ // src/implementations/http/astro/index.test.ts
111
+ import { describe } from 'vitest'
112
+ describe.skip('astro adapter — placeholder', () => {})
113
+ ```
114
+
115
+ - [ ] **Step 5: Run the build and tests to confirm the skeleton compiles**
116
+
117
+ Run:
118
+
119
+ ```bash
120
+ npm run build && npm test -- src/implementations/http/astro
121
+ ```
122
+
123
+ Expected: build succeeds, the placeholder describe is skipped, test run passes with 0 failures.
124
+
125
+ - [ ] **Step 6: Commit**
126
+
127
+ ```bash
128
+ git add package.json package-lock.json src/implementations/http/astro/
129
+ git commit -m "feat(astro): scaffold ts-procedures/astro subpath"
130
+ ```
131
+
132
+ ---
133
+
134
+ ## Task 2: stripPrefix helper
135
+
136
+ **Files:**
137
+ - Modify: `src/implementations/http/astro/rewrite-request.ts`
138
+ - Modify: `src/implementations/http/astro/index.test.ts`
139
+
140
+ - [ ] **Step 1: Write the failing tests**
141
+
142
+ Replace the contents of `src/implementations/http/astro/index.test.ts` with:
143
+
144
+ ```ts
145
+ import { describe, expect, test } from 'vitest'
146
+ import { stripPrefix } from './rewrite-request.js'
147
+
148
+ describe('stripPrefix', () => {
149
+ test('returns original request when prefix is undefined', () => {
150
+ const req = new Request('https://example.test/api/users/1')
151
+ expect(stripPrefix(req, undefined)).toBe(req)
152
+ })
153
+
154
+ test('returns original request when normalized prefix is "/"', () => {
155
+ const req = new Request('https://example.test/users/1')
156
+ expect(stripPrefix(req, '/')).toBe(req)
157
+ expect(stripPrefix(req, '')).toBe(req)
158
+ })
159
+
160
+ test('strips a leading-slash prefix', () => {
161
+ const req = new Request('https://example.test/api/users/1')
162
+ const out = stripPrefix(req, '/api')!
163
+ expect(new URL(out.url).pathname).toBe('/users/1')
164
+ })
165
+
166
+ test('strips a no-slash prefix (normalized)', () => {
167
+ const req = new Request('https://example.test/api/users/1')
168
+ const out = stripPrefix(req, 'api')!
169
+ expect(new URL(out.url).pathname).toBe('/users/1')
170
+ })
171
+
172
+ test('strips a trailing-slash prefix (normalized)', () => {
173
+ const req = new Request('https://example.test/api/users/1')
174
+ const out = stripPrefix(req, '/api/')!
175
+ expect(new URL(out.url).pathname).toBe('/users/1')
176
+ })
177
+
178
+ test('exact-match prefix becomes "/"', () => {
179
+ const req = new Request('https://example.test/api')
180
+ const out = stripPrefix(req, '/api')!
181
+ expect(new URL(out.url).pathname).toBe('/')
182
+ })
183
+
184
+ test('returns null when path does not start with prefix (404 short-circuit)', () => {
185
+ const req = new Request('https://example.test/other/users/1')
186
+ expect(stripPrefix(req, '/api')).toBeNull()
187
+ })
188
+
189
+ test('returns null when path shares prefix characters but not a segment boundary', () => {
190
+ // /apikey starts with /api but is not under /api — must NOT rewrite.
191
+ expect(stripPrefix(new Request('https://example.test/apikey/secret'), '/api')).toBeNull()
192
+ expect(stripPrefix(new Request('https://example.test/api-v2/x'), '/api')).toBeNull()
193
+ expect(stripPrefix(new Request('https://example.test/api_internal'), '/api')).toBeNull()
194
+ })
195
+
196
+ test('preserves the query string through the rewrite', () => {
197
+ const req = new Request('https://example.test/api/users?limit=10&tag=a&tag=b')
198
+ const out = stripPrefix(req, '/api')!
199
+ const url = new URL(out.url)
200
+ expect(url.pathname).toBe('/users')
201
+ expect(url.search).toBe('?limit=10&tag=a&tag=b')
202
+ })
203
+
204
+ test('preserves the request method', () => {
205
+ const req = new Request('https://example.test/api/users', { method: 'POST' })
206
+ const out = stripPrefix(req, '/api')!
207
+ expect(out.method).toBe('POST')
208
+ })
209
+
210
+ test('preserves headers', () => {
211
+ const req = new Request('https://example.test/api/users', {
212
+ method: 'POST',
213
+ headers: { 'X-Trace-Id': 'abc-123', 'Content-Type': 'application/json' },
214
+ })
215
+ const out = stripPrefix(req, '/api')!
216
+ expect(out.headers.get('X-Trace-Id')).toBe('abc-123')
217
+ expect(out.headers.get('Content-Type')).toBe('application/json')
218
+ })
219
+ })
220
+ ```
221
+
222
+ - [ ] **Step 2: Run the tests to verify they fail**
223
+
224
+ Run:
225
+
226
+ ```bash
227
+ npm test -- src/implementations/http/astro
228
+ ```
229
+
230
+ Expected: all `stripPrefix` tests fail with `stripPrefix is not a function` or import error.
231
+
232
+ - [ ] **Step 3: Implement `stripPrefix`**
233
+
234
+ Replace `src/implementations/http/astro/rewrite-request.ts` with:
235
+
236
+ ```ts
237
+ /**
238
+ * Returns a Request with `prefix` stripped from the URL pathname.
239
+ *
240
+ * - `prefix` undefined or normalizing to '/' → returns the original request unchanged.
241
+ * - Path does not start with the normalized prefix at a segment boundary → returns `null`
242
+ * (caller short-circuits to 404). E.g. with prefix '/api', the request '/apikey'
243
+ * returns null because 'key' is not preceded by a path separator.
244
+ * - Otherwise → returns a new Request with the rewritten URL and method/headers/body/signal/duplex
245
+ * preserved via `new Request(url, init)`.
246
+ *
247
+ * Normalization: leading and trailing slashes are optional. '/api', 'api', and '/api/' are equivalent.
248
+ */
249
+ export function stripPrefix(request: Request, prefix: string | undefined): Request | null {
250
+ if (prefix === undefined) return request
251
+ const normalized = '/' + prefix.replace(/^\/|\/$/g, '')
252
+ if (normalized === '/') return request
253
+
254
+ const url = new URL(request.url)
255
+ if (!url.pathname.startsWith(normalized)) return null
256
+
257
+ // Segment-boundary guard: the character right after the prefix in the path
258
+ // MUST be either undefined (exact match) or '/' (real segment boundary).
259
+ // Without this, '/apikey' would falsely match prefix '/api'.
260
+ const nextChar = url.pathname[normalized.length]
261
+ if (nextChar !== undefined && nextChar !== '/') return null
262
+
263
+ const rest = url.pathname.slice(normalized.length)
264
+ url.pathname = rest === '' ? '/' : rest
265
+
266
+ return new Request(url, request)
267
+ }
268
+ ```
269
+
270
+ - [ ] **Step 4: Run the tests to verify they pass**
271
+
272
+ Run:
273
+
274
+ ```bash
275
+ npm test -- src/implementations/http/astro
276
+ ```
277
+
278
+ Expected: all 11 `stripPrefix` tests pass.
279
+
280
+ - [ ] **Step 5: Commit**
281
+
282
+ ```bash
283
+ git add src/implementations/http/astro/rewrite-request.ts src/implementations/http/astro/index.test.ts
284
+ git commit -m "feat(astro): add stripPrefix URL-rewrite helper"
285
+ ```
286
+
287
+ ---
288
+
289
+ ## Task 3: astro-context (WeakMap + getAstroContext)
290
+
291
+ **Files:**
292
+ - Modify: `src/implementations/http/astro/astro-context.ts`
293
+ - Modify: `src/implementations/http/astro/index.test.ts`
294
+
295
+ - [ ] **Step 1: Write the failing tests**
296
+
297
+ Append to `src/implementations/http/astro/index.test.ts` (keep the existing `stripPrefix` block above):
298
+
299
+ ```ts
300
+ import { Hono } from 'hono'
301
+ import { setAstroContext, getAstroContext } from './astro-context.js'
302
+
303
+ // Minimal stand-in for Astro's APIContext for unit tests.
304
+ function fakeApiContext(overrides: Record<string, unknown> = {}) {
305
+ return {
306
+ locals: { user: { id: 'u-1' } },
307
+ cookies: {},
308
+ params: {},
309
+ request: new Request('https://example.test/'),
310
+ url: new URL('https://example.test/'),
311
+ redirect: () => new Response(null, { status: 302 }),
312
+ ...overrides,
313
+ } as unknown as import('astro').APIContext
314
+ }
315
+
316
+ describe('astro-context', () => {
317
+ test('getAstroContext returns the APIContext stashed for the request seen by Hono', async () => {
318
+ const req = new Request('https://example.test/probe')
319
+ const apiContext = fakeApiContext()
320
+ setAstroContext(req, apiContext)
321
+
322
+ const app = new Hono()
323
+ let observed: import('astro').APIContext | null = null
324
+ app.get('/probe', (c) => {
325
+ observed = getAstroContext(c)
326
+ return c.json({ ok: true })
327
+ })
328
+
329
+ const res = await app.fetch(req)
330
+ expect(res.status).toBe(200)
331
+ expect(observed).toBe(apiContext)
332
+ })
333
+
334
+ test('getAstroContext throws when called outside an Astro request scope', async () => {
335
+ const app = new Hono()
336
+ let captured: unknown = null
337
+ app.get('/probe', (c) => {
338
+ try {
339
+ getAstroContext(c)
340
+ } catch (err) {
341
+ captured = err
342
+ }
343
+ return c.json({ ok: true })
344
+ })
345
+
346
+ await app.fetch(new Request('https://example.test/probe'))
347
+ expect(captured).toBeInstanceOf(Error)
348
+ expect((captured as Error).message).toMatch(/outside an Astro request scope/i)
349
+ })
350
+ })
351
+ ```
352
+
353
+ - [ ] **Step 2: Run tests to verify they fail**
354
+
355
+ Run:
356
+
357
+ ```bash
358
+ npm test -- src/implementations/http/astro
359
+ ```
360
+
361
+ Expected: the two `astro-context` tests fail (`setAstroContext` / `getAstroContext` not exported).
362
+
363
+ - [ ] **Step 3: Implement `astro-context.ts`**
364
+
365
+ Replace `src/implementations/http/astro/astro-context.ts` with:
366
+
367
+ ```ts
368
+ import type { APIContext } from 'astro'
369
+ import type { Context as HonoContext } from 'hono'
370
+
371
+ const astroContextMap = new WeakMap<Request, APIContext>()
372
+
373
+ /**
374
+ * Internal — stash the Astro APIContext for the Request that Hono will receive.
375
+ *
376
+ * The key MUST be the post-rewrite Request (the one passed to `app.fetch`),
377
+ * because that is what Hono exposes via `c.req.raw` and what `getAstroContext`
378
+ * reads back. When no `pathPrefix` rewrite happens, this is the same Request
379
+ * that Astro originally invoked the adapter with.
380
+ */
381
+ export function setAstroContext(request: Request, apiContext: APIContext): void {
382
+ astroContextMap.set(request, apiContext)
383
+ }
384
+
385
+ /**
386
+ * Reads Astro's APIContext from inside a Hono factory-context closure.
387
+ *
388
+ * Throws when the request is not in scope — typically because the Hono app
389
+ * is being served outside the Astro adapter (e.g., bound to a Node listener
390
+ * or via a different adapter) and the closure was invoked there.
391
+ */
392
+ export function getAstroContext(c: HonoContext): APIContext {
393
+ const ctx = astroContextMap.get(c.req.raw)
394
+ if (!ctx) {
395
+ throw new Error(
396
+ 'getAstroContext was called outside an Astro request scope. ' +
397
+ 'Ensure this Hono app is being served via createAstroHandler when this closure runs.'
398
+ )
399
+ }
400
+ return ctx
401
+ }
402
+ ```
403
+
404
+ - [ ] **Step 4: Run tests to verify they pass**
405
+
406
+ Run:
407
+
408
+ ```bash
409
+ npm test -- src/implementations/http/astro
410
+ ```
411
+
412
+ Expected: all `stripPrefix` and `astro-context` tests pass.
413
+
414
+ - [ ] **Step 5: Commit**
415
+
416
+ ```bash
417
+ git add src/implementations/http/astro/astro-context.ts src/implementations/http/astro/index.test.ts
418
+ git commit -m "feat(astro): add WeakMap-backed getAstroContext bridge"
419
+ ```
420
+
421
+ ---
422
+
423
+ ## Task 4: createAstroHandler — single app, no prefix
424
+
425
+ **Files:**
426
+ - Modify: `src/implementations/http/astro/create-handler.ts`
427
+ - Modify: `src/implementations/http/astro/index.ts`
428
+ - Modify: `src/implementations/http/astro/index.test.ts`
429
+
430
+ - [ ] **Step 1: Write the failing test**
431
+
432
+ Append to `src/implementations/http/astro/index.test.ts`:
433
+
434
+ ```ts
435
+ import { Type } from 'typebox'
436
+ import { Procedures } from '../../../index.js'
437
+ import { HonoAPIAppBuilder } from '../hono-api/index.js'
438
+ import type { APIConfig } from '../../types.js'
439
+ import { createAstroHandler } from './create-handler.js'
440
+
441
+ function buildSimpleUserApi() {
442
+ const API = Procedures<{ db: Map<string, { id: string; name: string }> }, APIConfig>()
443
+ API.Create(
444
+ 'GetUser',
445
+ {
446
+ path: '/users/:id',
447
+ method: 'get',
448
+ schema: {
449
+ input: { pathParams: Type.Object({ id: Type.String() }) },
450
+ returnType: Type.Object({ id: Type.String(), name: Type.String() }),
451
+ },
452
+ },
453
+ async (ctx, { pathParams }) => {
454
+ const u = ctx.db.get(pathParams.id)
455
+ if (!u) throw new Error('not found')
456
+ return u
457
+ }
458
+ )
459
+ const db = new Map([['1', { id: '1', name: 'Ada' }]])
460
+ return new HonoAPIAppBuilder().register(API, () => ({ db })).build()
461
+ }
462
+
463
+ describe('createAstroHandler — single app, no prefix', () => {
464
+ test('exports ALL plus the seven HTTP method handlers', () => {
465
+ const handlers = createAstroHandler({ apps: buildSimpleUserApi() })
466
+ expect(typeof handlers.ALL).toBe('function')
467
+ for (const m of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] as const) {
468
+ expect(typeof handlers[m]).toBe('function')
469
+ }
470
+ })
471
+
472
+ test('GET roundtrips through the underlying Hono app', async () => {
473
+ const { ALL } = createAstroHandler({ apps: buildSimpleUserApi() })
474
+ const apiContext = fakeApiContext({
475
+ request: new Request('https://example.test/users/1'),
476
+ url: new URL('https://example.test/users/1'),
477
+ })
478
+ const res = await (ALL as any)(apiContext)
479
+ expect(res.status).toBe(200)
480
+ expect(await res.json()).toEqual({ id: '1', name: 'Ada' })
481
+ })
482
+ })
483
+ ```
484
+
485
+ - [ ] **Step 2: Run test to verify it fails**
486
+
487
+ Run:
488
+
489
+ ```bash
490
+ npm test -- src/implementations/http/astro
491
+ ```
492
+
493
+ Expected: the two `createAstroHandler` tests fail because `createAstroHandler` is not exported.
494
+
495
+ - [ ] **Step 3: Implement minimal `createAstroHandler`**
496
+
497
+ Replace `src/implementations/http/astro/create-handler.ts` with:
498
+
499
+ ```ts
500
+ import type { Hono } from 'hono'
501
+ import type { APIRoute } from 'astro'
502
+ import { setAstroContext } from './astro-context.js'
503
+ import { stripPrefix } from './rewrite-request.js'
504
+
505
+ export type AstroAdapterConfig = {
506
+ /** One or more built Hono apps. Order matters in first-match dispatch. */
507
+ apps: Hono | Hono[]
508
+ /**
509
+ * Path prefix matching the Astro catch-all mount point.
510
+ * For src/pages/api/[...rest].ts use '/api'. The adapter strips this
511
+ * before delegating, so Hono routes do NOT need to repeat the prefix.
512
+ *
513
+ * Normalization: leading and trailing slashes are optional; '/api',
514
+ * 'api', and '/api/' are equivalent. Undefined or '/' means no rewrite.
515
+ */
516
+ pathPrefix?: string
517
+ }
518
+
519
+ export type AstroHandlers = {
520
+ ALL: APIRoute
521
+ GET: APIRoute
522
+ POST: APIRoute
523
+ PUT: APIRoute
524
+ PATCH: APIRoute
525
+ DELETE: APIRoute
526
+ HEAD: APIRoute
527
+ OPTIONS: APIRoute
528
+ }
529
+
530
+ const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] as const
531
+
532
+ export function createAstroHandler(config: AstroAdapterConfig): AstroHandlers {
533
+ const apps = Array.isArray(config.apps) ? config.apps : [config.apps]
534
+
535
+ const ALL: APIRoute = async (apiContext) => {
536
+ const rewritten = stripPrefix(apiContext.request, config.pathPrefix)
537
+ if (rewritten === null) {
538
+ return new Response(null, { status: 404 })
539
+ }
540
+ setAstroContext(rewritten, apiContext)
541
+
542
+ for (const app of apps) {
543
+ const res = await app.fetch(rewritten)
544
+ if (res.status !== 404) return res
545
+ }
546
+ return new Response(null, { status: 404 })
547
+ }
548
+
549
+ const handlers = { ALL } as AstroHandlers
550
+ for (const method of HTTP_METHODS) {
551
+ handlers[method] = ALL
552
+ }
553
+ return handlers
554
+ }
555
+ ```
556
+
557
+ - [ ] **Step 4: Wire the public barrel**
558
+
559
+ Replace `src/implementations/http/astro/index.ts` with:
560
+
561
+ ```ts
562
+ export { createAstroHandler } from './create-handler.js'
563
+ export type { AstroAdapterConfig, AstroHandlers } from './create-handler.js'
564
+ export { getAstroContext } from './astro-context.js'
565
+ ```
566
+
567
+ - [ ] **Step 5: Run tests to verify they pass**
568
+
569
+ Run:
570
+
571
+ ```bash
572
+ npm test -- src/implementations/http/astro
573
+ ```
574
+
575
+ Expected: all tests up to and including `createAstroHandler — single app, no prefix` pass.
576
+
577
+ - [ ] **Step 6: Commit**
578
+
579
+ ```bash
580
+ git add src/implementations/http/astro/create-handler.ts src/implementations/http/astro/index.ts src/implementations/http/astro/index.test.ts
581
+ git commit -m "feat(astro): add createAstroHandler with single-app dispatch"
582
+ ```
583
+
584
+ ---
585
+
586
+ ## Task 5: pathPrefix support + getAstroContext integration
587
+
588
+ **Files:**
589
+ - Modify: `src/implementations/http/astro/index.test.ts`
590
+
591
+ `createAstroHandler` already calls `stripPrefix` and `setAstroContext` — this task adds the integration tests that prove they work end-to-end. No production changes if all goes well.
592
+
593
+ - [ ] **Step 1: Write the failing tests**
594
+
595
+ Append to `src/implementations/http/astro/index.test.ts`:
596
+
597
+ ```ts
598
+ describe('createAstroHandler — pathPrefix + getAstroContext', () => {
599
+ test('strips pathPrefix before delegating', async () => {
600
+ // Underlying Hono app has no /api in its routes.
601
+ const app = buildSimpleUserApi()
602
+ const { ALL } = createAstroHandler({ apps: app, pathPrefix: '/api' })
603
+ const apiContext = fakeApiContext({
604
+ request: new Request('https://example.test/api/users/1'),
605
+ url: new URL('https://example.test/api/users/1'),
606
+ })
607
+ const res = await (ALL as any)(apiContext)
608
+ expect(res.status).toBe(200)
609
+ expect(await res.json()).toEqual({ id: '1', name: 'Ada' })
610
+ })
611
+
612
+ test('returns 404 directly when request path is outside the prefix', async () => {
613
+ const { ALL } = createAstroHandler({ apps: buildSimpleUserApi(), pathPrefix: '/api' })
614
+ const apiContext = fakeApiContext({
615
+ request: new Request('https://example.test/somewhere-else'),
616
+ url: new URL('https://example.test/somewhere-else'),
617
+ })
618
+ const res = await (ALL as any)(apiContext)
619
+ expect(res.status).toBe(404)
620
+ })
621
+
622
+ test('factory closure can read APIContext via getAstroContext', async () => {
623
+ const API = Procedures<{ user: { id: string } | null }, APIConfig>()
624
+ API.Create(
625
+ 'WhoAmI',
626
+ { path: '/whoami', method: 'get', schema: { returnType: Type.Object({ userId: Type.Union([Type.String(), Type.Null()]) }) } },
627
+ async (ctx) => ({ userId: ctx.user?.id ?? null })
628
+ )
629
+
630
+ const app = new HonoAPIAppBuilder()
631
+ .register(API, (c) => {
632
+ const astro = getAstroContext(c)
633
+ return { user: (astro.locals as { user?: { id: string } }).user ?? null }
634
+ })
635
+ .build()
636
+
637
+ const { ALL } = createAstroHandler({ apps: app })
638
+ const apiContext = fakeApiContext({
639
+ locals: { user: { id: 'u-42' } },
640
+ request: new Request('https://example.test/whoami'),
641
+ url: new URL('https://example.test/whoami'),
642
+ })
643
+ const res = await (ALL as any)(apiContext)
644
+ expect(res.status).toBe(200)
645
+ expect(await res.json()).toEqual({ userId: 'u-42' })
646
+ })
647
+ })
648
+ ```
649
+
650
+ - [ ] **Step 2: Run tests to verify they pass without changes to production code**
651
+
652
+ Run:
653
+
654
+ ```bash
655
+ npm test -- src/implementations/http/astro
656
+ ```
657
+
658
+ Expected: all three new tests pass. (Production code from Task 4 already supports both behaviors — these tests just lock them in.)
659
+
660
+ If any test fails, fix the production code in `create-handler.ts` until they pass before continuing.
661
+
662
+ - [ ] **Step 3: Commit**
663
+
664
+ ```bash
665
+ git add src/implementations/http/astro/index.test.ts
666
+ git commit -m "test(astro): cover pathPrefix and getAstroContext end-to-end"
667
+ ```
668
+
669
+ ---
670
+
671
+ ## Task 6: Multi-app dispatch (404 fallthrough + non-404 short-circuit)
672
+
673
+ **Files:**
674
+ - Modify: `src/implementations/http/astro/index.test.ts`
675
+
676
+ The multi-app loop already exists in `create-handler.ts`. This task adds tests that cover both branches.
677
+
678
+ - [ ] **Step 1: Write the failing tests**
679
+
680
+ Append to `src/implementations/http/astro/index.test.ts`:
681
+
682
+ ```ts
683
+ import { HonoRPCAppBuilder } from '../hono-rpc/index.js'
684
+
685
+ describe('createAstroHandler — multi-app dispatch', () => {
686
+ function makeAppWithRoute(path: string, status: number, body: unknown) {
687
+ const app = new Hono()
688
+ app.all(path, (c) => c.json(body, status as any))
689
+ return app
690
+ }
691
+
692
+ test('first 404 falls through to the second app', async () => {
693
+ const a = makeAppWithRoute('/only-a', 200, { from: 'a' })
694
+ const b = makeAppWithRoute('/only-b', 200, { from: 'b' })
695
+ const { ALL } = createAstroHandler({ apps: [a, b] })
696
+
697
+ const apiContext = fakeApiContext({
698
+ request: new Request('https://example.test/only-b'),
699
+ url: new URL('https://example.test/only-b'),
700
+ })
701
+ const res = await (ALL as any)(apiContext)
702
+ expect(res.status).toBe(200)
703
+ expect(await res.json()).toEqual({ from: 'b' })
704
+ })
705
+
706
+ test('a non-404 response from the first app short-circuits dispatch', async () => {
707
+ const a = makeAppWithRoute('/error', 500, { boom: true })
708
+ const b = makeAppWithRoute('/error', 200, { from: 'b' })
709
+ const { ALL } = createAstroHandler({ apps: [a, b] })
710
+
711
+ const apiContext = fakeApiContext({
712
+ request: new Request('https://example.test/error'),
713
+ url: new URL('https://example.test/error'),
714
+ })
715
+ const res = await (ALL as any)(apiContext)
716
+ expect(res.status).toBe(500)
717
+ expect(await res.json()).toEqual({ boom: true })
718
+ })
719
+
720
+ test('all-404 returns the adapter\'s own 404 with empty body', async () => {
721
+ const a = makeAppWithRoute('/x', 200, { from: 'a' })
722
+ const b = makeAppWithRoute('/y', 200, { from: 'b' })
723
+ const { ALL } = createAstroHandler({ apps: [a, b] })
724
+
725
+ const apiContext = fakeApiContext({
726
+ request: new Request('https://example.test/nope'),
727
+ url: new URL('https://example.test/nope'),
728
+ })
729
+ const res = await (ALL as any)(apiContext)
730
+ expect(res.status).toBe(404)
731
+ expect(await res.text()).toBe('')
732
+ })
733
+ })
734
+ ```
735
+
736
+ - [ ] **Step 2: Run tests to verify they pass**
737
+
738
+ Run:
739
+
740
+ ```bash
741
+ npm test -- src/implementations/http/astro
742
+ ```
743
+
744
+ Expected: all three multi-app tests pass.
745
+
746
+ - [ ] **Step 3: Commit**
747
+
748
+ ```bash
749
+ git add src/implementations/http/astro/index.test.ts
750
+ git commit -m "test(astro): cover multi-app first-match and non-404 short-circuit"
751
+ ```
752
+
753
+ ---
754
+
755
+ ## Task 7: Stream pass-through
756
+
757
+ **Files:**
758
+ - Modify: `src/implementations/http/astro/index.test.ts`
759
+
760
+ Confirms a `HonoStreamAppBuilder` produces a streaming Response that survives the adapter intact.
761
+
762
+ - [ ] **Step 1: Write the failing test**
763
+
764
+ Append to `src/implementations/http/astro/index.test.ts`:
765
+
766
+ ```ts
767
+ import { HonoStreamAppBuilder } from '../hono-stream/index.js'
768
+ import type { StreamConfig } from '../../types.js'
769
+
770
+ describe('createAstroHandler — streams', () => {
771
+ test('SSE events from a HonoStream builder pass through the adapter', async () => {
772
+ const STREAM = Procedures<{}, StreamConfig>()
773
+ STREAM.CreateStream(
774
+ 'Counter',
775
+ {
776
+ path: '/counter',
777
+ schema: {
778
+ yieldType: Type.Object({ n: Type.Number() }),
779
+ },
780
+ },
781
+ async function* () {
782
+ yield { n: 1 }
783
+ yield { n: 2 }
784
+ yield { n: 3 }
785
+ }
786
+ )
787
+
788
+ const streamApp = new HonoStreamAppBuilder().register(STREAM, () => ({})).build()
789
+ const { ALL } = createAstroHandler({ apps: streamApp })
790
+
791
+ const apiContext = fakeApiContext({
792
+ request: new Request('https://example.test/counter', { method: 'GET' }),
793
+ url: new URL('https://example.test/counter'),
794
+ })
795
+ const res = await (ALL as any)(apiContext)
796
+ expect(res.status).toBe(200)
797
+ expect(res.headers.get('content-type')).toMatch(/event-stream/)
798
+
799
+ // Drain the SSE body and assert all three events appear in order.
800
+ const text = await res.text()
801
+ expect(text).toMatch(/"n":1/)
802
+ expect(text).toMatch(/"n":2/)
803
+ expect(text).toMatch(/"n":3/)
804
+ })
805
+ })
806
+ ```
807
+
808
+ NOTE: If `HonoStreamAppBuilder`'s constructor or `CreateStream` signature differs from this snippet, open `src/implementations/http/hono-stream/index.test.ts` to find the canonical example and copy the matching shape. Do NOT invent a different API surface.
809
+
810
+ - [ ] **Step 2: Run test to verify it passes (or fails informatively)**
811
+
812
+ Run:
813
+
814
+ ```bash
815
+ npm test -- src/implementations/http/astro
816
+ ```
817
+
818
+ Expected behaviour: the test passes. The adapter does not modify the streaming Response, so it should already work.
819
+
820
+ If the test fails because of a `HonoStreamAppBuilder` API mismatch (e.g., `CreateStream` has a different signature in the current version), update only the test wiring to match — don't change the adapter.
821
+
822
+ - [ ] **Step 3: Commit**
823
+
824
+ ```bash
825
+ git add src/implementations/http/astro/index.test.ts
826
+ git commit -m "test(astro): verify HonoStream SSE bodies pass through unchanged"
827
+ ```
828
+
829
+ ---
830
+
831
+ ## Task 8: Abort signal flow on client disconnect
832
+
833
+ **Files:**
834
+ - Modify: `src/implementations/http/astro/index.test.ts`
835
+
836
+ - [ ] **Step 1: Write the failing test**
837
+
838
+ Append to `src/implementations/http/astro/index.test.ts`:
839
+
840
+ ```ts
841
+ describe('createAstroHandler — abort signal', () => {
842
+ test('aborting the incoming request aborts the procedure handler\'s ctx.signal', async () => {
843
+ let observedSignal: AbortSignal | undefined
844
+ const aborted = new Promise<void>((resolve) => {
845
+ const API = Procedures<{}, APIConfig>()
846
+ API.Create(
847
+ 'Hang',
848
+ { path: '/hang', method: 'get', schema: { returnType: Type.Object({ ok: Type.Boolean() }) } },
849
+ async (ctx) => {
850
+ observedSignal = ctx.signal
851
+ ctx.signal?.addEventListener('abort', () => resolve(), { once: true })
852
+ // Wait for abort or a generous timeout.
853
+ await new Promise<void>((r) => setTimeout(r, 200))
854
+ return { ok: true }
855
+ }
856
+ )
857
+ const app = new HonoAPIAppBuilder().register(API, () => ({})).build()
858
+ const { ALL } = createAstroHandler({ apps: app })
859
+
860
+ const controller = new AbortController()
861
+ const apiContext = fakeApiContext({
862
+ request: new Request('https://example.test/hang', { signal: controller.signal }),
863
+ url: new URL('https://example.test/hang'),
864
+ })
865
+
866
+ // Fire ALL but don't await — abort while the handler is still running.
867
+ ;(ALL as any)(apiContext).catch(() => {})
868
+ setTimeout(() => controller.abort(), 20)
869
+ })
870
+
871
+ await aborted
872
+ expect(observedSignal?.aborted).toBe(true)
873
+ })
874
+ })
875
+ ```
876
+
877
+ - [ ] **Step 2: Run test to verify it passes**
878
+
879
+ Run:
880
+
881
+ ```bash
882
+ npm test -- src/implementations/http/astro
883
+ ```
884
+
885
+ Expected: the test passes. The HonoAPIAppBuilder already wires `ctx.signal = c.req.raw.signal`, and our `stripPrefix` preserves the signal via `new Request(url, request)`.
886
+
887
+ If it fails, the cause is most likely that the rewritten `new Request(url, request)` is not preserving the signal in this Node/undici version. Investigate by adding a `console.log(rewritten.signal === request.signal)` inside `create-handler.ts` temporarily; if signals do not survive cloning, switch the rewrite to construct the new Request with explicit `signal: request.signal` in the `init` argument.
888
+
889
+ - [ ] **Step 3: Commit**
890
+
891
+ ```bash
892
+ git add src/implementations/http/astro/index.test.ts
893
+ git commit -m "test(astro): verify client abort propagates to handler ctx.signal"
894
+ ```
895
+
896
+ ---
897
+
898
+ ## Task 9: README for the adapter module
899
+
900
+ **Files:**
901
+ - Create: `src/implementations/http/astro/README.md`
902
+
903
+ - [ ] **Step 1: Read a sibling README for tone and structure**
904
+
905
+ Open `src/implementations/http/hono-api/README.md` and skim the structure (overview → install → quick example → config reference → recipes).
906
+
907
+ - [ ] **Step 2: Write the README**
908
+
909
+ Create `src/implementations/http/astro/README.md` with this content:
910
+
911
+ ````markdown
912
+ # `ts-procedures/astro`
913
+
914
+ Drop one or more pre-built Hono apps (from `HonoAPIAppBuilder`, `HonoRPCAppBuilder`, or `HonoStreamAppBuilder`) into a single Astro catch-all endpoint. Procedure handlers can read Astro's per-request data (`locals`, `cookies`, `redirect`, `params`) inside their factory-context closures.
915
+
916
+ ## Install
917
+
918
+ The adapter is part of `ts-procedures`. Astro is an optional peer; install it in your Astro app:
919
+
920
+ ```bash
921
+ npm install ts-procedures hono astro
922
+ ```
923
+
924
+ ## Quick start
925
+
926
+ ```ts
927
+ // src/server/api.ts
928
+ import { HonoAPIAppBuilder } from 'ts-procedures/hono-api'
929
+ import { getAstroContext } from 'ts-procedures/astro'
930
+ import { usersAPI } from './procedures/users'
931
+ import { db } from './db'
932
+
933
+ export const apiApp = new HonoAPIAppBuilder()
934
+ .register(usersAPI, (c) => {
935
+ const astro = getAstroContext(c)
936
+ return { db, user: astro.locals.user ?? null }
937
+ })
938
+ .build()
939
+ ```
940
+
941
+ ```ts
942
+ // src/pages/api/[...rest].ts
943
+ import { createAstroHandler } from 'ts-procedures/astro'
944
+ import { apiApp } from '../../server/api'
945
+
946
+ export const { ALL } = createAstroHandler({
947
+ apps: apiApp,
948
+ pathPrefix: '/api',
949
+ })
950
+ ```
951
+
952
+ ## Config reference
953
+
954
+ | Option | Type | Description |
955
+ | ------------ | --------------------- | -------------------------------------------------------------------------------------------- |
956
+ | `apps` | `Hono \| Hono[]` | One or more built Hono apps. Order matters in first-match dispatch. |
957
+ | `pathPrefix` | `string \| undefined` | Path prefix matching the Astro catch-all mount point. `/api`, `api`, `/api/` are equivalent. |
958
+
959
+ ## Path prefix semantics
960
+
961
+ The catch-all file is at `src/pages/api/[...rest].ts`, so Astro receives requests with paths like `/api/users/123`. The Hono builder routes you registered probably use just `/users/:id`. Configuring `pathPrefix: '/api'` strips the prefix before delegating — your Hono routes don't need to repeat the prefix.
962
+
963
+ | Request path | `pathPrefix` | Inner Hono sees |
964
+ | ---------------- | ------------ | --------------- |
965
+ | `/api/users/123` | `'/api'` | `/users/123` |
966
+ | `/api` | `'/api'` | `/` |
967
+ | `/somewhere` | `'/api'` | (404, no app invoked) |
968
+ | `/users/123` | `undefined` | `/users/123` |
969
+
970
+ ## Multi-app dispatch
971
+
972
+ Pass an array. The adapter tries each app in order:
973
+
974
+ ```ts
975
+ export const { ALL } = createAstroHandler({
976
+ apps: [apiApp, rpcApp, streamsApp],
977
+ pathPrefix: '/api',
978
+ })
979
+ ```
980
+
981
+ Dispatch rules:
982
+ - The first response with `status !== 404` is returned.
983
+ - A `500` from app A stops dispatch (it is treated as a real answer, not a miss).
984
+ - Only literal 404s fall through to the next app.
985
+ - All apps 404 → adapter returns its own `Response(null, { status: 404 })`.
986
+
987
+ ## Streams
988
+
989
+ `HonoStreamAppBuilder` returns a `Response` with a `ReadableStream` body. Astro SSR forwards that body verbatim — no additional configuration. Client disconnects abort `ctx.signal` inside the stream handler.
990
+
991
+ ## What's NOT included
992
+
993
+ - Express RPC support (Express uses Node `req`/`res`, not Web Fetch).
994
+ - DocRegistry coupling — wire `DocRegistry` against the same builders separately for client codegen.
995
+ ````
996
+
997
+ - [ ] **Step 3: Commit**
998
+
999
+ ```bash
1000
+ git add src/implementations/http/astro/README.md
1001
+ git commit -m "docs(astro): add README for ts-procedures/astro subpath"
1002
+ ```
1003
+
1004
+ ---
1005
+
1006
+ ## Task 10: End-to-end walkthrough doc
1007
+
1008
+ **Files:**
1009
+ - Create: `docs/astro-adapter.md`
1010
+
1011
+ - [ ] **Step 1: Write the walkthrough**
1012
+
1013
+ Create `docs/astro-adapter.md` with this content:
1014
+
1015
+ ````markdown
1016
+ # Astro adapter walkthrough
1017
+
1018
+ This walkthrough builds an Astro app that serves ts-procedures handlers from a single catch-all endpoint.
1019
+
1020
+ ## Project shape
1021
+
1022
+ ```
1023
+ my-astro-app/
1024
+ ├── astro.config.mjs
1025
+ ├── package.json
1026
+ └── src/
1027
+ ├── server/
1028
+ │ ├── db.ts
1029
+ │ ├── procedures/
1030
+ │ │ └── users.ts
1031
+ │ └── api.ts
1032
+ └── pages/
1033
+ └── api/
1034
+ └── [...rest].ts
1035
+ ```
1036
+
1037
+ ## 1. Define procedures
1038
+
1039
+ ```ts
1040
+ // src/server/procedures/users.ts
1041
+ import { Procedures } from 'ts-procedures'
1042
+ import type { APIConfig } from 'ts-procedures/http'
1043
+ import { Type } from 'typebox'
1044
+
1045
+ type UserContext = {
1046
+ db: { findUser(id: string): Promise<{ id: string; name: string } | null> }
1047
+ currentUser: { id: string } | null
1048
+ }
1049
+
1050
+ export const usersAPI = Procedures<UserContext, APIConfig>()
1051
+
1052
+ usersAPI.Create(
1053
+ 'GetUser',
1054
+ {
1055
+ path: '/users/:id',
1056
+ method: 'get',
1057
+ schema: {
1058
+ input: { pathParams: Type.Object({ id: Type.String() }) },
1059
+ returnType: Type.Object({ id: Type.String(), name: Type.String() }),
1060
+ },
1061
+ },
1062
+ async (ctx, { pathParams }) => {
1063
+ const u = await ctx.db.findUser(pathParams.id)
1064
+ if (!u) throw new Error('not found')
1065
+ return u
1066
+ }
1067
+ )
1068
+ ```
1069
+
1070
+ ## 2. Build the Hono app once
1071
+
1072
+ ```ts
1073
+ // src/server/api.ts
1074
+ import { HonoAPIAppBuilder } from 'ts-procedures/hono-api'
1075
+ import { getAstroContext } from 'ts-procedures/astro'
1076
+ import { usersAPI } from './procedures/users'
1077
+ import { db } from './db'
1078
+
1079
+ export const apiApp = new HonoAPIAppBuilder()
1080
+ .register(usersAPI, (c) => {
1081
+ const astro = getAstroContext(c)
1082
+ return {
1083
+ db,
1084
+ currentUser: astro.locals.user ?? null,
1085
+ }
1086
+ })
1087
+ .build()
1088
+ ```
1089
+
1090
+ ## 3. Mount the catch-all
1091
+
1092
+ ```ts
1093
+ // src/pages/api/[...rest].ts
1094
+ import { createAstroHandler } from 'ts-procedures/astro'
1095
+ import { apiApp } from '../../server/api'
1096
+
1097
+ export const { ALL } = createAstroHandler({
1098
+ apps: apiApp,
1099
+ pathPrefix: '/api',
1100
+ })
1101
+ ```
1102
+
1103
+ That's it — `GET /api/users/123` runs through `usersAPI.GetUser`. The factory closure sees `astro.locals.user` populated from your Astro middleware.
1104
+
1105
+ ## Where do `Astro.locals.user` come from?
1106
+
1107
+ A typical pattern: an Astro middleware that reads a session cookie and sets `locals.user`:
1108
+
1109
+ ```ts
1110
+ // src/middleware.ts
1111
+ import { defineMiddleware } from 'astro:middleware'
1112
+ import { db } from './server/db'
1113
+
1114
+ export const onRequest = defineMiddleware(async (context, next) => {
1115
+ const sessionId = context.cookies.get('sid')?.value
1116
+ if (sessionId) {
1117
+ context.locals.user = await db.findUserBySession(sessionId)
1118
+ }
1119
+ return next()
1120
+ })
1121
+ ```
1122
+
1123
+ 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.
1124
+
1125
+ ## Client codegen — where does it go?
1126
+
1127
+ The adapter does NOT couple to `DocRegistry`. Wire codegen separately, against the same builders:
1128
+
1129
+ ```ts
1130
+ // scripts/build-docs.ts
1131
+ import { DocRegistry } from 'ts-procedures/http-docs'
1132
+ import { HonoAPIAppBuilder } from 'ts-procedures/hono-api'
1133
+ import { usersAPI } from '../src/server/procedures/users'
1134
+
1135
+ const builder = new HonoAPIAppBuilder().register(usersAPI, () => ({} as never))
1136
+ builder.build()
1137
+
1138
+ const registry = new DocRegistry().from(builder)
1139
+ console.log(JSON.stringify(registry.toEnvelope(), null, 2))
1140
+ ```
1141
+
1142
+ Then run `npx ts-procedures-codegen --file <envelope.json> --out ./generated/api`.
1143
+
1144
+ ## Multi-app composition
1145
+
1146
+ For larger projects with separate API/RPC/Stream surfaces, pass an array:
1147
+
1148
+ ```ts
1149
+ export const { ALL } = createAstroHandler({
1150
+ apps: [apiApp, rpcApp, streamsApp],
1151
+ pathPrefix: '/api',
1152
+ })
1153
+ ```
1154
+
1155
+ Order matters — first non-404 wins. See the module README for full dispatch rules.
1156
+ ````
1157
+
1158
+ - [ ] **Step 2: Commit**
1159
+
1160
+ ```bash
1161
+ git add docs/astro-adapter.md
1162
+ git commit -m "docs: add Astro adapter walkthrough"
1163
+ ```
1164
+
1165
+ ---
1166
+
1167
+ ## Task 11: Scaffold template for ts-procedures-scaffold skill
1168
+
1169
+ **Files:**
1170
+ - Create: `agent_config/claude-code/skills/ts-procedures-scaffold/templates/astro-catchall.ts`
1171
+
1172
+ - [ ] **Step 1: Read an existing template for tone**
1173
+
1174
+ Open `agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-rpc.ts` (or whichever exists) and skim the comment style.
1175
+
1176
+ - [ ] **Step 2: Write the template**
1177
+
1178
+ Create `agent_config/claude-code/skills/ts-procedures-scaffold/templates/astro-catchall.ts`:
1179
+
1180
+ ```ts
1181
+ // src/pages/api/[...rest].ts
1182
+ //
1183
+ // Catch-all entry point that serves every ts-procedures route from a single Astro file.
1184
+ // Adjust `pathPrefix` to match the directory the file lives in (e.g., '/api' for
1185
+ // src/pages/api/[...rest].ts; '/v1' for src/pages/v1/[...rest].ts).
1186
+ //
1187
+ // Build your Hono app(s) ONCE in a sibling module, then drop them in here.
1188
+
1189
+ import { createAstroHandler } from 'ts-procedures/astro'
1190
+ import { apiApp } from '../../server/api' // ← your built Hono app(s)
1191
+
1192
+ export const { ALL } = createAstroHandler({
1193
+ apps: apiApp,
1194
+ pathPrefix: '/api',
1195
+ })
1196
+ ```
1197
+
1198
+ - [ ] **Step 3: Commit**
1199
+
1200
+ ```bash
1201
+ git add agent_config/claude-code/skills/ts-procedures-scaffold/templates/astro-catchall.ts
1202
+ git commit -m "feat(agent-config): add astro-catchall scaffold template"
1203
+ ```
1204
+
1205
+ ---
1206
+
1207
+ ## Task 12: Update agent_config rules (api-reference + patterns + Copilot/Cursor)
1208
+
1209
+ **Files:**
1210
+ - Modify: `agent_config/claude-code/skills/ts-procedures/api-reference.md`
1211
+ - Modify: `agent_config/claude-code/skills/ts-procedures/patterns.md`
1212
+ - Modify: `agent_config/copilot/copilot-instructions.md`
1213
+ - Modify: `agent_config/cursor/cursorrules`
1214
+
1215
+ - [ ] **Step 1: Read the current api-reference.md**
1216
+
1217
+ Open `agent_config/claude-code/skills/ts-procedures/api-reference.md` and locate the existing section that lists builders (HonoAPIAppBuilder, HonoRPCAppBuilder, HonoStreamAppBuilder, etc.).
1218
+
1219
+ - [ ] **Step 2: Add an Astro adapter section**
1220
+
1221
+ After the existing builders section in `api-reference.md`, append:
1222
+
1223
+ ````markdown
1224
+ ## Astro adapter — `ts-procedures/astro`
1225
+
1226
+ For Astro apps, mount one or more pre-built Hono apps inside a single catch-all endpoint:
1227
+
1228
+ ```ts
1229
+ // src/pages/api/[...rest].ts
1230
+ import { createAstroHandler } from 'ts-procedures/astro'
1231
+ import { apiApp } from '../../server/api'
1232
+
1233
+ export const { ALL } = createAstroHandler({
1234
+ apps: apiApp,
1235
+ pathPrefix: '/api',
1236
+ })
1237
+ ```
1238
+
1239
+ Inside factory-context closures, read Astro's APIContext via `getAstroContext`:
1240
+
1241
+ ```ts
1242
+ import { getAstroContext } from 'ts-procedures/astro'
1243
+
1244
+ new HonoAPIAppBuilder()
1245
+ .register(usersAPI, (c) => {
1246
+ const astro = getAstroContext(c)
1247
+ return { db, user: astro.locals.user ?? null }
1248
+ })
1249
+ .build()
1250
+ ```
1251
+
1252
+ Multi-app: pass an array; first non-404 response wins. All-404 falls back to the adapter's own 404. Streams pass through unchanged.
1253
+
1254
+ Constraints:
1255
+ - Astro 5+ in SSR (or `prerender = false`).
1256
+ - Express builders are NOT supported by this adapter — Hono builders only.
1257
+ ````
1258
+
1259
+ - [ ] **Step 3: Update patterns.md**
1260
+
1261
+ Open `agent_config/claude-code/skills/ts-procedures/patterns.md` and add a new "Astro integration" pattern at the end of the file:
1262
+
1263
+ ````markdown
1264
+ ### Astro integration
1265
+
1266
+ When a downstream developer is building inside an Astro app, recommend the catch-all pattern:
1267
+
1268
+ 1. Build the Hono app ONCE in `src/server/api.ts` using `HonoAPIAppBuilder` / `HonoRPCAppBuilder` / `HonoStreamAppBuilder` exactly as you would for any other deployment.
1269
+ 2. Inside the factory-context closure, call `getAstroContext(c)` to read `locals`, `cookies`, `params`, etc.
1270
+ 3. Mount the built app(s) in a single catch-all file: `src/pages/api/[...rest].ts`. Use `pathPrefix: '/api'` so your Hono routes don't need to repeat the prefix.
1271
+
1272
+ Anti-pattern: don't create one Astro file per procedure — that loses the centralized factory wiring and forces the developer to repeat the context closure.
1273
+
1274
+ Anti-pattern: don't try to use `express-rpc` with the Astro adapter. The adapter only accepts Web-Fetch apps (Hono).
1275
+ ````
1276
+
1277
+ - [ ] **Step 4: Mirror the same content into Copilot and Cursor rules**
1278
+
1279
+ Per CLAUDE.md, `agent_config/copilot/copilot-instructions.md` and `agent_config/cursor/cursorrules` are kept identical inside their `<!-- BEGIN/END ts-procedures -->` markers.
1280
+
1281
+ In each file, locate the existing builders section and append (verbatim — same text in both files):
1282
+
1283
+ ```markdown
1284
+ ## Astro adapter
1285
+
1286
+ Catch-all endpoint pattern. Build Hono apps once with HonoAPI/RPC/Stream builders, mount via `createAstroHandler` in `src/pages/api/[...rest].ts` with `pathPrefix: '/api'`. Read Astro context inside factory closures with `getAstroContext(c)`. Multi-app dispatch is first-non-404-wins. Express builders are not supported.
1287
+ ```
1288
+
1289
+ - [ ] **Step 5: Run the docs-consistency check**
1290
+
1291
+ Run:
1292
+
1293
+ ```bash
1294
+ npm run check-docs
1295
+ ```
1296
+
1297
+ Expected: passes. If it fails because Copilot and Cursor diverged, reconcile them — both files should contain the same Astro section verbatim.
1298
+
1299
+ - [ ] **Step 6: Commit**
1300
+
1301
+ ```bash
1302
+ git add agent_config/
1303
+ git commit -m "docs(agent-config): teach Claude/Cursor/Copilot rules the Astro adapter"
1304
+ ```
1305
+
1306
+ ---
1307
+
1308
+ ## Task 13: Final validation
1309
+
1310
+ **Files:** none
1311
+
1312
+ - [ ] **Step 1: Run the full test suite**
1313
+
1314
+ Run:
1315
+
1316
+ ```bash
1317
+ npm test
1318
+ ```
1319
+
1320
+ Expected: all tests pass, including the new Astro suite.
1321
+
1322
+ - [ ] **Step 2: Run the build**
1323
+
1324
+ Run:
1325
+
1326
+ ```bash
1327
+ npm run build
1328
+ ```
1329
+
1330
+ Expected: build succeeds. The `./astro` subpath emits `build/implementations/http/astro/index.{js,d.ts}`.
1331
+
1332
+ - [ ] **Step 3: Run the lint**
1333
+
1334
+ Run:
1335
+
1336
+ ```bash
1337
+ npm run lint
1338
+ ```
1339
+
1340
+ Expected: no warnings or errors.
1341
+
1342
+ - [ ] **Step 4: Verify the subpath resolves**
1343
+
1344
+ Run:
1345
+
1346
+ ```bash
1347
+ node --input-type=module -e "import('ts-procedures/astro').then(m => console.log(Object.keys(m).sort()))"
1348
+ ```
1349
+
1350
+ Expected: `['createAstroHandler', 'getAstroContext']` (order may vary, but both names present).
1351
+
1352
+ - [ ] **Step 5: Final commit if any tail-end fixes were needed**
1353
+
1354
+ If steps 1-4 required any code adjustments, commit them with:
1355
+
1356
+ ```bash
1357
+ git add -A
1358
+ git commit -m "chore(astro): final cleanup after full-suite validation"
1359
+ ```
1360
+
1361
+ If nothing changed, skip this step.
1362
+
1363
+ ---
1364
+
1365
+ ## Self-Review
1366
+
1367
+ **Spec coverage:**
1368
+ - Approach (catch-all + delegate to Hono apps) — Tasks 4, 5, 6
1369
+ - WeakMap-based `getAstroContext` — Task 3, integration test in Task 5
1370
+ - Path prefix rewrite with all normalizations + query preservation — Task 2 (unit), Task 5 (integration)
1371
+ - Multi-app first-non-404 dispatch with non-404 short-circuit — Task 6
1372
+ - Stream pass-through — Task 7
1373
+ - Abort signal flow — Task 8
1374
+ - 404 fallback when nothing matches or prefix mismatches — Task 5 + Task 6
1375
+ - File layout (`astro-context.ts`, `rewrite-request.ts`, `create-handler.ts`, `index.ts`, `index.test.ts`, `README.md`) — Tasks 1, 2, 3, 4, 9
1376
+ - Package.json `./astro` export, `optionalDependencies` + `devDependencies` for `astro` — Task 1
1377
+ - `import type` only — Task 3 (`astro-context.ts` uses `import type { APIContext }`); Task 4 (`create-handler.ts` uses `import type { Hono }`, `import type { APIRoute }`)
1378
+ - `docs/astro-adapter.md` walkthrough — Task 10
1379
+ - Agent config updates (api-reference, patterns, scaffold template, Copilot/Cursor) — Tasks 11, 12
1380
+
1381
+ **Placeholder scan:** No "TBD"/"TODO"/"implement later". Every code block contains the actual content. Tests have specific assertions, not "verify behavior".
1382
+
1383
+ **Type consistency:**
1384
+ - `AstroAdapterConfig` — defined Task 4, referenced Task 4. Single definition.
1385
+ - `AstroHandlers` — defined Task 4, referenced Task 4. Single definition.
1386
+ - `setAstroContext(req, ctx)` — defined Task 3, called Task 4. Same signature both places.
1387
+ - `getAstroContext(c)` — defined Task 3, called Tasks 5, 9, 10, 11, 12. Same signature.
1388
+ - `stripPrefix(request, prefix)` — defined Task 2, called Task 4. Same signature.
1389
+
1390
+ **Out-of-scope items confirmed not in plan:**
1391
+ - Express adapter — explicitly excluded
1392
+ - Native Astro builders — explicitly excluded
1393
+ - DocRegistry coupling — wired separately in walkthrough (Task 10), not in adapter
1394
+ - `dispatch: 'merge'` knob — not introduced
1395
+
1396
+ Ready for execution.