acrylblends 0.2.0 → 0.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 (109) hide show
  1. package/dist/index.d.ts +17 -16
  2. package/dist/index.js +39 -19
  3. package/dist/index.js.map +1 -1
  4. package/package.json +1 -1
  5. package/plugins/acryl-inspect/LICENSE +21 -0
  6. package/plugins/acryl-inspect/plugin.json +11 -0
  7. package/plugins/acryl-inspect/src/index.ts +3 -0
  8. package/plugins/acryl-inspect/src/report.ts +123 -0
  9. package/plugins/acryl-inspect/src/service.ts +66 -0
  10. package/plugins/acryl-inspect/tests/report.spec.ts +64 -0
  11. package/plugins/acryl-inspect/tests/service.spec.ts +120 -0
  12. package/plugins/acryl-inspect/upstream.json +18 -0
  13. package/plugins/acryl-node-server/LICENSE +21 -0
  14. package/plugins/acryl-node-server/plugin.json +12 -0
  15. package/plugins/acryl-node-server/src/index.ts +94 -0
  16. package/plugins/acryl-node-server/tests/server.spec.ts +105 -0
  17. package/plugins/acryl-node-server/upstream.json +18 -0
  18. package/plugins/acryl-router/LICENSE +21 -0
  19. package/plugins/acryl-router/plugin.json +12 -0
  20. package/plugins/acryl-router/src/compose.ts +21 -0
  21. package/plugins/acryl-router/src/context.ts +59 -0
  22. package/plugins/acryl-router/src/helpers/index.ts +3 -0
  23. package/plugins/acryl-router/src/helpers/read-body.ts +59 -0
  24. package/plugins/acryl-router/src/helpers/sse.ts +83 -0
  25. package/plugins/acryl-router/src/helpers/validate.ts +44 -0
  26. package/plugins/acryl-router/src/http-error.ts +21 -0
  27. package/plugins/acryl-router/src/index.ts +6 -0
  28. package/plugins/acryl-router/src/middleware/body-limit.ts +25 -0
  29. package/plugins/acryl-router/src/middleware/cors.ts +46 -0
  30. package/plugins/acryl-router/src/middleware/index.ts +6 -0
  31. package/plugins/acryl-router/src/middleware/logger.ts +21 -0
  32. package/plugins/acryl-router/src/middleware/loopback-only.ts +82 -0
  33. package/plugins/acryl-router/src/middleware/request-id.ts +24 -0
  34. package/plugins/acryl-router/src/middleware/secure-headers.ts +23 -0
  35. package/plugins/acryl-router/src/pattern.ts +89 -0
  36. package/plugins/acryl-router/src/registrar.ts +90 -0
  37. package/plugins/acryl-router/src/service.ts +164 -0
  38. package/plugins/acryl-router/src/table.ts +156 -0
  39. package/plugins/acryl-router/src/types.ts +73 -0
  40. package/plugins/acryl-router/src/util.ts +23 -0
  41. package/plugins/acryl-router/tests/api.spec.ts +223 -0
  42. package/plugins/acryl-router/tests/conformance.ts +170 -0
  43. package/plugins/acryl-router/tests/helpers.spec.ts +172 -0
  44. package/plugins/acryl-router/tests/live-install.spec.ts +64 -0
  45. package/plugins/acryl-router/tests/loader.spec.ts +71 -0
  46. package/plugins/acryl-router/tests/middleware.spec.ts +183 -0
  47. package/plugins/acryl-router/tests/service.spec.ts +211 -0
  48. package/plugins/acryl-router/tests/table.spec.ts +47 -0
  49. package/plugins/acryl-router/upstream.json +20 -0
  50. package/plugins/acryl-router-http/LICENSE +21 -0
  51. package/plugins/acryl-router-http/plugin.json +11 -0
  52. package/plugins/acryl-router-http/src/bridge.ts +52 -0
  53. package/plugins/acryl-router-http/src/index.ts +63 -0
  54. package/plugins/acryl-router-http/src/serve.ts +24 -0
  55. package/plugins/acryl-router-http/tests/adapter.spec.ts +170 -0
  56. package/plugins/acryl-router-http/tests/stream.spec.ts +133 -0
  57. package/plugins/acryl-router-http/tests/surfaces.spec.ts +99 -0
  58. package/plugins/acryl-router-http/upstream.json +22 -0
  59. package/plugins/acryl-state/LICENSE +21 -0
  60. package/plugins/acryl-state/plugin.json +12 -0
  61. package/plugins/acryl-state/src/cell.ts +67 -0
  62. package/plugins/acryl-state/src/index.ts +3 -0
  63. package/plugins/acryl-state/src/react.ts +11 -0
  64. package/plugins/acryl-state/src/service.ts +101 -0
  65. package/plugins/acryl-state/tests/cell.spec.ts +71 -0
  66. package/plugins/acryl-state/tests/react.spec.ts +39 -0
  67. package/plugins/acryl-state/tests/service.spec.ts +132 -0
  68. package/plugins/acryl-state/upstream.json +20 -0
  69. package/plugins/builder/script.ts +8 -9
  70. package/plugins/extensions/index.ts +18 -1
  71. package/plugins/openai/prompt.ts +9 -5
  72. package/plugins/tasks/index.ts +14 -0
  73. package/plugins/tsconfig.json +9 -1
  74. package/templates/agent/blend.yaml +5 -7
  75. package/templates/agent/src/app.ts +8 -8
  76. package/templates/agent/src/routes.ts +32 -41
  77. package/templates/agent/template.json +3 -3
  78. package/templates/agent/test/builder.test.ts +22 -14
  79. package/templates/agent/test/model.test.ts +2 -3
  80. package/templates/{app → blank}/blend.yaml +3 -3
  81. package/templates/{app → blank}/src/app.ts +5 -5
  82. package/templates/blank/src/routes.ts +14 -0
  83. package/templates/blank/template.json +1 -0
  84. package/templates/{app → blank}/test/app.test.ts +1 -1
  85. package/templates/todo/README.md +27 -0
  86. package/templates/todo/blend.yaml +25 -0
  87. package/templates/todo/gitignore +2 -0
  88. package/templates/todo/package.json +23 -0
  89. package/templates/todo/src/app.ts +71 -0
  90. package/templates/todo/src/main.ts +19 -0
  91. package/templates/todo/src/page.ts +28 -0
  92. package/templates/todo/src/todo.ts +96 -0
  93. package/templates/todo/template.json +1 -0
  94. package/templates/todo/test/todo.test.ts +145 -0
  95. package/templates/todo/tsconfig.json +19 -0
  96. package/templates/todo/upstream.json +12 -0
  97. package/plugins/events/index.ts +0 -42
  98. package/plugins/events/plugin.json +0 -9
  99. package/plugins/http/index.ts +0 -64
  100. package/plugins/http/plugin.json +0 -10
  101. package/plugins/server/index.ts +0 -138
  102. package/plugins/server/plugin.json +0 -10
  103. package/templates/app/src/routes.ts +0 -14
  104. package/templates/app/template.json +0 -1
  105. /package/templates/{app → blank}/README.md +0 -0
  106. /package/templates/{app → blank}/gitignore +0 -0
  107. /package/templates/{app → blank}/package.json +0 -0
  108. /package/templates/{app → blank}/src/main.ts +0 -0
  109. /package/templates/{app → blank}/tsconfig.json +0 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The framework-neutral conformance suite for a `router` (spec 046, milestone M0). A candidate is one factory returning
