acrylblends 1.3.0 → 1.3.2
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.js +5 -3
- package/dist/index.js.map +1 -1
- package/docs/framework/workshop/README.md +2 -0
- package/legacy/README.md +4 -1
- package/legacy/agent/858de7a1ad0b/src/app.ts +289 -0
- package/legacy/agent/858de7a1ad0b/src/routes.ts +145 -0
- package/legacy/agent/b96ba6e3598a/src/app.ts +285 -0
- package/legacy/agent/b96ba6e3598a/src/routes.ts +145 -0
- package/legacy/agent/ecbc84f1c1e5/src/app.ts +291 -0
- package/legacy/agent/ecbc84f1c1e5/src/routes.ts +145 -0
- package/legacy/blank/2447ac6d5d24/src/app.ts +61 -0
- package/legacy/blank/387f7e25aaeb/src/app.ts +81 -0
- package/legacy/blank/5b9f10e1ba5e/src/app.ts +61 -0
- package/legacy/blank/5f6dd8910c50/src/app.ts +152 -0
- package/legacy/blank/5f6dd8910c50/src/routes.ts +75 -0
- package/legacy/blank/a7e8f539ffee/src/app.ts +160 -0
- package/legacy/blank/a7e8f539ffee/src/routes.ts +75 -0
- package/legacy/blank/bdb9735e013b/src/app.ts +61 -0
- package/legacy/blank/c3be1d735b1a/src/app.ts +61 -0
- package/legacy/blank/c3be1d735b1a/src/routes.ts +18 -0
- package/legacy/blank/e04303a7f8dd/src/app.ts +61 -0
- package/legacy/chat/5120c4a9fa61/src/app.ts +157 -0
- package/legacy/chat/5120c4a9fa61/src/routes.ts +29 -0
- package/legacy/chat/d4464dfb3b04/src/app.ts +165 -0
- package/legacy/chat/d4464dfb3b04/src/routes.ts +29 -0
- package/legacy/frozen.json +7 -0
- package/legacy/layouts.json +135 -45
- package/legacy/nodejs/9d59115118c7/src/app.ts +150 -0
- package/legacy/nodejs/9d59115118c7/src/routes.ts +59 -0
- package/legacy/nodejs/a6a340b4d3a6/src/app.ts +158 -0
- package/legacy/nodejs/a6a340b4d3a6/src/routes.ts +59 -0
- package/legacy/notes/fb43a1c5f3b4/src/app.ts +165 -0
- package/legacy/notes/fb43a1c5f3b4/src/routes.ts +21 -0
- package/legacy/notes/fc3fe7c749d2/src/app.ts +157 -0
- package/legacy/notes/fc3fe7c749d2/src/routes.ts +21 -0
- package/legacy/todo/164274ffcf7f/src/app.ts +145 -0
- package/legacy/todo/164274ffcf7f/src/todo.ts +137 -0
- package/lib/cordis/door.d.ts +10 -1
- package/lib/cordis/door.js +11 -4
- package/package.json +1 -1
- package/plugins/acryl-door/src/host/door.ts +17 -5
- package/plugins/builder/index.ts +1 -1
- package/plugins/builder/plugin.json +1 -1
- package/plugins/extensions/index.ts +57 -2
- package/plugins/extensions/routes.ts +92 -0
- package/plugins/home/index.ts +3 -1
- package/plugins/plugin-share/README.md +16 -1
- package/plugins/plugin-share/index.ts +130 -16
- package/plugins/plugin-share/plugin.json +1 -1
- package/templates/agent/src/app.ts +2 -0
- package/templates/blank/blend.yaml +3 -0
- package/templates/blank/src/app.ts +8 -0
- package/templates/blank/template.json +1 -0
- package/templates/chat/blend.yaml +3 -0
- package/templates/chat/src/app.ts +8 -0
- package/templates/chat/template.json +1 -0
- package/templates/nodejs/blend.yaml +3 -0
- package/templates/nodejs/src/app.ts +8 -0
- package/templates/nodejs/template.json +1 -0
- package/templates/notes/blend.yaml +3 -0
- package/templates/notes/src/app.ts +8 -0
- package/templates/notes/template.json +1 -0
- /package/legacy/blank/{d6d371c9a65e → 2447ac6d5d24}/src/routes.ts +0 -0
- /package/legacy/blank/{5a91c558af22 → 387f7e25aaeb}/src/routes.ts +0 -0
- /package/legacy/blank/{b2cbb759c889 → 5b9f10e1ba5e}/src/routes.ts +0 -0
- /package/legacy/blank/{9c650cf52bf3 → bdb9735e013b}/src/routes.ts +0 -0
- /package/legacy/blank/{aea589283445 → e04303a7f8dd}/src/routes.ts +0 -0
- /package/plugins/{builder → plugin-share}/package.ts +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
4
|
+
import { clientModules } from '../plugins/acryl-node-server/src/client-modules.ts'
|
|
5
|
+
import { Acryl } from '../plugins/acryl-router/src/index.ts'
|
|
6
|
+
import { aboutPage, counterPage } from './page.tsx'
|
|
7
|
+
|
|
8
|
+
/** The browser builds of the DOM renderer and the typed client, shipped by the `acrylblends` package this app depends on. */
|
|
9
|
+
const runtimeDir = join(
|
|
10
|
+
dirname(createRequire(import.meta.url).resolve('acrylblends/package.json')),
|
|
11
|
+
'lib',
|
|
12
|
+
'browser',
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
export const APP_NAME = '__APP_NAME__'
|
|
16
|
+
|
|
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
|
+
}
|
|
34
|
+
|
|
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>
|
|
37
|
+
|
|
38
|
+
/**
|
|
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.
|
|
42
|
+
*/
|
|
43
|
+
export const routes = {
|
|
44
|
+
name: 'routes',
|
|
45
|
+
inject: ['router'],
|
|
46
|
+
apply(ctx: Context, options: { prefix?: string } = {}) {
|
|
47
|
+
const prefix = options.prefix ?? ''
|
|
48
|
+
ctx.router.route(prefix === '' ? '/' : prefix, makeApp(prefix))
|
|
49
|
+
ctx.router.route(
|
|
50
|
+
`${prefix}/client`,
|
|
51
|
+
clientModules({
|
|
52
|
+
root: 'src/client',
|
|
53
|
+
compiled: 'dist/client',
|
|
54
|
+
runtimeDir,
|
|
55
|
+
mount: `${prefix}/client`,
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
},
|
|
59
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
|
4
|
+
import * as door from '../plugins/acryl-door/src/index.ts'
|
|
5
|
+
import * as nodeServer from '../plugins/acryl-node-server/src/index.ts'
|
|
6
|
+
import * as router from '../plugins/acryl-router/src/index.ts'
|
|
7
|
+
import { extensions } from '../plugins/extensions/index.ts'
|
|
8
|
+
import { type AppInstance, instance } from '../plugins/instance/index.ts'
|
|
9
|
+
import {
|
|
10
|
+
appPluginNames,
|
|
11
|
+
blueprintsIn,
|
|
12
|
+
compose,
|
|
13
|
+
loadAppPlugins,
|
|
14
|
+
loadManifest,
|
|
15
|
+
type ParameterValue,
|
|
16
|
+
} from '../plugins/manifest/index.ts'
|
|
17
|
+
import { pluginShare } from '../plugins/plugin-share/index.ts'
|
|
18
|
+
import { doorEnabled } from './door.ts'
|
|
19
|
+
import { FRAME_MOUNT_PREFIX, routes } from './routes.ts'
|
|
20
|
+
|
|
21
|
+
/** 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. */
|
|
22
|
+
export const registry = {
|
|
23
|
+
'acryl-router': router,
|
|
24
|
+
'acryl-node-server': nodeServer,
|
|
25
|
+
'acryl-door': door,
|
|
26
|
+
extensions,
|
|
27
|
+
'plugin-share': pluginShare,
|
|
28
|
+
routes,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const projectRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
32
|
+
|
|
33
|
+
/** The manifest next to this app's source. */
|
|
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')
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The app's manifest, resolved. A Blend finds its Blueprint in `blueprints/<id>.yaml`; an app that is itself a Blueprint never looks.
|
|
40
|
+
* `createApp` and the tests read it the same way.
|
|
41
|
+
*/
|
|
42
|
+
export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
|
|
43
|
+
return loadManifest(MANIFEST, {
|
|
44
|
+
known: [...Object.keys(registry), ...appPluginNames(PLUGINS_DIR)],
|
|
45
|
+
getBlueprint: blueprintsIn(join(projectRoot, 'blueprints')),
|
|
46
|
+
...(values ? { values } : {}),
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The manifest without some rows: without its server (and so its door) the app is reached in process, through `root.router.request`; the door is also left out in production. */
|
|
51
|
+
function without<M extends { plugins: readonly { use: string }[] }>(
|
|
52
|
+
manifest: M,
|
|
53
|
+
...uses: string[]
|
|
54
|
+
): M {
|
|
55
|
+
return {
|
|
56
|
+
...manifest,
|
|
57
|
+
plugins: manifest.plugins.map((row) =>
|
|
58
|
+
uses.includes(row.use) ? { ...row, disabled: true } : row,
|
|
59
|
+
),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface App {
|
|
64
|
+
readonly root: Context
|
|
65
|
+
/** The address the server listens on; empty when the app was made with `listen: false`. */
|
|
66
|
+
readonly url: string
|
|
67
|
+
stop(): Promise<void>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface AppOptions {
|
|
71
|
+
readonly values?: Readonly<Record<string, ParameterValue>>
|
|
72
|
+
/** 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. */
|
|
73
|
+
readonly prefix?: string
|
|
74
|
+
/** 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. */
|
|
75
|
+
readonly listen?: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The rows the frame host provides: the app contributes only the plugins that are its own. */
|
|
79
|
+
const HOST_ROWS = [
|
|
80
|
+
'instance',
|
|
81
|
+
'acryl-router',
|
|
82
|
+
'acryl-node-server',
|
|
83
|
+
'acryl-web-server',
|
|
84
|
+
'acryl-page',
|
|
85
|
+
'manifest',
|
|
86
|
+
'save',
|
|
87
|
+
'acryl-door',
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 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
|
|
92
|
+
* 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,
|
|
93
|
+
* which is where the door lives too, and mounts the app at {@link FRAME_MOUNT_PREFIX}.
|
|
94
|
+
*/
|
|
95
|
+
export async function composeInto(
|
|
96
|
+
ctx: Context,
|
|
97
|
+
chosen: AppInstance,
|
|
98
|
+
options: AppOptions & { readonly host?: boolean } = {},
|
|
99
|
+
): Promise<Fiber[]> {
|
|
100
|
+
const read = readManifest(options.values)
|
|
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')
|
|
108
|
+
if (!doorEnabled()) leaveOut.add('acryl-door')
|
|
109
|
+
// A pull runs a stranger's code and a publish leaves the machine: development only, like the door.
|
|
110
|
+
if (process.env.NODE_ENV === 'production') leaveOut.add('plugin-share')
|
|
111
|
+
const manifest = without(read, ...leaveOut)
|
|
112
|
+
// 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
|
|
113
|
+
// is imported here; all of them, off or on, belong to their row, so the extensions row never starts them a second time.
|
|
114
|
+
const ownRows = [
|
|
115
|
+
...new Set(read.plugins.map((row) => row.use).filter((use) => !Object.hasOwn(registry, use))),
|
|
116
|
+
]
|
|
117
|
+
const ownPlugins = await loadAppPlugins(
|
|
118
|
+
PLUGINS_DIR,
|
|
119
|
+
ownRows.filter((use) => manifest.plugins.some((row) => row.use === use && !row.disabled)),
|
|
120
|
+
)
|
|
121
|
+
return compose(
|
|
122
|
+
ctx,
|
|
123
|
+
manifest,
|
|
124
|
+
{ ...registry, ...ownPlugins },
|
|
125
|
+
{
|
|
126
|
+
// The routes row mounts the pages (and the client modules) at the prefix the frame host chose.
|
|
127
|
+
routes: { ...(prefix === undefined ? {} : { prefix }) },
|
|
128
|
+
// 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).
|
|
129
|
+
// In production they start read-only from where they are deployed: the app keeps its features, and nothing can be installed while it runs.
|
|
130
|
+
extensions: {
|
|
131
|
+
...(process.env.NODE_ENV === 'production' ? { readOnly: true } : {}),
|
|
132
|
+
sources: [{ dir: PLUGINS_DIR, trust: 'install' }],
|
|
133
|
+
rows: ownRows,
|
|
134
|
+
// Messages name a route as the plugin wrote it, not under the frame's canvas prefix.
|
|
135
|
+
...(prefix === undefined ? {} : { routePrefix: prefix }),
|
|
136
|
+
},
|
|
137
|
+
// Pulled plugins go where the extensions row installs from.
|
|
138
|
+
'plugin-share': { dir: PLUGINS_DIR },
|
|
139
|
+
// Only the app's own server listens; the frame host owns the server and its port.
|
|
140
|
+
...(own ? { server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 } } : {}),
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
|
|
146
|
+
export async function createApp(chosen: AppInstance, options: AppOptions = {}): Promise<App> {
|
|
147
|
+
const root = new Context()
|
|
148
|
+
const first = root.plugin(instance, chosen)
|
|
149
|
+
await first.await()
|
|
150
|
+
const fibers: Fiber[] = [first, ...(await composeInto(root, chosen, options))]
|
|
151
|
+
return {
|
|
152
|
+
root,
|
|
153
|
+
url: options.listen === false ? '' : root.nodeServer.url,
|
|
154
|
+
async stop() {
|
|
155
|
+
for (const fiber of [...fibers].reverse()) await fiber.dispose()
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
4
|
+
import { clientModules } from '../plugins/acryl-node-server/src/client-modules.ts'
|
|
5
|
+
import { Acryl } from '../plugins/acryl-router/src/index.ts'
|
|
6
|
+
import { aboutPage, counterPage } from './page.tsx'
|
|
7
|
+
|
|
8
|
+
/** The browser builds of the DOM renderer and the typed client, shipped by the `acrylblends` package this app depends on. */
|
|
9
|
+
const runtimeDir = join(
|
|
10
|
+
dirname(createRequire(import.meta.url).resolve('acrylblends/package.json')),
|
|
11
|
+
'lib',
|
|
12
|
+
'browser',
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
export const APP_NAME = '__APP_NAME__'
|
|
16
|
+
|
|
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
|
+
}
|
|
34
|
+
|
|
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>
|
|
37
|
+
|
|
38
|
+
/**
|
|
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.
|
|
42
|
+
*/
|
|
43
|
+
export const routes = {
|
|
44
|
+
name: 'routes',
|
|
45
|
+
inject: ['router'],
|
|
46
|
+
apply(ctx: Context, options: { prefix?: string } = {}) {
|
|
47
|
+
const prefix = options.prefix ?? ''
|
|
48
|
+
ctx.router.route(prefix === '' ? '/' : prefix, makeApp(prefix))
|
|
49
|
+
ctx.router.route(
|
|
50
|
+
`${prefix}/client`,
|
|
51
|
+
clientModules({
|
|
52
|
+
root: 'src/client',
|
|
53
|
+
compiled: 'dist/client',
|
|
54
|
+
runtimeDir,
|
|
55
|
+
mount: `${prefix}/client`,
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
},
|
|
59
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
|
4
|
+
import * as db from '../plugins/acryl-db-sqlite/src/index.ts'
|
|
5
|
+
import * as door from '../plugins/acryl-door/src/index.ts'
|
|
6
|
+
import * as nodeServer from '../plugins/acryl-node-server/src/index.ts'
|
|
7
|
+
import * as router from '../plugins/acryl-router/src/index.ts'
|
|
8
|
+
import { extensions } from '../plugins/extensions/index.ts'
|
|
9
|
+
import { type AppInstance, instance } from '../plugins/instance/index.ts'
|
|
10
|
+
import {
|
|
11
|
+
appPluginNames,
|
|
12
|
+
blueprintsIn,
|
|
13
|
+
compose,
|
|
14
|
+
loadAppPlugins,
|
|
15
|
+
loadManifest,
|
|
16
|
+
type ParameterValue,
|
|
17
|
+
} from '../plugins/manifest/index.ts'
|
|
18
|
+
import { pluginShare } from '../plugins/plugin-share/index.ts'
|
|
19
|
+
import { doorEnabled } from './door.ts'
|
|
20
|
+
import { FRAME_MOUNT_PREFIX, routes } from './routes.ts'
|
|
21
|
+
|
|
22
|
+
/** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
|
|
23
|
+
export const registry = {
|
|
24
|
+
'acryl-router': router,
|
|
25
|
+
'acryl-db-sqlite': db,
|
|
26
|
+
'acryl-node-server': nodeServer,
|
|
27
|
+
'acryl-door': door,
|
|
28
|
+
extensions,
|
|
29
|
+
'plugin-share': pluginShare,
|
|
30
|
+
routes,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const projectRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
34
|
+
|
|
35
|
+
/** The manifest next to this app's source. */
|
|
36
|
+
export const MANIFEST = join(projectRoot, 'blend.yaml')
|
|
37
|
+
/** 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. */
|
|
38
|
+
const PLUGINS_DIR = join(projectRoot, 'plugins')
|
|
39
|
+
|
|
40
|
+
/** The manifest, resolved. `createApp` and the tests read it the same way. */
|
|
41
|
+
export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
|
|
42
|
+
return loadManifest(MANIFEST, {
|
|
43
|
+
known: [...Object.keys(registry), ...appPluginNames(PLUGINS_DIR)],
|
|
44
|
+
getBlueprint: blueprintsIn(join(projectRoot, 'blueprints')),
|
|
45
|
+
...(values ? { values } : {}),
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The manifest without some rows: without its server (and so its door) the app is reached in process, through `root.router.request`; the door is also left out in production. */
|
|
50
|
+
function without<M extends { plugins: readonly { use: string }[] }>(
|
|
51
|
+
manifest: M,
|
|
52
|
+
...uses: string[]
|
|
53
|
+
): M {
|
|
54
|
+
return {
|
|
55
|
+
...manifest,
|
|
56
|
+
plugins: manifest.plugins.map((row) =>
|
|
57
|
+
uses.includes(row.use) ? { ...row, disabled: true } : row,
|
|
58
|
+
),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface App {
|
|
63
|
+
readonly root: Context
|
|
64
|
+
/** The address the server listens on; empty when the app was made with `listen: false`. */
|
|
65
|
+
readonly url: string
|
|
66
|
+
stop(): Promise<void>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AppOptions {
|
|
70
|
+
readonly values?: Readonly<Record<string, ParameterValue>>
|
|
71
|
+
/** 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`. */
|
|
72
|
+
readonly database?: string
|
|
73
|
+
/** 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. */
|
|
74
|
+
readonly prefix?: string
|
|
75
|
+
/** 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. */
|
|
76
|
+
readonly listen?: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The rows the frame host provides: the app contributes only the plugins that are its own. */
|
|
80
|
+
const HOST_ROWS = [
|
|
81
|
+
'instance',
|
|
82
|
+
'acryl-router',
|
|
83
|
+
'acryl-node-server',
|
|
84
|
+
'acryl-web-server',
|
|
85
|
+
'acryl-page',
|
|
86
|
+
'manifest',
|
|
87
|
+
'save',
|
|
88
|
+
'acryl-door',
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 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
|
|
93
|
+
* 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,
|
|
94
|
+
* 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
|
|
95
|
+
* place that names the SQLite provider is this file (`acrylblends check-deps`), so a different provider is a change here.
|
|
96
|
+
*/
|
|
97
|
+
export async function composeInto(
|
|
98
|
+
ctx: Context,
|
|
99
|
+
chosen: AppInstance,
|
|
100
|
+
options: AppOptions & { readonly host?: boolean } = {},
|
|
101
|
+
): Promise<Fiber[]> {
|
|
102
|
+
const read = readManifest(options.values)
|
|
103
|
+
const own = options.host !== true && options.listen !== false
|
|
104
|
+
// The frame host mounts the app under its own prefix; the app's own server keeps the root.
|
|
105
|
+
const prefix = options.prefix ?? (options.host === true ? FRAME_MOUNT_PREFIX : undefined)
|
|
106
|
+
// 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
|
|
107
|
+
// off.
|
|
108
|
+
const leaveOut = new Set<string>(options.host === true ? HOST_ROWS : [])
|
|
109
|
+
if (!own) leaveOut.add('acryl-node-server').add('acryl-door')
|
|
110
|
+
if (!doorEnabled()) leaveOut.add('acryl-door')
|
|
111
|
+
// A pull runs a stranger's code and a publish leaves the machine: development only, like the door.
|
|
112
|
+
if (process.env.NODE_ENV === 'production') leaveOut.add('plugin-share')
|
|
113
|
+
const manifest = without(read, ...leaveOut)
|
|
114
|
+
// 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
|
|
115
|
+
// is imported here; all of them, off or on, belong to their row, so the extensions row never starts them a second time.
|
|
116
|
+
const ownRows = [
|
|
117
|
+
...new Set(read.plugins.map((row) => row.use).filter((use) => !Object.hasOwn(registry, use))),
|
|
118
|
+
]
|
|
119
|
+
const ownPlugins = await loadAppPlugins(
|
|
120
|
+
PLUGINS_DIR,
|
|
121
|
+
ownRows.filter((use) => manifest.plugins.some((row) => row.use === use && !row.disabled)),
|
|
122
|
+
)
|
|
123
|
+
const file = options.database ?? 'data/notes.sqlite'
|
|
124
|
+
return compose(
|
|
125
|
+
ctx,
|
|
126
|
+
manifest,
|
|
127
|
+
{ ...registry, ...ownPlugins },
|
|
128
|
+
{
|
|
129
|
+
db: {
|
|
130
|
+
path: isAbsolute(file) ? file : resolve(projectRoot, file),
|
|
131
|
+
migrations: join(projectRoot, 'migrations'),
|
|
132
|
+
},
|
|
133
|
+
// The routes row mounts the app (and its client modules) at the prefix the frame host chose.
|
|
134
|
+
routes: { ...(prefix === undefined ? {} : { prefix }) },
|
|
135
|
+
// 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).
|
|
136
|
+
// In production they start read-only from where they are deployed: the app keeps its features, and nothing can be installed while it runs.
|
|
137
|
+
extensions: {
|
|
138
|
+
...(process.env.NODE_ENV === 'production' ? { readOnly: true } : {}),
|
|
139
|
+
sources: [{ dir: PLUGINS_DIR, trust: 'install' }],
|
|
140
|
+
rows: ownRows,
|
|
141
|
+
// Messages name a route as the plugin wrote it, not under the frame's canvas prefix.
|
|
142
|
+
...(prefix === undefined ? {} : { routePrefix: prefix }),
|
|
143
|
+
},
|
|
144
|
+
// Pulled plugins go where the extensions row installs from.
|
|
145
|
+
'plugin-share': { dir: PLUGINS_DIR },
|
|
146
|
+
// Only the app's own server listens; the frame host owns the server and its port.
|
|
147
|
+
...(own ? { server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 } } : {}),
|
|
148
|
+
},
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
|
|
153
|
+
export async function createApp(chosen: AppInstance, options: AppOptions = {}): Promise<App> {
|
|
154
|
+
const root = new Context()
|
|
155
|
+
const first = root.plugin(instance, chosen)
|
|
156
|
+
await first.await()
|
|
157
|
+
const fibers: Fiber[] = [first, ...(await composeInto(root, chosen, options))]
|
|
158
|
+
return {
|
|
159
|
+
root,
|
|
160
|
+
url: options.listen === false ? '' : root.nodeServer.url,
|
|
161
|
+
async stop() {
|
|
162
|
+
for (const fiber of [...fibers].reverse()) await fiber.dispose()
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import type {} from '../plugins/acryl-db/src/cordis.ts'
|
|
3
|
+
import { createApp } from './notes-app.ts'
|
|
4
|
+
import { createClient } from './runtime.ts'
|
|
5
|
+
|
|
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. */
|
|
13
|
+
export const routes = {
|
|
14
|
+
name: 'routes',
|
|
15
|
+
inject: ['router', 'db'],
|
|
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))
|
|
20
|
+
},
|
|
21
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
|
4
|
+
import * as db from '../plugins/acryl-db-sqlite/src/index.ts'
|
|
5
|
+
import * as door from '../plugins/acryl-door/src/index.ts'
|
|
6
|
+
import * as nodeServer from '../plugins/acryl-node-server/src/index.ts'
|
|
7
|
+
import * as router from '../plugins/acryl-router/src/index.ts'
|
|
8
|
+
import { extensions } from '../plugins/extensions/index.ts'
|
|
9
|
+
import { type AppInstance, instance } from '../plugins/instance/index.ts'
|
|
10
|
+
import {
|
|
11
|
+
appPluginNames,
|
|
12
|
+
blueprintsIn,
|
|
13
|
+
compose,
|
|
14
|
+
loadAppPlugins,
|
|
15
|
+
loadManifest,
|
|
16
|
+
type ParameterValue,
|
|
17
|
+
} from '../plugins/manifest/index.ts'
|
|
18
|
+
import { doorEnabled } from './door.ts'
|
|
19
|
+
import { FRAME_MOUNT_PREFIX, routes } from './routes.ts'
|
|
20
|
+
|
|
21
|
+
/** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
|
|
22
|
+
export const registry = {
|
|
23
|
+
'acryl-router': router,
|
|
24
|
+
'acryl-db-sqlite': db,
|
|
25
|
+
'acryl-node-server': nodeServer,
|
|
26
|
+
'acryl-door': door,
|
|
27
|
+
extensions,
|
|
28
|
+
routes,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const projectRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
32
|
+
|
|
33
|
+
/** The manifest next to this app's source. */
|
|
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')
|
|
37
|
+
|
|
38
|
+
/** The manifest, resolved. `createApp` and the tests read it the same way. */
|
|
39
|
+
export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
|
|
40
|
+
return loadManifest(MANIFEST, {
|
|
41
|
+
known: [...Object.keys(registry), ...appPluginNames(PLUGINS_DIR)],
|
|
42
|
+
getBlueprint: blueprintsIn(join(projectRoot, 'blueprints')),
|
|
43
|
+
...(values ? { values } : {}),
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The manifest without some rows: without its server (and so its door) the app is reached in process, through `root.router.request`; the door is also left out in production. */
|
|
48
|
+
function without<M extends { plugins: readonly { use: string }[] }>(
|
|
49
|
+
manifest: M,
|
|
50
|
+
...uses: string[]
|
|
51
|
+
): M {
|
|
52
|
+
return {
|
|
53
|
+
...manifest,
|
|
54
|
+
plugins: manifest.plugins.map((row) =>
|
|
55
|
+
uses.includes(row.use) ? { ...row, disabled: true } : row,
|
|
56
|
+
),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface App {
|
|
61
|
+
readonly root: Context
|
|
62
|
+
/** The address the server listens on; empty when the app was made with `listen: false`. */
|
|
63
|
+
readonly url: string
|
|
64
|
+
stop(): Promise<void>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AppOptions {
|
|
68
|
+
readonly values?: Readonly<Record<string, ParameterValue>>
|
|
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`. */
|
|
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
|
|
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. */
|
|
74
|
+
readonly listen?: boolean
|
|
75
|
+
}
|
|
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
|
+
|
|
89
|
+
/**
|
|
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.
|
|
94
|
+
*/
|
|
95
|
+
export async function composeInto(
|
|
96
|
+
ctx: Context,
|
|
97
|
+
chosen: AppInstance,
|
|
98
|
+
options: AppOptions & { readonly host?: boolean } = {},
|
|
99
|
+
): Promise<Fiber[]> {
|
|
100
|
+
const read = readManifest(options.values)
|
|
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')
|
|
108
|
+
if (!doorEnabled()) leaveOut.add('acryl-door')
|
|
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
|
+
)
|
|
119
|
+
const file = options.database ?? 'data/notes.sqlite'
|
|
120
|
+
return compose(
|
|
121
|
+
ctx,
|
|
122
|
+
manifest,
|
|
123
|
+
{ ...registry, ...ownPlugins },
|
|
124
|
+
{
|
|
125
|
+
db: {
|
|
126
|
+
path: isAbsolute(file) ? file : resolve(projectRoot, file),
|
|
127
|
+
migrations: join(projectRoot, 'migrations'),
|
|
128
|
+
},
|
|
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))]
|
|
150
|
+
return {
|
|
151
|
+
root,
|
|
152
|
+
url: options.listen === false ? '' : root.nodeServer.url,
|
|
153
|
+
async stop() {
|
|
154
|
+
for (const fiber of [...fibers].reverse()) await fiber.dispose()
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import type {} from '../plugins/acryl-db/src/cordis.ts'
|
|
3
|
+
import { createApp } from './notes-app.ts'
|
|
4
|
+
import { createClient } from './runtime.ts'
|
|
5
|
+
|
|
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. */
|
|
13
|
+
export const routes = {
|
|
14
|
+
name: 'routes',
|
|
15
|
+
inject: ['router', 'db'],
|
|
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))
|
|
20
|
+
},
|
|
21
|
+
}
|