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,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
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The Web adapter for `acryl-router` (spec 046, milestone M2). A plugin that owns routes on the router asks for a path
3
+ * prefix to be served over HTTP: `ctx.routerHttp.mount('/api/market')` claims that prefix on the DSH `webServer` and every
4
+ * request under it is run through `router.fetch`. The TUI and Desktop do not need this: they call `router.fetch` directly.
5
+ */
6
+
7
+ import type { IncomingMessage, ServerResponse } from 'node:http'
8
+ import type { RouterHttpService } from '../../acryl-router/src/index.ts'
9
+ import { Context, Service } from '@deepseek-ai/cordis'
10
+ import { serve } from './serve.ts'
11
+
12
+ /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
13
+ export const name = 'acryl-router-http'
14
+
15
+ /** Hard requirements: both services must exist, otherwise this plugin stays PENDING. */
16
+ export const inject = ['router', 'webServer']
17
+
18
+ /**
19
+ * The slice of the DSH `webServer` this adapter uses, as its own port: a prefix claim that returns its remover. The service
20
+ * is read by name, so this package imports nothing from the DeepSeek Harness (the layout gate keeps that count falling).
21
+ */
22
+ export interface WebServerPort {
23
+ register(route: {
24
+ readonly kind: 'prefix'
25
+ readonly path: string
26
+ readonly handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
27
+ }): () => void
28
+ }
29
+
30
+ const PREFIX_PATTERN = /^\/[A-Za-z0-9._~\-/]*[A-Za-z0-9._~-]$/
31
+
32
+ export class RouterHttp extends Service implements RouterHttpService {
33
+ private readonly router: Context['router']
34
+ private readonly web: WebServerPort
35
+
36
+ constructor(ctx: Context) {
37
+ super(ctx, 'routerHttp')
38
+ this.router = ctx.router
39
+ this.web = ctx.get('webServer') as WebServerPort
40
+ }
41
+
42
+ /**
43
+ * Serve `prefix` and everything below it through the router. Returns a remover; the mount also goes away when the
44
+ * calling plugin unloads. A prefix another route already claims throws, as the webServer treats it as misconfiguration.
45
+ */
46
+ mount(prefix: string): () => void {
47
+ if (!PREFIX_PATTERN.test(prefix)) throw new TypeError(`acryl-router-http: "${prefix}" is not a mountable path prefix`)
48
+ const remove = this.web.register({
49
+ kind: 'prefix',
50
+ path: prefix,
51
+ handler: (req, res) => serve(this.router, req, res),
52
+ })
53
+ this.ctx.effect(() => () => { remove() }, `acryl-router-http: mount ${prefix}`)
54
+ return remove
55
+ }
56
+ }
57
+
58
+ export function apply(ctx: Context): void {
59
+ new RouterHttp(ctx)
60
+ }
61
+
62
+ export { serve, type FetchRouter } from './serve.ts'
63
+ export { sendResponse, toRequest } from './bridge.ts'
@@ -0,0 +1,24 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http'
2
+ import { sendResponse, toRequest } from './bridge.ts'
3
+
4
+ /** The slice of the router this adapter needs; `acryl-router`'s `Router` satisfies it. */
5
+ export interface FetchRouter {
6
+ fetch(request: Request, connection?: { readonly remoteAddress?: string | undefined, readonly localPort?: number | undefined }): Promise<Response>
7
+ }
8
+
9
+ /**
10
+ * Serve one node request through the router and write the result. The request's abort signal fires when the client
11
+ * disconnects before the response finished; the socket's peer address and local port travel as connection info, never in the request.
12
+ */
13
+ export async function serve(router: FetchRouter, req: IncomingMessage, res: ServerResponse): Promise<void> {
14
+ const controller = new AbortController()
15
+ res.once('close', () => { if (!res.writableFinished) controller.abort() })
16
+ const method = (req.method ?? 'GET').toUpperCase()
17
+ let response: Response
18
+ try {
19
+ response = await router.fetch(toRequest(req, controller.signal), { remoteAddress: req.socket.remoteAddress, localPort: req.socket.localPort })
20
+ } catch {
21
+ response = new Response('Internal Server Error', { status: 500 })
22
+ }
23
+ await sendResponse(res, response, method)
24
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The Web adapter against the real DSH `webServer` listening on a loopback port: a plugin mounts a prefix, requests
3
+ * travel over real HTTP into `router.fetch`, and the lifecycle (PENDING, unload, reload) is observed the way a browser
4
+ * would see it. The router is the built `acryl-router` entry, as production loads it.
5
+ */
6
+
7
+ import { Context, type FiberState } from '@deepseek-ai/cordis'
8
+ import WebServer from '@deepseek-ai/dsh-host-webserver'
9
+ import { apply as routerApply, name as routerName } from '../../acryl-router/src/index.ts'
10
+ import { afterEach, describe, expect, it } from 'vitest'
11
+ import type { RouterHttpService } from '../../acryl-router/src/index.ts'
12
+ import { apply, inject, name } from '../src/index.ts'
13
+
14
+ const PENDING = 0 as FiberState.PENDING
15
+ const ACTIVE = 2 as FiberState.ACTIVE
16
+
17
+ const contexts: Context[] = []
18
+ const settle = (ms = 0): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
19
+ afterEach(async () => {
20
+ while (contexts.length > 0) await contexts.pop()?.fiber.dispose()
21
+ })
22
+
23
+ async function until(check: () => boolean, label: string): Promise<void> {
24
+ for (let i = 0; i < 300; i += 1) { if (check()) return; await settle(10) }
25
+ throw new Error(`timed out waiting for: ${label}`)
26
+ }
27
+
28
+ const adapter = { name, inject, apply }
29
+ const router = { name: routerName, apply: routerApply }
30
+
31
+ /** A whole stack on one root context: webServer on an OS-assigned port, the router, and the adapter. */
32
+ async function stack(): Promise<{ ctx: Context, base: () => string }> {
33
+ const ctx = new Context()
34
+ contexts.push(ctx)
35
+ ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 } as never)
36
+ ctx.plugin(router, { trailingSlash: 'strict' })
37
+ ctx.plugin(adapter)
38
+ await until(() => (ctx as unknown as { webServer?: { port: number } }).webServer?.port !== undefined && ctx.routerHttp !== undefined, 'the stack to start')
39
+ return { ctx, base: () => `http://127.0.0.1:${String((ctx as unknown as { webServer: { port: number } }).webServer.port)}` }
40
+ }
41
+
42
+ function demo(prefix: string, extra?: (ctx: Context) => void) {
43
+ return {
44
+ name: `demo${prefix.replaceAll('/', '-')}`,
45
+ inject: ['router', 'routerHttp'],
46
+ apply(ctx: Context): void {
47
+ ctx.routerHttp.mount(prefix)
48
+ ctx.router.get(`${prefix}/hello/:who`, c => c.json({ hello: c.params['who'], q: c.url.searchParams.get('q'), host: c.req.headers.get('host') !== null }))
49
+ ctx.router.post(`${prefix}/echo`, async c => c.json({ got: await c.req.text(), type: c.req.headers.get('content-type') }, 201))
50
+ ctx.router.get(`${prefix}/cookies`, () => {
51
+ const headers = new Headers()
52
+ headers.append('set-cookie', 'a=1; Path=/')
53
+ headers.append('set-cookie', 'b=2; Path=/')
54
+ return new Response('ok', { headers })
55
+ })
56
+ ctx.router.get(`${prefix}/stream`, () => new Response(new ReadableStream({
57
+ start(controller) { controller.enqueue(new TextEncoder().encode('one,')); controller.enqueue(new TextEncoder().encode('two')); controller.close() },
58
+ })))
59
+ ctx.router.get(`${prefix}/peer`, c => c.json({ remote: c.connection.remoteAddress ?? null, localPort: c.connection.localPort ?? null }))
60
+ extra?.(ctx)
61
+ },
62
+ }
63
+ }
64
+
65
+ describe('acryl-router-http against a real webServer', () => {
66
+ it('serves a mounted prefix over HTTP: params, query, JSON, status', async () => {
67
+ const { ctx, base } = await stack()
68
+ ctx.plugin(demo('/api/demo'))
69
+ await settle(20)
70
+ const response = await fetch(`${base()}/api/demo/hello/zed%20x?q=1`)
71
+ expect(response.status).toBe(200)
72
+ expect(response.headers.get('content-type')).toContain('application/json')
73
+ expect(await response.json()).toEqual({ hello: 'zed x', q: '1', host: true })
74
+ })
75
+
76
+ it('passes the socket peer address as connection info, not as part of the request', async () => {
77
+ const { ctx, base } = await stack()
78
+ ctx.plugin(demo('/api/demo'))
79
+ await settle(20)
80
+ const body = await (await fetch(`${base()}/api/demo/peer`)).json() as { remote: string | null, localPort: number | null }
81
+ expect(['127.0.0.1', '::ffff:127.0.0.1']).toContain(body.remote)
82
+ expect(body.localPort).toBe(Number(new URL(base()).port))
83
+ })
84
+
85
+ it('streams a request body into the handler and keeps status and headers on the way out', async () => {
86
+ const { ctx, base } = await stack()
87
+ ctx.plugin(demo('/api/demo'))
88
+ await settle(20)
89
+ const response = await fetch(`${base()}/api/demo/echo`, { method: 'POST', body: 'payload', headers: { 'content-type': 'text/plain' } })
90
+ expect(response.status).toBe(201)
91
+ expect(await response.json()).toEqual({ got: 'payload', type: 'text/plain' })
92
+ })
93
+
94
+ it('keeps repeated set-cookie headers separate and streams a response body', async () => {
95
+ const { ctx, base } = await stack()
96
+ ctx.plugin(demo('/api/demo'))
97
+ await settle(20)
98
+ const cookies = await fetch(`${base()}/api/demo/cookies`)
99
+ expect(cookies.headers.getSetCookie()).toEqual(['a=1; Path=/', 'b=2; Path=/'])
100
+ expect(await (await fetch(`${base()}/api/demo/stream`)).text()).toBe('one,two')
101
+ })
102
+
103
+ it('answers the router\'s 404 and 405 below the mount, and answers HEAD without a body', async () => {
104
+ const { ctx, base } = await stack()
105
+ ctx.plugin(demo('/api/demo'))
106
+ await settle(20)
107
+ const missing = await fetch(`${base()}/api/demo/none`)
108
+ expect(missing.status).toBe(404)
109
+ expect(await missing.text()).toBe('Not Found')
110
+ expect((await fetch(`${base()}/api/demo/echo`)).status).toBe(405)
111
+ const head = await fetch(`${base()}/api/demo/hello/a`, { method: 'HEAD' })
112
+ expect(head.status).toBe(200)
113
+ expect(head.headers.get('content-type')).toContain('application/json')
114
+ expect(await head.text()).toBe('')
115
+ })
116
+
117
+ it('aborts the handler signal when the HTTP client disconnects', async () => {
118
+ const { ctx, base } = await stack()
119
+ let aborted = false
120
+ ctx.plugin(demo('/api/demo', c => {
121
+ c.router.get('/api/demo/slow', async r => {
122
+ await new Promise<void>((_resolve, reject) => { r.signal.addEventListener('abort', () => { aborted = true; reject(new Error('aborted')) }, { once: true }) })
123
+ return r.text('never')
124
+ })
125
+ }))
126
+ await settle(20)
127
+ const controller = new AbortController()
128
+ const pending = fetch(`${base()}/api/demo/slow`, { signal: controller.signal }).catch(() => undefined)
129
+ await settle(50)
130
+ controller.abort()
131
+ await pending
132
+ await until(() => aborted, 'the handler to see the disconnect')
133
+ })
134
+
135
+ it('stops serving the prefix when the owning plugin unloads, and serves it again on reload, fifty times', async () => {
136
+ const { ctx, base } = await stack()
137
+ for (let i = 0; i < 50; i += 1) {
138
+ const fiber = ctx.plugin(demo('/api/demo'))
139
+ await settle(5)
140
+ expect((await fetch(`${base()}/api/demo/hello/a`)).status).toBe(200)
141
+ await fiber.dispose()
142
+ await settle(5)
143
+ // Unmounted: the webServer has no fallback in this stack, so it answers its own empty 404, not the router's body.
144
+ const gone = await fetch(`${base()}/api/demo/hello/a`)
145
+ expect(gone.status).toBe(404)
146
+ expect(await gone.text()).toBe('')
147
+ }
148
+ expect(ctx.router.size).toBe(0)
149
+ })
150
+
151
+ it('stays PENDING while webServer is missing and activates when it appears', async () => {
152
+ const ctx = new Context()
153
+ contexts.push(ctx)
154
+ ctx.plugin(router, { trailingSlash: 'strict' })
155
+ const fiber = ctx.plugin(adapter)
156
+ await settle(20)
157
+ expect(fiber.state).toBe(PENDING)
158
+ ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 } as never)
159
+ await until(() => fiber.state === ACTIVE, 'the adapter to activate')
160
+ })
161
+
162
+ it('refuses a prefix that is not a clean path and a prefix another plugin already mounted', async () => {
163
+ const { ctx } = await stack()
164
+ const http: RouterHttpService = ctx.routerHttp
165
+ expect(() => http.mount('no-slash')).toThrow(/mountable/)
166
+ expect(() => http.mount('/trailing/')).toThrow(/mountable/)
167
+ http.mount('/api/once')
168
+ expect(() => http.mount('/api/once')).toThrow(/duplicate/)
169
+ })
170
+ })