acrylblends 1.1.0 → 1.2.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 +45 -1
- package/dist/index.js +1980 -27
- 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 +1 -1
- package/docs/framework/workshop/README.md +138 -152
- 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 +42 -7
- package/lib/cordis/door.js +204 -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/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 +53 -23
- 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 +3 -0
- package/plugins/acryl-door/src/index.ts +166 -29
- 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/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 +26 -1
- package/plugins/extensions/resolve.ts +13 -0
- 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/snapshot.ts +275 -0
- package/plugins/openai/index.ts +64 -5
- package/plugins/openai/prompt.ts +23 -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/AGENTS.md +1 -1
- package/templates/agent/blend.yaml +4 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +1 -0
- package/templates/agent/src/app.ts +150 -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 +2 -0
- package/templates/agent/test/app.test.ts +45 -3
- 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/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/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/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/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
|
@@ -20,7 +20,8 @@ spec:
|
|
|
20
20
|
name: acryl-db-sqlite
|
|
21
21
|
- id: routes
|
|
22
22
|
name: routes
|
|
23
|
-
#
|
|
23
|
+
# 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.
|
|
24
|
+
# Read-only when NODE_ENV=production.
|
|
24
25
|
- id: extensions
|
|
25
26
|
name: extensions
|
|
26
27
|
- 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
|
},
|
|
@@ -8,13 +8,15 @@ import * as router from '../plugins/acryl-router/src/index.ts'
|
|
|
8
8
|
import { extensions } from '../plugins/extensions/index.ts'
|
|
9
9
|
import { type AppInstance, instance } from '../plugins/instance/index.ts'
|
|
10
10
|
import {
|
|
11
|
+
appPluginNames,
|
|
11
12
|
blueprintsIn,
|
|
12
13
|
compose,
|
|
14
|
+
loadAppPlugins,
|
|
13
15
|
loadManifest,
|
|
14
16
|
type ParameterValue,
|
|
15
17
|
} from '../plugins/manifest/index.ts'
|
|
16
18
|
import { doorEnabled } from './door.ts'
|
|
17
|
-
import { routes } from './routes.ts'
|
|
19
|
+
import { FRAME_MOUNT_PREFIX, routes } from './routes.ts'
|
|
18
20
|
|
|
19
21
|
/** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
|
|
20
22
|
export const registry = {
|
|
@@ -30,11 +32,13 @@ const projectRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
|
30
32
|
|
|
31
33
|
/** The manifest next to this app's source. */
|
|
32
34
|
export const MANIFEST = join(projectRoot, 'blend.yaml')
|
|
35
|
+
/** 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. */
|
|
36
|
+
const PLUGINS_DIR = join(projectRoot, 'plugins')
|
|
33
37
|
|
|
34
38
|
/** The manifest, resolved. `createApp` and the tests read it the same way. */
|
|
35
39
|
export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
|
|
36
40
|
return loadManifest(MANIFEST, {
|
|
37
|
-
known: Object.keys(registry),
|
|
41
|
+
known: [...Object.keys(registry), ...appPluginNames(PLUGINS_DIR)],
|
|
38
42
|
getBlueprint: blueprintsIn(join(projectRoot, 'blueprints')),
|
|
39
43
|
...(values ? { values } : {}),
|
|
40
44
|
})
|
|
@@ -64,42 +68,88 @@ export interface AppOptions {
|
|
|
64
68
|
readonly values?: Readonly<Record<string, ParameterValue>>
|
|
65
69
|
/** The SQLite file, relative to the app folder; an absolute path is used as it is (tests give a file in a throwaway home). Default: `data/notes.sqlite`. */
|
|
66
70
|
readonly database?: string
|
|
71
|
+
/** 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. */
|
|
72
|
+
readonly prefix?: string
|
|
67
73
|
/** 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. */
|
|
68
74
|
readonly listen?: boolean
|
|
69
75
|
}
|
|
70
76
|
|
|
77
|
+
/** The rows the frame host provides: the app contributes only the plugins that are its own. */
|
|
78
|
+
const HOST_ROWS = [
|
|
79
|
+
'instance',
|
|
80
|
+
'acryl-router',
|
|
81
|
+
'acryl-node-server',
|
|
82
|
+
'acryl-web-server',
|
|
83
|
+
'acryl-page',
|
|
84
|
+
'manifest',
|
|
85
|
+
'save',
|
|
86
|
+
'acryl-door',
|
|
87
|
+
]
|
|
88
|
+
|
|
71
89
|
/**
|
|
72
|
-
* The app
|
|
73
|
-
*
|
|
90
|
+
* 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
|
|
91
|
+
* 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,
|
|
92
|
+
* which is where the door lives too, and mounts the app at {@link FRAME_MOUNT_PREFIX}. The `db` row opens the database and applies `migrations/`; the one
|
|
93
|
+
* place that names the SQLite provider is this file (`acrylblends check-deps`), so a different provider is a change here.
|
|
74
94
|
*/
|
|
75
|
-
export async function
|
|
95
|
+
export async function composeInto(
|
|
96
|
+
ctx: Context,
|
|
97
|
+
chosen: AppInstance,
|
|
98
|
+
options: AppOptions & { readonly host?: boolean } = {},
|
|
99
|
+
): Promise<Fiber[]> {
|
|
76
100
|
const read = readManifest(options.values)
|
|
77
|
-
const
|
|
78
|
-
//
|
|
79
|
-
const
|
|
80
|
-
|
|
101
|
+
const own = options.host !== true && options.listen !== false
|
|
102
|
+
// The frame host mounts the app under its own prefix; the app's own server keeps the root.
|
|
103
|
+
const prefix = options.prefix ?? (options.host === true ? FRAME_MOUNT_PREFIX : undefined)
|
|
104
|
+
// 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
|
|
105
|
+
// off.
|
|
106
|
+
const leaveOut = new Set<string>(options.host === true ? HOST_ROWS : [])
|
|
107
|
+
if (!own) leaveOut.add('acryl-node-server').add('acryl-door')
|
|
81
108
|
if (!doorEnabled()) leaveOut.add('acryl-door')
|
|
82
|
-
if (process.env.NODE_ENV === 'production') leaveOut.add('extensions')
|
|
83
109
|
const manifest = without(read, ...leaveOut)
|
|
110
|
+
// 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
|
|
111
|
+
// is imported here; all of them, off or on, belong to their row, so the extensions row never starts them a second time.
|
|
112
|
+
const ownRows = [
|
|
113
|
+
...new Set(read.plugins.map((row) => row.use).filter((use) => !Object.hasOwn(registry, use))),
|
|
114
|
+
]
|
|
115
|
+
const ownPlugins = await loadAppPlugins(
|
|
116
|
+
PLUGINS_DIR,
|
|
117
|
+
ownRows.filter((use) => manifest.plugins.some((row) => row.use === use && !row.disabled)),
|
|
118
|
+
)
|
|
84
119
|
const file = options.database ?? 'data/notes.sqlite'
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
...(await compose(root, manifest, registry, {
|
|
120
|
+
return compose(
|
|
121
|
+
ctx,
|
|
122
|
+
manifest,
|
|
123
|
+
{ ...registry, ...ownPlugins },
|
|
124
|
+
{
|
|
91
125
|
db: {
|
|
92
126
|
path: isAbsolute(file) ? file : resolve(projectRoot, file),
|
|
93
127
|
migrations: join(projectRoot, 'migrations'),
|
|
94
128
|
},
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
129
|
+
// The routes row mounts the app (and its client modules) at the prefix the frame host chose.
|
|
130
|
+
routes: { ...(prefix === undefined ? {} : { prefix }) },
|
|
131
|
+
// 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).
|
|
132
|
+
// In production they start read-only from where they are deployed: the app keeps its features, and nothing can be installed while it runs.
|
|
133
|
+
extensions: {
|
|
134
|
+
...(process.env.NODE_ENV === 'production' ? { readOnly: true } : {}),
|
|
135
|
+
sources: [{ dir: PLUGINS_DIR, trust: 'install' }],
|
|
136
|
+
rows: ownRows,
|
|
137
|
+
},
|
|
138
|
+
// Only the app's own server listens; the frame host owns the server and its port.
|
|
139
|
+
...(own ? { server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 } } : {}),
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
|
|
145
|
+
export async function createApp(chosen: AppInstance, options: AppOptions = {}): Promise<App> {
|
|
146
|
+
const root = new Context()
|
|
147
|
+
const first = root.plugin(instance, chosen)
|
|
148
|
+
await first.await()
|
|
149
|
+
const fibers: Fiber[] = [first, ...(await composeInto(root, chosen, options))]
|
|
100
150
|
return {
|
|
101
151
|
root,
|
|
102
|
-
url: listen === false ? '' : root.nodeServer.url,
|
|
152
|
+
url: options.listen === false ? '' : root.nodeServer.url,
|
|
103
153
|
async stop() {
|
|
104
154
|
for (const fiber of [...fibers].reverse()) await fiber.dispose()
|
|
105
155
|
},
|
|
@@ -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
|
+
* modules 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
|
+
}
|
|
@@ -24,6 +24,8 @@ export interface AppOptions {
|
|
|
24
24
|
trustProxy?: boolean
|
|
25
25
|
/** The address people use in the browser, for example `https://notes.example.com`. Behind a proxy the app sees `http://127.0.0.1:3000`, so csrf needs it. Default: PUBLIC_ORIGIN. */
|
|
26
26
|
publicOrigin?: string
|
|
27
|
+
/** The path the app is mounted at: '' on its own server, `/app` in the ACRYL frame. Every link, form action and redirect is written from it. */
|
|
28
|
+
prefix?: string
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export function createApi(db: Db) {
|
|
@@ -74,11 +76,14 @@ export function createApp(
|
|
|
74
76
|
{
|
|
75
77
|
trustProxy = process.env.TRUST_PROXY === '1',
|
|
76
78
|
publicOrigin = process.env.PUBLIC_ORIGIN,
|
|
79
|
+
prefix = '',
|
|
77
80
|
}: AppOptions = {},
|
|
78
81
|
) {
|
|
79
82
|
const auth = authStore(db)
|
|
80
83
|
const notes = notesStore(db)
|
|
81
84
|
const app = new Acryl<Env>()
|
|
85
|
+
// Every URL the pages and redirects write starts at the path the app is mounted at.
|
|
86
|
+
const at = (path: string): string => `${prefix}${path}`
|
|
82
87
|
// Refuses a form POST that does not come from this site (checks Origin and Sec-Fetch-Site); JSON requests are not form-shaped and pass.
|
|
83
88
|
app.use(csrf(publicOrigin ? { origin: [publicOrigin] } : undefined))
|
|
84
89
|
// A secure cookie is only sent over HTTPS: set when the request is HTTPS, or a proxy we trust says it was.
|
|
@@ -100,14 +105,14 @@ export function createApp(
|
|
|
100
105
|
|
|
101
106
|
const requireUser = createMiddleware<Env>(async (c, next) => {
|
|
102
107
|
const user = await auth.userFor(getCookie(c, COOKIE))
|
|
103
|
-
if (!user) return c.redirect('/login')
|
|
108
|
+
if (!user) return c.redirect(at('/login'))
|
|
104
109
|
c.set('user', user)
|
|
105
110
|
await next()
|
|
106
111
|
})
|
|
107
112
|
|
|
108
|
-
app.get('/', (c) => c.redirect('/notes'))
|
|
109
|
-
app.get('/signup', (c) => c.html(authPage('signup')))
|
|
110
|
-
app.get('/login', (c) => c.html(authPage('login')))
|
|
113
|
+
app.get('/', (c) => c.redirect(at('/notes')))
|
|
114
|
+
app.get('/signup', (c) => c.html(authPage('signup', [], '', prefix)))
|
|
115
|
+
app.get('/login', (c) => c.html(authPage('login', [], '', prefix)))
|
|
111
116
|
|
|
112
117
|
app.post('/signup', async (c) => {
|
|
113
118
|
const form = await c.req.parseBody()
|
|
@@ -117,10 +122,10 @@ export function createApp(
|
|
|
117
122
|
const errors = checkCredentials(email, form.password, true)
|
|
118
123
|
if (errors.length === 0 && (await auth.findUser(email)))
|
|
119
124
|
errors.push('That email already has an account. Log in instead.')
|
|
120
|
-
if (errors.length > 0) return c.html(authPage('signup', errors, email), 400)
|
|
125
|
+
if (errors.length > 0) return c.html(authPage('signup', errors, email, prefix), 400)
|
|
121
126
|
const id = await auth.createUser(email, String(form.password))
|
|
122
127
|
setCookie(c, COOKIE, await auth.startSession(id), session(c))
|
|
123
|
-
return c.redirect('/notes', 303)
|
|
128
|
+
return c.redirect(at('/notes'), 303)
|
|
124
129
|
})
|
|
125
130
|
|
|
126
131
|
app.post('/login', async (c) => {
|
|
@@ -129,19 +134,19 @@ export function createApp(
|
|
|
129
134
|
.trim()
|
|
130
135
|
.toLowerCase()
|
|
131
136
|
const errors = checkCredentials(email, form.password, false)
|
|
132
|
-
if (errors.length > 0) return c.html(authPage('login', errors, email), 400)
|
|
137
|
+
if (errors.length > 0) return c.html(authPage('login', errors, email, prefix), 400)
|
|
133
138
|
const user = await auth.findUser(email)
|
|
134
139
|
if (!user || !(await verifyPassword(String(form.password), user.password_hash)))
|
|
135
|
-
return c.html(authPage('login', ['Wrong email or password.'], email), 401)
|
|
140
|
+
return c.html(authPage('login', ['Wrong email or password.'], email, prefix), 401)
|
|
136
141
|
setCookie(c, COOKIE, await auth.startSession(user.id), session(c))
|
|
137
|
-
return c.redirect('/notes', 303)
|
|
142
|
+
return c.redirect(at('/notes'), 303)
|
|
138
143
|
})
|
|
139
144
|
|
|
140
145
|
app.post('/logout', async (c) => {
|
|
141
146
|
const token = getCookie(c, COOKIE)
|
|
142
147
|
if (token) await auth.endSession(token)
|
|
143
148
|
deleteCookie(c, COOKIE, { path: '/' })
|
|
144
|
-
return c.redirect('/login', 303)
|
|
149
|
+
return c.redirect(at('/login'), 303)
|
|
145
150
|
})
|
|
146
151
|
|
|
147
152
|
app.use('/notes/*', requireUser)
|
|
@@ -149,10 +154,10 @@ export function createApp(
|
|
|
149
154
|
|
|
150
155
|
app.get('/notes', async (c) => {
|
|
151
156
|
const q = (c.req.query('q') ?? '').trim()
|
|
152
|
-
return c.html(listPage(c.get('user').email, await notes.list(c.get('user').id, q), q))
|
|
157
|
+
return c.html(listPage(c.get('user').email, await notes.list(c.get('user').id, q), q, prefix))
|
|
153
158
|
})
|
|
154
159
|
app.get('/notes/new', (c) =>
|
|
155
|
-
c.html(formPage(c.get('user').email, '/notes', { title: '', body: '' })),
|
|
160
|
+
c.html(formPage(c.get('user').email, '/notes', { title: '', body: '' }, [], prefix)),
|
|
156
161
|
)
|
|
157
162
|
app.post('/notes', async (c) => {
|
|
158
163
|
const form = await c.req.parseBody()
|
|
@@ -164,17 +169,18 @@ export function createApp(
|
|
|
164
169
|
'/notes',
|
|
165
170
|
{ title: String(form.title ?? ''), body: String(form.body ?? '') },
|
|
166
171
|
checked.issues.map((i) => i.message),
|
|
172
|
+
prefix,
|
|
167
173
|
),
|
|
168
174
|
400,
|
|
169
175
|
)
|
|
170
176
|
await notes.create(c.get('user').id, checked.value.title, checked.value.body)
|
|
171
|
-
return c.redirect('/notes', 303)
|
|
177
|
+
return c.redirect(at('/notes'), 303)
|
|
172
178
|
})
|
|
173
179
|
app.get('/notes/:id/edit', async (c) => {
|
|
174
180
|
const note = await notes.get(c.get('user').id, Number(c.req.param('id')))
|
|
175
181
|
return note
|
|
176
|
-
? c.html(formPage(c.get('user').email, `/notes/${note.id}`, note))
|
|
177
|
-
: c.html(listPage(c.get('user').email, [], ''), 404)
|
|
182
|
+
? c.html(formPage(c.get('user').email, `/notes/${note.id}`, note, [], prefix))
|
|
183
|
+
: c.html(listPage(c.get('user').email, [], '', prefix), 404)
|
|
178
184
|
})
|
|
179
185
|
app.post('/notes/:id', async (c) => {
|
|
180
186
|
const id = Number(c.req.param('id'))
|
|
@@ -187,17 +193,18 @@ export function createApp(
|
|
|
187
193
|
`/notes/${id}`,
|
|
188
194
|
{ title: String(form.title ?? ''), body: String(form.body ?? '') },
|
|
189
195
|
checked.issues.map((i) => i.message),
|
|
196
|
+
prefix,
|
|
190
197
|
),
|
|
191
198
|
400,
|
|
192
199
|
)
|
|
193
200
|
if (!(await notes.update(c.get('user').id, id, checked.value.title, checked.value.body)))
|
|
194
201
|
return c.text('No such note.', 404)
|
|
195
|
-
return c.redirect('/notes', 303)
|
|
202
|
+
return c.redirect(at('/notes'), 303)
|
|
196
203
|
})
|
|
197
204
|
app.post('/notes/:id/delete', async (c) => {
|
|
198
205
|
if (!(await notes.remove(c.get('user').id, Number(c.req.param('id')))))
|
|
199
206
|
return c.text('No such note.', 404)
|
|
200
|
-
return c.redirect('/notes', 303)
|
|
207
|
+
return c.redirect(at('/notes'), 303)
|
|
201
208
|
})
|
|
202
209
|
|
|
203
210
|
app.route('/api/notes', createApi(db))
|
|
@@ -3,12 +3,19 @@ import type {} from '../plugins/acryl-db/src/cordis.ts'
|
|
|
3
3
|
import { createApp } from './notes-app.ts'
|
|
4
4
|
import { createClient } from './runtime.ts'
|
|
5
5
|
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* 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
|
|
8
|
+
* URL the pages write is derived from the prefix the app is mounted at, so the standalone URLs do not move.
|
|
9
|
+
*/
|
|
10
|
+
export const FRAME_MOUNT_PREFIX = '/app'
|
|
11
|
+
|
|
12
|
+
/** 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. */
|
|
7
13
|
export const routes = {
|
|
8
14
|
name: 'routes',
|
|
9
15
|
inject: ['router', 'db'],
|
|
10
|
-
apply(ctx: Context) {
|
|
11
|
-
|
|
12
|
-
ctx.router.route('/
|
|
16
|
+
apply(ctx: Context, options: { prefix?: string } = {}) {
|
|
17
|
+
const prefix = options.prefix ?? ''
|
|
18
|
+
ctx.router.route(prefix === '' ? '/' : prefix, createApp(ctx.db, { prefix }))
|
|
19
|
+
ctx.router.route(`${prefix}/client`, createClient(prefix))
|
|
13
20
|
},
|
|
14
21
|
}
|
|
@@ -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
|
+
})
|
|
@@ -11,13 +11,16 @@ if (filter) filter.addEventListener('input', () => {
|
|
|
11
11
|
})
|
|
12
12
|
`
|
|
13
13
|
|
|
14
|
+
/** `base` is the path the app is mounted at: an empty string on its own server, `/app` when the ACRYL frame hosts it. Every URL here starts there. */
|
|
14
15
|
const Layout = ({
|
|
15
16
|
title,
|
|
16
17
|
user,
|
|
18
|
+
base,
|
|
17
19
|
children,
|
|
18
20
|
}: {
|
|
19
21
|
title: string
|
|
20
22
|
user?: string
|
|
23
|
+
base: string
|
|
21
24
|
children?: unknown
|
|
22
25
|
}) => (
|
|
23
26
|
<html lang="en">
|
|
@@ -37,23 +40,23 @@ const Layout = ({
|
|
|
37
40
|
<strong>Notes</strong>
|
|
38
41
|
{user ? (
|
|
39
42
|
<>
|
|
40
|
-
<a href=
|
|
41
|
-
<a href=
|
|
42
|
-
<form method="post" action=
|
|
43
|
+
<a href={`${base}/notes`}>All notes</a>
|
|
44
|
+
<a href={`${base}/notes/new`}>New note</a>
|
|
45
|
+
<form method="post" action={`${base}/logout`}>
|
|
43
46
|
<span>{user} </span>
|
|
44
47
|
<button type="submit">Log out</button>
|
|
45
48
|
</form>
|
|
46
49
|
</>
|
|
47
50
|
) : (
|
|
48
51
|
<>
|
|
49
|
-
<a href=
|
|
50
|
-
<a href=
|
|
52
|
+
<a href={`${base}/login`}>Log in</a>
|
|
53
|
+
<a href={`${base}/signup`}>Sign up</a>
|
|
51
54
|
</>
|
|
52
55
|
)}
|
|
53
56
|
</nav>
|
|
54
57
|
{children}
|
|
55
58
|
<script dangerouslySetInnerHTML={{ __html: counterScript }} />
|
|
56
|
-
<script type="module" src=
|
|
59
|
+
<script type="module" src={`${base}/client/counter.tsx`} />
|
|
57
60
|
{doorEnabled() ? <script type="module" src="/api/acryl-door/page.js" /> : null}
|
|
58
61
|
</body>
|
|
59
62
|
</html>
|
|
@@ -68,12 +71,17 @@ const Errors = ({ errors }: { errors: string[] }) =>
|
|
|
68
71
|
</ul>
|
|
69
72
|
) : null
|
|
70
73
|
|
|
71
|
-
export const authPage = (
|
|
74
|
+
export const authPage = (
|
|
75
|
+
mode: 'login' | 'signup',
|
|
76
|
+
errors: string[] = [],
|
|
77
|
+
email = '',
|
|
78
|
+
base = '',
|
|
79
|
+
): string =>
|
|
72
80
|
(
|
|
73
|
-
<Layout title={mode === 'login' ? 'Log in' : 'Sign up'}>
|
|
81
|
+
<Layout title={mode === 'login' ? 'Log in' : 'Sign up'} base={base}>
|
|
74
82
|
<h1>{mode === 'login' ? 'Log in' : 'Sign up'}</h1>
|
|
75
83
|
<Errors errors={errors} />
|
|
76
|
-
<form method="post" action={
|
|
84
|
+
<form method="post" action={`${base}/${mode}`}>
|
|
77
85
|
<label for="email">Email</label>
|
|
78
86
|
<input id="email" name="email" type="email" value={email} />
|
|
79
87
|
<label for="password">Password</label>
|
|
@@ -85,11 +93,11 @@ export const authPage = (mode: 'login' | 'signup', errors: string[] = [], email
|
|
|
85
93
|
</Layout>
|
|
86
94
|
).toString()
|
|
87
95
|
|
|
88
|
-
export const listPage = (user: string, notes: Note[], q: string): string =>
|
|
96
|
+
export const listPage = (user: string, notes: Note[], q: string, base = ''): string =>
|
|
89
97
|
(
|
|
90
|
-
<Layout title="Notes" user={user}>
|
|
98
|
+
<Layout title="Notes" user={user} base={base}>
|
|
91
99
|
<h1>Your notes</h1>
|
|
92
|
-
<form method="get" action=
|
|
100
|
+
<form method="get" action={`${base}/notes`}>
|
|
93
101
|
<label for="filter">Search</label>
|
|
94
102
|
<input id="filter" name="q" type="search" value={q} />
|
|
95
103
|
<button type="submit">Search</button>
|
|
@@ -98,8 +106,8 @@ export const listPage = (user: string, notes: Note[], q: string): string =>
|
|
|
98
106
|
<ul>
|
|
99
107
|
{notes.map((n) => (
|
|
100
108
|
<li data-note>
|
|
101
|
-
<a href={
|
|
102
|
-
<form method="post" action={
|
|
109
|
+
<a href={`${base}/notes/${n.id}/edit`}>{n.title}</a>
|
|
110
|
+
<form method="post" action={`${base}/notes/${n.id}/delete`} style="display:inline">
|
|
103
111
|
<button type="submit">Delete</button>
|
|
104
112
|
</form>
|
|
105
113
|
</li>
|
|
@@ -113,12 +121,13 @@ export const formPage = (
|
|
|
113
121
|
action: string,
|
|
114
122
|
note: { title: string; body: string },
|
|
115
123
|
errors: string[] = [],
|
|
124
|
+
base = '',
|
|
116
125
|
): string =>
|
|
117
126
|
(
|
|
118
|
-
<Layout title="Note" user={user}>
|
|
127
|
+
<Layout title="Note" user={user} base={base}>
|
|
119
128
|
<h1>{action === '/notes' ? 'New note' : 'Edit note'}</h1>
|
|
120
129
|
<Errors errors={errors} />
|
|
121
|
-
<form method="post" action={action}>
|
|
130
|
+
<form method="post" action={`${base}${action}`}>
|
|
122
131
|
<label for="title">Title</label>
|
|
123
132
|
<input id="title" name="title" value={note.title} />
|
|
124
133
|
<label for="body">Note</label>
|
|
@@ -127,7 +136,7 @@ export const formPage = (
|
|
|
127
136
|
</textarea>
|
|
128
137
|
<small id="count" aria-live="polite" data-island="counter" />
|
|
129
138
|
<p>
|
|
130
|
-
<button type="submit">Save</button> <a href=
|
|
139
|
+
<button type="submit">Save</button> <a href={`${base}/notes`}>Cancel</a>
|
|
131
140
|
</p>
|
|
132
141
|
</form>
|
|
133
142
|
</Layout>
|
|
@@ -23,11 +23,12 @@ const projectRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
|
23
23
|
*/
|
|
24
24
|
const stops: (() => Promise<void>)[] = []
|
|
25
25
|
after(() => Promise.all(stops.map((stop) => stop())))
|
|
26
|
-
async function booted() {
|
|
26
|
+
async function booted(options: { prefix?: string } = {}) {
|
|
27
27
|
const home = await mkdtemp(join(scratch, 'notes-test-'))
|
|
28
28
|
const app = await boot(withPort(pinnedInstance(home, { root: projectRoot }), 0), {
|
|
29
29
|
database: join(home, 'test.sqlite'),
|
|
30
30
|
listen: false,
|
|
31
|
+
...options,
|
|
31
32
|
})
|
|
32
33
|
stops.push(async () => {
|
|
33
34
|
await app.stop()
|
|
@@ -278,3 +279,40 @@ test('the note form loads the character counter, a client component compiled for
|
|
|
278
279
|
assert.equal((await running.root.router.request('/client/@acrylblends/jsx-dom.js')).status, 200)
|
|
279
280
|
assert.equal((await running.root.router.request('/client/../src/db.ts')).status, 404)
|
|
280
281
|
})
|
|
282
|
+
|
|
283
|
+
test('the frame mounts the app at /app: the login page, its links and redirects follow the prefix, and the client module is served there', async () => {
|
|
284
|
+
const running = await booted({ prefix: '/app' })
|
|
285
|
+
const root = running.root
|
|
286
|
+
const home = await root.router.request('/app')
|
|
287
|
+
assert.equal(home.status, 302)
|
|
288
|
+
assert.equal(home.headers.get('location'), '/app/notes')
|
|
289
|
+
const notes = await root.router.request('/app/notes')
|
|
290
|
+
assert.equal(notes.status, 302)
|
|
291
|
+
assert.equal(notes.headers.get('location'), '/app/login')
|
|
292
|
+
const login = await root.router.request('/app/login')
|
|
293
|
+
assert.equal(login.status, 200)
|
|
294
|
+
const loginHtml = await login.text()
|
|
295
|
+
assert.match(loginHtml, /action="\/app\/login"/u)
|
|
296
|
+
assert.match(loginHtml, /href="\/app\/signup"/u)
|
|
297
|
+
assert.match(loginHtml, /src="\/app\/client\/counter\.tsx"/u)
|
|
298
|
+
// Signing up through the framed path lands on the framed notes page, and its links stay under the prefix.
|
|
299
|
+
const signup = await root.router.request(
|
|
300
|
+
'/app/signup',
|
|
301
|
+
form({ email: 'framed@example.com', password: 'correct horse' }),
|
|
302
|
+
)
|
|
303
|
+
assert.equal(signup.status, 303)
|
|
304
|
+
assert.equal(signup.headers.get('location'), '/app/notes')
|
|
305
|
+
const cookie = (signup.headers.get('set-cookie') ?? '').split(';')[0] ?? ''
|
|
306
|
+
const list = await root.router.request('/app/notes', { headers: { cookie } })
|
|
307
|
+
assert.equal(list.status, 200)
|
|
308
|
+
const listHtml = await list.text()
|
|
309
|
+
assert.match(listHtml, /href="\/app\/notes\/new"/u)
|
|
310
|
+
assert.match(listHtml, /action="\/app\/notes"/u)
|
|
311
|
+
// The client component is compiled and served below the app's prefix.
|
|
312
|
+
const module = await root.router.request('/app/client/counter.tsx')
|
|
313
|
+
assert.equal(module.status, 200)
|
|
314
|
+
assert.match(module.headers.get('content-type') ?? '', /javascript/u)
|
|
315
|
+
assert.equal((await root.router.request('/app/client/@acrylblends/jsx-dom.js')).status, 200)
|
|
316
|
+
// The app's own URLs are not mounted in the frame; its own server keeps them (the tests above).
|
|
317
|
+
assert.equal((await root.router.request('/login')).status, 404)
|
|
318
|
+
})
|
|
@@ -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)
|
|
@@ -23,3 +23,6 @@ spec:
|
|
|
23
23
|
name: acryl-node-server
|
|
24
24
|
config:
|
|
25
25
|
hostname: "{{parameters.hostname}}"
|
|
26
|
+
# The door: an agent working in this folder looks at the page and acts on it through .acryl/bin/acryl. Development only: src/app.ts leaves it out when NODE_ENV=production.
|
|
27
|
+
- id: acryl-door
|
|
28
|
+
name: acryl-door
|
package/templates/todo/gitignore
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"test": "node --import ./plugins/acryl-node-server/src/register.ts --test \"test/**/*.test.{ts,tsx}\"",
|
|
17
17
|
"check": "tsc --noEmit",
|
|
18
18
|
"lock": "node plugins/manifest/lock.ts",
|
|
19
|
+
"snapshot": "node plugins/manifest/snapshot.ts",
|
|
19
20
|
"save": "node plugins/save/cli.ts",
|
|
20
21
|
"upgrade": "node plugins/manifest/upgrade-cli.ts"
|
|
21
22
|
},
|