acrylblends 0.0.0-stage → 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 (144) hide show
  1. package/bin/acrylblends.js +4 -0
  2. package/dist/index.d.ts +118 -0
  3. package/dist/index.js +454 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +50 -4
  6. package/plugins/acryl-inspect/LICENSE +21 -0
  7. package/plugins/acryl-inspect/plugin.json +11 -0
  8. package/plugins/acryl-inspect/src/index.ts +3 -0
  9. package/plugins/acryl-inspect/src/report.ts +123 -0
  10. package/plugins/acryl-inspect/src/service.ts +66 -0
  11. package/plugins/acryl-inspect/tests/report.spec.ts +64 -0
  12. package/plugins/acryl-inspect/tests/service.spec.ts +120 -0
  13. package/plugins/acryl-inspect/upstream.json +18 -0
  14. package/plugins/acryl-node-server/LICENSE +21 -0
  15. package/plugins/acryl-node-server/plugin.json +12 -0
  16. package/plugins/acryl-node-server/src/index.ts +94 -0
  17. package/plugins/acryl-node-server/tests/server.spec.ts +105 -0
  18. package/plugins/acryl-node-server/upstream.json +18 -0
  19. package/plugins/acryl-router/LICENSE +21 -0
  20. package/plugins/acryl-router/plugin.json +12 -0
  21. package/plugins/acryl-router/src/compose.ts +21 -0
  22. package/plugins/acryl-router/src/context.ts +59 -0
  23. package/plugins/acryl-router/src/helpers/index.ts +3 -0
  24. package/plugins/acryl-router/src/helpers/read-body.ts +59 -0
  25. package/plugins/acryl-router/src/helpers/sse.ts +83 -0
  26. package/plugins/acryl-router/src/helpers/validate.ts +44 -0
  27. package/plugins/acryl-router/src/http-error.ts +21 -0
  28. package/plugins/acryl-router/src/index.ts +6 -0
  29. package/plugins/acryl-router/src/middleware/body-limit.ts +25 -0
  30. package/plugins/acryl-router/src/middleware/cors.ts +46 -0
  31. package/plugins/acryl-router/src/middleware/index.ts +6 -0
  32. package/plugins/acryl-router/src/middleware/logger.ts +21 -0
  33. package/plugins/acryl-router/src/middleware/loopback-only.ts +82 -0
  34. package/plugins/acryl-router/src/middleware/request-id.ts +24 -0
  35. package/plugins/acryl-router/src/middleware/secure-headers.ts +23 -0
  36. package/plugins/acryl-router/src/pattern.ts +89 -0
  37. package/plugins/acryl-router/src/registrar.ts +90 -0
  38. package/plugins/acryl-router/src/service.ts +164 -0
  39. package/plugins/acryl-router/src/table.ts +156 -0
  40. package/plugins/acryl-router/src/types.ts +73 -0
  41. package/plugins/acryl-router/src/util.ts +23 -0
  42. package/plugins/acryl-router/tests/api.spec.ts +223 -0
  43. package/plugins/acryl-router/tests/conformance.ts +170 -0
  44. package/plugins/acryl-router/tests/helpers.spec.ts +172 -0
  45. package/plugins/acryl-router/tests/live-install.spec.ts +64 -0
  46. package/plugins/acryl-router/tests/loader.spec.ts +71 -0
  47. package/plugins/acryl-router/tests/middleware.spec.ts +183 -0
  48. package/plugins/acryl-router/tests/service.spec.ts +211 -0
  49. package/plugins/acryl-router/tests/table.spec.ts +47 -0
  50. package/plugins/acryl-router/upstream.json +20 -0
  51. package/plugins/acryl-router-http/LICENSE +21 -0
  52. package/plugins/acryl-router-http/plugin.json +11 -0
  53. package/plugins/acryl-router-http/src/bridge.ts +52 -0
  54. package/plugins/acryl-router-http/src/index.ts +63 -0
  55. package/plugins/acryl-router-http/src/serve.ts +24 -0
  56. package/plugins/acryl-router-http/tests/adapter.spec.ts +170 -0
  57. package/plugins/acryl-router-http/tests/stream.spec.ts +133 -0
  58. package/plugins/acryl-router-http/tests/surfaces.spec.ts +99 -0
  59. package/plugins/acryl-router-http/upstream.json +22 -0
  60. package/plugins/acryl-state/LICENSE +21 -0
  61. package/plugins/acryl-state/plugin.json +12 -0
  62. package/plugins/acryl-state/src/cell.ts +67 -0
  63. package/plugins/acryl-state/src/index.ts +3 -0
  64. package/plugins/acryl-state/src/react.ts +11 -0
  65. package/plugins/acryl-state/src/service.ts +101 -0
  66. package/plugins/acryl-state/tests/cell.spec.ts +71 -0
  67. package/plugins/acryl-state/tests/react.spec.ts +39 -0
  68. package/plugins/acryl-state/tests/service.spec.ts +132 -0
  69. package/plugins/acryl-state/upstream.json +20 -0
  70. package/plugins/agent/agent.ts +152 -0
  71. package/plugins/agent/index.ts +20 -0
  72. package/plugins/agent/memory.ts +24 -0
  73. package/plugins/agent/model.ts +79 -0
  74. package/plugins/agent/plugin.json +10 -0
  75. package/plugins/agent/tools.ts +50 -0
  76. package/plugins/builder/index.ts +193 -0
  77. package/plugins/builder/plugin.json +11 -0
  78. package/plugins/builder/script.ts +161 -0
  79. package/plugins/extensions/index.ts +755 -0
  80. package/plugins/extensions/ledger.ts +39 -0
  81. package/plugins/extensions/plugin.json +12 -0
  82. package/plugins/extensions/resolve.ts +30 -0
  83. package/plugins/extensions/stage.ts +44 -0
  84. package/plugins/instance/index.ts +167 -0
  85. package/plugins/instance/plugin.json +10 -0
  86. package/plugins/manifest/index.ts +635 -0
  87. package/plugins/manifest/lock.ts +94 -0
  88. package/plugins/manifest/plugin.json +13 -0
  89. package/plugins/manifest/upgrade-cli.ts +70 -0
  90. package/plugins/manifest/upgrade.ts +97 -0
  91. package/plugins/openai/index.ts +159 -0
  92. package/plugins/openai/plugin.json +11 -0
  93. package/plugins/openai/prompt.ts +35 -0
  94. package/plugins/sandbox/index.ts +150 -0
  95. package/plugins/sandbox/plugin.json +8 -0
  96. package/plugins/sandbox/runner.mjs +18 -0
  97. package/plugins/save/cli.ts +19 -0
  98. package/plugins/save/index.ts +55 -0
  99. package/plugins/save/plugin.json +12 -0
  100. package/plugins/settings/index.ts +270 -0
  101. package/plugins/settings/merge.ts +79 -0
  102. package/plugins/settings/plugin.json +13 -0
  103. package/plugins/tasks/index.ts +246 -0
  104. package/plugins/tasks/plugin.json +11 -0
  105. package/plugins/tasks/store.ts +86 -0
  106. package/plugins/tsconfig.json +27 -0
  107. package/templates/agent/Dockerfile +15 -0
  108. package/templates/agent/README.md +52 -0
  109. package/templates/agent/blend.yaml +56 -0
  110. package/templates/agent/dockerignore +3 -0
  111. package/templates/agent/gitignore +2 -0
  112. package/templates/agent/package.json +23 -0
  113. package/templates/agent/src/app.ts +98 -0
  114. package/templates/agent/src/main.ts +25 -0
  115. package/templates/agent/src/routes.ts +53 -0
  116. package/templates/agent/template.json +16 -0
  117. package/templates/agent/test/agent.test.ts +45 -0
  118. package/templates/agent/test/app.test.ts +46 -0
  119. package/templates/agent/test/builder.test.ts +194 -0
  120. package/templates/agent/test/model.test.ts +88 -0
  121. package/templates/agent/tsconfig.json +19 -0
  122. package/templates/blank/README.md +26 -0
  123. package/templates/blank/blend.yaml +23 -0
  124. package/templates/blank/gitignore +2 -0
  125. package/templates/blank/package.json +23 -0
  126. package/templates/blank/src/app.ts +61 -0
  127. package/templates/blank/src/main.ts +20 -0
  128. package/templates/blank/src/routes.ts +14 -0
  129. package/templates/blank/template.json +1 -0
  130. package/templates/blank/test/app.test.ts +56 -0
  131. package/templates/blank/tsconfig.json +19 -0
  132. package/templates/todo/README.md +27 -0
  133. package/templates/todo/blend.yaml +25 -0
  134. package/templates/todo/gitignore +2 -0
  135. package/templates/todo/package.json +23 -0
  136. package/templates/todo/src/app.ts +71 -0
  137. package/templates/todo/src/main.ts +19 -0
  138. package/templates/todo/src/page.ts +28 -0
  139. package/templates/todo/src/todo.ts +96 -0
  140. package/templates/todo/template.json +1 -0
  141. package/templates/todo/test/todo.test.ts +145 -0
  142. package/templates/todo/tsconfig.json +19 -0
  143. package/templates/todo/upstream.json +12 -0
  144. package/README.md +0 -3
