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.
- package/dist/index.d.ts +17 -16
- package/dist/index.js +39 -19
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/plugins/acryl-inspect/LICENSE +21 -0
- package/plugins/acryl-inspect/plugin.json +11 -0
- package/plugins/acryl-inspect/src/index.ts +3 -0
- package/plugins/acryl-inspect/src/report.ts +123 -0
- package/plugins/acryl-inspect/src/service.ts +66 -0
- package/plugins/acryl-inspect/tests/report.spec.ts +64 -0
- package/plugins/acryl-inspect/tests/service.spec.ts +120 -0
- package/plugins/acryl-inspect/upstream.json +18 -0
- package/plugins/acryl-node-server/LICENSE +21 -0
- package/plugins/acryl-node-server/plugin.json +12 -0
- package/plugins/acryl-node-server/src/index.ts +94 -0
- package/plugins/acryl-node-server/tests/server.spec.ts +105 -0
- package/plugins/acryl-node-server/upstream.json +18 -0
- package/plugins/acryl-router/LICENSE +21 -0
- package/plugins/acryl-router/plugin.json +12 -0
- package/plugins/acryl-router/src/compose.ts +21 -0
- package/plugins/acryl-router/src/context.ts +59 -0
- package/plugins/acryl-router/src/helpers/index.ts +3 -0
- package/plugins/acryl-router/src/helpers/read-body.ts +59 -0
- package/plugins/acryl-router/src/helpers/sse.ts +83 -0
- package/plugins/acryl-router/src/helpers/validate.ts +44 -0
- package/plugins/acryl-router/src/http-error.ts +21 -0
- package/plugins/acryl-router/src/index.ts +6 -0
- package/plugins/acryl-router/src/middleware/body-limit.ts +25 -0
- package/plugins/acryl-router/src/middleware/cors.ts +46 -0
- package/plugins/acryl-router/src/middleware/index.ts +6 -0
- package/plugins/acryl-router/src/middleware/logger.ts +21 -0
- package/plugins/acryl-router/src/middleware/loopback-only.ts +82 -0
- package/plugins/acryl-router/src/middleware/request-id.ts +24 -0
- package/plugins/acryl-router/src/middleware/secure-headers.ts +23 -0
- package/plugins/acryl-router/src/pattern.ts +89 -0
- package/plugins/acryl-router/src/registrar.ts +90 -0
- package/plugins/acryl-router/src/service.ts +164 -0
- package/plugins/acryl-router/src/table.ts +156 -0
- package/plugins/acryl-router/src/types.ts +73 -0
- package/plugins/acryl-router/src/util.ts +23 -0
- package/plugins/acryl-router/tests/api.spec.ts +223 -0
- package/plugins/acryl-router/tests/conformance.ts +170 -0
- package/plugins/acryl-router/tests/helpers.spec.ts +172 -0
- package/plugins/acryl-router/tests/live-install.spec.ts +64 -0
- package/plugins/acryl-router/tests/loader.spec.ts +71 -0
- package/plugins/acryl-router/tests/middleware.spec.ts +183 -0
- package/plugins/acryl-router/tests/service.spec.ts +211 -0
- package/plugins/acryl-router/tests/table.spec.ts +47 -0
- package/plugins/acryl-router/upstream.json +20 -0
- package/plugins/acryl-router-http/LICENSE +21 -0
- package/plugins/acryl-router-http/plugin.json +11 -0
- package/plugins/acryl-router-http/src/bridge.ts +52 -0
- package/plugins/acryl-router-http/src/index.ts +63 -0
- package/plugins/acryl-router-http/src/serve.ts +24 -0
- package/plugins/acryl-router-http/tests/adapter.spec.ts +170 -0
- package/plugins/acryl-router-http/tests/stream.spec.ts +133 -0
- package/plugins/acryl-router-http/tests/surfaces.spec.ts +99 -0
- package/plugins/acryl-router-http/upstream.json +22 -0
- package/plugins/acryl-state/LICENSE +21 -0
- package/plugins/acryl-state/plugin.json +12 -0
- package/plugins/acryl-state/src/cell.ts +67 -0
- package/plugins/acryl-state/src/index.ts +3 -0
- package/plugins/acryl-state/src/react.ts +11 -0
- package/plugins/acryl-state/src/service.ts +101 -0
- package/plugins/acryl-state/tests/cell.spec.ts +71 -0
- package/plugins/acryl-state/tests/react.spec.ts +39 -0
- package/plugins/acryl-state/tests/service.spec.ts +132 -0
- package/plugins/acryl-state/upstream.json +20 -0
- package/plugins/builder/script.ts +8 -9
- package/plugins/extensions/index.ts +18 -1
- package/plugins/openai/prompt.ts +9 -5
- package/plugins/tasks/index.ts +14 -0
- package/plugins/tsconfig.json +9 -1
- package/templates/agent/blend.yaml +5 -7
- package/templates/agent/src/app.ts +8 -8
- package/templates/agent/src/routes.ts +32 -41
- package/templates/agent/template.json +3 -3
- package/templates/agent/test/builder.test.ts +22 -14
- package/templates/agent/test/model.test.ts +2 -3
- package/templates/{app → blank}/blend.yaml +3 -3
- package/templates/{app → blank}/src/app.ts +5 -5
- package/templates/blank/src/routes.ts +14 -0
- package/templates/blank/template.json +1 -0
- package/templates/{app → blank}/test/app.test.ts +1 -1
- package/templates/todo/README.md +27 -0
- package/templates/todo/blend.yaml +25 -0
- package/templates/todo/gitignore +2 -0
- package/templates/todo/package.json +23 -0
- package/templates/todo/src/app.ts +71 -0
- package/templates/todo/src/main.ts +19 -0
- package/templates/todo/src/page.ts +28 -0
- package/templates/todo/src/todo.ts +96 -0
- package/templates/todo/template.json +1 -0
- package/templates/todo/test/todo.test.ts +145 -0
- package/templates/todo/tsconfig.json +19 -0
- package/templates/todo/upstream.json +12 -0
- package/plugins/events/index.ts +0 -42
- package/plugins/events/plugin.json +0 -9
- package/plugins/http/index.ts +0 -64
- package/plugins/http/plugin.json +0 -10
- package/plugins/server/index.ts +0 -138
- package/plugins/server/plugin.json +0 -10
- package/templates/app/src/routes.ts +0 -14
- package/templates/app/template.json +0 -1
- /package/templates/{app → blank}/README.md +0 -0
- /package/templates/{app → blank}/gitignore +0 -0
- /package/templates/{app → blank}/package.json +0 -0
- /package/templates/{app → blank}/src/main.ts +0 -0
- /package/templates/{app → blank}/tsconfig.json +0 -0
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ACRYL state service (`ctx.state`): observable cells owned by plugins. A cell created from a plugin's context is removed
|
|
3
|
+
* when that plugin unloads, so a surface never keeps a value alive for a plugin that is gone. In process the cells are read
|
|
4
|
+
* and subscribed to directly (the terminal UI); with a router present they are also readable as JSON and streamable as
|
|
5
|
+
* server-sent events (Web and Desktop), read-only: only the Host plugin that owns a cell writes it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Context, Service } from '@deepseek-ai/cordis'
|
|
9
|
+
import type { RouteContext } from '../../acryl-router/src/index.ts'
|
|
10
|
+
import { streamSSE } from '../../acryl-router/src/helpers/index.ts'
|
|
11
|
+
import { loopbackOnly } from '../../acryl-router/src/middleware/index.ts'
|
|
12
|
+
import { createCell, createDerived, type Cell, type ReadonlyCell } from './cell.ts'
|
|
13
|
+
|
|
14
|
+
declare module '@deepseek-ai/cordis' {
|
|
15
|
+
interface Context {
|
|
16
|
+
state: State
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
21
|
+
export const name = 'acryl-state'
|
|
22
|
+
|
|
23
|
+
const KEY_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/
|
|
24
|
+
|
|
25
|
+
export class State extends Service {
|
|
26
|
+
private readonly cells = new Map<string, ReadonlyCell<unknown>>()
|
|
27
|
+
|
|
28
|
+
constructor(ctx: Context) {
|
|
29
|
+
super(ctx, 'state')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private claim(key: string): void {
|
|
33
|
+
if (!KEY_PATTERN.test(key)) throw new TypeError(`acryl-state: key "${key}" must be dotted lowercase words, like "market.sources"`)
|
|
34
|
+
if (this.cells.has(key)) throw new Error(`acryl-state: key "${key}" is already in use`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private report = (error: unknown): void => {
|
|
38
|
+
this.ctx.logger.error(`acryl-state: a subscriber failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Create a writable cell owned by the calling plugin. The key must be new. */
|
|
42
|
+
cell<T>(key: string, initial: T): Cell<T> {
|
|
43
|
+
this.claim(key)
|
|
44
|
+
const cell = createCell(key, initial, this.report)
|
|
45
|
+
this.cells.set(key, cell as ReadonlyCell<unknown>)
|
|
46
|
+
this.ctx.effect(() => () => { this.cells.delete(key) }, `acryl-state: cell ${key}`)
|
|
47
|
+
return cell
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Create a read-only cell computed from other cells, owned by the calling plugin. */
|
|
51
|
+
derive<D extends readonly ReadonlyCell<unknown>[], T>(
|
|
52
|
+
key: string,
|
|
53
|
+
inputs: D,
|
|
54
|
+
compute: (...values: { [K in keyof D]: D[K] extends ReadonlyCell<infer V> ? V : never }) => T,
|
|
55
|
+
): ReadonlyCell<T> {
|
|
56
|
+
this.claim(key)
|
|
57
|
+
const derived = createDerived(key, inputs, compute, this.report)
|
|
58
|
+
this.cells.set(key, derived.cell as ReadonlyCell<unknown>)
|
|
59
|
+
this.ctx.effect(() => () => { derived.dispose(); this.cells.delete(key) }, `acryl-state: derived ${key}`)
|
|
60
|
+
return derived.cell
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A cell by key, if one is live. */
|
|
64
|
+
find<T = unknown>(key: string): ReadonlyCell<T> | undefined {
|
|
65
|
+
return this.cells.get(key) as ReadonlyCell<T> | undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
keys(): string[] {
|
|
69
|
+
return [...this.cells.keys()].sort()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The current value of `key` as a server-sent event stream: the value now, then one event per change, until the request is aborted, the client
|
|
74
|
+
* cancels, or the router is disposed. `streamSSE` holds the request in flight until then, so the router waits for the stream.
|
|
75
|
+
*/
|
|
76
|
+
stream(key: string, c: RouteContext): Response | undefined {
|
|
77
|
+
const cell = this.find(key)
|
|
78
|
+
if (cell === undefined) return undefined
|
|
79
|
+
return streamSSE(c, stream => {
|
|
80
|
+
stream.send(cell.get(), { event: 'value' })
|
|
81
|
+
return cell.subscribe(value => { stream.send(value, { event: 'value' }) })
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function apply(ctx: Context): void {
|
|
87
|
+
const state = new State(ctx)
|
|
88
|
+
// The same cells on the router, read-only, for any surface; present only where a router is. A request over a socket must come from this machine to this
|
|
89
|
+
// app; the terminal calls the router in process and is the host's own code.
|
|
90
|
+
ctx.inject(['router'], inner => {
|
|
91
|
+
const api = inner.router.group('/api/state', loopbackOnly({ inProcess: 'allow' }))
|
|
92
|
+
api.get('/', c => c.json({ keys: state.keys() }))
|
|
93
|
+
api.get('/:key', c => {
|
|
94
|
+
const cell = state.find(c.param('key') ?? '')
|
|
95
|
+
return cell === undefined ? c.json({ error: 'no such state' }, 404) : c.json({ key: cell.key, value: cell.get() })
|
|
96
|
+
})
|
|
97
|
+
api.get('/:key/events', c => state.stream(c.param('key') ?? '', c) ?? c.json({ error: 'no such state' }, 404))
|
|
98
|
+
// Over HTTP only where an adapter exists (Web and Desktop); the terminal needs none.
|
|
99
|
+
inner.inject(['routerHttp'], http => { http.routerHttp.mount('/api/state') })
|
|
100
|
+
})
|
|
101
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createCell, createDerived } from '../src/cell.ts'
|
|
3
|
+
|
|
4
|
+
describe('cells', () => {
|
|
5
|
+
it('holds a value, notifies on change with the previous value, and ignores a set to an equal value', () => {
|
|
6
|
+
const cell = createCell('a.b', 1)
|
|
7
|
+
const seen: Array<[number, number]> = []
|
|
8
|
+
cell.subscribe((value, previous) => seen.push([value, previous]))
|
|
9
|
+
cell.set(2)
|
|
10
|
+
cell.set(2)
|
|
11
|
+
cell.update(n => n + 1)
|
|
12
|
+
expect(cell.get()).toBe(3)
|
|
13
|
+
expect(seen).toEqual([[2, 1], [3, 2]])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('stops notifying after unsubscribe, and unsubscribing twice is harmless', () => {
|
|
17
|
+
const cell = createCell('a', 0)
|
|
18
|
+
const listener = vi.fn()
|
|
19
|
+
const stop = cell.subscribe(listener)
|
|
20
|
+
cell.set(1)
|
|
21
|
+
stop()
|
|
22
|
+
stop()
|
|
23
|
+
cell.set(2)
|
|
24
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('isolates a failing subscriber: the others still run and the error is reported', () => {
|
|
28
|
+
const report = vi.fn()
|
|
29
|
+
const cell = createCell('a', 0, report)
|
|
30
|
+
const after = vi.fn()
|
|
31
|
+
cell.subscribe(() => { throw new Error('boom') })
|
|
32
|
+
cell.subscribe(after)
|
|
33
|
+
cell.set(1)
|
|
34
|
+
expect(after).toHaveBeenCalledWith(1, 0)
|
|
35
|
+
expect(report).toHaveBeenCalledTimes(1)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('lets a subscriber unsubscribe itself while being notified', () => {
|
|
39
|
+
const cell = createCell('a', 0)
|
|
40
|
+
const calls: number[] = []
|
|
41
|
+
const stop = cell.subscribe(value => { calls.push(value); stop() })
|
|
42
|
+
cell.subscribe(value => calls.push(value * 10))
|
|
43
|
+
cell.set(1)
|
|
44
|
+
cell.set(2)
|
|
45
|
+
expect(calls).toEqual([1, 10, 20])
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('derived cells', () => {
|
|
50
|
+
it('recomputes from its inputs, notifies only when its own value changes, and detaches on dispose', () => {
|
|
51
|
+
const a = createCell('a', 2)
|
|
52
|
+
const b = createCell('b', 3)
|
|
53
|
+
const { cell: sum, dispose } = createDerived('sum', [a, b] as const, (x, y) => x + y)
|
|
54
|
+
const seen: number[] = []
|
|
55
|
+
sum.subscribe(value => seen.push(value))
|
|
56
|
+
expect(sum.get()).toBe(5)
|
|
57
|
+
a.set(10)
|
|
58
|
+
b.set(1)
|
|
59
|
+
expect(seen).toEqual([13, 11])
|
|
60
|
+
const parity = createDerived('parity', [a] as const, x => x % 2 === 0).cell
|
|
61
|
+
const flips: boolean[] = []
|
|
62
|
+
parity.subscribe(value => flips.push(value))
|
|
63
|
+
a.set(12)
|
|
64
|
+
a.set(14)
|
|
65
|
+
expect(flips).toEqual([])
|
|
66
|
+
dispose()
|
|
67
|
+
a.set(100)
|
|
68
|
+
// Detached: it keeps the last value it computed (14 + 1), not 100 + 1.
|
|
69
|
+
expect(sum.get()).toBe(15)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, cleanup, render } from '@testing-library/react'
|
|
3
|
+
import { createElement } from 'react'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { createCell } from '../src/cell.ts'
|
|
6
|
+
import { useCell } from '../src/react.ts'
|
|
7
|
+
|
|
8
|
+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
|
9
|
+
afterEach(cleanup)
|
|
10
|
+
|
|
11
|
+
function Counter({ cell }: { cell: ReturnType<typeof createCell<number>> }) {
|
|
12
|
+
return createElement('span', { 'data-testid': 'v' }, String(useCell(cell)))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('useCell', () => {
|
|
16
|
+
it('renders the current value and re-renders when the cell changes', () => {
|
|
17
|
+
const cell = createCell('r.count', 1)
|
|
18
|
+
const view = render(createElement(Counter, { cell }))
|
|
19
|
+
expect(view.getByTestId('v').textContent).toBe('1')
|
|
20
|
+
act(() => { cell.set(2) })
|
|
21
|
+
expect(view.getByTestId('v').textContent).toBe('2')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('unsubscribes on unmount, and a second component on the same cell stays in step', () => {
|
|
25
|
+
const cell = createCell('r.count', 0)
|
|
26
|
+
let live = 0
|
|
27
|
+
const watched = { ...cell, subscribe: (listener: Parameters<typeof cell.subscribe>[0]) => { live += 1; const stop = cell.subscribe(listener); return () => { live -= 1; stop() } } }
|
|
28
|
+
const a = render(createElement(Counter, { cell: watched }))
|
|
29
|
+
const b = render(createElement(Counter, { cell: watched }))
|
|
30
|
+
expect(live).toBe(2)
|
|
31
|
+
act(() => { cell.set(7) })
|
|
32
|
+
expect(a.container.querySelector('[data-testid=v]')?.textContent).toBe('7')
|
|
33
|
+
expect(b.container.querySelector('[data-testid=v]')?.textContent).toBe('7')
|
|
34
|
+
a.unmount()
|
|
35
|
+
expect(live).toBe(1)
|
|
36
|
+
b.unmount()
|
|
37
|
+
expect(live).toBe(0)
|
|
38
|
+
})
|
|
39
|
+
})
|