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,64 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { explain, renderReports, reportFiber, stateName, type FiberView } from '../src/report.ts'
|
|
3
|
+
|
|
4
|
+
function fiber(over: Partial<FiberView> & { effects?: FiberView['getEffects'] extends () => infer R ? R : never } = {}): FiberView {
|
|
5
|
+
const { effects = [], ...rest } = over
|
|
6
|
+
return { state: 2, inject: {}, runtime: { name: 'demo' }, getEffects: () => effects, ...rest }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('reportFiber', () => {
|
|
10
|
+
it('sorts effects into provides, routes, middleware and everything else, including nested ones', () => {
|
|
11
|
+
const report = reportFiber(fiber({
|
|
12
|
+
inject: { router: null, acrylSettings: null },
|
|
13
|
+
effects: [
|
|
14
|
+
{ label: 'ctx.provide("things")', children: [] },
|
|
15
|
+
{ label: 'acryl-router: GET /api/things/:id', children: [] },
|
|
16
|
+
{ label: 'acryl-router: POST /api/things', children: [{ label: 'inner timer', children: [] }] },
|
|
17
|
+
{ label: 'acryl-router: use /api', children: [] },
|
|
18
|
+
{ label: 'ctx.on("internal/status")', children: [] },
|
|
19
|
+
],
|
|
20
|
+
}))
|
|
21
|
+
expect(report).toEqual({
|
|
22
|
+
name: 'demo',
|
|
23
|
+
state: 'ACTIVE',
|
|
24
|
+
requires: ['acrylSettings', 'router'],
|
|
25
|
+
provides: ['things'],
|
|
26
|
+
routes: [{ method: 'GET', path: '/api/things/:id' }, { method: 'POST', path: '/api/things' }],
|
|
27
|
+
middleware: ['/api'],
|
|
28
|
+
other: ['inner timer', 'ctx.on("internal/status")'],
|
|
29
|
+
removedOnUnload: 6,
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('names the state and an anonymous plugin', () => {
|
|
34
|
+
expect(stateName(0)).toBe('PENDING')
|
|
35
|
+
expect(stateName(2)).toBe('ACTIVE')
|
|
36
|
+
expect(stateName(5)).toBe('UNLOADING')
|
|
37
|
+
expect(reportFiber(fiber({ runtime: null })).name).toBe('anonymous')
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('explain', () => {
|
|
42
|
+
it('says what a plugin needs, adds and removes on unload', () => {
|
|
43
|
+
const text = explain(reportFiber(fiber({
|
|
44
|
+
inject: { router: null },
|
|
45
|
+
effects: [{ label: 'acryl-router: GET /a', children: [] }],
|
|
46
|
+
})))
|
|
47
|
+
expect(text).toContain('demo (ACTIVE)')
|
|
48
|
+
expect(text).toContain('needs: router')
|
|
49
|
+
expect(text).toContain('routes: GET /a')
|
|
50
|
+
expect(text).toContain('unloading: removes 1 registration and nothing else')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('says a pending plugin is waiting for its services', () => {
|
|
54
|
+
const text = explain(reportFiber(fiber({ state: 0, inject: { router: null, webServer: null } })))
|
|
55
|
+
expect(text).toContain('demo (PENDING)')
|
|
56
|
+
expect(text).toContain('it starts when router, webServer are available')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('renders several plugins as blocks', () => {
|
|
60
|
+
const a = reportFiber(fiber({ runtime: { name: 'a' } }))
|
|
61
|
+
const b = reportFiber(fiber({ runtime: { name: 'b' } }))
|
|
62
|
+
expect(renderReports([a, b]).split('\n\n')).toHaveLength(2)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inspector against real Cordis plugins: what it reports is what Cordis holds, so the tests build a small system and
|
|
3
|
+
* compare the report with what the plugins actually did.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
7
|
+
import { apply as routerApply, name as routerName } from '../../acryl-router/src/index.ts'
|
|
8
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
9
|
+
import { apply, name, type PluginReport } from '../src/index.ts'
|
|
10
|
+
|
|
11
|
+
const contexts: Context[] = []
|
|
12
|
+
const settle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0))
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
while (contexts.length > 0) await contexts.pop()?.fiber.dispose()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const market = {
|
|
18
|
+
name: 'demo-market',
|
|
19
|
+
inject: ['router'],
|
|
20
|
+
apply(ctx: Context): void {
|
|
21
|
+
ctx.router.use('/api/demo', async (_c, next) => next())
|
|
22
|
+
ctx.router.get('/api/demo/state', c => c.json({ ok: true }))
|
|
23
|
+
ctx.router.post('/api/demo/sources', c => c.json({ ok: true }, 201))
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function system(): Promise<Context> {
|
|
28
|
+
const ctx = new Context()
|
|
29
|
+
contexts.push(ctx)
|
|
30
|
+
ctx.plugin({ name: routerName, apply: routerApply }, { trailingSlash: 'strict' })
|
|
31
|
+
ctx.plugin({ name, apply })
|
|
32
|
+
await settle()
|
|
33
|
+
return ctx
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const byName = (reports: readonly PluginReport[], pluginName: string): PluginReport => {
|
|
37
|
+
const found = reports.find(report => report.name === pluginName)
|
|
38
|
+
if (found === undefined) throw new Error(`no report for ${pluginName}`)
|
|
39
|
+
return found
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('acryl-inspect', () => {
|
|
43
|
+
it('counts what an inline ctx.inject callback registers as the owning plugin\'s', async () => {
|
|
44
|
+
const ctx = await system()
|
|
45
|
+
ctx.plugin({
|
|
46
|
+
name: 'demo-inline',
|
|
47
|
+
apply(inner: Context): void {
|
|
48
|
+
inner.inject(['router'], deeper => { deeper.router.get('/api/inline', c => c.json({ ok: true })) })
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
await settle()
|
|
52
|
+
const reports = ctx.inspect.plugins()
|
|
53
|
+
expect(byName(reports, 'demo-inline').routes).toEqual([{ method: 'GET', path: '/api/inline' }])
|
|
54
|
+
expect(reports.every(report => report.name !== 'anonymous')).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('reports what a plugin requires, provides and registers, from the running system', async () => {
|
|
58
|
+
const ctx = await system()
|
|
59
|
+
ctx.plugin(market)
|
|
60
|
+
await settle()
|
|
61
|
+
const reports = ctx.inspect.plugins()
|
|
62
|
+
|
|
63
|
+
expect(byName(reports, 'acryl-router')).toMatchObject({ state: 'ACTIVE', requires: [], provides: ['router'] })
|
|
64
|
+
expect(byName(reports, 'acryl-inspect')).toMatchObject({ state: 'ACTIVE', provides: ['inspect'] })
|
|
65
|
+
const demo = byName(reports, 'demo-market')
|
|
66
|
+
expect(demo).toMatchObject({
|
|
67
|
+
state: 'ACTIVE',
|
|
68
|
+
requires: ['router'],
|
|
69
|
+
provides: [],
|
|
70
|
+
routes: [{ method: 'GET', path: '/api/demo/state' }, { method: 'POST', path: '/api/demo/sources' }],
|
|
71
|
+
middleware: ['/api/demo'],
|
|
72
|
+
removedOnUnload: 3,
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('shows a plugin whose service is missing as PENDING and says what it waits for', async () => {
|
|
77
|
+
const ctx = await system()
|
|
78
|
+
ctx.plugin({ name: 'needs-a-thing', inject: ['neverProvided'], apply() { /* never runs */ } })
|
|
79
|
+
await settle()
|
|
80
|
+
expect(byName(ctx.inspect.plugins(), 'needs-a-thing')).toMatchObject({ state: 'PENDING', requires: ['neverProvided'] })
|
|
81
|
+
expect(ctx.inspect.explain('needs-a-thing')).toContain('it starts when neverProvided is available')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('stops listing a plugin that unloaded, and the router really lost what the report said would go', async () => {
|
|
85
|
+
const ctx = await system()
|
|
86
|
+
const baseline = ctx.router.size
|
|
87
|
+
const fiber = ctx.plugin(market)
|
|
88
|
+
await settle()
|
|
89
|
+
const promised = byName(ctx.inspect.plugins(), 'demo-market').removedOnUnload
|
|
90
|
+
expect(ctx.router.size - baseline).toBe(promised)
|
|
91
|
+
|
|
92
|
+
await fiber.dispose()
|
|
93
|
+
await settle()
|
|
94
|
+
expect(ctx.inspect.plugins().some(report => report.name === 'demo-market' && report.state === 'ACTIVE')).toBe(false)
|
|
95
|
+
expect(ctx.router.size).toBe(baseline)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('serves the same facts on the router, in process', async () => {
|
|
99
|
+
const ctx = await system()
|
|
100
|
+
ctx.plugin(market)
|
|
101
|
+
await settle()
|
|
102
|
+
const list = await ctx.router.fetch(new Request('http://acryl.test/api/inspect/plugins'))
|
|
103
|
+
expect(list.status).toBe(200)
|
|
104
|
+
const reports = await list.json() as PluginReport[]
|
|
105
|
+
expect(byName(reports, 'demo-market').routes).toHaveLength(2)
|
|
106
|
+
|
|
107
|
+
const one = await ctx.router.fetch(new Request('http://acryl.test/api/inspect/plugins/demo-market'))
|
|
108
|
+
expect(await one.text()).toContain('removes 3 registrations and nothing else')
|
|
109
|
+
expect((await ctx.router.fetch(new Request('http://acryl.test/api/inspect/plugins/nope'))).status).toBe(404)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('needs no router: without one it still answers in code and registers no routes', async () => {
|
|
113
|
+
const ctx = new Context()
|
|
114
|
+
contexts.push(ctx)
|
|
115
|
+
ctx.plugin({ name, apply })
|
|
116
|
+
await settle()
|
|
117
|
+
expect(byName(ctx.inspect.plugins(), 'acryl-inspect').routes).toEqual([])
|
|
118
|
+
expect(ctx.inspect.render()).toContain('acryl-inspect (ACTIVE)')
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": {
|
|
3
|
+
"repo": "acryldev/acryl",
|
|
4
|
+
"path": "plugins/acryl-inspect",
|
|
5
|
+
"commit": "11fed295161c0282d144b5ac62616988b58d8189"
|
|
6
|
+
},
|
|
7
|
+
"vendored": [
|
|
8
|
+
"src",
|
|
9
|
+
"tests",
|
|
10
|
+
"LICENSE"
|
|
11
|
+
],
|
|
12
|
+
"rewrittenSpecifiers": 3,
|
|
13
|
+
"digest": "sha256:0fa799474c66b7a2a159c17ea143030b9fc6a38fcb4219f5139e78fd4508bdb7",
|
|
14
|
+
"testsNotRun": {},
|
|
15
|
+
"notice": [
|
|
16
|
+
"LICENSE"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -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-node-server",
|
|
3
|
+
"description": "The standalone HTTP server as a Cordis plugin: one node:http listener that sends every request to the router. Port, loopback host and next-free-port scan; ends open streams and frees the port when unloaded. Vendored from ACRYL.",
|
|
4
|
+
"requires": ["acryl-router", "acryl-router-http"],
|
|
5
|
+
"inject": ["router"],
|
|
6
|
+
"provides": ["nodeServer"],
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
9
|
+
"@deepseek-ai/schemastery": "3.18.4"
|
|
10
|
+
},
|
|
11
|
+
"mount": "root.plugin(router); root.plugin(nodeServer, { port: 3000, hostname: '127.0.0.1', scan: 20 }); ctx.nodeServer.url"
|
|
12
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framework's standalone HTTP adapter (spec 046), the standalone counterpart of the Host adapter. It owns one
|
|
3
|
+
* `node:http` listener and hands every request to `ctx.router.fetch`, with the socket's peer address and local port as connection facts.
|
|
4
|
+
* Use it in an app that runs without the ACRYL Host; inside the Host, `acryl-router-http` mounts prefixes on the Host's own web server.
|
|
5
|
+
* One adapter per runtime: a plugin keeps asking `ctx.inject(['routerHttp'], ...)` for a mount, which simply never fires here, because this
|
|
6
|
+
* adapter serves the whole router.
|
|
7
|
+
*
|
|
8
|
+
* A configured port that is taken is followed by the next free one, at most `scan` further ports; port 0 lets the system choose. Disposal ends
|
|
9
|
+
* open streams, closes every connection and returns once the port is free.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createServer, type Server } from 'node:http'
|
|
13
|
+
import type { AddressInfo } from 'node:net'
|
|
14
|
+
import { Context, Service } from '@deepseek-ai/cordis'
|
|
15
|
+
import z from '@deepseek-ai/schemastery'
|
|
16
|
+
import { serve } from '../../acryl-router-http/src/index.ts'
|
|
17
|
+
import type {} from '../../acryl-router/src/index.ts'
|
|
18
|
+
|
|
19
|
+
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
20
|
+
export const name = 'acryl-node-server'
|
|
21
|
+
export const inject = ['router']
|
|
22
|
+
|
|
23
|
+
export interface Config {
|
|
24
|
+
hostname: string
|
|
25
|
+
port: number
|
|
26
|
+
scan: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const Config: z<Config> = z.object({
|
|
30
|
+
hostname: z.string().default('127.0.0.1').description('Address to bind. Loopback by default; binding elsewhere exposes the app to the network.'),
|
|
31
|
+
port: z.number().min(0).max(65535).default(0).description('Port to listen on; 0 lets the system choose. A taken port is followed by the next free one.'),
|
|
32
|
+
scan: z.number().min(0).default(20).description('How many further ports to try when the configured one is taken.'),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
declare module '@deepseek-ai/cordis' {
|
|
36
|
+
interface Context {
|
|
37
|
+
nodeServer: NodeServer
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class NodeServer extends Service {
|
|
42
|
+
readonly hostname: string
|
|
43
|
+
readonly config: Config
|
|
44
|
+
private readonly server: Server
|
|
45
|
+
private bound = 0
|
|
46
|
+
|
|
47
|
+
constructor(ctx: Context, config: Config) {
|
|
48
|
+
super(ctx, 'nodeServer')
|
|
49
|
+
this.config = config
|
|
50
|
+
this.hostname = config.hostname
|
|
51
|
+
const router = ctx.router
|
|
52
|
+
this.server = createServer((req, res) => { void serve(router, req, res) })
|
|
53
|
+
ctx.effect(() => async () => {
|
|
54
|
+
this.server.closeAllConnections()
|
|
55
|
+
if (this.server.listening) await new Promise<void>(resolve => { this.server.close(() => { resolve() }) })
|
|
56
|
+
}, 'acryl-node-server: close listener and open connections')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The port in use, 0 before the server listens. */
|
|
60
|
+
get port(): number {
|
|
61
|
+
return this.bound
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The address clients use. */
|
|
65
|
+
get url(): string {
|
|
66
|
+
return `http://${this.hostname.includes(':') ? `[${this.hostname}]` : this.hostname}:${String(this.bound)}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Bind, scanning past taken ports. */
|
|
70
|
+
async listen(): Promise<void> {
|
|
71
|
+
const { port, scan, hostname } = this.config
|
|
72
|
+
const last = port === 0 ? 0 : Math.min(65535, port + scan)
|
|
73
|
+
for (let candidate = port; candidate <= last; candidate += 1) {
|
|
74
|
+
try {
|
|
75
|
+
await new Promise<void>((resolve, reject) => {
|
|
76
|
+
const onError = (error: Error): void => { reject(error) }
|
|
77
|
+
this.server.once('error', onError)
|
|
78
|
+
this.server.listen(candidate, hostname, () => { this.server.off('error', onError); resolve() })
|
|
79
|
+
})
|
|
80
|
+
this.bound = (this.server.address() as AddressInfo).port
|
|
81
|
+
return
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw error
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
throw new Error(`acryl-node-server: no free port from ${String(port)} to ${String(last)} on ${hostname}`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Loads once the server listens, so a plugin that injects `nodeServer` can rely on `url`. A failure to bind fails the plugin. */
|
|
91
|
+
export async function apply(ctx: Context, config: Partial<Config> = {}): Promise<void> {
|
|
92
|
+
const server = new NodeServer(ctx, { hostname: '127.0.0.1', port: 0, scan: 20, ...config })
|
|
93
|
+
await server.listen()
|
|
94
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** The standalone adapter over real sockets: serving, connection facts, port scan, streams, disposal, reload. */
|
|
2
|
+
import { createServer } from 'node:http'
|
|
3
|
+
import type { AddressInfo } from 'node:net'
|
|
4
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
5
|
+
import { apply as routerApply, name as routerName } from '../../acryl-router/src/index.ts'
|
|
6
|
+
import { streamSSE } from '../../acryl-router/src/helpers/index.ts'
|
|
7
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
8
|
+
import * as nodeServer from '../src/index.ts'
|
|
9
|
+
|
|
10
|
+
/** The plugin as an object, so its config is the partial `apply` accepts rather than the Loader's validated shape. */
|
|
11
|
+
const plugin = { name: nodeServer.name, inject: nodeServer.inject, apply: nodeServer.apply }
|
|
12
|
+
const contexts: Context[] = []
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
while (contexts.length > 0) await contexts.pop()?.fiber.dispose()
|
|
15
|
+
})
|
|
16
|
+
const settle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 20))
|
|
17
|
+
|
|
18
|
+
async function boot(config: Partial<nodeServer.Config> = {}): Promise<Context> {
|
|
19
|
+
const ctx = new Context()
|
|
20
|
+
contexts.push(ctx)
|
|
21
|
+
ctx.plugin({ name: routerName, apply: routerApply })
|
|
22
|
+
ctx.plugin(plugin, config)
|
|
23
|
+
await settle()
|
|
24
|
+
return ctx
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Hold a port so a server asked for it has to scan. */
|
|
28
|
+
async function occupy(): Promise<{ port: number, release: () => Promise<void> }> {
|
|
29
|
+
const blocker = createServer()
|
|
30
|
+
await new Promise<void>(resolve => blocker.listen(0, '127.0.0.1', resolve))
|
|
31
|
+
return { port: (blocker.address() as AddressInfo).port, release: () => new Promise<void>(resolve => blocker.close(() => { resolve() })) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const portIsFree = async (port: number): Promise<boolean> => {
|
|
35
|
+
const probe = createServer()
|
|
36
|
+
return new Promise<boolean>(resolve => {
|
|
37
|
+
probe.once('error', () => { resolve(false) })
|
|
38
|
+
probe.listen(port, '127.0.0.1', () => { probe.close(() => { resolve(true) }) })
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('acryl-node-server', () => {
|
|
43
|
+
it('serves the router over a real socket with the peer and port as connection facts', async () => {
|
|
44
|
+
const ctx = await boot()
|
|
45
|
+
ctx.router.get('/who', c => c.json({ peer: c.connection.remoteAddress, port: c.connection.localPort }))
|
|
46
|
+
const body = await (await fetch(`${ctx.nodeServer.url}/who`)).json() as { peer: string, port: number }
|
|
47
|
+
expect(ctx.nodeServer.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/u)
|
|
48
|
+
expect(body.port).toBe(ctx.nodeServer.port)
|
|
49
|
+
expect(body.peer).toMatch(/127\.0\.0\.1/u)
|
|
50
|
+
expect((await fetch(`${ctx.nodeServer.url}/missing`)).status).toBe(404)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('scans to the next free port when the configured one is taken', async () => {
|
|
54
|
+
const taken = await occupy()
|
|
55
|
+
try {
|
|
56
|
+
const ctx = await boot({ port: taken.port, scan: 20 })
|
|
57
|
+
expect(ctx.nodeServer.port).toBeGreaterThan(taken.port)
|
|
58
|
+
expect(ctx.nodeServer.port).toBeLessThanOrEqual(taken.port + 20)
|
|
59
|
+
} finally {
|
|
60
|
+
await taken.release()
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('fails the plugin when no port in the scan range is free', async () => {
|
|
65
|
+
const taken = await occupy()
|
|
66
|
+
try {
|
|
67
|
+
const ctx = new Context()
|
|
68
|
+
contexts.push(ctx)
|
|
69
|
+
ctx.plugin({ name: routerName, apply: routerApply })
|
|
70
|
+
const fiber = ctx.plugin(plugin, { port: taken.port, scan: 0 })
|
|
71
|
+
await settle()
|
|
72
|
+
expect(fiber.state).toBe(3) // FAILED
|
|
73
|
+
} finally {
|
|
74
|
+
await taken.release()
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('on dispose ends open event streams and frees the port', async () => {
|
|
79
|
+
const ctx = await boot()
|
|
80
|
+
ctx.router.get('/events', c => streamSSE(c, stream => { stream.send('hello') }))
|
|
81
|
+
const port = ctx.nodeServer.port
|
|
82
|
+
const response = await fetch(`${ctx.nodeServer.url}/events`)
|
|
83
|
+
const reader = response.body!.getReader()
|
|
84
|
+
expect(new TextDecoder().decode((await reader.read()).value)).toContain('data: hello')
|
|
85
|
+
await ctx.fiber.dispose()
|
|
86
|
+
contexts.splice(contexts.indexOf(ctx), 1)
|
|
87
|
+
const ended = await reader.read().then(result => result.done, () => true)
|
|
88
|
+
expect(ended).toBe(true)
|
|
89
|
+
expect(await portIsFree(port)).toBe(true)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('reloads without leaking a listener', async () => {
|
|
93
|
+
const ctx = new Context()
|
|
94
|
+
contexts.push(ctx)
|
|
95
|
+
ctx.plugin({ name: routerName, apply: routerApply })
|
|
96
|
+
const ports: number[] = []
|
|
97
|
+
for (let round = 0; round < 5; round += 1) {
|
|
98
|
+
const fiber = ctx.plugin(plugin, {})
|
|
99
|
+
await settle()
|
|
100
|
+
ports.push(ctx.nodeServer.port)
|
|
101
|
+
await fiber.dispose()
|
|
102
|
+
}
|
|
103
|
+
for (const port of ports) expect(await portIsFree(port)).toBe(true)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": {
|
|
3
|
+
"repo": "acryldev/acryl",
|
|
4
|
+
"path": "plugins/acryl-node-server",
|
|
5
|
+
"commit": "11fed295161c0282d144b5ac62616988b58d8189"
|
|
6
|
+
},
|
|
7
|
+
"vendored": [
|
|
8
|
+
"src",
|
|
9
|
+
"tests",
|
|
10
|
+
"LICENSE"
|
|
11
|
+
],
|
|
12
|
+
"rewrittenSpecifiers": 4,
|
|
13
|
+
"digest": "sha256:0931c522f7b595d80dd39a4d6179d8ba05acef612f309985149f2501c2c4622e",
|
|
14
|
+
"testsNotRun": {},
|
|
15
|
+
"notice": [
|
|
16
|
+
"LICENSE"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -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-router",
|
|
3
|
+
"description": "The router service as a Cordis plugin: Web-standard Request and Response, path parameters, middleware, helpers, an in-process fetch and request() for tests. Vendored from ACRYL.",
|
|
4
|
+
"requires": [],
|
|
5
|
+
"inject": [],
|
|
6
|
+
"provides": ["router"],
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
9
|
+
"@deepseek-ai/schemastery": "3.18.4"
|
|
10
|
+
},
|
|
11
|
+
"mount": "root.plugin(router); then in your own plugin: inject: ['router'] and ctx.router.get('/path', (c) => c.json({ ok: true }))"
|
|
12
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Handler, Middleware, RouteContext } from './types.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run `middleware` in order around `final`. Each middleware receives `next`, which runs the rest of the chain and
|
|
5
|
+
* resolves to its response. Calling `next` twice in one middleware is a programming error and rejects.
|
|
6
|
+
*/
|
|
7
|
+
export function compose(middleware: readonly Middleware[], final: Handler): Handler {
|
|
8
|
+
return (c: RouteContext): Promise<Response> => {
|
|
9
|
+
const dispatch = async (index: number): Promise<Response> => {
|
|
10
|
+
const current = middleware[index]
|
|
11
|
+
if (current === undefined) return final(c)
|
|
12
|
+
let called = false
|
|
13
|
+
return current(c, () => {
|
|
14
|
+
if (called) return Promise.reject(new Error('router: next() called more than once'))
|
|
15
|
+
called = true
|
|
16
|
+
return dispatch(index + 1)
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
return dispatch(0)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ConnectionInfo, ResponseBody, RouteContext } from './types.ts'
|
|
2
|
+
|
|
3
|
+
const JSON_TYPE = 'application/json; charset=utf-8'
|
|
4
|
+
const TEXT_TYPE = 'text/plain; charset=utf-8'
|
|
5
|
+
const HTML_TYPE = 'text/html; charset=utf-8'
|
|
6
|
+
|
|
7
|
+
/** Statuses that must not carry a body; a `Response` constructed with one throws. */
|
|
8
|
+
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304])
|
|
9
|
+
const bodyFor = <T>(status: number, body: T): T | null => NULL_BODY_STATUSES.has(status) ? null : body
|
|
10
|
+
|
|
11
|
+
/** Build the per-request context. `notFound` is what `c.notFound()` runs: the router's own answer. */
|
|
12
|
+
export function createContext(
|
|
13
|
+
req: Request,
|
|
14
|
+
params: Record<string, string>,
|
|
15
|
+
signal: AbortSignal,
|
|
16
|
+
connection: ConnectionInfo,
|
|
17
|
+
track: (task: Promise<unknown>) => void,
|
|
18
|
+
notFound: (c: RouteContext) => Response | Promise<Response>,
|
|
19
|
+
): RouteContext {
|
|
20
|
+
const values = new Map<string, unknown>()
|
|
21
|
+
const url = new URL(req.url)
|
|
22
|
+
const defaults = new Headers()
|
|
23
|
+
let defaultStatus = 200
|
|
24
|
+
|
|
25
|
+
const respond = (body: ResponseBody, status: number | undefined, type: string | undefined, extra?: Record<string, string>): Response => {
|
|
26
|
+
const code = status ?? defaultStatus
|
|
27
|
+
const headers = new Headers(defaults)
|
|
28
|
+
if (type !== undefined && !headers.has('content-type')) headers.set('content-type', type)
|
|
29
|
+
for (const [name, value] of Object.entries(extra ?? {})) headers.set(name, value)
|
|
30
|
+
return new Response(bodyFor(code, body), { status: code, headers })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const context: RouteContext = {
|
|
34
|
+
req,
|
|
35
|
+
url,
|
|
36
|
+
params,
|
|
37
|
+
connection,
|
|
38
|
+
signal,
|
|
39
|
+
waitUntil: track,
|
|
40
|
+
get: <T>(key: string) => values.get(key) as T | undefined,
|
|
41
|
+
set: (key, value) => { values.set(key, value) },
|
|
42
|
+
header: (name, value) => { defaults.set(name, value) },
|
|
43
|
+
status: code => { defaultStatus = code },
|
|
44
|
+
query: ((name?: string) => {
|
|
45
|
+
if (name !== undefined) return url.searchParams.get(name) ?? undefined
|
|
46
|
+
const all: Record<string, string> = {}
|
|
47
|
+
for (const [key, value] of url.searchParams) if (!(key in all)) all[key] = value
|
|
48
|
+
return all
|
|
49
|
+
}) as RouteContext['query'],
|
|
50
|
+
param: ((name?: string) => name === undefined ? params : params[name]) as RouteContext['param'],
|
|
51
|
+
json: (data, status) => respond(JSON.stringify(data), status, JSON_TYPE),
|
|
52
|
+
text: (body, status) => respond(body, status, TEXT_TYPE),
|
|
53
|
+
html: (body, status) => respond(body, status, HTML_TYPE),
|
|
54
|
+
body: (data, status, headers) => respond(data, status, undefined, headers),
|
|
55
|
+
redirect: (location, status = 302) => respond(null, status, undefined, { location }),
|
|
56
|
+
notFound: () => notFound(context),
|
|
57
|
+
}
|
|
58
|
+
return context
|
|
59
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { HttpError } from '../http-error.ts'
|
|
2
|
+
import { BODY_LIMIT_KEY } from '../middleware/body-limit.ts'
|
|
3
|
+
import type { RouteContext } from '../types.ts'
|
|
4
|
+
|
|
5
|
+
export interface ReadBodyOptions {
|
|
6
|
+
/** The most bytes to accept. Defaults to the `bodyLimit` middleware's limit when it ran, otherwise 1 MiB. */
|
|
7
|
+
readonly maxBytes?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MAX_BYTES = 1024 * 1024
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read the request body as bytes, counting as it streams, and stop at the limit (a 413 `HttpError`). Stops at once when the request is aborted.
|
|
14
|
+
* A request with no body gives an empty array.
|
|
15
|
+
*/
|
|
16
|
+
export async function readBytes(c: RouteContext, options: ReadBodyOptions = {}): Promise<Uint8Array> {
|
|
17
|
+
const limit = options.maxBytes ?? c.get<number>(BODY_LIMIT_KEY) ?? DEFAULT_MAX_BYTES
|
|
18
|
+
c.signal.throwIfAborted()
|
|
19
|
+
const reader = c.req.body?.getReader()
|
|
20
|
+
if (reader === undefined) return new Uint8Array()
|
|
21
|
+
const onAbort = (): void => { void reader.cancel(c.signal.reason).catch(() => undefined) }
|
|
22
|
+
c.signal.addEventListener('abort', onAbort, { once: true })
|
|
23
|
+
const chunks: Uint8Array[] = []
|
|
24
|
+
let size = 0
|
|
25
|
+
try {
|
|
26
|
+
for (;;) {
|
|
27
|
+
const { done, value } = await reader.read()
|
|
28
|
+
if (done) break
|
|
29
|
+
size += value.byteLength
|
|
30
|
+
if (size > limit) {
|
|
31
|
+
await reader.cancel().catch(() => undefined)
|
|
32
|
+
throw new HttpError(413, { message: 'payload too large' })
|
|
33
|
+
}
|
|
34
|
+
chunks.push(value)
|
|
35
|
+
}
|
|
36
|
+
} finally {
|
|
37
|
+
c.signal.removeEventListener('abort', onAbort)
|
|
38
|
+
}
|
|
39
|
+
c.signal.throwIfAborted()
|
|
40
|
+
const bytes = new Uint8Array(size)
|
|
41
|
+
let offset = 0
|
|
42
|
+
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength }
|
|
43
|
+
return bytes
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The body as UTF-8 text, within the limit. */
|
|
47
|
+
export async function readText(c: RouteContext, options?: ReadBodyOptions): Promise<string> {
|
|
48
|
+
return new TextDecoder().decode(await readBytes(c, options))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The body parsed as JSON, within the limit. Bad JSON is a 400 `HttpError`. */
|
|
52
|
+
export async function readJson(c: RouteContext, options?: ReadBodyOptions): Promise<unknown> {
|
|
53
|
+
const text = await readText(c, options)
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(text) as unknown
|
|
56
|
+
} catch {
|
|
57
|
+
throw new HttpError(400, { message: 'invalid json' })
|
|
58
|
+
}
|
|
59
|
+
}
|