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
package/README.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
A TypeScript RPC framework that creates type-safe, schema-validated procedure calls with a single function definition. Define your procedures once and get full type inference, runtime validation and procedure documentation/configuration.
|
|
4
4
|
|
|
5
|
+
## Goals of this Project
|
|
6
|
+
|
|
7
|
+
1. Full type safety
|
|
8
|
+
2. Auto generated documentation and client api spec
|
|
9
|
+
3. Fast and performant
|
|
10
|
+
4. Excellent DX (and agent documentation)
|
|
11
|
+
|
|
12
|
+
|
|
5
13
|
## Installation
|
|
6
14
|
|
|
7
15
|
```bash
|
|
@@ -695,6 +695,40 @@ Or use the `.safe()` sibling for exhaustive Result-based narrowing (see `pattern
|
|
|
695
695
|
|
|
696
696
|
---
|
|
697
697
|
|
|
698
|
+
## 22. Using `noRuntimeValidation` on a public-facing factory
|
|
699
|
+
|
|
700
|
+
**Problem:** Setting `Procedures({ config: { noRuntimeValidation: true } })` on a factory whose procedures are exposed via HTTP, mounted on a public surface, or invoked with caller-supplied JSON. Schema validation is the only thing standing between untyped wire data and your handlers — turning it off lets malformed payloads reach business logic.
|
|
701
|
+
|
|
702
|
+
```typescript
|
|
703
|
+
// BAD — these procedures are mounted on Hono and called by browsers
|
|
704
|
+
const { Create } = Procedures({ config: { noRuntimeValidation: true } })
|
|
705
|
+
|
|
706
|
+
const { CreateUser } = Create('CreateUser', {
|
|
707
|
+
path: '/users', method: 'post',
|
|
708
|
+
schema: { input: { body: Type.Object({ email: Type.String() }) } },
|
|
709
|
+
}, async (ctx, { body }) => {
|
|
710
|
+
// body.email is typed `string`, but at runtime it can be anything —
|
|
711
|
+
// the request validator was disabled at the factory level.
|
|
712
|
+
await db.users.insert({ email: body.email })
|
|
713
|
+
})
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
**Fix:** Leave validation on for any factory whose handlers can be reached from untrusted callers. Only opt out when the factory is purely internal (e.g., a back-of-house procedure registry called from another already-validated procedure or a fully type-checked compile step).
|
|
717
|
+
|
|
718
|
+
```typescript
|
|
719
|
+
// GOOD — public factory keeps runtime validation
|
|
720
|
+
const { Create: CreatePublic } = Procedures()
|
|
721
|
+
|
|
722
|
+
// GOOD — internal factory used only from already-validated callers
|
|
723
|
+
const { Create: CreateInternal } = Procedures({
|
|
724
|
+
config: { noRuntimeValidation: true },
|
|
725
|
+
})
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
**Why:** `noRuntimeValidation` skips both `schema.params` and `schema.input` AJV runs for every procedure on the factory — there is no per-procedure escape hatch. TypeScript types still flow, but TS types vanish at runtime. AJV is also what enforces `coerceTypes` and `removeAdditional`; once disabled, extra wire fields will leak through and string/number coercion stops happening. The flag is only `true` (no `false`) because the safe default — validation on — should never be expressed by a config value at all; an unset config is the safe path.
|
|
729
|
+
|
|
730
|
+
---
|
|
731
|
+
|
|
698
732
|
## Summary Table
|
|
699
733
|
|
|
700
734
|
| # | Anti-Pattern | Risk | Severity |
|
|
@@ -720,3 +754,4 @@ Or use the `.safe()` sibling for exhaustive Result-based narrowing (see `pattern
|
|
|
720
754
|
| 19 | Mismatched path param names | Build-time error or confusing validation failures | CRITICAL |
|
|
721
755
|
| 20 | Hand-writing onError instanceof ladders | Drifting response shapes, untyped client errors | WARNING |
|
|
722
756
|
| 21 | Catching raw DOMException/TypeError from generated callables | Framework normalizes these; raw platform errors won't reach catch blocks after 7.0.0 | WARNING |
|
|
757
|
+
| 22 | `noRuntimeValidation` on a public-facing factory | Untrusted wire data reaches handlers without AJV; coerceTypes/removeAdditional also disabled | CRITICAL |
|
|
@@ -7,6 +7,9 @@ Factory function that creates a scoped procedure registration system.
|
|
|
7
7
|
```typescript
|
|
8
8
|
function Procedures<TContext = unknown, TExtendedConfig = unknown>(
|
|
9
9
|
builder?: {
|
|
10
|
+
config?: {
|
|
11
|
+
noRuntimeValidation?: true
|
|
12
|
+
}
|
|
10
13
|
onCreate?: (procedure: {
|
|
11
14
|
name: string
|
|
12
15
|
isStream?: boolean
|
|
@@ -26,6 +29,7 @@ function Procedures<TContext = unknown, TExtendedConfig = unknown>(
|
|
|
26
29
|
|
|
27
30
|
### Parameters
|
|
28
31
|
|
|
32
|
+
- `builder.config.noRuntimeValidation` — When `true`, every procedure registered through this factory skips AJV validation of `schema.params` and `schema.input` at call time (applies to both `Create` and `CreateStream`). JSON Schema is still computed at registration time, so `info.schema` and codegen are unaffected. Shape is `noRuntimeValidation?: true` — only `true` is accepted, making the opt-out explicit. Use only for trusted internal factories whose callers are already type-checked at build time; do **not** enable for procedures that accept input from public clients or untyped JSON bodies.
|
|
29
33
|
- `builder.onCreate` — Called each time a procedure is registered. Use for framework integration, route registration, or logging.
|
|
30
34
|
|
|
31
35
|
### Return Value
|
|
@@ -1392,6 +1396,42 @@ type DefinitionInfo = {
|
|
|
1392
1396
|
|
|
1393
1397
|
---
|
|
1394
1398
|
|
|
1399
|
+
## Astro adapter — `ts-procedures/astro`
|
|
1400
|
+
|
|
1401
|
+
For Astro apps, mount one or more pre-built Hono apps inside a single catch-all endpoint:
|
|
1402
|
+
|
|
1403
|
+
```ts
|
|
1404
|
+
// src/pages/api/[...rest].ts
|
|
1405
|
+
import { createAstroHandler } from 'ts-procedures/astro'
|
|
1406
|
+
import { apiApp } from '../../server/api'
|
|
1407
|
+
|
|
1408
|
+
export const { ALL } = createAstroHandler({
|
|
1409
|
+
apps: apiApp,
|
|
1410
|
+
pathPrefix: '/api',
|
|
1411
|
+
})
|
|
1412
|
+
```
|
|
1413
|
+
|
|
1414
|
+
Inside factory-context closures, read Astro's APIContext via `getAstroContext`:
|
|
1415
|
+
|
|
1416
|
+
```ts
|
|
1417
|
+
import { getAstroContext } from 'ts-procedures/astro'
|
|
1418
|
+
|
|
1419
|
+
new HonoAPIAppBuilder()
|
|
1420
|
+
.register(usersAPI, (c) => {
|
|
1421
|
+
const astro = getAstroContext(c)
|
|
1422
|
+
return { db, user: astro.locals.user ?? null }
|
|
1423
|
+
})
|
|
1424
|
+
.build()
|
|
1425
|
+
```
|
|
1426
|
+
|
|
1427
|
+
Multi-app: pass an array; first non-404 response wins. All-404 falls back to the adapter's own 404. Streams pass through unchanged.
|
|
1428
|
+
|
|
1429
|
+
Constraints:
|
|
1430
|
+
- Astro 5+ in SSR (or `prerender = false`).
|
|
1431
|
+
- Express builders are NOT supported by this adapter — Hono builders only.
|
|
1432
|
+
|
|
1433
|
+
---
|
|
1434
|
+
|
|
1395
1435
|
## Imports
|
|
1396
1436
|
|
|
1397
1437
|
```typescript
|
|
@@ -1195,3 +1195,17 @@ const axiosAdapter: ClientAdapter = {
|
|
|
1195
1195
|
},
|
|
1196
1196
|
}
|
|
1197
1197
|
```
|
|
1198
|
+
|
|
1199
|
+
---
|
|
1200
|
+
|
|
1201
|
+
### Astro integration
|
|
1202
|
+
|
|
1203
|
+
When a downstream developer is building inside an Astro app, recommend the catch-all pattern:
|
|
1204
|
+
|
|
1205
|
+
1. Build the Hono app ONCE in `src/server/api.ts` using `HonoAPIAppBuilder` / `HonoRPCAppBuilder` / `HonoStreamAppBuilder` exactly as you would for any other deployment.
|
|
1206
|
+
2. Inside the factory-context closure, call `getAstroContext(c)` to read `locals`, `cookies`, `params`, etc.
|
|
1207
|
+
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.
|
|
1208
|
+
|
|
1209
|
+
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.
|
|
1210
|
+
|
|
1211
|
+
Anti-pattern: don't try to use `express-rpc` with the Astro adapter. The adapter only accepts Web-Fetch apps (Hono).
|
|
@@ -35,6 +35,7 @@ If either argument is missing, ask the user for `<type>` and `<Name>`.
|
|
|
35
35
|
| `hono-stream` | `templates/hono-stream.md` | `{{Name}}.stream-rpc.ts`, `{{Name}}.stream-rpc.test.ts` |
|
|
36
36
|
| `hono-api` | `templates/hono-api.md` | `{{Name}}.api.ts`, `{{Name}}.api.test.ts` |
|
|
37
37
|
| `client` | `templates/client.md` | `{{Name}}.client.ts`, `{{Name}}.client.test.ts` |
|
|
38
|
+
| `astro-catchall` | `templates/astro-catchall.md` | `src/pages/api/[...rest].ts` |
|
|
38
39
|
|
|
39
40
|
## Rules
|
|
40
41
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Astro Catch-All Template
|
|
2
|
+
|
|
3
|
+
Catch-all Astro API route that serves every ts-procedures route from a single file.
|
|
4
|
+
|
|
5
|
+
## Implementation — `src/pages/api/[...rest].ts`
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
// src/pages/api/[...rest].ts
|
|
9
|
+
//
|
|
10
|
+
// Catch-all entry point that serves every ts-procedures route from a single Astro file.
|
|
11
|
+
// Adjust `pathPrefix` to match the directory the file lives in (e.g., '/api' for
|
|
12
|
+
// src/pages/api/[...rest].ts; '/v1' for src/pages/v1/[...rest].ts).
|
|
13
|
+
//
|
|
14
|
+
// Build your Hono app(s) ONCE in a sibling module, then drop them in here.
|
|
15
|
+
|
|
16
|
+
import { createAstroHandler } from 'ts-procedures/astro'
|
|
17
|
+
import { apiApp } from '../../server/api' // ← your built Hono app(s)
|
|
18
|
+
|
|
19
|
+
export const { ALL } = createAstroHandler({
|
|
20
|
+
apps: apiApp,
|
|
21
|
+
pathPrefix: '/api',
|
|
22
|
+
})
|
|
23
|
+
```
|
|
@@ -62,6 +62,8 @@ import type { DocEnvelope, HeaderDoc, ErrorDoc } from 'ts-procedures/http-docs'
|
|
|
62
62
|
|
|
63
63
|
8. **getProcedures() for introspection.** Returns all registered procedures with metadata.
|
|
64
64
|
|
|
65
|
+
9. **`Procedures({ config: { noRuntimeValidation: true } })` skips per-call AJV validation** for every procedure on the factory (both `Create` and `CreateStream`, both `schema.params` and `schema.input`). Shape is `noRuntimeValidation?: true` — only `true` is accepted. JSON Schema is still computed at registration time, so codegen and `info.schema` are unaffected; only the validator runs are bypassed. **Never enable this on a factory whose handlers are reachable from public/untrusted callers** — coerceTypes and removeAdditional also stop running. Reserve for trusted internal factories whose callers are already type-checked at build time. Independent of the per-call `ctx.isPrevalidated` escape hatch used by HTTP builders.
|
|
66
|
+
|
|
65
67
|
## Procedure Pattern
|
|
66
68
|
|
|
67
69
|
```typescript
|
|
@@ -192,6 +194,10 @@ const app = new HonoAPIAppBuilder({ pathPrefix: '/api' })
|
|
|
192
194
|
// GET /api/users/:id → 200, POST /api/users → 201
|
|
193
195
|
```
|
|
194
196
|
|
|
197
|
+
## Astro adapter
|
|
198
|
+
|
|
199
|
+
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.
|
|
200
|
+
|
|
195
201
|
## Error Handling
|
|
196
202
|
|
|
197
203
|
| Error Class | Trigger | HTTP Status |
|
|
@@ -62,6 +62,8 @@ import type { DocEnvelope, HeaderDoc, ErrorDoc } from 'ts-procedures/http-docs'
|
|
|
62
62
|
|
|
63
63
|
8. **getProcedures() for introspection.** Returns all registered procedures with metadata.
|
|
64
64
|
|
|
65
|
+
9. **`Procedures({ config: { noRuntimeValidation: true } })` skips per-call AJV validation** for every procedure on the factory (both `Create` and `CreateStream`, both `schema.params` and `schema.input`). Shape is `noRuntimeValidation?: true` — only `true` is accepted. JSON Schema is still computed at registration time, so codegen and `info.schema` are unaffected; only the validator runs are bypassed. **Never enable this on a factory whose handlers are reachable from public/untrusted callers** — coerceTypes and removeAdditional also stop running. Reserve for trusted internal factories whose callers are already type-checked at build time. Independent of the per-call `ctx.isPrevalidated` escape hatch used by HTTP builders.
|
|
66
|
+
|
|
65
67
|
## Procedure Pattern
|
|
66
68
|
|
|
67
69
|
```typescript
|
|
@@ -192,6 +194,10 @@ const app = new HonoAPIAppBuilder({ pathPrefix: '/api' })
|
|
|
192
194
|
// GET /api/users/:id → 200, POST /api/users → 201
|
|
193
195
|
```
|
|
194
196
|
|
|
197
|
+
## Astro adapter
|
|
198
|
+
|
|
199
|
+
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.
|
|
200
|
+
|
|
195
201
|
## Error Handling
|
|
196
202
|
|
|
197
203
|
| Error Class | Trigger | HTTP Status |
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
import type { Context as HonoContext } from 'hono';
|
|
3
|
+
/**
|
|
4
|
+
* Internal — stash the Astro APIContext for the Request that Hono will receive.
|
|
5
|
+
*
|
|
6
|
+
* The key MUST be the post-rewrite Request (the one passed to `app.fetch`),
|
|
7
|
+
* because that is what Hono exposes via `c.req.raw` and what `getAstroContext`
|
|
8
|
+
* reads back. When no `pathPrefix` rewrite happens, this is the same Request
|
|
9
|
+
* that Astro originally invoked the adapter with.
|
|
10
|
+
*/
|
|
11
|
+
export declare function setAstroContext(request: Request, apiContext: APIContext): void;
|
|
12
|
+
/**
|
|
13
|
+
* Reads Astro's APIContext from inside a Hono factory-context closure.
|
|
14
|
+
*
|
|
15
|
+
* Throws when the request is not in scope — typically because the Hono app
|
|
16
|
+
* is being served outside the Astro adapter (e.g., bound to a Node listener
|
|
17
|
+
* or via a different adapter) and the closure was invoked there.
|
|
18
|
+
*/
|
|
19
|
+
export declare function getAstroContext(c: HonoContext): APIContext;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const astroContextMap = new WeakMap();
|
|
2
|
+
/**
|
|
3
|
+
* Internal — stash the Astro APIContext for the Request that Hono will receive.
|
|
4
|
+
*
|
|
5
|
+
* The key MUST be the post-rewrite Request (the one passed to `app.fetch`),
|
|
6
|
+
* because that is what Hono exposes via `c.req.raw` and what `getAstroContext`
|
|
7
|
+
* reads back. When no `pathPrefix` rewrite happens, this is the same Request
|
|
8
|
+
* that Astro originally invoked the adapter with.
|
|
9
|
+
*/
|
|
10
|
+
export function setAstroContext(request, apiContext) {
|
|
11
|
+
astroContextMap.set(request, apiContext);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Reads Astro's APIContext from inside a Hono factory-context closure.
|
|
15
|
+
*
|
|
16
|
+
* Throws when the request is not in scope — typically because the Hono app
|
|
17
|
+
* is being served outside the Astro adapter (e.g., bound to a Node listener
|
|
18
|
+
* or via a different adapter) and the closure was invoked there.
|
|
19
|
+
*/
|
|
20
|
+
export function getAstroContext(c) {
|
|
21
|
+
const ctx = astroContextMap.get(c.req.raw);
|
|
22
|
+
if (!ctx) {
|
|
23
|
+
throw new Error('getAstroContext was called outside an Astro request scope. ' +
|
|
24
|
+
'Ensure this Hono app is being served via createAstroHandler when this closure runs.');
|
|
25
|
+
}
|
|
26
|
+
return ctx;
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=astro-context.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"astro-context.js","sourceRoot":"","sources":["../../../../src/implementations/http/astro/astro-context.ts"],"names":[],"mappings":"AAGA,MAAM,eAAe,GAAG,IAAI,OAAO,EAAuB,CAAA;AAE1D;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAAC,OAAgB,EAAE,UAAsB;IACtE,eAAe,CAAC,GAAG,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;AAC1C,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAAC,CAAc;IAC5C,MAAM,GAAG,GAAG,eAAe,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IAC1C,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,MAAM,IAAI,KAAK,CACb,6DAA6D;YAC3D,qFAAqF,CACxF,CAAA;IACH,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
import type { APIRoute } from 'astro';
|
|
3
|
+
export type AstroAdapterConfig = {
|
|
4
|
+
/** One or more built Hono apps. Order matters in first-match dispatch. */
|
|
5
|
+
apps: Hono | Hono[];
|
|
6
|
+
/**
|
|
7
|
+
* Path prefix matching the Astro catch-all mount point.
|
|
8
|
+
* For src/pages/api/[...rest].ts use '/api'. The adapter strips this
|
|
9
|
+
* before delegating, so Hono routes do NOT need to repeat the prefix.
|
|
10
|
+
*
|
|
11
|
+
* Normalization: leading and trailing slashes are optional; '/api',
|
|
12
|
+
* 'api', and '/api/' are equivalent. Undefined or '/' means no rewrite.
|
|
13
|
+
*/
|
|
14
|
+
pathPrefix?: string;
|
|
15
|
+
};
|
|
16
|
+
export type AstroHandlers = {
|
|
17
|
+
ALL: APIRoute;
|
|
18
|
+
GET: APIRoute;
|
|
19
|
+
POST: APIRoute;
|
|
20
|
+
PUT: APIRoute;
|
|
21
|
+
PATCH: APIRoute;
|
|
22
|
+
DELETE: APIRoute;
|
|
23
|
+
HEAD: APIRoute;
|
|
24
|
+
OPTIONS: APIRoute;
|
|
25
|
+
};
|
|
26
|
+
export declare function createAstroHandler(config: AstroAdapterConfig): AstroHandlers;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { setAstroContext } from './astro-context.js';
|
|
2
|
+
import { stripPrefix } from './rewrite-request.js';
|
|
3
|
+
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
|
|
4
|
+
export function createAstroHandler(config) {
|
|
5
|
+
const apps = Array.isArray(config.apps) ? config.apps : [config.apps];
|
|
6
|
+
if (apps.length === 0) {
|
|
7
|
+
throw new Error('createAstroHandler: `apps` must contain at least one Hono app.');
|
|
8
|
+
}
|
|
9
|
+
const ALL = async (apiContext) => {
|
|
10
|
+
const rewritten = stripPrefix(apiContext.request, config.pathPrefix);
|
|
11
|
+
if (rewritten === null) {
|
|
12
|
+
return new Response(null, { status: 404 });
|
|
13
|
+
}
|
|
14
|
+
setAstroContext(rewritten, apiContext);
|
|
15
|
+
for (const app of apps) {
|
|
16
|
+
const res = await app.fetch(rewritten);
|
|
17
|
+
if (res.status !== 404)
|
|
18
|
+
return res;
|
|
19
|
+
}
|
|
20
|
+
return new Response(null, { status: 404 });
|
|
21
|
+
};
|
|
22
|
+
const handlers = { ALL };
|
|
23
|
+
for (const method of HTTP_METHODS) {
|
|
24
|
+
handlers[method] = ALL;
|
|
25
|
+
}
|
|
26
|
+
return handlers;
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=create-handler.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-handler.js","sourceRoot":"","sources":["../../../../src/implementations/http/astro/create-handler.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAA;AACpD,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AA2BlD,MAAM,YAAY,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,CAAU,CAAA;AAE1F,MAAM,UAAU,kBAAkB,CAAC,MAA0B;IAC3D,MAAM,IAAI,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;IAErE,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACtB,MAAM,IAAI,KAAK,CAAC,gEAAgE,CAAC,CAAA;IACnF,CAAC;IAED,MAAM,GAAG,GAAa,KAAK,EAAE,UAAU,EAAE,EAAE;QACzC,MAAM,SAAS,GAAG,WAAW,CAAC,UAAU,CAAC,OAAO,EAAE,MAAM,CAAC,UAAU,CAAC,CAAA;QACpE,IAAI,SAAS,KAAK,IAAI,EAAE,CAAC;YACvB,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAA;QAC5C,CAAC;QACD,eAAe,CAAC,SAAS,EAAE,UAAU,CAAC,CAAA;QAEtC,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACvB,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC,KAAK,CAAC,SAAS,CAAC,CAAA;YACtC,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG;gBAAE,OAAO,GAAG,CAAA;QACpC,CAAC;QACD,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAA;IAC5C,CAAC,CAAA;IAED,MAAM,QAAQ,GAAG,EAAE,GAAG,EAAmB,CAAA;IACzC,KAAK,MAAM,MAAM,IAAI,YAAY,EAAE,CAAC;QAClC,QAAQ,CAAC,MAAM,CAAC,GAAG,GAAG,CAAA;IACxB,CAAC;IACD,OAAO,QAAQ,CAAA;AACjB,CAAC"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Public surface for ts-procedures/astro.
|
|
2
|
+
// Note: setAstroContext is intentionally NOT re-exported — it's an
|
|
3
|
+
// internal helper used only by createAstroHandler.
|
|
4
|
+
export { createAstroHandler } from './create-handler.js';
|
|
5
|
+
export { getAstroContext } from './astro-context.js';
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/implementations/http/astro/index.ts"],"names":[],"mappings":"AAAA,0CAA0C;AAC1C,mEAAmE;AACnE,mDAAmD;AACnD,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AAExD,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { Type } from 'typebox';
|
|
4
|
+
import { stripPrefix } from './rewrite-request.js';
|
|
5
|
+
import { setAstroContext, getAstroContext } from './astro-context.js';
|
|
6
|
+
import { createAstroHandler } from './create-handler.js';
|
|
7
|
+
import { Procedures } from '../../../index.js';
|
|
8
|
+
import { HonoAPIAppBuilder } from '../hono-api/index.js';
|
|
9
|
+
import { HonoStreamAppBuilder } from '../hono-stream/index.js';
|
|
10
|
+
// Minimal stand-in for Astro's APIContext for unit tests.
|
|
11
|
+
function fakeApiContext(overrides = {}) {
|
|
12
|
+
return {
|
|
13
|
+
locals: { user: { id: 'u-1' } },
|
|
14
|
+
cookies: {},
|
|
15
|
+
params: {},
|
|
16
|
+
request: new Request('https://example.test/'),
|
|
17
|
+
url: new URL('https://example.test/'),
|
|
18
|
+
redirect: () => new Response(null, { status: 302 }),
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function buildSimpleUserApi() {
|
|
23
|
+
const API = Procedures();
|
|
24
|
+
API.Create('GetUser', {
|
|
25
|
+
path: '/users/:id',
|
|
26
|
+
method: 'get',
|
|
27
|
+
schema: {
|
|
28
|
+
input: { pathParams: Type.Object({ id: Type.String() }) },
|
|
29
|
+
returnType: Type.Object({ id: Type.String(), name: Type.String() }),
|
|
30
|
+
},
|
|
31
|
+
}, async (ctx, { pathParams }) => {
|
|
32
|
+
const u = ctx.db.get(pathParams.id);
|
|
33
|
+
if (!u)
|
|
34
|
+
throw new Error('not found');
|
|
35
|
+
return u;
|
|
36
|
+
});
|
|
37
|
+
const db = new Map([['1', { id: '1', name: 'Ada' }]]);
|
|
38
|
+
return new HonoAPIAppBuilder().register(API, () => ({ db })).build();
|
|
39
|
+
}
|
|
40
|
+
describe('stripPrefix', () => {
|
|
41
|
+
test('returns original request when prefix is undefined', () => {
|
|
42
|
+
const req = new Request('https://example.test/api/users/1');
|
|
43
|
+
expect(stripPrefix(req, undefined)).toBe(req);
|
|
44
|
+
});
|
|
45
|
+
test('returns original request when normalized prefix is "/"', () => {
|
|
46
|
+
const req = new Request('https://example.test/users/1');
|
|
47
|
+
expect(stripPrefix(req, '/')).toBe(req);
|
|
48
|
+
expect(stripPrefix(req, '')).toBe(req);
|
|
49
|
+
});
|
|
50
|
+
test('strips a leading-slash prefix', () => {
|
|
51
|
+
const req = new Request('https://example.test/api/users/1');
|
|
52
|
+
const out = stripPrefix(req, '/api');
|
|
53
|
+
expect(new URL(out.url).pathname).toBe('/users/1');
|
|
54
|
+
});
|
|
55
|
+
test('strips a no-slash prefix (normalized)', () => {
|
|
56
|
+
const req = new Request('https://example.test/api/users/1');
|
|
57
|
+
const out = stripPrefix(req, 'api');
|
|
58
|
+
expect(new URL(out.url).pathname).toBe('/users/1');
|
|
59
|
+
});
|
|
60
|
+
test('strips a trailing-slash prefix (normalized)', () => {
|
|
61
|
+
const req = new Request('https://example.test/api/users/1');
|
|
62
|
+
const out = stripPrefix(req, '/api/');
|
|
63
|
+
expect(new URL(out.url).pathname).toBe('/users/1');
|
|
64
|
+
});
|
|
65
|
+
test('exact-match prefix becomes "/"', () => {
|
|
66
|
+
const req = new Request('https://example.test/api');
|
|
67
|
+
const out = stripPrefix(req, '/api');
|
|
68
|
+
expect(new URL(out.url).pathname).toBe('/');
|
|
69
|
+
});
|
|
70
|
+
test('returns null when path does not start with prefix (404 short-circuit)', () => {
|
|
71
|
+
const req = new Request('https://example.test/other/users/1');
|
|
72
|
+
expect(stripPrefix(req, '/api')).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
test('returns null when path shares prefix characters but not a segment boundary', () => {
|
|
75
|
+
// /apikey starts with /api but is not under /api — must NOT rewrite.
|
|
76
|
+
expect(stripPrefix(new Request('https://example.test/apikey/secret'), '/api')).toBeNull();
|
|
77
|
+
expect(stripPrefix(new Request('https://example.test/api-v2/x'), '/api')).toBeNull();
|
|
78
|
+
expect(stripPrefix(new Request('https://example.test/api_internal'), '/api')).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
test('preserves the query string through the rewrite', () => {
|
|
81
|
+
const req = new Request('https://example.test/api/users?limit=10&tag=a&tag=b');
|
|
82
|
+
const out = stripPrefix(req, '/api');
|
|
83
|
+
const url = new URL(out.url);
|
|
84
|
+
expect(url.pathname).toBe('/users');
|
|
85
|
+
expect(url.search).toBe('?limit=10&tag=a&tag=b');
|
|
86
|
+
});
|
|
87
|
+
test('preserves the request method', () => {
|
|
88
|
+
const req = new Request('https://example.test/api/users', { method: 'POST' });
|
|
89
|
+
const out = stripPrefix(req, '/api');
|
|
90
|
+
expect(out.method).toBe('POST');
|
|
91
|
+
});
|
|
92
|
+
test('preserves headers', () => {
|
|
93
|
+
const req = new Request('https://example.test/api/users', {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { 'X-Trace-Id': 'abc-123', 'Content-Type': 'application/json' },
|
|
96
|
+
});
|
|
97
|
+
const out = stripPrefix(req, '/api');
|
|
98
|
+
expect(out.headers.get('X-Trace-Id')).toBe('abc-123');
|
|
99
|
+
expect(out.headers.get('Content-Type')).toBe('application/json');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe('astro-context', () => {
|
|
103
|
+
test('getAstroContext returns the APIContext stashed for the request seen by Hono', async () => {
|
|
104
|
+
const req = new Request('https://example.test/probe');
|
|
105
|
+
const apiContext = fakeApiContext();
|
|
106
|
+
setAstroContext(req, apiContext);
|
|
107
|
+
const app = new Hono();
|
|
108
|
+
let observed = null;
|
|
109
|
+
app.get('/probe', (c) => {
|
|
110
|
+
observed = getAstroContext(c);
|
|
111
|
+
return c.json({ ok: true });
|
|
112
|
+
});
|
|
113
|
+
const res = await app.fetch(req);
|
|
114
|
+
expect(res.status).toBe(200);
|
|
115
|
+
expect(observed).toBe(apiContext);
|
|
116
|
+
});
|
|
117
|
+
test('getAstroContext throws when called outside an Astro request scope', async () => {
|
|
118
|
+
const app = new Hono();
|
|
119
|
+
let captured = null;
|
|
120
|
+
app.get('/probe', (c) => {
|
|
121
|
+
try {
|
|
122
|
+
getAstroContext(c);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
captured = err;
|
|
126
|
+
}
|
|
127
|
+
return c.json({ ok: true });
|
|
128
|
+
});
|
|
129
|
+
await app.fetch(new Request('https://example.test/probe'));
|
|
130
|
+
expect(captured).toBeInstanceOf(Error);
|
|
131
|
+
expect(captured.message).toMatch(/outside an Astro request scope/i);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe('createAstroHandler — single app, no prefix', () => {
|
|
135
|
+
test('exports ALL plus the seven HTTP method handlers', () => {
|
|
136
|
+
const handlers = createAstroHandler({ apps: buildSimpleUserApi() });
|
|
137
|
+
expect(typeof handlers.ALL).toBe('function');
|
|
138
|
+
for (const m of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']) {
|
|
139
|
+
expect(typeof handlers[m]).toBe('function');
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
test('GET roundtrips through the underlying Hono app', async () => {
|
|
143
|
+
const { ALL } = createAstroHandler({ apps: buildSimpleUserApi() });
|
|
144
|
+
const apiContext = fakeApiContext({
|
|
145
|
+
request: new Request('https://example.test/users/1'),
|
|
146
|
+
url: new URL('https://example.test/users/1'),
|
|
147
|
+
});
|
|
148
|
+
const res = await ALL(apiContext);
|
|
149
|
+
expect(res.status).toBe(200);
|
|
150
|
+
expect(await res.json()).toEqual({ id: '1', name: 'Ada' });
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe('createAstroHandler — pathPrefix + getAstroContext', () => {
|
|
154
|
+
test('strips pathPrefix before delegating', async () => {
|
|
155
|
+
// Underlying Hono app has no /api in its routes.
|
|
156
|
+
const app = buildSimpleUserApi();
|
|
157
|
+
const { ALL } = createAstroHandler({ apps: app, pathPrefix: '/api' });
|
|
158
|
+
const apiContext = fakeApiContext({
|
|
159
|
+
request: new Request('https://example.test/api/users/1'),
|
|
160
|
+
url: new URL('https://example.test/api/users/1'),
|
|
161
|
+
});
|
|
162
|
+
const res = await ALL(apiContext);
|
|
163
|
+
expect(res.status).toBe(200);
|
|
164
|
+
expect(await res.json()).toEqual({ id: '1', name: 'Ada' });
|
|
165
|
+
});
|
|
166
|
+
test('returns 404 directly when request path is outside the prefix', async () => {
|
|
167
|
+
const { ALL } = createAstroHandler({ apps: buildSimpleUserApi(), pathPrefix: '/api' });
|
|
168
|
+
const apiContext = fakeApiContext({
|
|
169
|
+
request: new Request('https://example.test/somewhere-else'),
|
|
170
|
+
url: new URL('https://example.test/somewhere-else'),
|
|
171
|
+
});
|
|
172
|
+
const res = await ALL(apiContext);
|
|
173
|
+
expect(res.status).toBe(404);
|
|
174
|
+
});
|
|
175
|
+
test('factory closure can read APIContext via getAstroContext', async () => {
|
|
176
|
+
const API = Procedures();
|
|
177
|
+
API.Create('WhoAmI', { path: '/whoami', method: 'get', schema: { returnType: Type.Object({ userId: Type.Union([Type.String(), Type.Null()]) }) } }, async (ctx) => ({ userId: ctx.user?.id ?? null }));
|
|
178
|
+
const app = new HonoAPIAppBuilder()
|
|
179
|
+
.register(API, (c) => {
|
|
180
|
+
const astro = getAstroContext(c);
|
|
181
|
+
return { user: astro.locals.user ?? null };
|
|
182
|
+
})
|
|
183
|
+
.build();
|
|
184
|
+
const { ALL } = createAstroHandler({ apps: app });
|
|
185
|
+
const apiContext = fakeApiContext({
|
|
186
|
+
locals: { user: { id: 'u-42' } },
|
|
187
|
+
request: new Request('https://example.test/whoami'),
|
|
188
|
+
url: new URL('https://example.test/whoami'),
|
|
189
|
+
});
|
|
190
|
+
const res = await ALL(apiContext);
|
|
191
|
+
expect(res.status).toBe(200);
|
|
192
|
+
expect(await res.json()).toEqual({ userId: 'u-42' });
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
describe('createAstroHandler — multi-app dispatch', () => {
|
|
196
|
+
function makeAppWithRoute(path, status, body) {
|
|
197
|
+
const app = new Hono();
|
|
198
|
+
app.all(path, (c) => c.json(body, status));
|
|
199
|
+
return app;
|
|
200
|
+
}
|
|
201
|
+
test('first 404 falls through to the second app', async () => {
|
|
202
|
+
const a = makeAppWithRoute('/only-a', 200, { from: 'a' });
|
|
203
|
+
const b = makeAppWithRoute('/only-b', 200, { from: 'b' });
|
|
204
|
+
const { ALL } = createAstroHandler({ apps: [a, b] });
|
|
205
|
+
const apiContext = fakeApiContext({
|
|
206
|
+
request: new Request('https://example.test/only-b'),
|
|
207
|
+
url: new URL('https://example.test/only-b'),
|
|
208
|
+
});
|
|
209
|
+
const res = await ALL(apiContext);
|
|
210
|
+
expect(res.status).toBe(200);
|
|
211
|
+
expect(await res.json()).toEqual({ from: 'b' });
|
|
212
|
+
});
|
|
213
|
+
test('a non-404 response from the first app short-circuits dispatch', async () => {
|
|
214
|
+
const a = makeAppWithRoute('/error', 500, { boom: true });
|
|
215
|
+
const b = makeAppWithRoute('/error', 200, { from: 'b' });
|
|
216
|
+
const { ALL } = createAstroHandler({ apps: [a, b] });
|
|
217
|
+
const apiContext = fakeApiContext({
|
|
218
|
+
request: new Request('https://example.test/error'),
|
|
219
|
+
url: new URL('https://example.test/error'),
|
|
220
|
+
});
|
|
221
|
+
const res = await ALL(apiContext);
|
|
222
|
+
expect(res.status).toBe(500);
|
|
223
|
+
expect(await res.json()).toEqual({ boom: true });
|
|
224
|
+
});
|
|
225
|
+
test('all-404 returns the adapter\'s own 404 with empty body', async () => {
|
|
226
|
+
const a = makeAppWithRoute('/x', 200, { from: 'a' });
|
|
227
|
+
const b = makeAppWithRoute('/y', 200, { from: 'b' });
|
|
228
|
+
const { ALL } = createAstroHandler({ apps: [a, b] });
|
|
229
|
+
const apiContext = fakeApiContext({
|
|
230
|
+
request: new Request('https://example.test/nope'),
|
|
231
|
+
url: new URL('https://example.test/nope'),
|
|
232
|
+
});
|
|
233
|
+
const res = await ALL(apiContext);
|
|
234
|
+
expect(res.status).toBe(404);
|
|
235
|
+
expect(await res.text()).toBe('');
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
describe('createAstroHandler — streams', () => {
|
|
239
|
+
test('SSE events from a HonoStream builder pass through the adapter', async () => {
|
|
240
|
+
const STREAM = Procedures();
|
|
241
|
+
STREAM.CreateStream('Counter', {
|
|
242
|
+
scope: 'counter',
|
|
243
|
+
version: 1,
|
|
244
|
+
schema: {
|
|
245
|
+
yieldType: Type.Object({ n: Type.Number() }),
|
|
246
|
+
},
|
|
247
|
+
}, async function* () {
|
|
248
|
+
yield { n: 1 };
|
|
249
|
+
yield { n: 2 };
|
|
250
|
+
yield { n: 3 };
|
|
251
|
+
});
|
|
252
|
+
const streamApp = new HonoStreamAppBuilder().register(STREAM, () => ({})).build();
|
|
253
|
+
const { ALL } = createAstroHandler({ apps: streamApp });
|
|
254
|
+
const apiContext = fakeApiContext({
|
|
255
|
+
request: new Request('https://example.test/counter/counter/1', { method: 'GET' }),
|
|
256
|
+
url: new URL('https://example.test/counter/counter/1'),
|
|
257
|
+
});
|
|
258
|
+
const res = await ALL(apiContext);
|
|
259
|
+
expect(res.status).toBe(200);
|
|
260
|
+
expect(res.headers.get('content-type')).toContain('text/event-stream');
|
|
261
|
+
// Drain the SSE body and assert all three events appear in order.
|
|
262
|
+
const text = await res.text();
|
|
263
|
+
expect(text).toContain('event: Counter');
|
|
264
|
+
expect(text).toContain('data: {"n":1}');
|
|
265
|
+
expect(text).toContain('data: {"n":2}');
|
|
266
|
+
expect(text).toContain('data: {"n":3}');
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
describe('createAstroHandler — abort signal', () => {
|
|
270
|
+
test("aborting the incoming request aborts the procedure handler's ctx.signal", async () => {
|
|
271
|
+
let observedSignal;
|
|
272
|
+
const aborted = new Promise((resolve) => {
|
|
273
|
+
const API = Procedures();
|
|
274
|
+
API.Create('Hang', { path: '/hang', method: 'get', schema: { returnType: Type.Object({ ok: Type.Boolean() }) } }, async (ctx) => {
|
|
275
|
+
observedSignal = ctx.signal;
|
|
276
|
+
ctx.signal?.addEventListener('abort', () => resolve(), { once: true });
|
|
277
|
+
// Wait for abort or a generous timeout.
|
|
278
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
279
|
+
return { ok: true };
|
|
280
|
+
});
|
|
281
|
+
const app = new HonoAPIAppBuilder().register(API, () => ({})).build();
|
|
282
|
+
const { ALL } = createAstroHandler({ apps: app });
|
|
283
|
+
const controller = new AbortController();
|
|
284
|
+
const apiContext = fakeApiContext({
|
|
285
|
+
request: new Request('https://example.test/hang', { signal: controller.signal }),
|
|
286
|
+
url: new URL('https://example.test/hang'),
|
|
287
|
+
});
|
|
288
|
+
ALL(apiContext).catch(() => { });
|
|
289
|
+
setTimeout(() => controller.abort(), 20);
|
|
290
|
+
});
|
|
291
|
+
await aborted;
|
|
292
|
+
expect(observedSignal?.aborted).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
//# sourceMappingURL=index.test.js.map
|