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,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
+ })
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Server-sent events end to end (spec 046, M5): the actual DSH `webServer`, the router, the HTTP adapter and the state
3
+ * service. A browser-shaped `fetch` reads the stream; a change in the Host reaches it; a disconnect and a plugin unload both
4
+ * end it and leave nothing subscribed.
5
+ */
6
+
7
+ import { request as httpRequest } from 'node:http'
8
+ import { Context } from '@deepseek-ai/cordis'
9
+ import WebServer from '@deepseek-ai/dsh-host-webserver'
10
+ import { apply as routerApply, name as routerName } from '../../acryl-router/src/index.ts'
11
+ import { apply as stateApply, name as stateName } from '../../acryl-state/src/index.ts'
12
+ import { afterEach, describe, expect, it } from 'vitest'
13
+ import { apply as httpApply, inject as httpInject, name as httpName } from '../src/index.ts'
14
+
15
+ const contexts: Context[] = []
16
+ const settle = (ms = 0): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
17
+ afterEach(async () => {
18
+ while (contexts.length > 0) await contexts.pop()?.fiber.dispose()
19
+ })
20
+
21
+ async function until(check: () => boolean, label: string): Promise<void> {
22
+ for (let i = 0; i < 300; i += 1) { if (check()) return; await settle(10) }
23
+ throw new Error(`timed out waiting for: ${label}`)
24
+ }
25
+
26
+ async function stack(): Promise<{ ctx: Context, base: string }> {
27
+ const ctx = new Context()
28
+ contexts.push(ctx)
29
+ ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 } as never)
30
+ ctx.plugin({ name: routerName, apply: routerApply }, { trailingSlash: 'strict' })
31
+ ctx.plugin({ name: httpName, inject: httpInject, apply: httpApply })
32
+ ctx.plugin({ name: stateName, apply: stateApply })
33
+ const web = ctx as unknown as { webServer?: { port: number } }
34
+ await until(() => web.webServer?.port !== undefined && (ctx as unknown as { routerHttp?: unknown }).routerHttp !== undefined && (ctx as unknown as { state?: unknown }).state !== undefined, 'the stack')
35
+ await settle(30)
36
+ return { ctx, base: `http://127.0.0.1:${String(web.webServer!.port)}` }
37
+ }
38
+
39
+ /** Collect SSE records from a fetch response until `count` arrived. */
40
+ async function read(reader: ReadableStreamDefaultReader<Uint8Array>, count: number): Promise<string[]> {
41
+ const decoder = new TextDecoder()
42
+ let buffer = ''
43
+ const out: string[] = []
44
+ while (out.length < count) {
45
+ const { done, value } = await reader.read()
46
+ if (done) break
47
+ buffer += decoder.decode(value, { stream: true })
48
+ for (let at = buffer.indexOf('\n\n'); at !== -1; at = buffer.indexOf('\n\n')) {
49
+ out.push(buffer.slice(0, at))
50
+ buffer = buffer.slice(at + 2)
51
+ }
52
+ }
53
+ return out
54
+ }
55
+
56
+ describe('server-sent events through the real stack', () => {
57
+ it('streams the value now and each change to a real HTTP client, with the stream headers', async () => {
58
+ const { ctx, base } = await stack()
59
+ const cell = ctx.state.cell<{ n: number }>('app.counter', { n: 0 })
60
+ const response = await fetch(`${base}/api/state/app.counter/events`)
61
+ expect(response.status).toBe(200)
62
+ expect(response.headers.get('content-type')).toContain('text/event-stream')
63
+ const reader = response.body!.getReader()
64
+ const first = read(reader, 3)
65
+ await settle(20)
66
+ cell.set({ n: 1 })
67
+ cell.set({ n: 2 })
68
+ expect(await first).toEqual(['event: value\ndata: {"n":0}', 'event: value\ndata: {"n":1}', 'event: value\ndata: {"n":2}'])
69
+ await reader.cancel()
70
+ })
71
+
72
+ it('stops pushing to a client that disconnected: the cell keeps no subscriber for it', async () => {
73
+ const { ctx, base } = await stack()
74
+ const cell = ctx.state.cell<number>('app.n', 0)
75
+ const controller = new AbortController()
76
+ const response = await fetch(`${base}/api/state/app.n/events`, { signal: controller.signal })
77
+ const reader = response.body!.getReader()
78
+ await read(reader, 1)
79
+ let seenByProbe = 0
80
+ // While connected, a change reaches the stream; count the cell's own subscribers through a counting wrapper.
81
+ controller.abort()
82
+ await reader.cancel().catch(() => undefined)
83
+ await settle(50)
84
+ const stop = cell.subscribe(() => { seenByProbe += 1 })
85
+ cell.set(1)
86
+ stop()
87
+ expect(seenByProbe).toBe(1)
88
+ // The disconnect released the stream: the router has no request left in flight to abort at disposal.
89
+ const started = Date.now()
90
+ await ctx.fiber.dispose()
91
+ contexts.length = 0
92
+ expect(Date.now() - started).toBeLessThan(1500)
93
+ })
94
+
95
+ it('ends an open stream when the state plugin unloads', async () => {
96
+ const { ctx, base } = await stack()
97
+ ctx.state.cell<number>('app.n', 0)
98
+ const response = await fetch(`${base}/api/state/app.n/events`)
99
+ const reader = response.body!.getReader()
100
+ await read(reader, 1)
101
+ const ended = reader.read()
102
+ const routerFiber = [...[...ctx.registry.values()].find(runtime => runtime.name === routerName)!.fibers][0]!
103
+ await routerFiber.dispose()
104
+ await expect(ended).resolves.toMatchObject({ done: true })
105
+ })
106
+ })
107
+
108
+ describe('the loopback guard over real HTTP', () => {
109
+ /** A raw request, so the test chooses the Host header (fetch would set its own). */
110
+ function raw(port: number, path: string, headers: Record<string, string>): Promise<{ status: number, body: string }> {
111
+ return new Promise((resolve, reject) => {
112
+ const req = httpRequest({ host: '127.0.0.1', port, path, headers }, res => {
113
+ let body = ''
114
+ res.on('data', chunk => { body += String(chunk) })
115
+ res.on('end', () => { resolve({ status: res.statusCode ?? 0, body }) })
116
+ })
117
+ req.on('error', reject)
118
+ req.end()
119
+ })
120
+ }
121
+
122
+ it('serves the state routes to this machine and refuses a request that names another host or is marked cross-site', async () => {
123
+ const { ctx, base } = await stack()
124
+ ctx.state.cell<number>('app.n', 3)
125
+ const port = Number(new URL(base).port)
126
+ const ok = await raw(port, '/api/state/app.n', { host: `127.0.0.1:${String(port)}` })
127
+ expect(ok.status).toBe(200)
128
+ expect(JSON.parse(ok.body)).toEqual({ key: 'app.n', value: 3 })
129
+ expect((await raw(port, '/api/state/app.n', { host: 'evil.example' })).status).toBe(403)
130
+ expect((await raw(port, '/api/state/app.n', { host: `127.0.0.1:${String(port + 1)}` })).status).toBe(403)
131
+ expect((await raw(port, '/api/state/app.n', { host: `127.0.0.1:${String(port)}`, 'sec-fetch-site': 'cross-site' })).status).toBe(403)
132
+ })
133
+ })
@@ -0,0 +1,99 @@
1
+ /**
2
+ * One plugin, two compositions (spec 046, M3): the same `apply` runs in a Web or Desktop composition (the DSH webServer,
3
+ * the router and the HTTP adapter) and in a terminal composition (the router alone, no server at all). The routes are
4
+ * registered once, in `apply`; serving them over HTTP is the optional part, requested through an inner `inject`. The
5
+ * assertions compare what a browser receives with what the terminal's in-process call receives.
6
+ */
7
+
8
+ import { Context } from '@deepseek-ai/cordis'
9
+ import WebServer from '@deepseek-ai/dsh-host-webserver'
10
+ import { apply as routerApply, name as routerName } from '../../acryl-router/src/index.ts'
11
+ import { afterEach, describe, expect, it } from 'vitest'
12
+ import { apply as httpApply, inject as httpInject, name as httpName } from '../src/index.ts'
13
+
14
+ const contexts: Context[] = []
15
+ const settle = (ms = 0): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
16
+ afterEach(async () => {
17
+ while (contexts.length > 0) await contexts.pop()?.fiber.dispose()
18
+ })
19
+
20
+ async function until(check: () => boolean, label: string): Promise<void> {
21
+ for (let i = 0; i < 300; i += 1) { if (check()) return; await settle(10) }
22
+ throw new Error(`timed out waiting for: ${label}`)
23
+ }
24
+
25
+ /** A capability as a plugin author writes it: routes in `apply`, HTTP only if an adapter happens to be present. */
26
+ const notes = {
27
+ name: 'notes-demo',
28
+ inject: ['router'],
29
+ apply(ctx: Context): void {
30
+ const store = new Map<string, string>()
31
+ ctx.router.post('/api/notes/:id', async c => {
32
+ const text = await c.req.text()
33
+ store.set(c.params['id'] ?? '', text)
34
+ return c.json({ id: c.params['id'], saved: text.length }, 201)
35
+ })
36
+ ctx.router.get('/api/notes/:id', c => {
37
+ const text = store.get(c.params['id'] ?? '')
38
+ return text === undefined ? c.json({ error: 'not found' }, 404) : c.json({ id: c.params['id'], text })
39
+ })
40
+ ctx.inject(['routerHttp'], inner => { inner.routerHttp.mount('/api/notes') })
41
+ },
42
+ }
43
+
44
+ /** Web or Desktop: a loopback webServer, the router and the adapter. */
45
+ async function webComposition(): Promise<{ ctx: Context, base: string }> {
46
+ const ctx = new Context()
47
+ contexts.push(ctx)
48
+ ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 } as never)
49
+ ctx.plugin({ name: routerName, apply: routerApply }, { trailingSlash: 'strict' })
50
+ ctx.plugin({ name: httpName, inject: httpInject, apply: httpApply })
51
+ const web = ctx as unknown as { webServer?: { port: number } }
52
+ await until(() => web.webServer?.port !== undefined && (ctx as unknown as { routerHttp?: unknown }).routerHttp !== undefined, 'the web composition')
53
+ ctx.plugin(notes)
54
+ await settle(30)
55
+ return { ctx, base: `http://127.0.0.1:${String(web.webServer!.port)}` }
56
+ }
57
+
58
+ /** Terminal: the router only. No server, no adapter, nothing listening. */
59
+ async function terminalComposition(): Promise<Context> {
60
+ const ctx = new Context()
61
+ contexts.push(ctx)
62
+ ctx.plugin({ name: routerName, apply: routerApply }, { trailingSlash: 'strict' })
63
+ await settle(20)
64
+ ctx.plugin(notes)
65
+ await settle(20)
66
+ return ctx
67
+ }
68
+
69
+ async function snapshot(response: Response): Promise<{ status: number, type: string | null, body: unknown }> {
70
+ return { status: response.status, type: response.headers.get('content-type'), body: await response.json() }
71
+ }
72
+
73
+ describe('the same capability on every surface', () => {
74
+ it('answers a browser over HTTP and the terminal in process with the same status, headers and body', async () => {
75
+ const web = await webComposition()
76
+ const terminal = await terminalComposition()
77
+
78
+ const viaHttp = [
79
+ await snapshot(await fetch(`${web.base}/api/notes/a`)),
80
+ await snapshot(await fetch(`${web.base}/api/notes/a`, { method: 'POST', body: 'hello' })),
81
+ await snapshot(await fetch(`${web.base}/api/notes/a`)),
82
+ ]
83
+ const router = (terminal as unknown as { router: { fetch(request: Request): Promise<Response> } }).router
84
+ const inProcess = [
85
+ await snapshot(await router.fetch(new Request('http://acryl.test/api/notes/a'))),
86
+ await snapshot(await router.fetch(new Request('http://acryl.test/api/notes/a', { method: 'POST', body: 'hello' }))),
87
+ await snapshot(await router.fetch(new Request('http://acryl.test/api/notes/a'))),
88
+ ]
89
+
90
+ expect(inProcess).toEqual(viaHttp)
91
+ expect(viaHttp.map(step => step.status)).toEqual([404, 201, 200])
92
+ })
93
+
94
+ it('opens no socket in the terminal composition: nothing in that context is a server', async () => {
95
+ const terminal = await terminalComposition()
96
+ expect((terminal as unknown as { webServer?: unknown }).webServer).toBeUndefined()
97
+ expect((terminal as unknown as { routerHttp?: unknown }).routerHttp).toBeUndefined()
98
+ })
99
+ })
@@ -0,0 +1,22 @@
1
+ {
2
+ "source": {
3
+ "repo": "acryldev/acryl",
4
+ "path": "plugins/acryl-router-http",
5
+ "commit": "11fed295161c0282d144b5ac62616988b58d8189"
6
+ },
7
+ "vendored": [
8
+ "src",
9
+ "tests",
10
+ "LICENSE"
11
+ ],
12
+ "rewrittenSpecifiers": 6,
13
+ "digest": "sha256:6a79e85a11bf61a13604736ef9776f878a024ef19e1282481bdfe13343916bc4",
14
+ "testsNotRun": {
15
+ "tests/adapter.spec.ts": "needs @deepseek-ai/dsh-host-webserver (the DSH web server); acryl-node-server has its own tests",
16
+ "tests/stream.spec.ts": "needs @deepseek-ai/dsh-host-webserver (the DSH web server)",
17
+ "tests/surfaces.spec.ts": "needs @deepseek-ai/dsh-host-webserver (the DSH web server)"
18
+ },
19
+ "notice": [
20
+ "LICENSE"
21
+ ]
22
+ }
@@ -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,12 @@
1
+ {
2
+ "name": "acryl-state",
3
+ "description": "Observable cells and derived cells, shared state with JSON and server-sent event routes on the router. Vendored from ACRYL.",
4
+ "requires": ["acryl-router"],
5
+ "inject": ["router"],
6
+ "provides": ["state"],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4"
9
+ },
10
+ "mount": "root.plugin(state); ctx.state.cell('todos.count', 0), then count.update((n) => n + 1)",
11
+ "omit": ["src/react.ts"]
12
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Observable cells: the reactive core of the state service, with no Cordis and no DOM. A cell holds one value, tells its
3
+ * subscribers when it changes, and can be derived from other cells. The shape is a writable cell and a derived cell
4
+ * (spec 046, M5): small enough to read in one sitting, and the same cell serves a React hook, a terminal component and a
5
+ * server-sent stream.
6
+ */
7
+
8
+ export type Listener<T> = (value: T, previous: T) => void
9
+
10
+ export interface ReadonlyCell<T> {
11
+ readonly key: string
12
+ get(): T
13
+ /** Call `listener` after every change (never for a set to an equal value). Returns the unsubscribe function. */
14
+ subscribe(listener: Listener<T>): () => void
15
+ }
16
+
17
+ export interface Cell<T> extends ReadonlyCell<T> {
18
+ set(value: T): void
19
+ update(change: (current: T) => T): void
20
+ }
21
+
22
+ type ErrorSink = (error: unknown) => void
23
+
24
+ /** A writable cell. `report` receives anything a listener throws; one failing listener never blocks the others. */
25
+ export function createCell<T>(key: string, initial: T, report: ErrorSink = () => undefined): Cell<T> {
26
+ let value = initial
27
+ const listeners = new Set<Listener<T>>()
28
+ const set = (next: T): void => {
29
+ if (Object.is(next, value)) return
30
+ const previous = value
31
+ value = next
32
+ for (const listener of [...listeners]) {
33
+ try { listener(next, previous) } catch (error) { report(error) }
34
+ }
35
+ }
36
+ return {
37
+ key,
38
+ get: () => value,
39
+ set,
40
+ update: change => { set(change(value)) },
41
+ subscribe: listener => {
42
+ listeners.add(listener)
43
+ return () => { listeners.delete(listener) }
44
+ },
45
+ }
46
+ }
47
+
48
+ type Values<D extends readonly ReadonlyCell<unknown>[]> = { [K in keyof D]: D[K] extends ReadonlyCell<infer V> ? V : never }
49
+
50
+ /**
51
+ * A read-only cell computed from other cells and recomputed whenever one of them changes. `dispose` detaches it from its
52
+ * inputs; after that it keeps its last value.
53
+ */
54
+ export function createDerived<D extends readonly ReadonlyCell<unknown>[], T>(
55
+ key: string,
56
+ inputs: D,
57
+ compute: (...values: Values<D>) => T,
58
+ report: ErrorSink = () => undefined,
59
+ ): { readonly cell: ReadonlyCell<T>, readonly dispose: () => void } {
60
+ const read = (): T => compute(...(inputs.map(input => input.get()) as unknown as Values<D>))
61
+ const inner = createCell<T>(key, read(), report)
62
+ const stops = inputs.map(input => input.subscribe(() => { inner.set(read()) }))
63
+ return {
64
+ cell: { key, get: inner.get, subscribe: inner.subscribe },
65
+ dispose: () => { for (const stop of stops) stop() },
66
+ }
67
+ }
@@ -0,0 +1,3 @@
1
+ export { State, apply, name } from './service.ts'
2
+ export { createCell, createDerived } from './cell.ts'
3
+ export type { Cell, Listener, ReadonlyCell } from './cell.ts'
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The React binding: one hook that reads a cell and re-renders when it changes. `useSyncExternalStore` is React's own
3
+ * contract for exactly this, so it is tear-free under concurrent rendering. Import from `acryl-state/react`.
4
+ */
5
+
6
+ import { useSyncExternalStore } from 'react'
7
+ import type { ReadonlyCell } from './cell.ts'
8
+
9
+ export function useCell<T>(cell: ReadonlyCell<T>): T {
10
+ return useSyncExternalStore(listener => cell.subscribe(listener), cell.get, cell.get)
11
+ }