acrylblends 1.1.0 → 1.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/README.md +9 -0
- package/dist/index.d.ts +47 -2
- package/dist/index.js +2017 -32
- package/dist/index.js.map +1 -1
- package/docs/framework/agents/index.md +1 -1
- package/docs/framework/getting-started/desktop.md +23 -4
- package/docs/framework/guides/create-acrylblends.md +5 -3
- package/docs/framework/workshop/README.md +139 -153
- package/docs/framework/workshop/facilitator.md +69 -101
- package/legacy/README.md +9 -0
- package/legacy/agent/157ce3db1112/src/app.ts +98 -0
- package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
- package/legacy/agent/24c681a00fca/src/app.ts +160 -0
- package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
- package/legacy/agent/2e790da977b2/src/app.ts +98 -0
- package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
- package/legacy/agent/6307045545dc/src/app.ts +168 -0
- package/legacy/agent/6307045545dc/src/routes.ts +83 -0
- package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
- package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
- package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
- package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
- package/legacy/agent/74ece148269c/src/app.ts +168 -0
- package/legacy/agent/74ece148269c/src/routes.ts +93 -0
- package/legacy/agent/855359304707/src/app.ts +168 -0
- package/legacy/agent/855359304707/src/routes.ts +126 -0
- package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
- package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
- package/legacy/agent/c2f30958043b/src/app.ts +98 -0
- package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
- package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
- package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
- package/legacy/blank/aea589283445/src/routes.ts +16 -0
- package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
- package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
- package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
- package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
- package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
- package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
- package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
- package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
- package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
- package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
- package/legacy/layouts.json +524 -0
- package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
- package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
- package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
- package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
- package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
- package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
- package/legacy/notes/4edc36949c15/src/app.ts +107 -0
- package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
- package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
- package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
- package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
- package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
- package/legacy/todo/02e613b3625c/src/app.ts +71 -0
- package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
- package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
- package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
- package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
- package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
- package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
- package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
- package/legacy/todo/8e64211312f3/src/app.ts +71 -0
- package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
- package/legacy/todo/b79078a87565/src/app.ts +71 -0
- package/legacy/todo/b79078a87565/src/todo.ts +125 -0
- package/lib/{audit-BFpULBCj.d.ts → audit-Bok_UNCn.d.ts} +26 -3
- package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
- package/lib/cordis/door.d.ts +87 -9
- package/lib/cordis/door.js +324 -60
- package/lib/cordis/inspect.js +1 -1
- package/lib/cordis/node-server.d.ts +101 -3
- package/lib/cordis/node-server.js +3 -3
- package/lib/cordis/router-http.d.ts +7 -1
- package/lib/cordis/router-http.js +44 -3
- package/lib/cordis/router.d.ts +1 -1
- package/lib/cordis/router.js +1 -1
- package/lib/cordis/state.js +1 -1
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +10 -1
- package/lib/door/page.js +46 -7
- package/lib/door/protocol.d.ts +1 -1
- package/lib/drivers.d.ts +10 -1
- package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
- package/lib/node-server.d.ts +7 -1
- package/lib/node-server.js +2 -2
- package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
- package/lib/{snapshot-text-BwM6T2nG.js → snapshot-text-CXTnjxGx.js} +87 -7
- package/lib/src-CtkIX_Gg.js +427 -0
- package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
- package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
- package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
- package/package.json +2 -1
- package/plugins/acryl-door/plugin.json +3 -0
- package/plugins/acryl-door/src/cli/acryl.ts +63 -6
- package/plugins/acryl-door/src/host/channel.ts +86 -9
- package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
- package/plugins/acryl-door/src/host/door-file.ts +7 -2
- package/plugins/acryl-door/src/host/door.ts +128 -24
- package/plugins/acryl-door/src/host/operator-routes.ts +21 -1
- package/plugins/acryl-door/src/host/page-routes.ts +3 -1
- package/plugins/acryl-door/src/host/page-socket.ts +18 -8
- package/plugins/acryl-door/src/host/verbs.ts +14 -1
- package/plugins/acryl-door/src/index.ts +168 -30
- package/plugins/acryl-door/src/page/base.ts +32 -0
- package/plugins/acryl-door/src/page/channel.ts +3 -1
- package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +32 -4
- package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
- package/plugins/acryl-door/src/page/index.ts +41 -2
- package/plugins/acryl-door/src/page/outline.ts +56 -0
- package/plugins/acryl-door/src/page/protocol.ts +4 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
- package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
- package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
- package/plugins/acryl-node-server/src/client-modules.ts +7 -1
- package/plugins/acryl-node-server/src/index.ts +304 -16
- package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
- package/plugins/acryl-page/plugin.json +12 -0
- package/plugins/acryl-page/src/index.ts +163 -0
- package/plugins/acryl-router/src/service.ts +77 -14
- package/plugins/acryl-router-http/src/index.ts +15 -1
- package/plugins/acryl-router-http/src/serve.ts +31 -0
- package/plugins/acryl-web-server/plugin.json +14 -0
- package/plugins/acryl-web-server/src/index.ts +164 -0
- package/plugins/agent/agent.ts +38 -7
- package/plugins/agent/index.ts +1 -1
- package/plugins/agent/model.ts +10 -2
- package/plugins/agent/tools.ts +9 -3
- package/plugins/agent-panel/client/describe.ts +57 -0
- package/plugins/agent-panel/client/driver-pick.ts +22 -0
- package/plugins/agent-panel/client/frame.ts +51 -0
- package/plugins/agent-panel/client/main.ts +238 -26
- package/plugins/agent-panel/drivers.ts +7 -2
- package/plugins/agent-panel/headless.ts +5 -2
- package/plugins/agent-panel/index.ts +146 -57
- package/plugins/agent-panel/mount.ts +27 -0
- package/plugins/agent-panel/visitors.ts +231 -0
- package/plugins/builder/index.ts +13 -35
- package/plugins/builder/package.ts +186 -0
- package/plugins/builder/plugin.json +3 -1
- package/plugins/desktop/frame.ts +231 -0
- package/plugins/desktop/launch.ts +44 -9
- package/plugins/desktop/main.ts +100 -32
- package/plugins/desktop/window.ts +21 -4
- package/plugins/driver-pty/client/terminal.ts +4 -1
- package/plugins/driver-pty/index.ts +38 -11
- package/plugins/driver-pty/spawn.ts +38 -0
- package/plugins/driver-pty/terminal.ts +2 -0
- package/plugins/extensions/index.ts +47 -18
- package/plugins/extensions/plugin-store.ts +27 -0
- package/plugins/extensions/plugin.json +1 -1
- package/plugins/extensions/resolve.ts +13 -0
- package/plugins/extensions/stage.ts +15 -5
- package/plugins/feedback/client.d.ts +22 -0
- package/plugins/feedback/client.js +471 -0
- package/plugins/feedback/index.ts +143 -0
- package/plugins/feedback/plugin.json +11 -0
- package/plugins/feedback/store.ts +307 -0
- package/plugins/home/index.ts +122 -0
- package/plugins/home/plugin.json +11 -0
- package/plugins/instance/index.ts +6 -0
- package/plugins/manifest/app-plugins.ts +86 -0
- package/plugins/manifest/index.ts +12 -2
- package/plugins/manifest/plugin.json +1 -1
- package/plugins/manifest/snapshot.ts +275 -0
- package/plugins/openai/index.ts +64 -5
- package/plugins/openai/prompt.ts +23 -0
- package/plugins/plugin-share/README.md +51 -0
- package/plugins/plugin-share/index.ts +467 -0
- package/plugins/plugin-share/plugin.json +15 -0
- package/plugins/plugin-share/publish.ts +65 -0
- package/plugins/plugin-share/pull-check.ts +129 -0
- package/plugins/plugin-share/pulled-records.ts +70 -0
- package/shared/AGENTS.core.md +36 -16
- package/shared/skills/acryl-add-ui/SKILL.md +19 -0
- package/shared/skills/acryl-app/SKILL.md +13 -0
- package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
- package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
- package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
- package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
- package/shared/skills/cordis-plugin/SKILL.md +26 -0
- package/templates/agent/.gitattributes +2 -0
- package/templates/agent/AGENTS.md +1 -1
- package/templates/agent/blend.yaml +6 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +1 -0
- package/templates/agent/src/app.ts +154 -33
- package/templates/agent/src/frame.ts +17 -0
- package/templates/agent/src/main.ts +5 -3
- package/templates/agent/src/routes.ts +38 -19
- package/templates/agent/template.json +3 -0
- package/templates/agent/test/app.test.ts +45 -3
- package/templates/blank/.gitattributes +2 -0
- package/templates/blank/AGENTS.md +3 -2
- package/templates/blank/blend.yaml +11 -0
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +1 -0
- package/templates/blank/src/app.ts +100 -29
- package/templates/blank/src/door.ts +7 -0
- package/templates/blank/src/first-page.ts +5 -0
- package/templates/blank/src/frame.ts +17 -0
- package/templates/blank/src/routes.ts +65 -8
- package/templates/blank/template.json +14 -1
- package/templates/blank/test/app.test.ts +103 -16
- package/templates/chat/.gitattributes +2 -0
- package/templates/chat/blend.yaml +2 -1
- package/templates/chat/gitignore +1 -0
- package/templates/chat/package.json +1 -0
- package/templates/chat/src/app.ts +72 -22
- package/templates/chat/src/chat-app.ts +11 -2
- package/templates/chat/src/client/chat.tsx +4 -2
- package/templates/chat/src/frame.ts +17 -0
- package/templates/chat/src/page.tsx +6 -4
- package/templates/chat/src/routes.ts +14 -4
- package/templates/chat/src/runtime.ts +9 -3
- package/templates/chat/template.json +3 -1
- package/templates/chat/test/chat.test.ts +46 -1
- package/templates/chat/test/server.test.ts +17 -3
- package/templates/nodejs/.gitattributes +2 -0
- package/templates/nodejs/blend.yaml +2 -1
- package/templates/nodejs/gitignore +1 -0
- package/templates/nodejs/package.json +1 -0
- package/templates/nodejs/src/app.ts +76 -21
- package/templates/nodejs/src/frame.ts +17 -0
- package/templates/nodejs/src/page.tsx +3 -3
- package/templates/nodejs/src/routes.ts +32 -13
- package/templates/nodejs/template.json +3 -1
- package/templates/nodejs/test/app.test.ts +38 -3
- package/templates/nodejs/test/server.test.ts +17 -3
- package/templates/notes/.gitattributes +2 -0
- package/templates/notes/blend.yaml +2 -1
- package/templates/notes/gitignore +1 -0
- package/templates/notes/package.json +1 -0
- package/templates/notes/src/app.ts +72 -22
- package/templates/notes/src/frame.ts +17 -0
- package/templates/notes/src/notes-app.ts +24 -17
- package/templates/notes/src/routes.ts +11 -4
- package/templates/notes/src/runtime.ts +9 -3
- package/templates/notes/src/views.tsx +26 -17
- package/templates/notes/template.json +3 -1
- package/templates/notes/test/notes.test.ts +39 -1
- package/templates/notes/test/server.test.ts +17 -3
- package/templates/todo/.gitattributes +2 -0
- package/templates/todo/blend.yaml +3 -0
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +1 -0
- package/templates/todo/src/app.ts +81 -27
- package/templates/todo/src/door.ts +7 -0
- package/templates/todo/src/frame.ts +17 -0
- package/templates/todo/src/page.ts +13 -6
- package/templates/todo/src/todo.ts +18 -6
- package/templates/todo/template.json +11 -1
- package/templates/todo/test/todo.test.ts +67 -9
- package/lib/src-LX0E4ec4.js +0 -117
- package/shared/SKILL.md +0 -14
|
@@ -33,8 +33,16 @@ const ChatSchema: StandardSchema<ChatInput> = {
|
|
|
33
33
|
},
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
36
|
+
/**
|
|
37
|
+
* `access` is who may use the chat (default: anyone, as in development and in the tests); see `src/access.ts`. `prefix` is the path the app is mounted at:
|
|
38
|
+
* '' on its own server, `/app` in the ACRYL frame. Every URL the page writes is derived from it.
|
|
39
|
+
*/
|
|
40
|
+
export function createApp(
|
|
41
|
+
db: Db,
|
|
42
|
+
llm: Llm = openAiCompatible,
|
|
43
|
+
access: Access = { kind: 'open' },
|
|
44
|
+
{ prefix = '' }: { prefix?: string } = {},
|
|
45
|
+
) {
|
|
38
46
|
const store = chatStore(db)
|
|
39
47
|
const app = new Acryl()
|
|
40
48
|
const gate = accessGate(access)
|
|
@@ -50,6 +58,7 @@ export function createApp(db: Db, llm: Llm = openAiCompatible, access: Access =
|
|
|
50
58
|
await store.conversations(),
|
|
51
59
|
current,
|
|
52
60
|
current ? await store.messages(current.id) : [],
|
|
61
|
+
prefix,
|
|
53
62
|
),
|
|
54
63
|
)
|
|
55
64
|
})
|
|
@@ -47,7 +47,7 @@ const Chat = ({ start }: { start: number | undefined }) => {
|
|
|
47
47
|
setBusy(true)
|
|
48
48
|
controller.current = new AbortController()
|
|
49
49
|
try {
|
|
50
|
-
const response = await fetch(
|
|
50
|
+
const response = await fetch(`${base}/api/chat`, {
|
|
51
51
|
method: 'POST',
|
|
52
52
|
headers: { 'content-type': 'application/json' },
|
|
53
53
|
body: JSON.stringify({ message, conversation: conversation.current }),
|
|
@@ -65,7 +65,7 @@ const Chat = ({ start }: { start: number | undefined }) => {
|
|
|
65
65
|
await readEvents(response.body, (name, data) => {
|
|
66
66
|
if (name === 'meta') {
|
|
67
67
|
conversation.current = (JSON.parse(data) as { conversation: number }).conversation
|
|
68
|
-
history.replaceState(null, '',
|
|
68
|
+
history.replaceState(null, '', `${base}/?c=${conversation.current}`)
|
|
69
69
|
} else if (name === 'token') {
|
|
70
70
|
soFar += JSON.parse(data) as string
|
|
71
71
|
setAnswer(soFar)
|
|
@@ -111,5 +111,7 @@ const Chat = ({ start }: { start: number | undefined }) => {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
const root = document.getElementById('chat')!
|
|
114
|
+
/** The path the app is mounted at: '' on its own server, `/app` in the ACRYL frame. The page writes it on the element. */
|
|
115
|
+
const base = root.dataset.base ?? ''
|
|
114
116
|
const start = root.dataset.conversation ? Number(root.dataset.conversation) : undefined
|
|
115
117
|
render(<Chat start={start} />, root)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app as the ACRYL frame hosts it (`npm run dev`): the generated layer mounts this row, which claims {@link FRAME_MOUNT_PREFIX} on the frame's web
|
|
3
|
+
* server. The app's own rows (the generated `app-plugins` row, which calls `composeInto`) mount the app at that prefix, so its page, its streaming API
|
|
4
|
+
* and its client modules answer at `/app`. Nothing here runs in production: `npm start` composes the app on its own server (no `routerHttp` service, so
|
|
5
|
+
* this row is not mounted there), where the app keeps the root as it always did.
|
|
6
|
+
*/
|
|
7
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
8
|
+
import { FRAME_MOUNT_PREFIX } from './routes.ts'
|
|
9
|
+
|
|
10
|
+
export const name = 'app-routes'
|
|
11
|
+
|
|
12
|
+
/** The frame always has it: its layer carries the router and the web adapter. */
|
|
13
|
+
export const inject = ['routerHttp']
|
|
14
|
+
|
|
15
|
+
export function apply(ctx: Context): void {
|
|
16
|
+
ctx.routerHttp.mount(FRAME_MOUNT_PREFIX)
|
|
17
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { Conversation, Message } from './db.ts'
|
|
2
2
|
import { doorEnabled } from './door.ts'
|
|
3
3
|
|
|
4
|
+
/** `base` is the path the app is mounted at ('' on its own server, `/app` in the ACRYL frame): every URL here, and the client's own, starts there. */
|
|
4
5
|
export const chatPage = (
|
|
5
6
|
conversations: Conversation[],
|
|
6
7
|
current: Conversation | undefined,
|
|
7
8
|
messages: Message[],
|
|
9
|
+
base = '',
|
|
8
10
|
): string =>
|
|
9
11
|
(
|
|
10
12
|
<html lang="en">
|
|
@@ -22,9 +24,9 @@ export const chatPage = (
|
|
|
22
24
|
<body>
|
|
23
25
|
<h1>Chat</h1>
|
|
24
26
|
<nav>
|
|
25
|
-
<a href=
|
|
27
|
+
<a href={`${base}/`}>New chat</a>
|
|
26
28
|
{conversations.map((c) => (
|
|
27
|
-
<a href={
|
|
29
|
+
<a href={`${base}/?c=${c.id}`}>{c.title}</a>
|
|
28
30
|
))}
|
|
29
31
|
</nav>
|
|
30
32
|
<div id="log">
|
|
@@ -32,8 +34,8 @@ export const chatPage = (
|
|
|
32
34
|
<p class={`row ${m.role}`}>{m.content}</p>
|
|
33
35
|
))}
|
|
34
36
|
</div>
|
|
35
|
-
<div id="chat" data-conversation={current ? String(current.id) : ''} />
|
|
36
|
-
<script type="module" src=
|
|
37
|
+
<div id="chat" data-conversation={current ? String(current.id) : ''} data-base={base} />
|
|
38
|
+
<script type="module" src={`${base}/client/chat.tsx`} />
|
|
37
39
|
{doorEnabled() ? <script type="module" src="/api/acryl-door/page.js" /> : null}
|
|
38
40
|
</body>
|
|
39
41
|
</html>
|
|
@@ -5,15 +5,25 @@ import { createApp } from './chat-app.ts'
|
|
|
5
5
|
import { openAiCompatible } from './llm.ts'
|
|
6
6
|
import { createClient } from './runtime.ts'
|
|
7
7
|
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* The only place the app's mount prefix is named: the root on its own server (`npm start`), `/app` when the ACRYL frame hosts it (`npm run dev`). Every
|
|
10
|
+
* URL the page writes is derived from the prefix the app is mounted at, so the standalone URLs do not move.
|
|
11
|
+
*/
|
|
12
|
+
export const FRAME_MOUNT_PREFIX = '/app'
|
|
13
|
+
|
|
14
|
+
/** The app's own plugin: it builds the app on the `db` port and mounts it on the router at `options.prefix` (the root when absent). The mounts are removed when the plugin is disposed. */
|
|
9
15
|
export const routes = {
|
|
10
16
|
name: 'routes',
|
|
11
17
|
inject: ['router', 'db'],
|
|
12
|
-
apply(ctx: Context) {
|
|
18
|
+
apply(ctx: Context, options: { prefix?: string } = {}) {
|
|
19
|
+
const prefix = options.prefix ?? ''
|
|
13
20
|
// In production the chat is closed unless it has a token or is made public on purpose: anyone who reaches it spends the model key.
|
|
14
21
|
const { access, notices } = chatAccess(process.env)
|
|
15
22
|
for (const line of notices) console.warn(line)
|
|
16
|
-
ctx.router.route(
|
|
17
|
-
|
|
23
|
+
ctx.router.route(
|
|
24
|
+
prefix === '' ? '/' : prefix,
|
|
25
|
+
createApp(ctx.db, openAiCompatible, access, { prefix }),
|
|
26
|
+
)
|
|
27
|
+
ctx.router.route(`${prefix}/client`, createClient(prefix))
|
|
18
28
|
},
|
|
19
29
|
}
|
|
@@ -11,7 +11,13 @@ const runtimeDir = join(
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Client components without a bundler: `src/client` is compiled when the browser asks; `npm run build` compiles it once for production
|
|
14
|
-
* (`NODE_ENV=production` then serves only `dist/client`). `src/routes.ts` mounts it at
|
|
14
|
+
* (`NODE_ENV=production` then serves only `dist/client`). `src/routes.ts` mounts it at `<prefix>/client` on the router, next to the app; the explicit
|
|
15
|
+
* mount is the path below which its modules are found, so the same app works at the root and under the frame's prefix.
|
|
15
16
|
*/
|
|
16
|
-
export const createClient = () =>
|
|
17
|
-
clientModules({
|
|
17
|
+
export const createClient = (prefix = '') =>
|
|
18
|
+
clientModules({
|
|
19
|
+
root: 'src/client',
|
|
20
|
+
compiled: 'dist/client',
|
|
21
|
+
runtimeDir,
|
|
22
|
+
mount: `${prefix}/client`,
|
|
23
|
+
})
|
|
@@ -4,13 +4,16 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { after, test } from 'node:test'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
7
8
|
import type {} from '../plugins/acryl-db/src/cordis.ts'
|
|
8
9
|
import type { Db } from '../plugins/acryl-db/src/index.ts'
|
|
10
|
+
import * as router from '../plugins/acryl-router/src/index.ts'
|
|
9
11
|
import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
|
|
10
12
|
import { chatAccess } from '../src/access.ts'
|
|
11
13
|
import { createApp as boot } from '../src/app.ts'
|
|
12
14
|
import { createApp } from '../src/chat-app.ts'
|
|
13
15
|
import type { Llm } from '../src/llm.ts'
|
|
16
|
+
import { FRAME_MOUNT_PREFIX } from '../src/routes.ts'
|
|
14
17
|
|
|
15
18
|
/** Throwaway homes and databases live inside the project (`.acrylblends/` is ignored by git), because a sandboxed agent may not write to the system temp folder. */
|
|
16
19
|
const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
|
|
@@ -24,11 +27,12 @@ const projectRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
|
24
27
|
*/
|
|
25
28
|
const stops: (() => Promise<void>)[] = []
|
|
26
29
|
after(() => Promise.all(stops.map((stop) => stop())))
|
|
27
|
-
async function booted() {
|
|
30
|
+
async function booted(options: { prefix?: string } = {}) {
|
|
28
31
|
const home = await mkdtemp(join(scratch, 'chat-test-'))
|
|
29
32
|
const app = await boot(withPort(pinnedInstance(home, { root: projectRoot }), 0), {
|
|
30
33
|
database: join(home, 'test.sqlite'),
|
|
31
34
|
listen: false,
|
|
35
|
+
...options,
|
|
32
36
|
})
|
|
33
37
|
stops.push(async () => {
|
|
34
38
|
await app.stop()
|
|
@@ -261,3 +265,44 @@ test('ACRYL_PRODUCTION_KEEP=chat makes the chat public with a loud WARNING and a
|
|
|
261
265
|
// Other routes are not counted.
|
|
262
266
|
assert.equal((await app.request('/api/conversations')).status, 200)
|
|
263
267
|
})
|
|
268
|
+
|
|
269
|
+
test('the frame mounts the chat at /app: the page, the streaming API and the client follow the prefix', async () => {
|
|
270
|
+
const running = await booted({ prefix: FRAME_MOUNT_PREFIX })
|
|
271
|
+
const page = await running.root.router.request(FRAME_MOUNT_PREFIX)
|
|
272
|
+
assert.equal(page.status, 200)
|
|
273
|
+
const html = await page.text()
|
|
274
|
+
assert.match(html, /<h1>Chat<\/h1>/u)
|
|
275
|
+
assert.match(html, /data-base="\/app"/u)
|
|
276
|
+
assert.match(html, /src="\/app\/client\/chat\.tsx"/u)
|
|
277
|
+
assert.equal((await running.root.router.request(`${FRAME_MOUNT_PREFIX}/health`)).status, 200)
|
|
278
|
+
// The streaming API answers at the prefix, with a local model.
|
|
279
|
+
const { llm } = mock(['Hel', 'lo'])
|
|
280
|
+
const root = new Context()
|
|
281
|
+
try {
|
|
282
|
+
await root.plugin(router)
|
|
283
|
+
root.router.route(
|
|
284
|
+
FRAME_MOUNT_PREFIX,
|
|
285
|
+
createApp(running.root.db, llm, undefined, { prefix: FRAME_MOUNT_PREFIX }),
|
|
286
|
+
)
|
|
287
|
+
const res = await root.router.request(`${FRAME_MOUNT_PREFIX}/api/chat`, post({ message: 'hi' }))
|
|
288
|
+
assert.equal(res.status, 200)
|
|
289
|
+
const got = events(await res.text())
|
|
290
|
+
assert.deepEqual(
|
|
291
|
+
got.map(([e]) => e),
|
|
292
|
+
['meta', 'token', 'token', 'done'],
|
|
293
|
+
)
|
|
294
|
+
// The app's own URLs are not mounted in the frame; its own server keeps them (the tests above).
|
|
295
|
+
assert.equal((await root.router.request('/health')).status, 404)
|
|
296
|
+
} finally {
|
|
297
|
+
await root.fiber.dispose()
|
|
298
|
+
}
|
|
299
|
+
// The client component is compiled and served below the app's prefix.
|
|
300
|
+
const module = await running.root.router.request(`${FRAME_MOUNT_PREFIX}/client/chat.tsx`)
|
|
301
|
+
assert.equal(module.status, 200)
|
|
302
|
+
assert.match(module.headers.get('content-type') ?? '', /javascript/u)
|
|
303
|
+
assert.equal(
|
|
304
|
+
(await running.root.router.request(`${FRAME_MOUNT_PREFIX}/client/@acrylblends/jsx-dom.js`))
|
|
305
|
+
.status,
|
|
306
|
+
200,
|
|
307
|
+
)
|
|
308
|
+
})
|
|
@@ -12,10 +12,24 @@ const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblend
|
|
|
12
12
|
mkdirSync(scratch, { recursive: true })
|
|
13
13
|
|
|
14
14
|
/** A sandbox may forbid listening on a port (Codex's workspace-write does); the tests that start the real server are skipped there, and every other test runs in process. */
|
|
15
|
+
/**
|
|
16
|
+
* Whether this environment forbids listening at all: only a sandbox's refusal (EPERM/EACCES) means the real-server test must skip. A loaded
|
|
17
|
+
* machine can fail a bind transiently, so that is retried briefly; any other outcome lets the real test run, where a host that truly cannot
|
|
18
|
+
* listen fails loudly instead of hiding behind a skip.
|
|
19
|
+
*/
|
|
15
20
|
const canListen = await new Promise<boolean>((done) => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
let attempts = 0
|
|
22
|
+
const probeOnce = (): void => {
|
|
23
|
+
attempts += 1
|
|
24
|
+
const probe = createServer()
|
|
25
|
+
probe.once('error', (error: NodeJS.ErrnoException) => {
|
|
26
|
+
if (error.code === 'EPERM' || error.code === 'EACCES') done(false)
|
|
27
|
+
else if (attempts < 3) setTimeout(probeOnce, 100)
|
|
28
|
+
else done(true)
|
|
29
|
+
})
|
|
30
|
+
probe.listen(0, '127.0.0.1', () => probe.close(() => done(true)))
|
|
31
|
+
}
|
|
32
|
+
probeOnce()
|
|
19
33
|
})
|
|
20
34
|
const skip = canListen ? false : 'this environment does not allow listening on a port'
|
|
21
35
|
if (!canListen)
|
|
@@ -17,7 +17,8 @@ spec:
|
|
|
17
17
|
name: acryl-router
|
|
18
18
|
- id: routes
|
|
19
19
|
name: routes
|
|
20
|
-
#
|
|
20
|
+
# Plugin folders without a row of their own (a live install with the door's `plugin add`); a folder with a row is the app's and starts by it.
|
|
21
|
+
# Read-only when NODE_ENV=production.
|
|
21
22
|
- id: extensions
|
|
22
23
|
name: extensions
|
|
23
24
|
- id: server
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"check": "tsc --noEmit && tsc --noEmit -p src/client && acrylblends check-deps",
|
|
18
18
|
"build": "acrylblends build-client",
|
|
19
19
|
"lock": "node plugins/manifest/lock.ts",
|
|
20
|
+
"snapshot": "node plugins/manifest/snapshot.ts",
|
|
20
21
|
"save": "node plugins/save/cli.ts",
|
|
21
22
|
"upgrade": "node plugins/manifest/upgrade-cli.ts"
|
|
22
23
|
},
|
|
@@ -7,15 +7,17 @@ import * as router from '../plugins/acryl-router/src/index.ts'
|
|
|
7
7
|
import { extensions } from '../plugins/extensions/index.ts'
|
|
8
8
|
import { type AppInstance, instance } from '../plugins/instance/index.ts'
|
|
9
9
|
import {
|
|
10
|
+
appPluginNames,
|
|
10
11
|
blueprintsIn,
|
|
11
12
|
compose,
|
|
13
|
+
loadAppPlugins,
|
|
12
14
|
loadManifest,
|
|
13
15
|
type ParameterValue,
|
|
14
16
|
} from '../plugins/manifest/index.ts'
|
|
15
17
|
import { doorEnabled } from './door.ts'
|
|
16
|
-
import { routes } from './routes.ts'
|
|
18
|
+
import { FRAME_MOUNT_PREFIX, routes } from './routes.ts'
|
|
17
19
|
|
|
18
|
-
/** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
|
|
20
|
+
/** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them, and adds the app's own plugins from `plugins/` by name. */
|
|
19
21
|
export const registry = {
|
|
20
22
|
'acryl-router': router,
|
|
21
23
|
'acryl-node-server': nodeServer,
|
|
@@ -28,6 +30,8 @@ const projectRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
|
28
30
|
|
|
29
31
|
/** The manifest next to this app's source. */
|
|
30
32
|
export const MANIFEST = join(projectRoot, 'blend.yaml')
|
|
33
|
+
/** The app's plugins: the library copies `registry` imports, and the app's own plugins (a feature each), which `blend.yaml` mounts by their folder name. */
|
|
34
|
+
const PLUGINS_DIR = join(projectRoot, 'plugins')
|
|
31
35
|
|
|
32
36
|
/**
|
|
33
37
|
* The app's manifest, resolved. A Blend finds its Blueprint in `blueprints/<id>.yaml`; an app that is itself a Blueprint never looks.
|
|
@@ -35,7 +39,7 @@ export const MANIFEST = join(projectRoot, 'blend.yaml')
|
|
|
35
39
|
*/
|
|
36
40
|
export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
|
|
37
41
|
return loadManifest(MANIFEST, {
|
|
38
|
-
known: Object.keys(registry),
|
|
42
|
+
known: [...Object.keys(registry), ...appPluginNames(PLUGINS_DIR)],
|
|
39
43
|
getBlueprint: blueprintsIn(join(projectRoot, 'blueprints')),
|
|
40
44
|
...(values ? { values } : {}),
|
|
41
45
|
})
|
|
@@ -61,33 +65,84 @@ export interface App {
|
|
|
61
65
|
stop(): Promise<void>
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
export interface AppOptions {
|
|
69
|
+
readonly values?: Readonly<Record<string, ParameterValue>>
|
|
70
|
+
/** The path the app is mounted at; absent on its own server, where it keeps the root, and {@link FRAME_MOUNT_PREFIX} in the frame. */
|
|
71
|
+
readonly prefix?: string
|
|
72
|
+
/** Start the HTTP server (default). Tests pass `false` and call `app.root.router.request(path)`: it needs no port, so it also runs where a sandbox forbids listening. */
|
|
73
|
+
readonly listen?: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The rows the frame host provides: the app contributes only the plugins that are its own. */
|
|
77
|
+
const HOST_ROWS = [
|
|
78
|
+
'instance',
|
|
79
|
+
'acryl-router',
|
|
80
|
+
'acryl-node-server',
|
|
81
|
+
'acryl-web-server',
|
|
82
|
+
'acryl-page',
|
|
83
|
+
'manifest',
|
|
84
|
+
'save',
|
|
85
|
+
'acryl-door',
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The app's rows, composed into `ctx`: the app's own server calls this from `createApp`, and the generated frame layer mounts it as one row, so the frame
|
|
90
|
+
* runs the app exactly as the app's own server does (same rows, same order, same config). `host: true` leaves the infrastructure rows to the frame host,
|
|
91
|
+
* which is where the door lives too, and mounts the app at {@link FRAME_MOUNT_PREFIX}.
|
|
92
|
+
*/
|
|
93
|
+
export async function composeInto(
|
|
94
|
+
ctx: Context,
|
|
66
95
|
chosen: AppInstance,
|
|
67
|
-
options:
|
|
68
|
-
): Promise<
|
|
96
|
+
options: AppOptions & { readonly host?: boolean } = {},
|
|
97
|
+
): Promise<Fiber[]> {
|
|
69
98
|
const read = readManifest(options.values)
|
|
70
|
-
const
|
|
71
|
-
//
|
|
72
|
-
const
|
|
73
|
-
|
|
99
|
+
const own = options.host !== true && options.listen !== false
|
|
100
|
+
// The frame host mounts the app under its own prefix; the app's own server keeps the root.
|
|
101
|
+
const prefix = options.prefix ?? (options.host === true ? FRAME_MOUNT_PREFIX : undefined)
|
|
102
|
+
// Rows left out: the server (and the door, which needs it) when the app is reached in process or inside the frame host; the door when it is switched
|
|
103
|
+
// off.
|
|
104
|
+
const leaveOut = new Set<string>(options.host === true ? HOST_ROWS : [])
|
|
105
|
+
if (!own) leaveOut.add('acryl-node-server').add('acryl-door')
|
|
74
106
|
if (!doorEnabled()) leaveOut.add('acryl-door')
|
|
75
|
-
if (process.env.NODE_ENV === 'production') leaveOut.add('extensions')
|
|
76
107
|
const manifest = without(read, ...leaveOut)
|
|
108
|
+
// The rows that name one of the app's own plugin folders (a feature: plugins/<name>/ and its row) rather than a registry import. Each one that runs
|
|
109
|
+
// is imported here; all of them, off or on, belong to their row, so the extensions row never starts them a second time.
|
|
110
|
+
const ownRows = [
|
|
111
|
+
...new Set(read.plugins.map((row) => row.use).filter((use) => !Object.hasOwn(registry, use))),
|
|
112
|
+
]
|
|
113
|
+
const ownPlugins = await loadAppPlugins(
|
|
114
|
+
PLUGINS_DIR,
|
|
115
|
+
ownRows.filter((use) => manifest.plugins.some((row) => row.use === use && !row.disabled)),
|
|
116
|
+
)
|
|
117
|
+
return compose(
|
|
118
|
+
ctx,
|
|
119
|
+
manifest,
|
|
120
|
+
{ ...registry, ...ownPlugins },
|
|
121
|
+
{
|
|
122
|
+
// The routes row mounts the pages (and the client modules) at the prefix the frame host chose.
|
|
123
|
+
routes: { ...(prefix === undefined ? {} : { prefix }) },
|
|
124
|
+
// The app's own plugins live in plugins/, next to the plugins copied from the library (plugins/library.json lists those, so they are not installed twice).
|
|
125
|
+
// In production they start read-only from where they are deployed: the app keeps its features, and nothing can be installed while it runs.
|
|
126
|
+
extensions: {
|
|
127
|
+
...(process.env.NODE_ENV === 'production' ? { readOnly: true } : {}),
|
|
128
|
+
sources: [{ dir: PLUGINS_DIR, trust: 'install' }],
|
|
129
|
+
rows: ownRows,
|
|
130
|
+
},
|
|
131
|
+
// Only the app's own server listens; the frame host owns the server and its port.
|
|
132
|
+
...(own ? { server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 } } : {}),
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
|
|
138
|
+
export async function createApp(chosen: AppInstance, options: AppOptions = {}): Promise<App> {
|
|
77
139
|
const root = new Context()
|
|
78
140
|
const first = root.plugin(instance, chosen)
|
|
79
141
|
await first.await()
|
|
80
|
-
const fibers: Fiber[] = [
|
|
81
|
-
first,
|
|
82
|
-
...(await compose(root, manifest, registry, {
|
|
83
|
-
// What is installed while the app runs lives in plugins/, next to the plugins copied from the library (plugins/library.json lists those, so they are not installed twice).
|
|
84
|
-
extensions: { sources: [{ dir: join(projectRoot, 'plugins'), trust: 'install' }] },
|
|
85
|
-
server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
|
|
86
|
-
})),
|
|
87
|
-
]
|
|
142
|
+
const fibers: Fiber[] = [first, ...(await composeInto(root, chosen, options))]
|
|
88
143
|
return {
|
|
89
144
|
root,
|
|
90
|
-
url: listen === false ? '' : root.nodeServer.url,
|
|
145
|
+
url: options.listen === false ? '' : root.nodeServer.url,
|
|
91
146
|
async stop() {
|
|
92
147
|
for (const fiber of [...fibers].reverse()) await fiber.dispose()
|
|
93
148
|
},
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app as the ACRYL frame hosts it (`npm run dev`): the generated layer mounts this row, which claims {@link FRAME_MOUNT_PREFIX} on the frame's web
|
|
3
|
+
* server. The app's own rows (the generated `app-plugins` row, which calls `composeInto`) mount the app at that prefix, so its pages and its client
|
|
4
|
+
* component answer at `/app`. Nothing here runs in production: `npm start` composes the app on its own server (no `routerHttp` service, so this row is not
|
|
5
|
+
* mounted there), where the app keeps the root as it always did.
|
|
6
|
+
*/
|
|
7
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
8
|
+
import { FRAME_MOUNT_PREFIX } from './routes.ts'
|
|
9
|
+
|
|
10
|
+
export const name = 'app-routes'
|
|
11
|
+
|
|
12
|
+
/** The frame always has it: its layer carries the router and the web adapter. */
|
|
13
|
+
export const inject = ['routerHttp']
|
|
14
|
+
|
|
15
|
+
export function apply(ctx: Context): void {
|
|
16
|
+
ctx.routerHttp.mount(FRAME_MOUNT_PREFIX)
|
|
17
|
+
}
|
|
@@ -15,14 +15,14 @@ export const failingPage = (): string => {
|
|
|
15
15
|
throw new Error('a page failed')
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/** A page with one client component: `src/client/counter.tsx` runs in the browser, compiled on request by `clientModules` (see src/routes.ts). */
|
|
19
|
-
export const counterPage = (): string =>
|
|
18
|
+
/** A page with one client component: `src/client/counter.tsx` runs in the browser, compiled on request by `clientModules` (see src/routes.ts). `prefix` is the path the app is mounted at, so the script URL follows it. */
|
|
19
|
+
export const counterPage = (prefix = ''): string =>
|
|
20
20
|
(
|
|
21
21
|
<html lang="en">
|
|
22
22
|
<body>
|
|
23
23
|
<h1>Counter</h1>
|
|
24
24
|
<div id="counter" />
|
|
25
|
-
<script type="module" src=
|
|
25
|
+
<script type="module" src={`${prefix}/client/counter.tsx`} />
|
|
26
26
|
{doorEnabled() ? <script type="module" src="/api/acryl-door/page.js" /> : null}
|
|
27
27
|
</body>
|
|
28
28
|
</html>
|
|
@@ -14,27 +14,46 @@ const runtimeDir = join(
|
|
|
14
14
|
|
|
15
15
|
export const APP_NAME = '__APP_NAME__'
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The only place the app's mount prefix is named: the root on its own server (`npm start`), `/app` when the ACRYL frame hosts it (`npm run dev`). Every
|
|
19
|
+
* URL the pages write is derived from the prefix the app is mounted at, so the standalone URLs do not move.
|
|
20
|
+
*/
|
|
21
|
+
export const FRAME_MOUNT_PREFIX = '/app'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The app's own pages, mountable at any prefix: the standalone server mounts it at the root, the frame row at {@link FRAME_MOUNT_PREFIX}.
|
|
25
|
+
* A page that carries a client component writes its URL from the prefix it is mounted at.
|
|
26
|
+
*/
|
|
27
|
+
export function makeApp(prefix = ''): Acryl {
|
|
28
|
+
return new Acryl()
|
|
29
|
+
.get('/', (c) => c.json({ app: APP_NAME, ok: true }))
|
|
30
|
+
.get('/health', (c) => c.json({ ok: true }))
|
|
31
|
+
.get('/about', (c) => c.html(aboutPage()))
|
|
32
|
+
.get('/counter', (c) => c.html(counterPage(prefix)))
|
|
33
|
+
}
|
|
23
34
|
|
|
24
|
-
|
|
35
|
+
/** The routes, in a file that does not import the Node adapter's server, so browser code can import the type: `import type { AppType } from '../routes.ts'`. */
|
|
36
|
+
export type AppType = ReturnType<typeof makeApp>
|
|
25
37
|
|
|
26
38
|
/**
|
|
27
|
-
* The app's own plugin: the
|
|
28
|
-
* compiles it once for production, and `NODE_ENV=production` then serves only `dist/client`). The mounts are
|
|
39
|
+
* The app's own plugin: the pages above at `options.prefix` (the root when absent), and the client components without a bundler (`src/client` is compiled
|
|
40
|
+
* when the browser asks; `npm run build` compiles it once for production, and `NODE_ENV=production` then serves only `dist/client`). The mounts are
|
|
41
|
+
* removed when the plugin is disposed.
|
|
29
42
|
*/
|
|
30
43
|
export const routes = {
|
|
31
44
|
name: 'routes',
|
|
32
45
|
inject: ['router'],
|
|
33
|
-
apply(ctx: Context) {
|
|
34
|
-
|
|
46
|
+
apply(ctx: Context, options: { prefix?: string } = {}) {
|
|
47
|
+
const prefix = options.prefix ?? ''
|
|
48
|
+
ctx.router.route(prefix === '' ? '/' : prefix, makeApp(prefix))
|
|
35
49
|
ctx.router.route(
|
|
36
|
-
|
|
37
|
-
clientModules({
|
|
50
|
+
`${prefix}/client`,
|
|
51
|
+
clientModules({
|
|
52
|
+
root: 'src/client',
|
|
53
|
+
compiled: 'dist/client',
|
|
54
|
+
runtimeDir,
|
|
55
|
+
mount: `${prefix}/client`,
|
|
56
|
+
}),
|
|
38
57
|
)
|
|
39
58
|
},
|
|
40
59
|
}
|
|
@@ -6,7 +6,7 @@ import { test } from 'node:test'
|
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
7
|
import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
|
|
8
8
|
import { type App, createApp } from '../src/app.ts'
|
|
9
|
-
import { APP_NAME } from '../src/routes.ts'
|
|
9
|
+
import { APP_NAME, FRAME_MOUNT_PREFIX } from '../src/routes.ts'
|
|
10
10
|
|
|
11
11
|
/** Throwaway homes and databases live inside the project (`.acrylblends/` is ignored by git), because a sandboxed agent may not write to the system temp folder. */
|
|
12
12
|
const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
|
|
@@ -14,11 +14,12 @@ mkdirSync(scratch, { recursive: true })
|
|
|
14
14
|
|
|
15
15
|
const projectRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
16
16
|
|
|
17
|
-
/** Boot the app in a throwaway home on any free port, run the test, always stop it and remove the home. */
|
|
18
|
-
async function withApp(run: (app: App) => Promise<void
|
|
17
|
+
/** Boot the app in a throwaway home on any free port, run the test, always stop it and remove the home. `prefix` mounts it where the frame does. */
|
|
18
|
+
async function withApp(run: (app: App) => Promise<void>, options: { prefix?: string } = {}) {
|
|
19
19
|
const home = await mkdtemp(join(scratch, 'app-test-'))
|
|
20
20
|
const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0), {
|
|
21
21
|
listen: false,
|
|
22
|
+
...options,
|
|
22
23
|
})
|
|
23
24
|
try {
|
|
24
25
|
await run(app)
|
|
@@ -76,3 +77,37 @@ test('GET /counter serves a page with a client component, and the component is c
|
|
|
76
77
|
const runtime = await app.root.router.request('/client/@acrylblends/jsx-dom.js')
|
|
77
78
|
assert.equal(runtime.status, 200)
|
|
78
79
|
}))
|
|
80
|
+
|
|
81
|
+
test('the frame mounts the app at /app: its pages, health route and client component follow the prefix', () =>
|
|
82
|
+
withApp(
|
|
83
|
+
async (app) => {
|
|
84
|
+
const root = await app.root.router.request(FRAME_MOUNT_PREFIX)
|
|
85
|
+
assert.equal(root.status, 200)
|
|
86
|
+
assert.deepEqual(await root.json(), { app: APP_NAME, ok: true })
|
|
87
|
+
const about = await app.root.router.request(`${FRAME_MOUNT_PREFIX}/about`)
|
|
88
|
+
assert.equal(about.status, 200)
|
|
89
|
+
assert.match(await about.text(), /<main><h1>About<\/h1>/u)
|
|
90
|
+
const health = await app.root.router.request(`${FRAME_MOUNT_PREFIX}/health`)
|
|
91
|
+
assert.equal(health.status, 200)
|
|
92
|
+
assert.deepEqual(await health.json(), { ok: true })
|
|
93
|
+
const page = await app.root.router.request(`${FRAME_MOUNT_PREFIX}/counter`)
|
|
94
|
+
assert.equal(page.status, 200)
|
|
95
|
+
assert.match(
|
|
96
|
+
await page.text(),
|
|
97
|
+
new RegExp(`<script type="module" src="${FRAME_MOUNT_PREFIX}/client/counter\\.tsx">`, 'u'),
|
|
98
|
+
)
|
|
99
|
+
const module = await app.root.router.request(`${FRAME_MOUNT_PREFIX}/client/counter.tsx`)
|
|
100
|
+
assert.equal(module.status, 200)
|
|
101
|
+
assert.match(module.headers.get('content-type') ?? '', /javascript/u)
|
|
102
|
+
assert.doesNotMatch(await module.text(), /<Counter|<button/u)
|
|
103
|
+
// The component's import of the DOM renderer is served below the app's prefix too.
|
|
104
|
+
assert.equal(
|
|
105
|
+
(await app.root.router.request(`${FRAME_MOUNT_PREFIX}/client/@acrylblends/jsx-dom.js`))
|
|
106
|
+
.status,
|
|
107
|
+
200,
|
|
108
|
+
)
|
|
109
|
+
// The app's own URLs are not mounted in the frame; its own server keeps them (the tests above).
|
|
110
|
+
assert.equal((await app.root.router.request('/about')).status, 404)
|
|
111
|
+
},
|
|
112
|
+
{ prefix: FRAME_MOUNT_PREFIX },
|
|
113
|
+
))
|
|
@@ -12,10 +12,24 @@ const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblend
|
|
|
12
12
|
mkdirSync(scratch, { recursive: true })
|
|
13
13
|
|
|
14
14
|
/** A sandbox may forbid listening on a port (Codex's workspace-write does); the tests that start the real server are skipped there, and every other test runs in process. */
|
|
15
|
+
/**
|
|
16
|
+
* Whether this environment forbids listening at all: only a sandbox's refusal (EPERM/EACCES) means the real-server test must skip. A loaded
|
|
17
|
+
* machine can fail a bind transiently, so that is retried briefly; any other outcome lets the real test run, where a host that truly cannot
|
|
18
|
+
* listen fails loudly instead of hiding behind a skip.
|
|
19
|
+
*/
|
|
15
20
|
const canListen = await new Promise<boolean>((done) => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
let attempts = 0
|
|
22
|
+
const probeOnce = (): void => {
|
|
23
|
+
attempts += 1
|
|
24
|
+
const probe = createServer()
|
|
25
|
+
probe.once('error', (error: NodeJS.ErrnoException) => {
|
|
26
|
+
if (error.code === 'EPERM' || error.code === 'EACCES') done(false)
|
|
27
|
+
else if (attempts < 3) setTimeout(probeOnce, 100)
|
|
28
|
+
else done(true)
|
|
29
|
+
})
|
|
30
|
+
probe.listen(0, '127.0.0.1', () => probe.close(() => done(true)))
|
|
31
|
+
}
|
|
32
|
+
probeOnce()
|
|
19
33
|
})
|
|
20
34
|
const skip = canListen ? false : 'this environment does not allow listening on a port'
|
|
21
35
|
if (!canListen)
|