ts-procedures 7.1.2 → 7.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +35 -0
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +40 -0
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +14 -0
- package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +1 -0
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/astro-catchall.md +23 -0
- package/agent_config/copilot/copilot-instructions.md +6 -0
- package/agent_config/cursor/cursorrules +6 -0
- package/build/implementations/http/astro/astro-context.d.ts +19 -0
- package/build/implementations/http/astro/astro-context.js +28 -0
- package/build/implementations/http/astro/astro-context.js.map +1 -0
- package/build/implementations/http/astro/create-handler.d.ts +26 -0
- package/build/implementations/http/astro/create-handler.js +28 -0
- package/build/implementations/http/astro/create-handler.js.map +1 -0
- package/build/implementations/http/astro/index.d.ts +3 -0
- package/build/implementations/http/astro/index.js +6 -0
- package/build/implementations/http/astro/index.js.map +1 -0
- package/build/implementations/http/astro/index.test.d.ts +1 -0
- package/build/implementations/http/astro/index.test.js +295 -0
- package/build/implementations/http/astro/index.test.js.map +1 -0
- package/build/implementations/http/astro/rewrite-request.d.ts +13 -0
- package/build/implementations/http/astro/rewrite-request.js +32 -0
- package/build/implementations/http/astro/rewrite-request.js.map +1 -0
- package/build/index.d.ts +10 -0
- package/build/index.js +12 -13
- package/build/index.js.map +1 -1
- package/build/index.test.js +107 -0
- package/build/index.test.js.map +1 -1
- package/docs/astro-adapter.md +227 -0
- package/docs/core.md +19 -0
- package/docs/superpowers/plans/2026-05-07-astro-adapter.md +1396 -0
- package/docs/superpowers/specs/2026-05-07-astro-adapter-design.md +254 -0
- package/package.json +8 -2
- package/src/implementations/http/astro/README.md +89 -0
- package/src/implementations/http/astro/astro-context.ts +34 -0
- package/src/implementations/http/astro/create-handler.ts +59 -0
- package/src/implementations/http/astro/index.test.ts +350 -0
- package/src/implementations/http/astro/index.ts +6 -0
- package/src/implementations/http/astro/rewrite-request.ts +31 -0
- package/src/index.test.ts +171 -0
- package/src/index.ts +27 -15
|
@@ -0,0 +1,350 @@
|
|
|
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
|
+
import type { APIConfig, RPCConfig } from '../../types.js'
|
|
11
|
+
|
|
12
|
+
// Minimal stand-in for Astro's APIContext for unit tests.
|
|
13
|
+
function fakeApiContext(overrides: Record<string, unknown> = {}) {
|
|
14
|
+
return {
|
|
15
|
+
locals: { user: { id: 'u-1' } },
|
|
16
|
+
cookies: {},
|
|
17
|
+
params: {},
|
|
18
|
+
request: new Request('https://example.test/'),
|
|
19
|
+
url: new URL('https://example.test/'),
|
|
20
|
+
redirect: () => new Response(null, { status: 302 }),
|
|
21
|
+
...overrides,
|
|
22
|
+
} as unknown as import('astro').APIContext
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildSimpleUserApi() {
|
|
26
|
+
const API = Procedures<{ db: Map<string, { id: string; name: string }> }, APIConfig>()
|
|
27
|
+
API.Create(
|
|
28
|
+
'GetUser',
|
|
29
|
+
{
|
|
30
|
+
path: '/users/:id',
|
|
31
|
+
method: 'get',
|
|
32
|
+
schema: {
|
|
33
|
+
input: { pathParams: Type.Object({ id: Type.String() }) },
|
|
34
|
+
returnType: Type.Object({ id: Type.String(), name: Type.String() }),
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
async (ctx, { pathParams }) => {
|
|
38
|
+
const u = ctx.db.get(pathParams.id)
|
|
39
|
+
if (!u) throw new Error('not found')
|
|
40
|
+
return u
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
const db = new Map([['1', { id: '1', name: 'Ada' }]])
|
|
44
|
+
return new HonoAPIAppBuilder().register(API, () => ({ db })).build()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('stripPrefix', () => {
|
|
48
|
+
test('returns original request when prefix is undefined', () => {
|
|
49
|
+
const req = new Request('https://example.test/api/users/1')
|
|
50
|
+
expect(stripPrefix(req, undefined)).toBe(req)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('returns original request when normalized prefix is "/"', () => {
|
|
54
|
+
const req = new Request('https://example.test/users/1')
|
|
55
|
+
expect(stripPrefix(req, '/')).toBe(req)
|
|
56
|
+
expect(stripPrefix(req, '')).toBe(req)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('strips a leading-slash prefix', () => {
|
|
60
|
+
const req = new Request('https://example.test/api/users/1')
|
|
61
|
+
const out = stripPrefix(req, '/api')!
|
|
62
|
+
expect(new URL(out.url).pathname).toBe('/users/1')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('strips a no-slash prefix (normalized)', () => {
|
|
66
|
+
const req = new Request('https://example.test/api/users/1')
|
|
67
|
+
const out = stripPrefix(req, 'api')!
|
|
68
|
+
expect(new URL(out.url).pathname).toBe('/users/1')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('strips a trailing-slash prefix (normalized)', () => {
|
|
72
|
+
const req = new Request('https://example.test/api/users/1')
|
|
73
|
+
const out = stripPrefix(req, '/api/')!
|
|
74
|
+
expect(new URL(out.url).pathname).toBe('/users/1')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('exact-match prefix becomes "/"', () => {
|
|
78
|
+
const req = new Request('https://example.test/api')
|
|
79
|
+
const out = stripPrefix(req, '/api')!
|
|
80
|
+
expect(new URL(out.url).pathname).toBe('/')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('returns null when path does not start with prefix (404 short-circuit)', () => {
|
|
84
|
+
const req = new Request('https://example.test/other/users/1')
|
|
85
|
+
expect(stripPrefix(req, '/api')).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('returns null when path shares prefix characters but not a segment boundary', () => {
|
|
89
|
+
// /apikey starts with /api but is not under /api — must NOT rewrite.
|
|
90
|
+
expect(stripPrefix(new Request('https://example.test/apikey/secret'), '/api')).toBeNull()
|
|
91
|
+
expect(stripPrefix(new Request('https://example.test/api-v2/x'), '/api')).toBeNull()
|
|
92
|
+
expect(stripPrefix(new Request('https://example.test/api_internal'), '/api')).toBeNull()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('preserves the query string through the rewrite', () => {
|
|
96
|
+
const req = new Request('https://example.test/api/users?limit=10&tag=a&tag=b')
|
|
97
|
+
const out = stripPrefix(req, '/api')!
|
|
98
|
+
const url = new URL(out.url)
|
|
99
|
+
expect(url.pathname).toBe('/users')
|
|
100
|
+
expect(url.search).toBe('?limit=10&tag=a&tag=b')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('preserves the request method', () => {
|
|
104
|
+
const req = new Request('https://example.test/api/users', { method: 'POST' })
|
|
105
|
+
const out = stripPrefix(req, '/api')!
|
|
106
|
+
expect(out.method).toBe('POST')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('preserves headers', () => {
|
|
110
|
+
const req = new Request('https://example.test/api/users', {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'X-Trace-Id': 'abc-123', 'Content-Type': 'application/json' },
|
|
113
|
+
})
|
|
114
|
+
const out = stripPrefix(req, '/api')!
|
|
115
|
+
expect(out.headers.get('X-Trace-Id')).toBe('abc-123')
|
|
116
|
+
expect(out.headers.get('Content-Type')).toBe('application/json')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('astro-context', () => {
|
|
121
|
+
test('getAstroContext returns the APIContext stashed for the request seen by Hono', async () => {
|
|
122
|
+
const req = new Request('https://example.test/probe')
|
|
123
|
+
const apiContext = fakeApiContext()
|
|
124
|
+
setAstroContext(req, apiContext)
|
|
125
|
+
|
|
126
|
+
const app = new Hono()
|
|
127
|
+
let observed: import('astro').APIContext | null = null
|
|
128
|
+
app.get('/probe', (c) => {
|
|
129
|
+
observed = getAstroContext(c)
|
|
130
|
+
return c.json({ ok: true })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const res = await app.fetch(req)
|
|
134
|
+
expect(res.status).toBe(200)
|
|
135
|
+
expect(observed).toBe(apiContext)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('getAstroContext throws when called outside an Astro request scope', async () => {
|
|
139
|
+
const app = new Hono()
|
|
140
|
+
let captured: unknown = null
|
|
141
|
+
app.get('/probe', (c) => {
|
|
142
|
+
try {
|
|
143
|
+
getAstroContext(c)
|
|
144
|
+
} catch (err) {
|
|
145
|
+
captured = err
|
|
146
|
+
}
|
|
147
|
+
return c.json({ ok: true })
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
await app.fetch(new Request('https://example.test/probe'))
|
|
151
|
+
expect(captured).toBeInstanceOf(Error)
|
|
152
|
+
expect((captured as Error).message).toMatch(/outside an Astro request scope/i)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('createAstroHandler — single app, no prefix', () => {
|
|
157
|
+
test('exports ALL plus the seven HTTP method handlers', () => {
|
|
158
|
+
const handlers = createAstroHandler({ apps: buildSimpleUserApi() })
|
|
159
|
+
expect(typeof handlers.ALL).toBe('function')
|
|
160
|
+
for (const m of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] as const) {
|
|
161
|
+
expect(typeof handlers[m]).toBe('function')
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('GET roundtrips through the underlying Hono app', async () => {
|
|
166
|
+
const { ALL } = createAstroHandler({ apps: buildSimpleUserApi() })
|
|
167
|
+
const apiContext = fakeApiContext({
|
|
168
|
+
request: new Request('https://example.test/users/1'),
|
|
169
|
+
url: new URL('https://example.test/users/1'),
|
|
170
|
+
})
|
|
171
|
+
const res = await ALL(apiContext)
|
|
172
|
+
expect(res.status).toBe(200)
|
|
173
|
+
expect(await res.json()).toEqual({ id: '1', name: 'Ada' })
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('createAstroHandler — pathPrefix + getAstroContext', () => {
|
|
178
|
+
test('strips pathPrefix before delegating', async () => {
|
|
179
|
+
// Underlying Hono app has no /api in its routes.
|
|
180
|
+
const app = buildSimpleUserApi()
|
|
181
|
+
const { ALL } = createAstroHandler({ apps: app, pathPrefix: '/api' })
|
|
182
|
+
const apiContext = fakeApiContext({
|
|
183
|
+
request: new Request('https://example.test/api/users/1'),
|
|
184
|
+
url: new URL('https://example.test/api/users/1'),
|
|
185
|
+
})
|
|
186
|
+
const res = await ALL(apiContext)
|
|
187
|
+
expect(res.status).toBe(200)
|
|
188
|
+
expect(await res.json()).toEqual({ id: '1', name: 'Ada' })
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('returns 404 directly when request path is outside the prefix', async () => {
|
|
192
|
+
const { ALL } = createAstroHandler({ apps: buildSimpleUserApi(), pathPrefix: '/api' })
|
|
193
|
+
const apiContext = fakeApiContext({
|
|
194
|
+
request: new Request('https://example.test/somewhere-else'),
|
|
195
|
+
url: new URL('https://example.test/somewhere-else'),
|
|
196
|
+
})
|
|
197
|
+
const res = await ALL(apiContext)
|
|
198
|
+
expect(res.status).toBe(404)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('factory closure can read APIContext via getAstroContext', async () => {
|
|
202
|
+
const API = Procedures<{ user: { id: string } | null }, APIConfig>()
|
|
203
|
+
API.Create(
|
|
204
|
+
'WhoAmI',
|
|
205
|
+
{ path: '/whoami', method: 'get', schema: { returnType: Type.Object({ userId: Type.Union([Type.String(), Type.Null()]) }) } },
|
|
206
|
+
async (ctx) => ({ userId: ctx.user?.id ?? null })
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
const app = new HonoAPIAppBuilder()
|
|
210
|
+
.register(API, (c) => {
|
|
211
|
+
const astro = getAstroContext(c)
|
|
212
|
+
return { user: (astro.locals as { user?: { id: string } }).user ?? null }
|
|
213
|
+
})
|
|
214
|
+
.build()
|
|
215
|
+
|
|
216
|
+
const { ALL } = createAstroHandler({ apps: app })
|
|
217
|
+
const apiContext = fakeApiContext({
|
|
218
|
+
locals: { user: { id: 'u-42' } },
|
|
219
|
+
request: new Request('https://example.test/whoami'),
|
|
220
|
+
url: new URL('https://example.test/whoami'),
|
|
221
|
+
})
|
|
222
|
+
const res = await ALL(apiContext)
|
|
223
|
+
expect(res.status).toBe(200)
|
|
224
|
+
expect(await res.json()).toEqual({ userId: 'u-42' })
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('createAstroHandler — multi-app dispatch', () => {
|
|
229
|
+
function makeAppWithRoute(path: string, status: number, body: unknown) {
|
|
230
|
+
const app = new Hono()
|
|
231
|
+
app.all(path, (c) => c.json(body, status as any))
|
|
232
|
+
return app
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
test('first 404 falls through to the second app', async () => {
|
|
236
|
+
const a = makeAppWithRoute('/only-a', 200, { from: 'a' })
|
|
237
|
+
const b = makeAppWithRoute('/only-b', 200, { from: 'b' })
|
|
238
|
+
const { ALL } = createAstroHandler({ apps: [a, b] })
|
|
239
|
+
|
|
240
|
+
const apiContext = fakeApiContext({
|
|
241
|
+
request: new Request('https://example.test/only-b'),
|
|
242
|
+
url: new URL('https://example.test/only-b'),
|
|
243
|
+
})
|
|
244
|
+
const res = await ALL(apiContext)
|
|
245
|
+
expect(res.status).toBe(200)
|
|
246
|
+
expect(await res.json()).toEqual({ from: 'b' })
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('a non-404 response from the first app short-circuits dispatch', async () => {
|
|
250
|
+
const a = makeAppWithRoute('/error', 500, { boom: true })
|
|
251
|
+
const b = makeAppWithRoute('/error', 200, { from: 'b' })
|
|
252
|
+
const { ALL } = createAstroHandler({ apps: [a, b] })
|
|
253
|
+
|
|
254
|
+
const apiContext = fakeApiContext({
|
|
255
|
+
request: new Request('https://example.test/error'),
|
|
256
|
+
url: new URL('https://example.test/error'),
|
|
257
|
+
})
|
|
258
|
+
const res = await ALL(apiContext)
|
|
259
|
+
expect(res.status).toBe(500)
|
|
260
|
+
expect(await res.json()).toEqual({ boom: true })
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('all-404 returns the adapter\'s own 404 with empty body', async () => {
|
|
264
|
+
const a = makeAppWithRoute('/x', 200, { from: 'a' })
|
|
265
|
+
const b = makeAppWithRoute('/y', 200, { from: 'b' })
|
|
266
|
+
const { ALL } = createAstroHandler({ apps: [a, b] })
|
|
267
|
+
|
|
268
|
+
const apiContext = fakeApiContext({
|
|
269
|
+
request: new Request('https://example.test/nope'),
|
|
270
|
+
url: new URL('https://example.test/nope'),
|
|
271
|
+
})
|
|
272
|
+
const res = await ALL(apiContext)
|
|
273
|
+
expect(res.status).toBe(404)
|
|
274
|
+
expect(await res.text()).toBe('')
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
describe('createAstroHandler — streams', () => {
|
|
279
|
+
test('SSE events from a HonoStream builder pass through the adapter', async () => {
|
|
280
|
+
const STREAM = Procedures<{}, RPCConfig>()
|
|
281
|
+
STREAM.CreateStream(
|
|
282
|
+
'Counter',
|
|
283
|
+
{
|
|
284
|
+
scope: 'counter',
|
|
285
|
+
version: 1,
|
|
286
|
+
schema: {
|
|
287
|
+
yieldType: Type.Object({ n: Type.Number() }),
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
async function* () {
|
|
291
|
+
yield { n: 1 }
|
|
292
|
+
yield { n: 2 }
|
|
293
|
+
yield { n: 3 }
|
|
294
|
+
}
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
const streamApp = new HonoStreamAppBuilder().register(STREAM, () => ({})).build()
|
|
298
|
+
const { ALL } = createAstroHandler({ apps: streamApp })
|
|
299
|
+
|
|
300
|
+
const apiContext = fakeApiContext({
|
|
301
|
+
request: new Request('https://example.test/counter/counter/1', { method: 'GET' }),
|
|
302
|
+
url: new URL('https://example.test/counter/counter/1'),
|
|
303
|
+
})
|
|
304
|
+
const res = await ALL(apiContext)
|
|
305
|
+
expect(res.status).toBe(200)
|
|
306
|
+
expect(res.headers.get('content-type')).toContain('text/event-stream')
|
|
307
|
+
|
|
308
|
+
// Drain the SSE body and assert all three events appear in order.
|
|
309
|
+
const text = await res.text()
|
|
310
|
+
expect(text).toContain('event: Counter')
|
|
311
|
+
expect(text).toContain('data: {"n":1}')
|
|
312
|
+
expect(text).toContain('data: {"n":2}')
|
|
313
|
+
expect(text).toContain('data: {"n":3}')
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
describe('createAstroHandler — abort signal', () => {
|
|
318
|
+
test("aborting the incoming request aborts the procedure handler's ctx.signal", async () => {
|
|
319
|
+
let observedSignal: AbortSignal | undefined
|
|
320
|
+
const aborted = new Promise<void>((resolve) => {
|
|
321
|
+
const API = Procedures<{}, APIConfig>()
|
|
322
|
+
API.Create(
|
|
323
|
+
'Hang',
|
|
324
|
+
{ path: '/hang', method: 'get', schema: { returnType: Type.Object({ ok: Type.Boolean() }) } },
|
|
325
|
+
async (ctx) => {
|
|
326
|
+
observedSignal = ctx.signal
|
|
327
|
+
ctx.signal?.addEventListener('abort', () => resolve(), { once: true })
|
|
328
|
+
// Wait for abort or a generous timeout.
|
|
329
|
+
await new Promise<void>((r) => setTimeout(r, 200))
|
|
330
|
+
return { ok: true }
|
|
331
|
+
}
|
|
332
|
+
)
|
|
333
|
+
const app = new HonoAPIAppBuilder().register(API, () => ({})).build()
|
|
334
|
+
const { ALL } = createAstroHandler({ apps: app })
|
|
335
|
+
|
|
336
|
+
const controller = new AbortController()
|
|
337
|
+
const apiContext = fakeApiContext({
|
|
338
|
+
request: new Request('https://example.test/hang', { signal: controller.signal }),
|
|
339
|
+
url: new URL('https://example.test/hang'),
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
// Fire ALL but don't await — abort while the handler is still running.
|
|
343
|
+
;(ALL(apiContext) as Promise<Response>).catch(() => {})
|
|
344
|
+
setTimeout(() => controller.abort(), 20)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
await aborted
|
|
348
|
+
expect(observedSignal?.aborted).toBe(true)
|
|
349
|
+
})
|
|
350
|
+
})
|
|
@@ -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 type { AstroAdapterConfig, AstroHandlers } from './create-handler.js'
|
|
6
|
+
export { getAstroContext } from './astro-context.js'
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a Request with `prefix` stripped from the URL pathname.
|
|
3
|
+
*
|
|
4
|
+
* - `prefix` undefined or normalizing to '/' → returns the original request unchanged.
|
|
5
|
+
* - Path does not start with the normalized prefix at a segment boundary → returns `null`
|
|
6
|
+
* (caller short-circuits to 404). E.g. with prefix '/api', the request '/apikey'
|
|
7
|
+
* returns null because 'key' is not preceded by a path separator.
|
|
8
|
+
* - Otherwise → returns a new Request with the rewritten URL and method/headers/body/signal/duplex
|
|
9
|
+
* preserved via `new Request(url, init)`.
|
|
10
|
+
*
|
|
11
|
+
* Normalization: leading and trailing slashes are optional. '/api', 'api', and '/api/' are equivalent.
|
|
12
|
+
*/
|
|
13
|
+
export function stripPrefix(request: Request, prefix: string | undefined): Request | null {
|
|
14
|
+
if (prefix === undefined) return request
|
|
15
|
+
const normalized = '/' + prefix.replace(/^\/|\/$/g, '')
|
|
16
|
+
if (normalized === '/') return request
|
|
17
|
+
|
|
18
|
+
const url = new URL(request.url)
|
|
19
|
+
if (!url.pathname.startsWith(normalized)) return null
|
|
20
|
+
|
|
21
|
+
// Segment-boundary guard: the character right after the prefix in the path
|
|
22
|
+
// MUST be either undefined (exact match) or '/' (real segment boundary).
|
|
23
|
+
// Without this, '/apikey' would falsely match prefix '/api'.
|
|
24
|
+
const nextChar = url.pathname[normalized.length]
|
|
25
|
+
if (nextChar !== undefined && nextChar !== '/') return null
|
|
26
|
+
|
|
27
|
+
const rest = url.pathname.slice(normalized.length)
|
|
28
|
+
url.pathname = rest === '' ? '/' : rest
|
|
29
|
+
|
|
30
|
+
return new Request(url, request)
|
|
31
|
+
}
|
package/src/index.test.ts
CHANGED
|
@@ -1220,3 +1220,174 @@ describe('isPrevalidated context property', () => {
|
|
|
1220
1220
|
})
|
|
1221
1221
|
})
|
|
1222
1222
|
})
|
|
1223
|
+
|
|
1224
|
+
describe('builder.config.noRuntimeValidation', () => {
|
|
1225
|
+
test('Create skips params validation when noRuntimeValidation is true', async () => {
|
|
1226
|
+
const { Create } = Procedures({ config: { noRuntimeValidation: true } })
|
|
1227
|
+
|
|
1228
|
+
const { SkipParams } = Create(
|
|
1229
|
+
'SkipParams',
|
|
1230
|
+
{
|
|
1231
|
+
schema: {
|
|
1232
|
+
params: Type.Object({ name: Type.String() }),
|
|
1233
|
+
},
|
|
1234
|
+
},
|
|
1235
|
+
async (_ctx, params) => params
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1238
|
+
// Missing required `name` would normally throw ProcedureValidationError
|
|
1239
|
+
const result = await SkipParams({}, {} as any)
|
|
1240
|
+
expect(result).toEqual({})
|
|
1241
|
+
})
|
|
1242
|
+
|
|
1243
|
+
test('Create skips input channel validation when noRuntimeValidation is true', async () => {
|
|
1244
|
+
const { Create } = Procedures({ config: { noRuntimeValidation: true } })
|
|
1245
|
+
|
|
1246
|
+
const { SkipInput } = Create(
|
|
1247
|
+
'SkipInput',
|
|
1248
|
+
{
|
|
1249
|
+
schema: {
|
|
1250
|
+
input: {
|
|
1251
|
+
pathParams: Type.Object({ id: Type.String() }),
|
|
1252
|
+
body: Type.Object({ name: Type.String() }),
|
|
1253
|
+
},
|
|
1254
|
+
},
|
|
1255
|
+
},
|
|
1256
|
+
async (_ctx, params) => params
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
// Both channels missing required fields — normally throws on the first one
|
|
1260
|
+
const result = await SkipInput(
|
|
1261
|
+
{},
|
|
1262
|
+
{ pathParams: {}, body: {} } as any
|
|
1263
|
+
)
|
|
1264
|
+
expect(result).toEqual({ pathParams: {}, body: {} })
|
|
1265
|
+
})
|
|
1266
|
+
|
|
1267
|
+
test('Create still validates when noRuntimeValidation is omitted', async () => {
|
|
1268
|
+
const { Create } = Procedures({ config: {} })
|
|
1269
|
+
|
|
1270
|
+
const { ValidateDefault } = Create(
|
|
1271
|
+
'ValidateDefault',
|
|
1272
|
+
{
|
|
1273
|
+
schema: {
|
|
1274
|
+
params: Type.Object({ name: Type.String() }),
|
|
1275
|
+
},
|
|
1276
|
+
},
|
|
1277
|
+
async (_ctx, params) => params
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
await expect(ValidateDefault({}, {} as any)).rejects.toBeInstanceOf(ProcedureValidationError)
|
|
1281
|
+
})
|
|
1282
|
+
|
|
1283
|
+
test('Create validates when builder is omitted entirely', async () => {
|
|
1284
|
+
const { Create } = Procedures()
|
|
1285
|
+
|
|
1286
|
+
const { ValidateNoBuilder } = Create(
|
|
1287
|
+
'ValidateNoBuilder',
|
|
1288
|
+
{
|
|
1289
|
+
schema: {
|
|
1290
|
+
params: Type.Object({ name: Type.String() }),
|
|
1291
|
+
},
|
|
1292
|
+
},
|
|
1293
|
+
async (_ctx, params) => params
|
|
1294
|
+
)
|
|
1295
|
+
|
|
1296
|
+
await expect(ValidateNoBuilder({}, {} as any)).rejects.toBeInstanceOf(
|
|
1297
|
+
ProcedureValidationError
|
|
1298
|
+
)
|
|
1299
|
+
})
|
|
1300
|
+
|
|
1301
|
+
test('CreateStream skips params validation when noRuntimeValidation is true', async () => {
|
|
1302
|
+
const { CreateStream } = Procedures({ config: { noRuntimeValidation: true } })
|
|
1303
|
+
|
|
1304
|
+
const { StreamSkipParams } = CreateStream(
|
|
1305
|
+
'StreamSkipParams',
|
|
1306
|
+
{
|
|
1307
|
+
schema: {
|
|
1308
|
+
params: Type.Object({ count: Type.Number() }),
|
|
1309
|
+
},
|
|
1310
|
+
},
|
|
1311
|
+
async function* (_ctx, params) {
|
|
1312
|
+
yield { received: params }
|
|
1313
|
+
}
|
|
1314
|
+
)
|
|
1315
|
+
|
|
1316
|
+
const values: any[] = []
|
|
1317
|
+
for await (const val of StreamSkipParams({}, {} as any)) {
|
|
1318
|
+
values.push(val)
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
expect(values).toEqual([{ received: {} }])
|
|
1322
|
+
})
|
|
1323
|
+
|
|
1324
|
+
test('CreateStream skips input channel validation when noRuntimeValidation is true', async () => {
|
|
1325
|
+
const { CreateStream } = Procedures({ config: { noRuntimeValidation: true } })
|
|
1326
|
+
|
|
1327
|
+
const { StreamSkipInput } = CreateStream(
|
|
1328
|
+
'StreamSkipInput',
|
|
1329
|
+
{
|
|
1330
|
+
schema: {
|
|
1331
|
+
input: {
|
|
1332
|
+
pathParams: Type.Object({ id: Type.String() }),
|
|
1333
|
+
},
|
|
1334
|
+
},
|
|
1335
|
+
},
|
|
1336
|
+
async function* (_ctx, params) {
|
|
1337
|
+
yield params
|
|
1338
|
+
}
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
const values: any[] = []
|
|
1342
|
+
for await (const val of StreamSkipInput({}, { pathParams: {} } as any)) {
|
|
1343
|
+
values.push(val)
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
expect(values).toEqual([{ pathParams: {} }])
|
|
1347
|
+
})
|
|
1348
|
+
|
|
1349
|
+
test('CreateStream still validates when noRuntimeValidation is omitted', async () => {
|
|
1350
|
+
const { CreateStream } = Procedures({ config: {} })
|
|
1351
|
+
|
|
1352
|
+
const { StreamValidateDefault } = CreateStream(
|
|
1353
|
+
'StreamValidateDefault',
|
|
1354
|
+
{
|
|
1355
|
+
schema: {
|
|
1356
|
+
params: Type.Object({ count: Type.Number() }),
|
|
1357
|
+
},
|
|
1358
|
+
},
|
|
1359
|
+
async function* (_ctx, params) {
|
|
1360
|
+
yield params
|
|
1361
|
+
}
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
await expect(async () => {
|
|
1365
|
+
for await (const _v of StreamValidateDefault({}, {} as any)) {
|
|
1366
|
+
// consume
|
|
1367
|
+
}
|
|
1368
|
+
}).rejects.toBeInstanceOf(ProcedureValidationError)
|
|
1369
|
+
})
|
|
1370
|
+
|
|
1371
|
+
test('noRuntimeValidation does not interfere with onCreate callback', async () => {
|
|
1372
|
+
const seen: string[] = []
|
|
1373
|
+
const { Create } = Procedures({
|
|
1374
|
+
config: { noRuntimeValidation: true },
|
|
1375
|
+
onCreate: (proc) => {
|
|
1376
|
+
seen.push(proc.name)
|
|
1377
|
+
},
|
|
1378
|
+
})
|
|
1379
|
+
|
|
1380
|
+
Create(
|
|
1381
|
+
'Registered',
|
|
1382
|
+
{ schema: { params: Type.Object({ id: Type.String() }) } },
|
|
1383
|
+
async (_ctx, params) => params
|
|
1384
|
+
)
|
|
1385
|
+
|
|
1386
|
+
expect(seen).toEqual(['Registered'])
|
|
1387
|
+
})
|
|
1388
|
+
|
|
1389
|
+
test('noRuntimeValidation: false is rejected by the type system', () => {
|
|
1390
|
+
// @ts-expect-error - only `true` (or omitted) is allowed for noRuntimeValidation
|
|
1391
|
+
void Procedures({ config: { noRuntimeValidation: false } })
|
|
1392
|
+
})
|
|
1393
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -58,11 +58,22 @@ export type TStreamProcedureRegistration<TContext = unknown, TExtendedConfig = u
|
|
|
58
58
|
handler: (ctx: TContext, params?: any) => AsyncGenerator<any, any, unknown>
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export type TBuilderConfig = {
|
|
62
|
+
/**
|
|
63
|
+
* The default is to validate schema inputs at runtime, to disable pass `true` for this property.
|
|
64
|
+
*
|
|
65
|
+
* @Note: this is not recommended for public unknown data, only use for internal procedure
|
|
66
|
+
* factories with type-checking build steps.
|
|
67
|
+
*/
|
|
68
|
+
noRuntimeValidation?: true
|
|
69
|
+
}
|
|
70
|
+
|
|
61
71
|
export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unknown>(
|
|
62
72
|
/**
|
|
63
73
|
* Optionally provided builder to register Procedures
|
|
64
74
|
*/
|
|
65
75
|
builder?: {
|
|
76
|
+
config?: TBuilderConfig
|
|
66
77
|
onCreate?: (
|
|
67
78
|
procedure: Prettify<{
|
|
68
79
|
name: string
|
|
@@ -148,9 +159,12 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
|
|
|
148
159
|
|
|
149
160
|
handler: async (ctx: Prettify<TContext>, params: TSchemaLib<TParams>) => {
|
|
150
161
|
try {
|
|
151
|
-
// Skip validation if caller has already validated (e.g., HonoStreamAppBuilder)
|
|
152
|
-
const
|
|
153
|
-
|
|
162
|
+
// Skip validation if caller has already validated (e.g., HonoStreamAppBuilder) or if builder config dictates noRuntimeValidation
|
|
163
|
+
const skipValidation =
|
|
164
|
+
(ctx as { isPrevalidated?: boolean }).isPrevalidated ||
|
|
165
|
+
builder?.config?.noRuntimeValidation
|
|
166
|
+
|
|
167
|
+
if (validations?.params && !skipValidation) {
|
|
154
168
|
const { errors } = validations.params(params)
|
|
155
169
|
|
|
156
170
|
if (errors) {
|
|
@@ -166,7 +180,7 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
|
|
|
166
180
|
// Validate each input channel independently for better error messages.
|
|
167
181
|
// Double validation (per-channel + merged) is intentional for developer experience;
|
|
168
182
|
// the cost is negligible — revisit if validation becomes a performance bottleneck.
|
|
169
|
-
if (validations?.input && !
|
|
183
|
+
if (validations?.input && !skipValidation) {
|
|
170
184
|
for (const [channel, validator] of Object.entries(validations.input)) {
|
|
171
185
|
const channelValue = (params as Record<string, unknown>)?.[channel]
|
|
172
186
|
const { errors } = validator(channelValue)
|
|
@@ -330,9 +344,12 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
|
|
|
330
344
|
// Create abort controller for this stream
|
|
331
345
|
const abortController = new AbortController()
|
|
332
346
|
|
|
333
|
-
// Skip validation if caller has already validated (e.g., HonoStreamAppBuilder)
|
|
334
|
-
const
|
|
335
|
-
|
|
347
|
+
// Skip validation if caller has already validated (e.g., HonoStreamAppBuilder) or if builder config dictates noRuntimeValidation
|
|
348
|
+
const skipValidation =
|
|
349
|
+
(ctx as { isPrevalidated?: boolean }).isPrevalidated ||
|
|
350
|
+
builder?.config?.noRuntimeValidation
|
|
351
|
+
|
|
352
|
+
if (validations?.params && !skipValidation) {
|
|
336
353
|
const { errors } = validations.params(params)
|
|
337
354
|
|
|
338
355
|
if (errors) {
|
|
@@ -346,7 +363,7 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
|
|
|
346
363
|
}
|
|
347
364
|
|
|
348
365
|
// Validate each input channel independently (see Create for rationale)
|
|
349
|
-
if (validations?.input && !
|
|
366
|
+
if (validations?.input && !skipValidation) {
|
|
350
367
|
for (const [channel, validator] of Object.entries(validations.input)) {
|
|
351
368
|
const channelValue = (params as Record<string, unknown>)?.[channel]
|
|
352
369
|
const { errors } = validator(channelValue)
|
|
@@ -416,14 +433,9 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
|
|
|
416
433
|
// user-defined errors inside ProcedureError defeats route-declared
|
|
417
434
|
// typed-error dispatch on the client. Augment the stack trace in
|
|
418
435
|
// place with the procedure's definition site when available.
|
|
419
|
-
if (
|
|
420
|
-
definitionInfo.definedAt &&
|
|
421
|
-
error &&
|
|
422
|
-
typeof error.stack === 'string'
|
|
423
|
-
) {
|
|
436
|
+
if (definitionInfo.definedAt && error && typeof error.stack === 'string') {
|
|
424
437
|
const { file, line, column } = definitionInfo.definedAt
|
|
425
|
-
error.stack =
|
|
426
|
-
`${error.stack}\n--- Procedure "${name}" defined at ---\n at ${file}:${line}:${column}`
|
|
438
|
+
error.stack = `${error.stack}\n--- Procedure "${name}" defined at ---\n at ${file}:${line}:${column}`
|
|
427
439
|
}
|
|
428
440
|
throw error
|
|
429
441
|
} finally {
|