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,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
+ })
@@ -0,0 +1,211 @@
1
+ /**
2
+ * The router as a Cordis service (spec 046, M1): the shared conformance suite through a real `Context`, then the
3
+ * lifecycle mechanics the repo protocol requires: PENDING while the provider is missing, reactivation when it appears,
4
+ * provider replacement without duplicate registrations, disposal, repeated mount and reload, and in-flight abort.
5
+ */
6
+
7
+ import { Context, type FiberState } from '@deepseek-ai/cordis'
8
+ import { afterEach, describe, expect, it } from 'vitest'
9
+ import { apply, Config, name, type Router } from '../src/index.ts'
10
+ import { conformance, type Candidate } from './conformance.ts'
11
+
12
+ const PENDING = 0 as FiberState.PENDING
13
+ const ACTIVE = 2 as FiberState.ACTIVE
14
+ const DISPOSED = 4 as FiberState.DISPOSED
15
+
16
+ const contexts: Context[] = []
17
+ const settle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0))
18
+
19
+ function root(): Context {
20
+ const ctx = new Context()
21
+ contexts.push(ctx)
22
+ return ctx
23
+ }
24
+
25
+ const provider = { name, apply }
26
+ const config: Config = { trailingSlash: 'strict', disposeGraceMs: 2000 }
27
+
28
+ async function mountRouter(): Promise<{ ctx: Context, router: Router }> {
29
+ const ctx = root()
30
+ ctx.plugin(provider, config)
31
+ await settle()
32
+ return { ctx, router: ctx.router }
33
+ }
34
+
35
+ afterEach(async () => {
36
+ while (contexts.length > 0) await contexts.pop()?.fiber.dispose()
37
+ })
38
+
39
+ conformance('the Router service in a Cordis Context', async () => {
40
+ const { router } = await mountRouter()
41
+ const candidate: Candidate = {
42
+ route: (method, path, handler) => router.route(method, path, handler),
43
+ use: (path, middleware) => router.use(path, middleware),
44
+ fetch: request => router.fetch(request),
45
+ size: () => router.size,
46
+ }
47
+ return candidate
48
+ })
49
+
50
+ /** A consumer plugin that needs `router` and registers one route, the way the market will. */
51
+ function consumer(path: string, label: string) {
52
+ return {
53
+ name: `consumer${label}`,
54
+ inject: ['router'],
55
+ apply(ctx: Context): void {
56
+ ctx.router.get(path, c => c.text(label))
57
+ },
58
+ }
59
+ }
60
+
61
+ const get = (router: Router, path: string): Promise<Response> => router.fetch(new Request(`http://acryl.test${path}`))
62
+
63
+ describe('acryl-router lifecycle through Cordis', () => {
64
+ it('leaves a consumer PENDING while router is missing and activates it when router appears', async () => {
65
+ const ctx = root()
66
+ const fiber = ctx.plugin(consumer('/c', 'A'))
67
+ await settle()
68
+ expect(fiber.state).toBe(PENDING)
69
+
70
+ ctx.plugin(provider, config)
71
+ await settle()
72
+ expect(fiber.state).toBe(ACTIVE)
73
+ expect(await (await get(ctx.router, '/c')).text()).toBe('A')
74
+ expect(ctx.router.size).toBe(1)
75
+ })
76
+
77
+ it('removes a consumer\'s routes when the consumer unloads and leaves other consumers alone', async () => {
78
+ const { ctx, router } = await mountRouter()
79
+ const a = ctx.plugin(consumer('/a', 'A'))
80
+ ctx.plugin(consumer('/b', 'B'))
81
+ await settle()
82
+ expect(router.size).toBe(2)
83
+
84
+ await a.dispose()
85
+ await settle()
86
+ expect(a.state).toBe(DISPOSED)
87
+ expect((await get(router, '/a')).status).toBe(404)
88
+ expect(await (await get(router, '/b')).text()).toBe('B')
89
+ expect(router.size).toBe(1)
90
+ })
91
+
92
+ it('replaces the provider without leaving doubled registrations: consumers re-register exactly once', async () => {
93
+ const ctx = root()
94
+ const first = ctx.plugin(provider, config)
95
+ const fiber = ctx.plugin(consumer('/c', 'A'))
96
+ await settle()
97
+ const oldRouter = ctx.router
98
+ expect(fiber.state).toBe(ACTIVE)
99
+
100
+ await first.dispose()
101
+ await settle()
102
+ expect(fiber.state).toBe(PENDING)
103
+ expect(oldRouter.size).toBe(0)
104
+
105
+ ctx.plugin(provider, config)
106
+ await settle()
107
+ expect(fiber.state).toBe(ACTIVE)
108
+ expect(ctx.router).not.toBe(oldRouter)
109
+ expect(ctx.router.size).toBe(1)
110
+ expect(await (await get(ctx.router, '/c')).text()).toBe('A')
111
+ })
112
+
113
+ it('returns to its baseline after fifty consumer mount and unload cycles', async () => {
114
+ const { ctx, router } = await mountRouter()
115
+ ctx.plugin(consumer('/stay', 'S'))
116
+ await settle()
117
+ const baseline = router.size
118
+ for (let i = 0; i < 50; i += 1) {
119
+ const fiber = ctx.plugin(consumer(`/cycle/${String(i)}`, `C${String(i)}`))
120
+ await settle()
121
+ expect(router.size).toBe(baseline + 1)
122
+ await fiber.dispose()
123
+ await settle()
124
+ }
125
+ expect(router.size).toBe(baseline)
126
+ })
127
+
128
+ it('aborts an in-flight request and waits for it when the router is disposed, then answers 503', async () => {
129
+ const ctx = root()
130
+ const fiber = ctx.plugin(provider, config)
131
+ await settle()
132
+ const router = ctx.router
133
+ let aborted = false
134
+ router.get('/slow', async c => {
135
+ await new Promise<void>((_resolve, reject) => { c.signal.addEventListener('abort', () => { aborted = true; reject(new Error('aborted')) }, { once: true }) })
136
+ return c.text('late')
137
+ })
138
+ const pending = router.fetch(new Request('http://acryl.test/slow'))
139
+ await settle()
140
+
141
+ await fiber.dispose()
142
+ await settle()
143
+ expect(aborted).toBe(true)
144
+ expect((await pending).status).toBe(499)
145
+ expect((await router.fetch(new Request('http://acryl.test/slow'))).status).toBe(503)
146
+ })
147
+
148
+ it('hands the transport\'s connection info to the handler and gives an in-process call an empty one', async () => {
149
+ const { router } = await mountRouter()
150
+ router.get('/who', c => c.json({ remote: c.connection.remoteAddress ?? null }))
151
+ const over = await router.fetch(new Request('http://acryl.test/who'), { remoteAddress: '127.0.0.1' })
152
+ expect(await over.json()).toEqual({ remote: '127.0.0.1' })
153
+ const inProcess = await router.fetch(new Request('http://acryl.test/who'))
154
+ expect(await inProcess.json()).toEqual({ remote: null })
155
+ })
156
+
157
+ it('keeps a streaming request in flight through waitUntil: disposal aborts it and waits until it has ended', async () => {
158
+ const ctx = root()
159
+ const fiber = ctx.plugin(provider, config)
160
+ await settle()
161
+ const router = ctx.router
162
+ let ended = false
163
+ router.get('/stream', c => {
164
+ const body = new ReadableStream<Uint8Array>({
165
+ start(controller) {
166
+ const done = new Promise<void>(resolve => {
167
+ c.signal.addEventListener('abort', () => { ended = true; controller.close(); resolve() }, { once: true })
168
+ })
169
+ c.waitUntil(done)
170
+ controller.enqueue(new TextEncoder().encode('first'))
171
+ },
172
+ })
173
+ return new Response(body)
174
+ })
175
+ const response = await router.fetch(new Request('http://acryl.test/stream'))
176
+ expect(response.status).toBe(200)
177
+ const reader = response.body!.getReader()
178
+ expect(new TextDecoder().decode((await reader.read()).value)).toBe('first')
179
+
180
+ await fiber.dispose()
181
+ expect(ended).toBe(true)
182
+ await expect(reader.read()).resolves.toMatchObject({ done: true })
183
+ })
184
+
185
+ it('gives up on a request that ignores the abort after the grace period instead of hanging disposal', async () => {
186
+ const ctx = root()
187
+ const fiber = ctx.plugin(provider, { trailingSlash: 'strict', disposeGraceMs: 30 })
188
+ await settle()
189
+ ctx.router.get('/stuck', c => { c.waitUntil(new Promise(() => undefined)); return c.text('x') })
190
+ await ctx.router.fetch(new Request('http://acryl.test/stuck'))
191
+ const started = Date.now()
192
+ await fiber.dispose()
193
+ expect(Date.now() - started).toBeLessThan(1000)
194
+ expect(fiber.state).toBe(DISPOSED)
195
+ })
196
+
197
+ it('forwards the caller\'s own abort to the handler signal', async () => {
198
+ const { router } = await mountRouter()
199
+ let seen = false
200
+ router.get('/slow', async c => {
201
+ await new Promise<void>((_resolve, reject) => { c.signal.addEventListener('abort', () => { seen = true; reject(new Error('aborted')) }, { once: true }) })
202
+ return c.text('x')
203
+ })
204
+ const controller = new AbortController()
205
+ const pending = router.fetch(new Request('http://acryl.test/slow', { signal: controller.signal }))
206
+ await settle()
207
+ controller.abort()
208
+ expect((await pending).status).toBe(499)
209
+ expect(seen).toBe(true)
210
+ })
211
+ })
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { RouteTable } from '../src/table.ts'
3
+ import { conformance, type Candidate } from './conformance.ts'
4
+
5
+ function tableCandidate(trailingSlash: 'strict' | 'ignore' = 'strict', onError?: (e: unknown) => void): Candidate & { table: RouteTable } {
6
+ const table = new RouteTable({ trailingSlash, ...(onError === undefined ? {} : { onError }) })
7
+ return {
8
+ table,
9
+ route: (method, path, handler) => table.add(method, path, handler),
10
+ use: (path, middleware) => table.use(path, middleware),
11
+ fetch: request => table.dispatch(request, new AbortController().signal),
12
+ size: () => table.size,
13
+ }
14
+ }
15
+
16
+ conformance('RouteTable (the pure core)', () => tableCandidate())
17
+
18
+ describe('RouteTable specifics', () => {
19
+ it('keeps /a and /a/ distinct under strict and joins them under ignore', async () => {
20
+ const strict = tableCandidate('strict')
21
+ strict.route('GET', '/a', c => c.text('a'))
22
+ expect((await strict.fetch(new Request('http://t/a/'))).status).toBe(404)
23
+ const loose = tableCandidate('ignore')
24
+ loose.route('GET', '/a', c => c.text('a'))
25
+ expect((await loose.fetch(new Request('http://t/a/'))).status).toBe(200)
26
+ })
27
+
28
+ it('reports handler errors to onError and still answers 500', async () => {
29
+ const seen: unknown[] = []
30
+ const c = tableCandidate('strict', e => seen.push(e))
31
+ c.route('GET', '/x', () => { throw new Error('nope') })
32
+ expect((await c.fetch(new Request('http://t/x'))).status).toBe(500)
33
+ expect(seen).toHaveLength(1)
34
+ })
35
+
36
+ it('answers 499 instead of 500 when the request was aborted', async () => {
37
+ const c = tableCandidate()
38
+ const controller = new AbortController()
39
+ c.route('GET', '/slow', async ctx => {
40
+ await new Promise((_resolve, reject) => { ctx.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) })
41
+ return ctx.text('never')
42
+ })
43
+ const pending = c.table.dispatch(new Request('http://t/slow'), controller.signal)
44
+ controller.abort()
45
+ expect((await pending).status).toBe(499)
46
+ })
47
+ })
@@ -0,0 +1,20 @@
1
+ {
2
+ "source": {
3
+ "repo": "acryldev/acryl",
4
+ "path": "plugins/acryl-router",
5
+ "commit": "11fed295161c0282d144b5ac62616988b58d8189"
6
+ },
7
+ "vendored": [
8
+ "src",
9
+ "tests",
10
+ "LICENSE"
11
+ ],
12
+ "rewrittenSpecifiers": 0,
13
+ "digest": "sha256:3873e59cdc36ab606be983680dc2f338141563ebafb13b36215c6b7de7c80d42",
14
+ "testsNotRun": {
15
+ "tests/loader.spec.ts": "loads the built lib/ entry through the real Loader; the library ships source and has no build"
16
+ },
17
+ "notice": [
18
+ "LICENSE"
19
+ ]
20
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WebBoxes s.r.o.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "acryl-router-http",
3
+ "description": "The serve() and request bridge that acryl-node-server uses to put the router on node:http. Mounting it as a plugin needs the DSH web server, which a generated app does not have. Vendored from ACRYL.",
4
+ "requires": ["acryl-router"],
5
+ "inject": ["router"],
6
+ "provides": [],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4"
9
+ },
10
+ "mount": "not mounted: it is the library behind acryl-node-server"
11
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The two conversions between `node:http` and Web-standard fetch objects. Pure functions, no Cordis, so the adapter's
3
+ * behavior is testable without a server.
4
+ */
5
+
6
+ import type { IncomingMessage, ServerResponse } from 'node:http'
7
+ import { Readable } from 'node:stream'
8
+ import { pipeline } from 'node:stream/promises'
9
+
10
+ const FALLBACK_ORIGIN = 'http://localhost'
11
+
12
+ /** Build the Web `Request` for an incoming message. The body, when the method has one, is streamed, not buffered. */
13
+ export function toRequest(req: IncomingMessage, signal: AbortSignal): Request {
14
+ let url: URL
15
+ try {
16
+ url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`)
17
+ } catch {
18
+ url = new URL(req.url ?? '/', FALLBACK_ORIGIN)
19
+ }
20
+ const headers = new Headers()
21
+ for (const [key, value] of Object.entries(req.headers)) {
22
+ if (value === undefined) continue
23
+ if (Array.isArray(value)) for (const item of value) headers.append(key, item)
24
+ else headers.set(key, value)
25
+ }
26
+ const method = (req.method ?? 'GET').toUpperCase()
27
+ if (method === 'GET' || method === 'HEAD') return new Request(url, { method, headers, signal })
28
+ return new Request(url, { method, headers, signal, body: Readable.toWeb(req) as ReadableStream, duplex: 'half' })
29
+ }
30
+
31
+ /**
32
+ * Write a Web `Response` to the node response: status, headers (repeated `set-cookie` kept separate) and the streamed body.
33
+ * A client that disconnects mid-stream ends the copy quietly; the caller's abort signal is what stops the handler.
34
+ */
35
+ export async function sendResponse(res: ServerResponse, response: Response, method: string): Promise<void> {
36
+ const headers: Record<string, string | string[]> = {}
37
+ response.headers.forEach((value, key) => { if (key !== 'set-cookie') headers[key] = value })
38
+ const cookies = response.headers.getSetCookie()
39
+ if (cookies.length > 0) headers['set-cookie'] = cookies
40
+ res.writeHead(response.status, headers)
41
+ // A stream may stay silent for a while; the status line and headers must reach the client now, not with the first chunk.
42
+ res.flushHeaders()
43
+ if (response.body === null || method === 'HEAD') {
44
+ res.end()
45
+ return
46
+ }
47
+ try {
48
+ await pipeline(Readable.fromWeb(response.body as import('node:stream/web').ReadableStream), res)
49
+ } catch {
50
+ if (!res.writableEnded) res.destroy()
51
+ }
52
+ }