@@ -0,0 +1,223 @@
1
+ /**
2
+ * The registration surface (spec 046): several handlers per route, `all` and `on`, optional-path `use`, `group`, `onError` and
3
+ * `notFound`, `HttpError`, `request()`, and the context helpers. Everything runs through the real Cordis service, so ownership is checked too.
4
+ */
5
+
6
+ import { Context } from '@deepseek-ai/cordis'
7
+ import { afterEach, describe, expect, it } from 'vitest'
8
+ import { apply, HttpError, name, type Router } from '../src/index.ts'
9
+
10
+ const contexts: Context[] = []
11
+ const settle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0))
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
+ const text = async (response: Response | Promise<Response>): Promise<string> => (await response).text()
25
+
26
+ describe('registration', () => {
27
+ it('runs route-level middleware before the handler, in order, for that route only', async () => {
28
+ const { router } = await mount()
29
+ const seen: string[] = []
30
+ router.get('/a', async (_c, next) => { seen.push('m1'); return next() }, async (_c, next) => { seen.push('m2'); return next() }, c => { seen.push('h'); return c.text('a') })
31
+ router.get('/b', c => c.text('b'))
32
+ expect(await text(router.request('/a'))).toBe('a')
33
+ expect(seen).toEqual(['m1', 'm2', 'h'])
34
+ seen.length = 0
35
+ await router.request('/b')
36
+ expect(seen).toEqual([])
37
+ })
38
+
39
+ it('answers any method with all(), and a chosen set with on()', async () => {
40
+ const { router } = await mount()
41
+ router.all('/any', c => c.text(c.req.method))
42
+ router.on(['GET', 'POST'], '/two', c => c.text(c.req.method))
43
+ expect(await text(router.request('/any', { method: 'PATCH' }))).toBe('PATCH')
44
+ expect(await text(router.request('/two', { method: 'POST' }))).toBe('POST')
45
+ expect((await router.request('/two', { method: 'DELETE' })).status).toBe(405)
46
+ })
47
+
48
+ it('removes every method of an on() registration with the one remover it returns', async () => {
49
+ const { router } = await mount()
50
+ const remove = router.on(['GET', 'POST'], '/two', c => c.text('x'))
51
+ const before = router.size
52
+ remove()
53
+ expect(before - router.size).toBe(2)
54
+ })
55
+
56
+ it('applies use() without a path to everything', async () => {
57
+ const { router } = await mount()
58
+ router.use(async (_c, next) => { const response = await next(); response.headers.set('x-seen', '1'); return response })
59
+ router.get('/x', c => c.text('x'))
60
+ expect((await router.request('/x')).headers.get('x-seen')).toBe('1')
61
+ expect((await router.request('/missing')).headers.get('x-seen')).toBe('1')
62
+ })
63
+ })
64
+
65
+ describe('group', () => {
66
+ it('prefixes paths, nests, and scopes group middleware to the prefix', async () => {
67
+ const { router } = await mount()
68
+ const seen: string[] = []
69
+ const api = router.group('/api', async (_c, next) => { seen.push('api'); return next() })
70
+ api.get('/', c => c.text('index'))
71
+ api.get('/items/:id', c => c.json({ id: c.param('id') }))
72
+ const admin = api.group('/admin')
73
+ admin.use(async (_c, next) => { seen.push('admin'); return next() })
74
+ admin.get('/stats', c => c.text('stats'))
75
+ router.get('/outside', c => c.text('outside'))
76
+
77
+ expect(await text(router.request('/api'))).toBe('index')
78
+ expect(await (await router.request('/api/items/7')).json()).toEqual({ id: '7' })
79
+ expect(await text(router.request('/api/admin/stats'))).toBe('stats')
80
+ expect(seen).toEqual(['api', 'api', 'api', 'admin'])
81
+ seen.length = 0
82
+ await router.request('/outside')
83
+ expect(seen).toEqual([])
84
+ })
85
+
86
+ it('belongs to the plugin that made it: its routes vanish when that plugin unloads', async () => {
87
+ const { ctx, router } = await mount()
88
+ const baseline = router.size
89
+ const fiber = ctx.plugin({
90
+ name: 'notes',
91
+ inject: ['router'],
92
+ apply(c: Context) {
93
+ const notes = c.router.group('/notes')
94
+ notes.get('/', x => x.text('list'))
95
+ notes.post('/', x => x.text('create', 201))
96
+ },
97
+ })
98
+ await settle()
99
+ expect(router.size - baseline).toBe(2)
100
+ expect(await text(router.request('/notes'))).toBe('list')
101
+ await fiber.dispose()
102
+ await settle()
103
+ expect(router.size).toBe(baseline)
104
+ expect((await router.request('/notes')).status).toBe(404)
105
+ })
106
+
107
+ it('rejects a prefix that is not a plain path', async () => {
108
+ const { router } = await mount()
109
+ expect(() => router.group('/api/')).toThrow(/plain path/)
110
+ expect(() => router.group('/a/:id')).toThrow(/plain path/)
111
+ expect(() => router.group('/a/*')).toThrow(/plain path/)
112
+ expect(() => router.group('api')).toThrow(/plain path/)
113
+ })
114
+ })
115
+
116
+ describe('errors and not found', () => {
117
+ it('turns a thrown HttpError into its response without logging an error', async () => {
118
+ const { router } = await mount()
119
+ router.get('/secret', () => { throw new HttpError(401, { message: 'Sign in first' }) })
120
+ router.get('/custom', () => { throw new HttpError(418, { response: new Response('teapot', { status: 418, headers: { 'x-brew': 'yes' } }) }) })
121
+ const denied = await router.request('/secret')
122
+ expect(denied.status).toBe(401)
123
+ expect(await denied.text()).toBe('Sign in first')
124
+ const teapot = await router.request('/custom')
125
+ expect(teapot.status).toBe(418)
126
+ expect(teapot.headers.get('x-brew')).toBe('yes')
127
+ })
128
+
129
+ it('lets one onError handler own every error, including HttpError, and removes it with its plugin', async () => {
130
+ const { ctx, router } = await mount()
131
+ const fiber = ctx.plugin({
132
+ name: 'errors',
133
+ inject: ['router'],
134
+ apply(c: Context) {
135
+ c.router.onError((error, x) => x.json({ message: error instanceof Error ? error.message : 'unknown' }, error instanceof HttpError ? error.status : 500))
136
+ },
137
+ })
138
+ await settle()
139
+ router.get('/boom', () => { throw new Error('boom') })
140
+ router.get('/http', () => { throw new HttpError(403, { message: 'nope' }) })
141
+ expect(await (await router.request('/boom')).json()).toEqual({ message: 'boom' })
142
+ const forbidden = await router.request('/http')
143
+ expect(forbidden.status).toBe(403)
144
+ expect(await forbidden.json()).toEqual({ message: 'nope' })
145
+ expect(() => router.onError(() => new Response('second'))).toThrow(/already registered/)
146
+ await fiber.dispose()
147
+ await settle()
148
+ expect((await router.request('/boom')).status).toBe(500)
149
+ })
150
+
151
+ it('answers 500 when the error handler itself throws', async () => {
152
+ const { router } = await mount()
153
+ router.onError(() => { throw new Error('handler broke') })
154
+ router.get('/boom', () => { throw new Error('boom') })
155
+ expect((await router.request('/boom')).status).toBe(500)
156
+ })
157
+
158
+ it('uses a registered notFound handler, also through c.notFound()', async () => {
159
+ const { router } = await mount()
160
+ router.notFound(c => c.json({ missing: c.url.pathname }, 404))
161
+ router.get('/maybe', c => c.req.headers.has('x-found') ? c.text('found') : c.notFound())
162
+ const missing = await router.request('/nowhere')
163
+ expect(missing.status).toBe(404)
164
+ expect(await missing.json()).toEqual({ missing: '/nowhere' })
165
+ expect((await router.request('/maybe')).status).toBe(404)
166
+ expect(await text(router.request('/maybe', { headers: { 'x-found': '1' } }))).toBe('found')
167
+ expect(() => router.notFound(() => new Response('second'))).toThrow(/already registered/)
168
+ })
169
+ })
170
+
171
+ describe('context helpers', () => {
172
+ it('carries header() and status() into the response helpers', async () => {
173
+ const { router } = await mount()
174
+ router.get('/h', c => { c.header('x-app', 'acryl'); c.status(202); return c.json({ ok: true }) })
175
+ const response = await router.request('/h')
176
+ expect(response.status).toBe(202)
177
+ expect(response.headers.get('x-app')).toBe('acryl')
178
+ expect(response.headers.get('content-type')).toContain('application/json')
179
+ })
180
+
181
+ it('builds html, raw bodies and redirects', async () => {
182
+ const { router } = await mount()
183
+ router.get('/html', c => c.html('<h1>hi</h1>'))
184
+ router.get('/raw', c => c.body(new Uint8Array([1, 2, 3]), 200, { 'content-type': 'application/octet-stream' }))
185
+ router.get('/go', c => c.redirect('/html', 301))
186
+ router.get('/empty', c => c.body(null, 204))
187
+ const html = await router.request('/html')
188
+ expect(html.headers.get('content-type')).toContain('text/html')
189
+ expect(await html.text()).toBe('<h1>hi</h1>')
190
+ expect([...new Uint8Array(await (await router.request('/raw')).arrayBuffer())]).toEqual([1, 2, 3])
191
+ const moved = await router.request('/go')
192
+ expect(moved.status).toBe(301)
193
+ expect(moved.headers.get('location')).toBe('/html')
194
+ expect((await router.request('/empty')).status).toBe(204)
195
+ })
196
+
197
+ it('reads query and path parameters one at a time or all at once', async () => {
198
+ const { router } = await mount()
199
+ router.get('/u/:name', c => c.json({ one: c.query('q'), all: c.query(), name: c.param('name'), params: c.param() }))
200
+ const body = await (await router.request('/u/zed?q=1&q=2&r=3')).json()
201
+ expect(body).toEqual({ one: '1', all: { q: '1', r: '3' }, name: 'zed', params: { name: 'zed' } })
202
+ })
203
+
204
+ it('shares values between middleware and handler with set() and get()', async () => {
205
+ const { router } = await mount()
206
+ router.use(async (c, next) => { c.set('user', 'ada'); return next() })
207
+ router.get('/me', c => c.text(c.get<string>('user') ?? '?'))
208
+ expect(await text(router.request('/me'))).toBe('ada')
209
+ })
210
+ })
211
+
212
+ describe('request()', () => {
213
+ it('accepts a relative path, a URL, and a Request, with init and connection info', async () => {
214
+ const { router } = await mount()
215
+ router.post('/echo', async c => c.json({ got: await c.req.text(), peer: c.connection.remoteAddress ?? null }))
216
+ const viaPath = await router.request('/echo', { method: 'POST', body: 'a' })
217
+ expect(await viaPath.json()).toEqual({ got: 'a', peer: null })
218
+ const viaUrl = await router.request(new URL('http://example.test/echo'), { method: 'POST', body: 'b' }, { remoteAddress: '127.0.0.1' })
219
+ expect(await viaUrl.json()).toEqual({ got: 'b', peer: '127.0.0.1' })
220
+ const viaRequest = await router.request(new Request('http://example.test/echo', { method: 'POST', body: 'c' }))
221
+ expect((await viaRequest.json() as { got: string }).got).toBe('c')
222
+ })
223
+ })
@@ -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
+ })