3
+ * five operations; the same checks run against every implementation, so choosing between the rewrite, H3 or
4
+ * a Rou3-based core is a matter of one adapter function and a test run.
5
+ */
6
+
7
+ import { describe, expect, it } from 'vitest'
8
+ import type { Handler, Middleware, RouteMethod } from '../src/types.ts'
9
+
10
+ export interface Candidate {
11
+ route(method: RouteMethod, path: string, handler: Handler): () => void
12
+ use(path: string, middleware: Middleware): () => void
13
+ fetch(request: Request): Promise<Response>
14
+ /** Routes plus middleware currently registered. */
15
+ size(): number
16
+ }
17
+
18
+ const req = (path: string, method = 'GET'): Request => new Request(`http://acryl.test${path}`, { method })
19
+ const body = async (response: Response): Promise<string> => response.text()
20
+ const echo = (label: string): Handler => c => c.text(`${label}:${JSON.stringify(c.params)}`)
21
+
22
+ export function conformance(label: string, make: () => Promise<Candidate> | Candidate): void {
23
+ describe(`router conformance: ${label}`, () => {
24
+ it('matches exact, parameter and wildcard routes and decodes parameters', async () => {
25
+ const r = await make()
26
+ r.route('GET', '/', echo('root'))
27
+ r.route('GET', '/market', echo('market'))
28
+ r.route('GET', '/market/:id', echo('item'))
29
+ r.route('GET', '/files/*', echo('files'))
30
+ expect(await body(await r.fetch(req('/')))).toBe('root:{}')
31
+ expect(await body(await r.fetch(req('/market')))).toBe('market:{}')
32
+ expect(await body(await r.fetch(req('/market/a%20b')))).toBe('item:{"id":"a b"}')
33
+ expect(await body(await r.fetch(req('/files/a/b/c')))).toBe('files:{"*":"a/b/c"}')
34
+ expect((await r.fetch(req('/market/a/b'))).status).toBe(404)
35
+ })
36
+
37
+ it('prefers static over parameter over wildcard, whatever the registration order', async () => {
38
+ const r = await make()
39
+ r.route('GET', '/x/*', echo('wild'))
40
+ r.route('GET', '/x/:id', echo('param'))
41
+ r.route('GET', '/x/fixed', echo('static'))
42
+ expect(await body(await r.fetch(req('/x/fixed')))).toBe('static:{}')
43
+ expect(await body(await r.fetch(req('/x/other')))).toBe('param:{"id":"other"}')
44
+ expect(await body(await r.fetch(req('/x/other/deep')))).toBe('wild:{"*":"other/deep"}')
45
+ })
46
+
47
+ it('answers 404 for unknown paths and 405 with Allow for a known path with another method', async () => {
48
+ const r = await make()
49
+ r.route('GET', '/a', echo('a'))
50
+ r.route('POST', '/a', echo('a-post'))
51
+ expect((await r.fetch(req('/nope'))).status).toBe(404)
52
+ const refused = await r.fetch(req('/a', 'DELETE'))
53
+ expect(refused.status).toBe(405)
54
+ expect(refused.headers.get('allow')).toBe('GET, HEAD, POST')
55
+ })
56
+
57
+ it('answers HEAD from the GET route with the same status and headers and no body, unless a HEAD route exists', async () => {
58
+ const r = await make()
59
+ r.route('GET', '/h', c => c.json({ big: 'body' }, 202))
60
+ const head = await r.fetch(req('/h', 'HEAD'))
61
+ expect(head.status).toBe(202)
62
+ expect(head.headers.get('content-type')).toContain('application/json')
63
+ expect(await body(head)).toBe('')
64
+ r.route('HEAD', '/h', c => c.text('', 204))
65
+ expect((await r.fetch(req('/h', 'HEAD'))).status).toBe(204)
66
+ })
67
+
68
+ it('lets a specific method beat the any-method route, and the any-method route catch the rest', async () => {
69
+ const r = await make()
70
+ r.route('*', '/m', echo('any'))
71
+ r.route('GET', '/m', echo('get'))
72
+ expect(await body(await r.fetch(req('/m')))).toBe('get:{}')
73
+ expect(await body(await r.fetch(req('/m', 'PUT')))).toBe('any:{}')
74
+ })
75
+
76
+ it('throws on a duplicate route, including one that differs only in parameter name', async () => {
77
+ const r = await make()
78
+ r.route('GET', '/dup/:id', echo('one'))
79
+ expect(() => r.route('GET', '/dup/:name', echo('two'))).toThrow(/duplicate/i)
80
+ expect(() => r.route('POST', '/dup/:name', echo('post'))).not.toThrow()
81
+ })
82
+
83
+ it('rejects malformed paths', async () => {
84
+ const r = await make()
85
+ expect(() => r.route('GET', 'no-slash', echo('x'))).toThrow()
86
+ expect(() => r.route('GET', '/a/*/b', echo('x'))).toThrow()
87
+ expect(() => r.route('GET', '/a/:/b', echo('x'))).toThrow()
88
+ expect(() => r.route('GET', '/a/:id/:id', echo('x'))).toThrow()
89
+ })
90
+
91
+ it('runs middleware in registration order around the handler and lets one short-circuit', async () => {
92
+ const r = await make()
93
+ const seen: string[] = []
94
+ r.use('/api', async (c, next) => { seen.push('a-in'); c.set('who', 'alice'); const res = await next(); seen.push('a-out'); return res })
95
+ r.use('/api', async (_c, next) => { seen.push('b-in'); const res = await next(); seen.push('b-out'); return res })
96
+ r.use('/api/private', (c) => c.text('denied', 401))
97
+ r.route('GET', '/api/ok', c => c.text(`hello ${c.get<string>('who') ?? '?'}`))
98
+ r.route('GET', '/api/private/x', c => c.text('secret'))
99
+ r.route('GET', '/open', c => c.text('open'))
100
+
101
+ expect(await body(await r.fetch(req('/api/ok')))).toBe('hello alice')
102
+ expect(seen).toEqual(['a-in', 'b-in', 'b-out', 'a-out'])
103
+ seen.length = 0
104
+ const denied = await r.fetch(req('/api/private/x'))
105
+ expect(denied.status).toBe(401)
106
+ // The /api middleware still ran before the /api/private one refused; /open is outside every middleware path.
107
+ expect(seen).toEqual(['a-in', 'b-in', 'b-out', 'a-out'])
108
+ seen.length = 0
109
+ expect(await body(await r.fetch(req('/open')))).toBe('open')
110
+ expect(seen).toEqual([])
111
+ })
112
+
113
+ it('turns a throwing handler into a 500 and a double next() into a 500', async () => {
114
+ const r = await make()
115
+ r.route('GET', '/boom', () => { throw new Error('boom') })
116
+ r.use('/twice', async (_c, next) => { await next(); return next() })
117
+ r.route('GET', '/twice', echo('t'))
118
+ expect((await r.fetch(req('/boom'))).status).toBe(500)
119
+ expect((await r.fetch(req('/twice'))).status).toBe(500)
120
+ })
121
+
122
+ it('removes a route when its remover runs, is idempotent, and leaves other routes alone', async () => {
123
+ const r = await make()
124
+ const removeA = r.route('GET', '/a', echo('a'))
125
+ r.route('GET', '/b', echo('b'))
126
+ expect((await r.fetch(req('/a'))).status).toBe(200)
127
+ removeA()
128
+ removeA()
129
+ expect((await r.fetch(req('/a'))).status).toBe(404)
130
+ expect((await r.fetch(req('/b'))).status).toBe(200)
131
+ r.route('GET', '/a', echo('a2'))
132
+ expect(await body(await r.fetch(req('/a')))).toBe('a2:{}')
133
+ })
134
+
135
+ it('removes middleware with its remover', async () => {
136
+ const r = await make()
137
+ r.route('GET', '/p', echo('p'))
138
+ const remove = r.use('/', (c) => c.text('blocked', 403))
139
+ expect((await r.fetch(req('/p'))).status).toBe(403)
140
+ remove()
141
+ expect((await r.fetch(req('/p'))).status).toBe(200)
142
+ })
143
+
144
+ it('returns to its baseline size after fifty register and remove cycles', async () => {
145
+ const r = await make()
146
+ r.route('GET', '/stay', echo('stay'))
147
+ const baseline = r.size()
148
+ for (let i = 0; i < 50; i += 1) {
149
+ const a = r.route('GET', `/cycle/${String(i)}`, echo('c'))
150
+ const b = r.use(`/cycle/${String(i)}`, async (_c, next) => next())
151
+ expect(r.size()).toBe(baseline + 2)
152
+ a()
153
+ b()
154
+ }
155
+ expect(r.size()).toBe(baseline)
156
+ })
157
+
158
+ it('gives the same response through fetch as a handler builds, including headers and status', async () => {
159
+ const r = await make()
160
+ r.route('POST', '/echo/:name', async c => {
161
+ const text = await c.req.text()
162
+ return c.json({ name: c.params['name'], text, query: c.url.searchParams.get('q') }, 201)
163
+ })
164
+ const response = await r.fetch(new Request('http://acryl.test/echo/zed?q=1', { method: 'POST', body: 'hi' }))
165
+ expect(response.status).toBe(201)
166
+ expect(response.headers.get('content-type')).toContain('application/json')
167
+ expect(await response.json()).toEqual({ name: 'zed', text: 'hi', query: '1' })
168
+ })
169
+ })
170
+ }
@@ -0,0 +1,172 @@
1
+ /** The route helpers (spec 046): bounded body reading, server-sent events, and validation. */
2
+
3
+ import { Context } from '@deepseek-ai/cordis'
4
+ import z from '@deepseek-ai/schemastery'
5
+ import { afterEach, describe, expect, it } from 'vitest'
6
+ import { apply, name, type Router } from '../src/index.ts'
7
+ import { readBytes, readJson, readText, streamSSE, valid, validate } from '../src/helpers/index.ts'
8
+ import { bodyLimit } from '../src/middleware/index.ts'
9
+
10
+ const contexts: Context[] = []
11
+ const settle = (ms = 0): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
12
+ afterEach(async () => {
13
+ while (contexts.length > 0) await contexts.pop()?.fiber.dispose()
14
+ })
15
+
16
+ async function mount(): Promise<{ ctx: Context, router: Router }> {
17
+ const ctx = new Context()
18
+ contexts.push(ctx)
19
+ ctx.plugin({ name, apply })
20
+ await settle()
21
+ return { ctx, router: ctx.router }
22
+ }
23
+
24
+ describe('readJson / readText / readBytes', () => {
25
+ it('reads a JSON body, and an empty body as no bytes', async () => {
26
+ const { router } = await mount()
27
+ router.post('/j', async c => c.json({ got: await readJson(c) }))
28
+ router.post('/t', async c => c.text(String((await readBytes(c)).byteLength)))
29
+ expect(await (await router.request('/j', { method: 'POST', body: '{"a":1}' })).json()).toEqual({ got: { a: 1 } })
30
+ expect(await (await router.request('/t', { method: 'POST' })).text()).toBe('0')
31
+ })
32
+
33
+ it('answers 400 for bad JSON and 413 over the limit, counting the bytes it reads', async () => {
34
+ const { router } = await mount()
35
+ router.post('/j', async c => c.json(await readJson(c, { maxBytes: 16 })))
36
+ expect((await router.request('/j', { method: 'POST', body: '{nope' })).status).toBe(400)
37
+ const big = await router.request('/j', { method: 'POST', body: JSON.stringify({ pad: 'x'.repeat(100) }) })
38
+ expect(big.status).toBe(413)
39
+ })
40
+
41
+ it('stops a chunked body that has no declared length at the limit', async () => {
42
+ const { router } = await mount()
43
+ router.post('/t', async c => c.text(await readText(c, { maxBytes: 8 })))
44
+ const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(new TextEncoder().encode('0123456789')); controller.close() } })
45
+ const response = await router.request(new Request('http://localhost/t', { method: 'POST', body: stream, duplex: 'half' } as RequestInit))
46
+ expect(response.status).toBe(413)
47
+ })
48
+
49
+ it('uses the limit the bodyLimit middleware set', async () => {
50
+ const { router } = await mount()
51
+ router.use(bodyLimit({ maxBytes: 4 }))
52
+ router.post('/t', async c => c.text(await readText(c)))
53
+ const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(new TextEncoder().encode('abcdefgh')); controller.close() } })
54
+ expect((await router.request(new Request('http://localhost/t', { method: 'POST', body: stream, duplex: 'half' } as RequestInit))).status).toBe(413)
55
+ })
56
+ })
57
+
58
+ describe('streamSSE', () => {
59
+ async function read(response: Response, count: number): Promise<string[]> {
60
+ const reader = response.body!.getReader()
61
+ const decoder = new TextDecoder()
62
+ let buffer = ''
63
+ const out: string[] = []
64
+ while (out.length < count) {
65
+ const { done, value } = await reader.read()
66
+ if (done) break
67
+ buffer += decoder.decode(value, { stream: true })
68
+ for (let at = buffer.indexOf('\n\n'); at !== -1; at = buffer.indexOf('\n\n')) { out.push(buffer.slice(0, at)); buffer = buffer.slice(at + 2) }
69
+ }
70
+ reader.releaseLock()
71
+ return out
72
+ }
73
+
74
+ it('sends events with ids, names, JSON and multi-line text, and a comment', async () => {
75
+ const { router } = await mount()
76
+ router.get('/s', c => streamSSE(c, stream => {
77
+ stream.send({ n: 1 }, { event: 'tick', id: '7' })
78
+ stream.send('line one\nline two')
79
+ stream.comment('keep-alive')
80
+ }))
81
+ const events = await read(await router.request('/s'), 3)
82
+ expect(events).toEqual(['id: 7\nevent: tick\ndata: {"n":1}', 'data: line one\ndata: line two', ': keep-alive'])
83
+ })
84
+
85
+ it('runs its cleanup once when the client aborts, and ends the stream', async () => {
86
+ const { router } = await mount()
87
+ let cleaned = 0
88
+ router.get('/s', c => streamSSE(c, () => () => { cleaned += 1 }))
89
+ const controller = new AbortController()
90
+ const response = await router.request('/s', { signal: controller.signal })
91
+ const reader = response.body!.getReader()
92
+ controller.abort()
93
+ await expect(reader.read()).resolves.toMatchObject({ done: true })
94
+ await settle(5)
95
+ expect(cleaned).toBe(1)
96
+ })
97
+
98
+ it('lets the server close the stream, and runs the cleanup once even if cancelled afterwards', async () => {
99
+ const { router } = await mount()
100
+ let cleaned = 0
101
+ router.get('/s', c => streamSSE(c, stream => { stream.send('only'); stream.close(); return () => { cleaned += 1 } }))
102
+ const response = await router.request('/s')
103
+ const reader = response.body!.getReader()
104
+ expect((await reader.read()).done).toBe(false)
105
+ await expect(reader.read()).resolves.toMatchObject({ done: true })
106
+ await reader.cancel()
107
+ expect(cleaned).toBe(1)
108
+ })
109
+
110
+ it('is ended and awaited when its plugin unloads the router', async () => {
111
+ const { ctx, router } = await mount()
112
+ let cleaned = 0
113
+ router.get('/s', c => streamSSE(c, () => () => { cleaned += 1 }))
114
+ const response = await router.request('/s')
115
+ const reader = response.body!.getReader()
116
+ const ended = reader.read()
117
+ await ctx.fiber.dispose()
118
+ contexts.length = 0
119
+ await expect(ended).resolves.toMatchObject({ done: true })
120
+ expect(cleaned).toBe(1)
121
+ })
122
+ })
123
+
124
+ describe('validate', () => {
125
+ const Body = z.object({ name: z.string().required(), age: z.number().default(0) })
126
+
127
+ it('hands the handler the parsed value, with defaults applied', async () => {
128
+ const { router } = await mount()
129
+ router.post('/u', validate('json', Body), c => c.json(valid<{ name: string, age: number }>(c, 'json')))
130
+ const response = await router.request('/u', { method: 'POST', body: '{"name":"ada"}' })
131
+ expect(response.status).toBe(200)
132
+ expect(await response.json()).toEqual({ name: 'ada', age: 0 })
133
+ })
134
+
135
+ it('answers 400 with the issues for an invalid body, query or parameter', async () => {
136
+ const { router } = await mount()
137
+ router.post('/u', validate('json', Body), c => c.text('ok'))
138
+ router.get('/q', validate('query', z.object({ page: z.string().required() })), c => c.text('ok'))
139
+ router.get('/p/:id', validate('param', z.object({ id: z.string().pattern(/^\d+$/u).required() })), c => c.text('ok'))
140
+ const bad = await router.request('/u', { method: 'POST', body: '{}' })
141
+ expect(bad.status).toBe(400)
142
+ expect(((await bad.json()) as { error: string }).error).toBe('invalid json')
143
+ expect((await router.request('/q')).status).toBe(400)
144
+ expect((await router.request('/q?page=2')).status).toBe(200)
145
+ expect((await router.request('/p/abc')).status).toBe(400)
146
+ expect((await router.request('/p/42')).status).toBe(200)
147
+ })
148
+
149
+ it('tells the author when valid() is used without validate()', async () => {
150
+ const { router } = await mount()
151
+ router.get('/x', c => c.text(String(valid(c, 'json'))))
152
+ expect((await router.request('/x')).status).toBe(500)
153
+ })
154
+ })
155
+
156
+ describe('streamSSE keepalive and validate on bad JSON', () => {
157
+ it('sends a comment line on an idle stream and stops when it ends', async () => {
158
+ const { router } = await mount()
159
+ router.get('/s', c => streamSSE(c, () => undefined, { keepAliveMs: 10 }))
160
+ const response = await router.request('/s')
161
+ const reader = response.body!.getReader()
162
+ const chunk = new TextDecoder().decode((await reader.read()).value)
163
+ expect(chunk).toBe(': keepalive\n\n')
164
+ await reader.cancel()
165
+ })
166
+
167
+ it('answers 400, not 500, when the body is not JSON', async () => {
168
+ const { router } = await mount()
169
+ router.post('/v', validate('json', z.object({ a: z.number() }) as never), c => c.json(valid(c, 'json')))
170
+ expect((await router.request('/v', { method: 'POST', body: '{nope' })).status).toBe(400)
171
+ })
172
+ })
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Mounting a plugin after startup from inside a Service method (what an Extensions service does). Inside a service method `this.ctx` is Cordis's
3
+ * "shadow" context, which resolves services as the SERVICE'S OWN fiber; a child plugin mounted with it inherits that shadow, so its `ctx.router`
4
+ * is looked up in the installer's fiber (which never injected it) and throws "cannot get property router without inject", even though the child's
5
+ * own `fiber.inject` and `ctx.get('router')` are fine. The context captured in the constructor is not shadowed and mounts a plugin that behaves
6
+ * exactly like one mounted at startup.
7
+ */
8
+ import { Context, Service } from '@deepseek-ai/cordis'
9
+ import { afterEach, describe, expect, it } from 'vitest'
10
+ import { apply, name } from '../src/index.ts'
11
+
12
+ const contexts: Context[] = []
13
+ const settle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0))
14
+ afterEach(async () => {
15
+ while (contexts.length > 0) await contexts.pop()?.fiber.dispose()
16
+ })
17
+
18
+ class Installer extends Service {
19
+ private readonly owner: Context
20
+ constructor(ctx: Context) {
21
+ super(ctx, 'installer')
22
+ this.owner = ctx
23
+ }
24
+
25
+ /** Right: mount from the context captured in the constructor. */
26
+ async install(module: object): Promise<void> {
27
+ await this.owner.plugin(module as { apply(ctx: Context): void })
28
+ }
29
+
30
+ /** Wrong: `this.ctx` inside a method is the shadow context. */
31
+ async installFromShadow(module: object): Promise<void> {
32
+ await this.ctx.plugin(module as { apply(ctx: Context): void })
33
+ }
34
+ }
35
+
36
+ async function system(): Promise<Context & { installer: Installer }> {
37
+ const root = new Context()
38
+ contexts.push(root)
39
+ root.plugin({ name, apply })
40
+ root.plugin({ name: 'installer', apply: (ctx: Context) => { new Installer(ctx) } })
41
+ await settle()
42
+ return root as Context & { installer: Installer }
43
+ }
44
+
45
+ const extension = (): object => ({
46
+ name: 'live-ext',
47
+ inject: ['router'],
48
+ apply(ctx: Context): void {
49
+ ctx.router.get('/live', c => c.json({ ok: true }))
50
+ },
51
+ })
52
+
53
+ describe('live install from a service method', () => {
54
+ it('works from the constructor context: the plugin reads ctx.router like one mounted at startup', async () => {
55
+ const root = await system()
56
+ await root.installer.install(extension())
57
+ expect(await (await root.router.request('/live')).json()).toEqual({ ok: true })
58
+ })
59
+
60
+ it('fails from this.ctx, which is Cordis behavior the framework docs warn about', async () => {
61
+ const root = await system()
62
+ await expect(root.installer.installFromShadow(extension())).rejects.toThrow(/cannot get property "router" without inject/u)
63
+ })
64
+ })
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The router mounted the way production mounts every plugin: a real `@deepseek-ai/cordis-plugin-loader` Loader row
3
+ * pointing at the built `lib/index.js`. Covers what a hand-built `ctx.plugin` cannot: the row id equals the package
4
+ * name, the `cordis.patch.yml` insert is loadable, and disabling and enabling the row ten times leaves nothing behind.
5
+ * @throws if the package has not been built: run `pnpm run build` first.
6
+ */
7
+
8
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
9
+ import { tmpdir } from 'node:os'
10
+ import { join } from 'node:path'
11
+ import { Context, type FiberState } from '@deepseek-ai/cordis'
12
+ import { Loader } from '@deepseek-ai/cordis-plugin-loader'
13
+ import { afterEach, describe, expect, it } from 'vitest'
14
+
15
+ const ACTIVE = 2 as FiberState.ACTIVE
16
+ const DISPOSED = 4 as FiberState.DISPOSED
17
+ const BUILT_ENTRY = new URL('../lib/index.js', import.meta.url).href
18
+
19
+ const roots: string[] = []
20
+ const contexts: Context[] = []
21
+ afterEach(async () => {
22
+ while (contexts.length > 0) await contexts.pop()?.fiber.dispose()
23
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
24
+ })
25
+
26
+ async function mount(): Promise<{ ctx: Context, entryId: string }> {
27
+ const root = mkdtempSync(join(tmpdir(), 'acryl-router-loader-'))
28
+ roots.push(root)
29
+ writeFileSync(join(root, 'row.mjs'), `export * from ${JSON.stringify(BUILT_ENTRY)}\n`)
30
+ const ctx = new Context()
31
+ contexts.push(ctx)
32
+ await ctx.plugin(Loader, { baseUrl: `file://${root}/` })
33
+ const entryId = await ctx.loader.create({ name: './row.mjs' })
34
+ await ctx.loader.await()
35
+ return { ctx, entryId }
36
+ }
37
+
38
+ function entryOf(ctx: Context, entryId: string) {
39
+ const found = [...ctx.loader.entries()].find(candidate => candidate.id === entryId)
40
+ if (found === undefined) throw new Error('entry not found')
41
+ return found
42
+ }
43
+
44
+ describe('acryl-router through a real Loader', () => {
45
+ it('activates from the built entry and serves a request in process', async () => {
46
+ const { ctx, entryId } = await mount()
47
+ expect(entryOf(ctx, entryId).fiber?.state).toBe(ACTIVE)
48
+ ctx.router.get('/ping', c => c.json({ ok: true }))
49
+ const response = await ctx.router.fetch(new Request('http://acryl.test/ping'))
50
+ expect(await response.json()).toEqual({ ok: true })
51
+ })
52
+
53
+ it('answers 503 and drops its routes when the row is disabled, and serves again when re-enabled, ten times', async () => {
54
+ const { ctx, entryId } = await mount()
55
+ const first = ctx.router
56
+ for (let i = 0; i < 10; i += 1) {
57
+ await entryOf(ctx, entryId).update({ disabled: true })
58
+ await ctx.loader.await()
59
+ const fiber = entryOf(ctx, entryId).fiber
60
+ expect(fiber === undefined || fiber.state === DISPOSED).toBe(true)
61
+ await entryOf(ctx, entryId).update({ disabled: false })
62
+ await ctx.loader.await()
63
+ expect(entryOf(ctx, entryId).fiber?.state).toBe(ACTIVE)
64
+ }
65
+ expect(ctx.router).not.toBe(first)
66
+ expect(ctx.router.size).toBe(0)
67
+ expect((await first.fetch(new Request('http://acryl.test/x'))).status).toBe(503)
68
+ ctx.router.get('/again', c => c.text('ok'))
69
+ expect(await (await ctx.router.fetch(new Request('http://acryl.test/again'))).text()).toBe('ok')
70
+ })
71
+ })
@@ -0,0 +1,183 @@
1
+ /** The built-in middleware (spec 046), each through the real Cordis service and `router.request`. */
2
+
3
+ import { Context } from '@deepseek-ai/cordis'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { apply, name, type Router } from '../src/index.ts'
6
+ import { bodyLimit, cors, logger, loopbackOnly, requestId, secureHeaders } from '../src/middleware/index.ts'
7
+
8
+ const contexts: Context[] = []
9
+ const settle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0))
10
+ afterEach(async () => {
11
+ while (contexts.length > 0) await contexts.pop()?.fiber.dispose()
12
+ })
13
+
14
+ async function mount(): Promise<Router> {
15
+ const ctx = new Context()
16
+ contexts.push(ctx)
17
+ ctx.plugin({ name, apply })
18
+ await settle()
19
+ return ctx.router
20
+ }
21
+
22
+ describe('secureHeaders', () => {
23
+ it('adds the hardening headers without overriding what the handler set, and lets a default be removed', async () => {
24
+ const router = await mount()
25
+ router.use(secureHeaders({ headers: { 'x-frame-options': '' } }))
26
+ router.get('/a', c => c.text('a'))
27
+ router.get('/b', () => new Response('b', { headers: { 'referrer-policy': 'origin' } }))
28
+ const a = await router.request('/a')
29
+ expect(a.headers.get('x-content-type-options')).toBe('nosniff')
30
+ expect(a.headers.get('x-frame-options')).toBeNull()
31
+ expect((await router.request('/b')).headers.get('referrer-policy')).toBe('origin')
32
+ })
33
+
34
+ it('also works on an immutable response', async () => {
35
+ const router = await mount()
36
+ router.use(secureHeaders())
37
+ router.get('/go', () => Response.redirect('http://example.test/x', 302))
38
+ const response = await router.request('/go')
39
+ expect(response.status).toBe(302)
40
+ expect(response.headers.get('x-content-type-options')).toBe('nosniff')
41
+ })
42
+ })
43
+
44
+ describe('requestId', () => {
45
+ it('keeps a well-formed incoming id, replaces a bad one, and exposes it to handlers', async () => {
46
+ const router = await mount()
47
+ router.use(requestId({ generate: () => 'generated-1' }))
48
+ router.get('/id', c => c.text(c.get<string>('requestId') ?? ''))
49
+ const kept = await router.request('/id', { headers: { 'x-request-id': 'abc-123' } })
50
+ expect(await kept.text()).toBe('abc-123')
51
+ expect(kept.headers.get('x-request-id')).toBe('abc-123')
52
+ const replaced = await router.request('/id', { headers: { 'x-request-id': 'bad id with spaces' } })
53
+ expect(await replaced.text()).toBe('generated-1')
54
+ expect((await router.request('/id')).headers.get('x-request-id')).toBe('generated-1')
55
+ })
56
+ })
57
+
58
+ describe('logger', () => {
59
+ it('writes an in line and an out line with status and time to the sink, and reports a throw', async () => {
60
+ const router = await mount()
61
+ const lines: string[] = []
62
+ router.use(logger(line => lines.push(line)))
63
+ router.get('/ok', c => c.text('ok'))
64
+ router.get('/boom', () => { throw new Error('boom') })
65
+ await router.request('/ok')
66
+ await router.request('/boom')
67
+ expect(lines[0]).toBe('<-- GET /ok')
68
+ expect(lines[1]).toMatch(/^--> GET \/ok 200 \d+ms$/)
69
+ expect(lines[2]).toBe('<-- GET /boom')
70
+ expect(lines[3]).toMatch(/^--> GET \/boom threw boom \d+ms$/)
71
+ })
72
+ })
73
+
74
+ describe('bodyLimit', () => {
75
+ it('refuses a declared length over the limit and lets a smaller one through', async () => {
76
+ const router = await mount()
77
+ router.use(bodyLimit({ maxBytes: 10 }))
78
+ router.post('/in', async c => c.text(await c.req.text()))
79
+ const big = await router.request('/in', { method: 'POST', body: 'x'.repeat(50), headers: { 'content-length': '50' } })
80
+ expect(big.status).toBe(413)
81
+ expect(await big.json()).toEqual({ error: 'payload too large', maxBytes: 10 })
82
+ expect(await (await router.request('/in', { method: 'POST', body: 'small', headers: { 'content-length': '5' } })).text()).toBe('small')
83
+ })
84
+
85
+ it('uses a custom error response', async () => {
86
+ const router = await mount()
87
+ router.use(bodyLimit({ maxBytes: 1, onError: c => c.text('too big', 400) }))
88
+ router.post('/in', c => c.text('ok'))
89
+ const response = await router.request('/in', { method: 'POST', body: 'xx', headers: { 'content-length': '2' } })
90
+ expect(response.status).toBe(400)
91
+ expect(await response.text()).toBe('too big')
92
+ })
93
+ })
94
+
95
+ describe('cors', () => {
96
+ it('answers a preflight itself and adds the headers to normal responses', async () => {
97
+ const router = await mount()
98
+ router.use(cors({ origin: ['https://app.example'], allowMethods: ['GET', 'POST'], maxAge: 600, credentials: true }))
99
+ router.get('/x', c => c.text('x'))
100
+ const preflight = await router.request('/x', { method: 'OPTIONS', headers: { origin: 'https://app.example', 'access-control-request-method': 'POST', 'access-control-request-headers': 'x-a, x-b' } })
101
+ expect(preflight.status).toBe(204)
102
+ expect(preflight.headers.get('access-control-allow-origin')).toBe('https://app.example')
103
+ expect(preflight.headers.get('access-control-allow-methods')).toBe('GET,POST')
104
+ expect(preflight.headers.get('access-control-allow-headers')).toBe('x-a,x-b')
105
+ expect(preflight.headers.get('access-control-max-age')).toBe('600')
106
+ expect(preflight.headers.get('access-control-allow-credentials')).toBe('true')
107
+ const normal = await router.request('/x', { headers: { origin: 'https://app.example' } })
108
+ expect(normal.headers.get('access-control-allow-origin')).toBe('https://app.example')
109
+ expect(normal.headers.get('vary')).toContain('Origin')
110
+ })
111
+
112
+ it('does not allow an origin outside the list', async () => {
113
+ const router = await mount()
114
+ router.use(cors({ origin: 'https://app.example' }))
115
+ router.get('/x', c => c.text('x'))
116
+ const response = await router.request('/x', { headers: { origin: 'https://evil.example' } })
117
+ expect(response.headers.get('access-control-allow-origin')).toBeNull()
118
+ })
119
+ })
120
+
121
+ describe('loopbackOnly', () => {
122
+ const peer = { remoteAddress: '127.0.0.1', localPort: 3105 }
123
+ const headers = (extra: Record<string, string> = {}): Record<string, string> => ({ host: '127.0.0.1:3105', ...extra })
124
+
125
+ it('lets a loopback peer on the real port through and refuses everything else with a 403', async () => {
126
+ const router = await mount()
127
+ router.use(loopbackOnly())
128
+ router.get('/x', c => c.text('x'))
129
+ expect((await router.request('/x', { headers: headers() }, peer)).status).toBe(200)
130
+ expect((await router.request('/x', { headers: headers() }, { remoteAddress: '203.0.113.9', localPort: 3105 })).status).toBe(403)
131
+ expect((await router.request('/x', { headers: headers({ host: '127.0.0.1:9999' }) }, peer)).status).toBe(403)
132
+ expect((await router.request('/x', { headers: headers({ host: 'evil.example:3105' }) }, peer)).status).toBe(403)
133
+ expect((await router.request('/x', { headers: headers({ 'sec-fetch-site': 'cross-site' }) }, peer)).status).toBe(403)
134
+ expect((await router.request('/x', { headers: headers() })).status).toBe(403)
135
+ })
136
+
137
+ it('accepts an IPv4-mapped loopback address and ::1', async () => {
138
+ const router = await mount()
139
+ router.use(loopbackOnly())
140
+ router.get('/x', c => c.text('x'))
141
+ expect((await router.request('/x', { headers: headers() }, { remoteAddress: '::ffff:127.0.0.1', localPort: 3105 })).status).toBe(200)
142
+ expect((await router.request('/x', { headers: headers() }, { remoteAddress: '::1', localPort: 3105 })).status).toBe(200)
143
+ })
144
+
145
+ it('refuses a change from a foreign or null Origin, and accepts this app\'s own', async () => {
146
+ const router = await mount()
147
+ router.use(loopbackOnly())
148
+ router.post('/do', c => c.text('done'))
149
+ expect((await router.request('/do', { method: 'POST', headers: headers({ origin: 'http://evil.example' }) }, peer)).status).toBe(403)
150
+ expect((await router.request('/do', { method: 'POST', headers: headers({ origin: 'null' }) }, peer)).status).toBe(403)
151
+ expect((await router.request('/do', { method: 'POST', headers: headers({ origin: 'http://127.0.0.1:3105' }) }, peer)).status).toBe(200)
152
+ })
153
+
154
+ it('accepts a change with no Origin (curl) only when the Host is a loopback address with this port', async () => {
155
+ const router = await mount()
156
+ router.use(loopbackOnly())
157
+ router.post('/do', c => c.text('done'))
158
+ expect((await router.request('/do', { method: 'POST', headers: headers() }, peer)).status).toBe(200)
159
+ expect((await router.request('/do', { method: 'POST', headers: headers({ host: 'localhost:3105' }) }, peer)).status).toBe(200)
160
+ expect((await router.request('/do', { method: 'POST', headers: headers({ host: '[::1]:3105' }) }, { remoteAddress: '::1', localPort: 3105 })).status).toBe(200)
161
+ // DNS rebinding: the page's own domain arrives as the Host, so a foreign Host is refused with or without an Origin.
162
+ expect((await router.request('/do', { method: 'POST', headers: headers({ host: 'rebind.example:3105' }) }, peer)).status).toBe(403)
163
+ expect((await router.request('/do', { method: 'POST', headers: headers({ host: '127.0.0.1:9999' }) }, peer)).status).toBe(403)
164
+ })
165
+
166
+ it('refuses an in-process call by default and lets it through when told the caller is the host', async () => {
167
+ const router = await mount()
168
+ router.get('/guarded', loopbackOnly(), c => c.text('x'))
169
+ router.get('/host', loopbackOnly({ inProcess: 'allow' }), c => c.text('x'))
170
+ expect((await router.request('/guarded')).status).toBe(403)
171
+ expect((await router.request('/host')).status).toBe(200)
172
+ // A request with any connection facts is checked in full, even when inProcess is allowed.
173
+ expect((await router.request('/host', { headers: { host: '127.0.0.1:3105' } }, { remoteAddress: '203.0.113.9', localPort: 3105 })).status).toBe(403)
174
+ expect((await router.request('/host', { headers: { host: '127.0.0.1:3105' } }, { remoteAddress: '127.0.0.1' })).status).toBe(403)
175
+ })
176
+
177
+ it('can check only the peer when mutations are none', async () => {
178
+ const router = await mount()
179
+ router.use(loopbackOnly({ mutations: 'none' }))
180
+ router.post('/do', c => c.text('done'))
181
+ expect((await router.request('/do', { method: 'POST', headers: headers() }, peer)).status).toBe(200)
182
+ })
183
+ })