acrylblends 0.7.7 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +14 -6
- package/dist/index.js +130 -31
- package/dist/index.js.map +1 -1
- package/docs/framework/README.md +3 -3
- package/docs/framework/UPSTREAM.md +1 -1
- package/docs/framework/agents/index.md +18 -3
- package/docs/framework/getting-started/basic.md +1 -2
- package/docs/framework/getting-started/desktop.md +3 -3
- package/docs/framework/guides/bring-your-own-tool.md +216 -0
- package/docs/framework/guides/client-modules.md +4 -3
- package/docs/framework/guides/create-acrylblends.md +5 -4
- package/docs/framework/guides/data.md +7 -6
- package/docs/framework/helpers/streaming.md +12 -27
- package/docs/framework/upstream.json +3 -3
- package/lib/browser/read-sse.js +84 -0
- package/lib/db/sqlite.d.ts +1 -1
- package/lib/db.d.ts +1 -84
- package/lib/index-BLTYsyc1.d.ts +93 -0
- package/lib/index-D4FAsBZu.d.ts +85 -0
- package/lib/kv/db.d.ts +2 -2
- package/lib/kv/json.d.ts +1 -1
- package/lib/kv/json.js +87 -24
- package/lib/kv.d.ts +1 -92
- package/lib/node-server.d.ts +3 -3
- package/lib/node-server.js +14 -8
- package/lib/read-sse.d.ts +33 -0
- package/lib/read-sse.js +84 -0
- package/package.json +5 -1
- package/plugins/acryl-db/plugin.json +7 -2
- package/plugins/acryl-db/src/conformance.ts +224 -0
- package/plugins/acryl-db/upstream.json +2 -2
- package/plugins/acryl-db-sqlite/plugin.json +4 -1
- package/plugins/acryl-db-sqlite/upstream.json +1 -1
- package/plugins/acryl-door/LICENSE +47 -0
- package/plugins/acryl-door/plugin.json +14 -0
- package/plugins/acryl-door/src/cli/acryl.ts +207 -0
- package/plugins/acryl-door/src/host/api.ts +9 -0
- package/plugins/acryl-door/src/host/audit.ts +64 -0
- package/plugins/acryl-door/src/host/channel.ts +142 -0
- package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
- package/plugins/acryl-door/src/host/door-file.ts +94 -0
- package/plugins/acryl-door/src/host/door.ts +195 -0
- package/plugins/acryl-door/src/host/http.ts +80 -0
- package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
- package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
- package/plugins/acryl-door/src/host/page-routes.ts +65 -0
- package/plugins/acryl-door/src/host/shim.ts +65 -0
- package/plugins/acryl-door/src/host/verbs.ts +109 -0
- package/plugins/acryl-door/src/index.ts +133 -0
- package/plugins/acryl-door/src/page/api.ts +9 -0
- package/plugins/acryl-door/src/page/channel.ts +135 -0
- package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
- package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
- package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
- package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
- package/plugins/acryl-door/src/page/index.ts +40 -0
- package/plugins/acryl-door/src/page/indicator.ts +72 -0
- package/plugins/acryl-door/src/page/main.ts +7 -0
- package/plugins/acryl-door/src/page/protocol.ts +213 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
- package/plugins/acryl-door/src/page/user-input.ts +24 -0
- package/plugins/acryl-door/upstream.json +28 -0
- package/plugins/acryl-inspect/upstream.json +1 -1
- package/plugins/acryl-kv/plugin.json +7 -2
- package/plugins/acryl-kv/src/conformance.ts +226 -0
- package/plugins/acryl-kv/upstream.json +1 -1
- package/plugins/acryl-kv-db/plugin.json +4 -1
- package/plugins/acryl-kv-db/upstream.json +1 -1
- package/plugins/acryl-kv-json/plugin.json +4 -1
- package/plugins/acryl-kv-json/src/open.ts +86 -22
- package/plugins/acryl-kv-json/upstream.json +2 -2
- package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
- package/plugins/acryl-node-server/src/client-modules.ts +10 -9
- package/plugins/acryl-node-server/upstream.json +2 -2
- package/plugins/acryl-router/src/browser/read-sse.ts +3 -0
- package/plugins/acryl-router/src/helpers/read-sse.ts +111 -0
- package/plugins/acryl-router/src/service.ts +3 -0
- package/plugins/acryl-router/upstream.json +2 -2
- package/plugins/acryl-router-http/upstream.json +1 -1
- package/plugins/acryl-state/upstream.json +1 -1
- package/plugins/agent-panel/client/main.ts +159 -0
- package/plugins/agent-panel/index.ts +232 -0
- package/plugins/agent-panel/plugin.json +21 -0
- package/plugins/builder/index.ts +2 -2
- package/plugins/builder/plugin.json +1 -1
- package/plugins/desktop/main.ts +3 -3
- package/plugins/desktop/plugin.json +2 -2
- package/plugins/desktop/upstream.json +1 -1
- package/plugins/desktop-web/main.ts +2 -2
- package/plugins/desktop-web/plugin.json +2 -2
- package/plugins/desktop-web/upstream.json +1 -1
- package/plugins/extensions/index.ts +19 -1
- package/plugins/extensions/plugin.json +1 -1
- package/plugins/extensions/resolve.ts +17 -3
- package/plugins/manifest/lock.ts +7 -3
- package/plugins/manifest/upgrade-cli.ts +1 -1
- package/plugins/save/cli.ts +1 -1
- package/plugins/save/plugin.json +1 -1
- package/plugins/tsconfig.json +6 -0
- package/shared/AGENTS.core.md +10 -10
- package/shared/DEFAULTS.md +45 -0
- package/shared/SKILL.md +14 -0
- package/templates/agent/AGENTS.md +1 -1
- package/templates/agent/README.md +6 -6
- package/templates/agent/blend.yaml +3 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +8 -8
- package/templates/agent/src/app.ts +46 -17
- package/templates/agent/src/main.ts +2 -2
- package/templates/agent/src/routes.ts +19 -6
- package/templates/agent/template.json +2 -1
- package/templates/agent/test/agent.test.ts +11 -5
- package/templates/agent/test/app.test.ts +30 -9
- package/templates/agent/test/builder.test.ts +35 -24
- package/templates/agent/test/model.test.ts +25 -20
- package/templates/agent/tsconfig.json +48 -50
- package/templates/blank/AGENTS.md +1 -1
- package/templates/blank/README.md +2 -2
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +8 -8
- package/templates/blank/src/about.tsx +2 -2
- package/templates/blank/src/app.ts +29 -9
- package/templates/blank/src/main.ts +1 -1
- package/templates/blank/src/routes.ts +1 -1
- package/templates/blank/test/app.test.ts +29 -10
- package/templates/blank/tsconfig.json +1 -1
- package/templates/chat/AGENTS.md +16 -0
- package/templates/chat/blend.yaml +29 -0
- package/templates/chat/gitignore +6 -0
- package/templates/chat/migrations/001-chat.sql +12 -0
- package/templates/chat/package.json +33 -0
- package/templates/chat/src/app.ts +106 -0
- package/templates/chat/src/chat-app.ts +101 -0
- package/templates/chat/src/client/chat.tsx +115 -0
- package/templates/chat/src/client/tsconfig.json +26 -0
- package/templates/chat/src/db.ts +43 -0
- package/templates/chat/src/door.ts +7 -0
- package/templates/chat/src/llm.ts +42 -0
- package/templates/chat/src/main.ts +28 -0
- package/templates/chat/src/page.tsx +40 -0
- package/templates/chat/src/routes.ts +15 -0
- package/templates/chat/src/runtime.ts +17 -0
- package/templates/chat/template.json +11 -0
- package/templates/chat/test/chat.test.ts +198 -0
- package/templates/chat/test/server.test.ts +78 -0
- package/templates/chat/tsconfig.json +20 -0
- package/templates/nodejs/AGENTS.md +9 -0
- package/templates/nodejs/blend.yaml +26 -0
- package/templates/nodejs/gitignore +3 -0
- package/templates/nodejs/package.json +30 -0
- package/templates/nodejs/src/app.ts +92 -0
- package/templates/nodejs/src/client/counter.tsx +12 -0
- package/templates/nodejs/src/client/tsconfig.json +26 -0
- package/templates/nodejs/src/door.ts +7 -0
- package/templates/nodejs/src/main.ts +25 -0
- package/templates/nodejs/src/page.tsx +29 -0
- package/templates/nodejs/src/routes.ts +40 -0
- package/templates/nodejs/template.json +3 -0
- package/templates/nodejs/test/app.test.ts +78 -0
- package/templates/nodejs/test/server.test.ts +83 -0
- package/templates/nodejs/tsconfig.json +20 -0
- package/templates/notes/AGENTS.md +26 -0
- package/templates/notes/blend.yaml +29 -0
- package/templates/notes/gitignore +6 -0
- package/templates/notes/migrations/001-notes.sql +18 -0
- package/templates/notes/package.json +33 -0
- package/templates/notes/src/app.ts +106 -0
- package/templates/notes/src/auth.ts +57 -0
- package/templates/notes/src/client/counter.tsx +21 -0
- package/templates/notes/src/client/tsconfig.json +26 -0
- package/templates/notes/src/db.ts +50 -0
- package/templates/notes/src/door.ts +7 -0
- package/templates/notes/src/main.ts +28 -0
- package/templates/notes/src/notes-app.ts +205 -0
- package/templates/notes/src/routes.ts +14 -0
- package/templates/notes/src/runtime.ts +17 -0
- package/templates/notes/src/schema.ts +38 -0
- package/templates/notes/src/views.tsx +136 -0
- package/templates/notes/template.json +11 -0
- package/templates/notes/test/notes.test.ts +280 -0
- package/templates/notes/test/server.test.ts +78 -0
- package/templates/notes/tsconfig.json +20 -0
- package/templates/todo/README.md +1 -1
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +8 -8
- package/templates/todo/src/app.ts +32 -12
- package/templates/todo/src/main.ts +1 -1
- package/templates/todo/src/todo.ts +4 -4
- package/templates/todo/test/todo.test.ts +48 -23
- package/templates/todo/tsconfig.json +1 -1
- package/templates/todo/upstream.json +2 -2
- package/lib/database-D_UN6We8.d.ts +0 -44
- package/lib/kv-DWDm0Lja.d.ts +0 -39
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
/** 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'`. */
|
|
18
|
+
export const pages = new Acryl()
|
|
19
|
+
.get('/', (c) => c.json({ app: APP_NAME, ok: true }))
|
|
20
|
+
.get('/health', (c) => c.json({ ok: true }))
|
|
21
|
+
.get('/about', (c) => c.html(aboutPage()))
|
|
22
|
+
.get('/counter', (c) => c.html(counterPage()))
|
|
23
|
+
|
|
24
|
+
export type AppType = typeof pages
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The app's own plugin: the routes above, and the client components without a bundler (`src/client` is compiled when the browser asks; `npm run build`
|
|
28
|
+
* compiles it once for production, and `NODE_ENV=production` then serves only `dist/client`). The mounts are removed when the plugin is disposed.
|
|
29
|
+
*/
|
|
30
|
+
export const routes = {
|
|
31
|
+
name: 'routes',
|
|
32
|
+
inject: ['router'],
|
|
33
|
+
apply(ctx: Context) {
|
|
34
|
+
ctx.router.route('/', pages)
|
|
35
|
+
ctx.router.route(
|
|
36
|
+
'/client',
|
|
37
|
+
clientModules({ root: 'src/client', compiled: 'dist/client', runtimeDir }),
|
|
38
|
+
)
|
|
39
|
+
},
|
|
40
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdirSync } from 'node:fs'
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
|
|
8
|
+
import { type App, createApp } from '../src/app.ts'
|
|
9
|
+
import { APP_NAME } from '../src/routes.ts'
|
|
10
|
+
|
|
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
|
+
const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
|
|
13
|
+
mkdirSync(scratch, { recursive: true })
|
|
14
|
+
|
|
15
|
+
const projectRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
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>) {
|
|
19
|
+
const home = await mkdtemp(join(scratch, 'app-test-'))
|
|
20
|
+
const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0), {
|
|
21
|
+
listen: false,
|
|
22
|
+
})
|
|
23
|
+
try {
|
|
24
|
+
await run(app)
|
|
25
|
+
} finally {
|
|
26
|
+
await app.stop()
|
|
27
|
+
await rm(home, { recursive: true, force: true })
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('GET / answers with the app name', () =>
|
|
32
|
+
withApp(async (app) => {
|
|
33
|
+
const response = await app.root.router.request('/')
|
|
34
|
+
assert.equal(response.status, 200)
|
|
35
|
+
assert.deepEqual(await response.json(), { app: APP_NAME, ok: true })
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
test('GET /about renders the JSX page', () =>
|
|
39
|
+
withApp(async (app) => {
|
|
40
|
+
const response = await app.root.router.request('/about')
|
|
41
|
+
assert.equal(response.status, 200)
|
|
42
|
+
assert.match(response.headers.get('content-type') ?? '', /html/)
|
|
43
|
+
assert.match(
|
|
44
|
+
await response.text(),
|
|
45
|
+
/<main><h1>About<\/h1><p>Rendered on the server with the JSX runtime in plugins\/acryl-router\.<\/p><\/main>/,
|
|
46
|
+
)
|
|
47
|
+
}))
|
|
48
|
+
|
|
49
|
+
test('an error thrown in a .tsx file names the .tsx line in its stack', async () => {
|
|
50
|
+
const { failingPage } = await import('../src/page.tsx')
|
|
51
|
+
let stack = ''
|
|
52
|
+
try {
|
|
53
|
+
failingPage()
|
|
54
|
+
} catch (error) {
|
|
55
|
+
stack = String((error as Error).stack)
|
|
56
|
+
}
|
|
57
|
+
assert.match(stack, /page\.tsx:\d+/)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('answers health checks and 404 for unknown paths', () =>
|
|
61
|
+
withApp(async (app) => {
|
|
62
|
+
assert.equal((await app.root.router.request('/health')).status, 200)
|
|
63
|
+
assert.equal((await app.root.router.request('/nope')).status, 404)
|
|
64
|
+
}))
|
|
65
|
+
|
|
66
|
+
test('GET /counter serves a page with a client component, and the component is compiled for the browser', () =>
|
|
67
|
+
withApp(async (app) => {
|
|
68
|
+
const page = await app.root.router.request('/counter')
|
|
69
|
+
assert.equal(page.status, 200)
|
|
70
|
+
assert.match(await page.text(), /<script type="module" src="\/client\/counter\.tsx">/)
|
|
71
|
+
const module = await app.root.router.request('/client/counter.tsx')
|
|
72
|
+
assert.equal(module.status, 200)
|
|
73
|
+
assert.match(module.headers.get('content-type') ?? '', /javascript/)
|
|
74
|
+
assert.doesNotMatch(await module.text(), /<Counter|<button/)
|
|
75
|
+
// The component's import of the DOM renderer is pointed at the browser build the acrylblends package ships.
|
|
76
|
+
const runtime = await app.root.router.request('/client/@acrylblends/jsx-dom.js')
|
|
77
|
+
assert.equal(runtime.status, 200)
|
|
78
|
+
}))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import { once } from 'node:events'
|
|
4
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
|
5
|
+
import { createServer } from 'node:net'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { test } from 'node:test'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
|
|
10
|
+
/** 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. */
|
|
11
|
+
const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
|
|
12
|
+
mkdirSync(scratch, { recursive: true })
|
|
13
|
+
|
|
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
|
+
const canListen = await new Promise<boolean>((done) => {
|
|
16
|
+
const probe = createServer()
|
|
17
|
+
probe.once('error', () => done(false))
|
|
18
|
+
probe.listen(0, '127.0.0.1', () => probe.close(() => done(true)))
|
|
19
|
+
})
|
|
20
|
+
const skip = canListen ? false : 'this environment does not allow listening on a port'
|
|
21
|
+
if (!canListen)
|
|
22
|
+
console.log(
|
|
23
|
+
`skipped: the tests that start the real server (${skip}); every other test ran in process`,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
/** Start `src/main.ts` the way `npm start` does, on a port the system chooses, and read the address it prints. */
|
|
27
|
+
async function start(env: Record<string, string> = {}) {
|
|
28
|
+
const home = mkdtempSync(join(scratch, 'server-test-'))
|
|
29
|
+
const child = spawn(
|
|
30
|
+
process.execPath,
|
|
31
|
+
['--import', './plugins/acryl-node-server/src/register.ts', 'src/main.ts'],
|
|
32
|
+
{
|
|
33
|
+
env: {
|
|
34
|
+
...process.env,
|
|
35
|
+
ACRYLBLENDS_HOME: home,
|
|
36
|
+
ACRYLBLENDS_PORT: '0',
|
|
37
|
+
ACRYL_DOOR: 'off',
|
|
38
|
+
...env,
|
|
39
|
+
},
|
|
40
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
let out = ''
|
|
44
|
+
const address = await new Promise<string>((resolve, reject) => {
|
|
45
|
+
child.once('exit', (code) =>
|
|
46
|
+
reject(new Error(`the server exited with ${code} before it listened`)),
|
|
47
|
+
)
|
|
48
|
+
child.stdout.on('data', (chunk) => {
|
|
49
|
+
out += chunk
|
|
50
|
+
const match = /listening on (http:\/\/\S+)/.exec(out)
|
|
51
|
+
if (match?.[1]) resolve(match[1])
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
return { child, address, home }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test('the server listens on 127.0.0.1 unless HOST says otherwise', { skip }, async () => {
|
|
58
|
+
const { child, address, home } = await start()
|
|
59
|
+
try {
|
|
60
|
+
assert.match(address, /^http:\/\/127\.0\.0\.1:\d+$/)
|
|
61
|
+
} finally {
|
|
62
|
+
child.kill('SIGTERM')
|
|
63
|
+
await once(child, 'exit')
|
|
64
|
+
rmSync(home, { recursive: true, force: true })
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
|
69
|
+
test(`${signal} stops the server cleanly and frees the port`, { skip }, async () => {
|
|
70
|
+
const { child, address, home } = await start()
|
|
71
|
+
try {
|
|
72
|
+
assert.equal((await fetch(address)).status, 200)
|
|
73
|
+
const started = Date.now()
|
|
74
|
+
child.kill(signal)
|
|
75
|
+
const [code] = await once(child, 'exit')
|
|
76
|
+
assert.equal(code, 0)
|
|
77
|
+
assert.ok(Date.now() - started < 5000, 'the server took too long to stop')
|
|
78
|
+
await assert.rejects(fetch(address), 'the port should be free')
|
|
79
|
+
} finally {
|
|
80
|
+
rmSync(home, { recursive: true, force: true })
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2024",
|
|
4
|
+
"lib": ["ES2024", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noImplicitOverride": true,
|
|
9
|
+
"verbatimModuleSyntax": true,
|
|
10
|
+
"erasableSyntaxOnly": true,
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"types": ["node"],
|
|
15
|
+
"jsx": "react-jsx",
|
|
16
|
+
"jsxImportSource": "#acryl-jsx"
|
|
17
|
+
},
|
|
18
|
+
"include": ["src", "test", "plugins"],
|
|
19
|
+
"exclude": ["src/client"]
|
|
20
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
|
|
2
|
+
## This app (notes): accounts, sessions, a database
|
|
3
|
+
|
|
4
|
+
A Cordis app: `blend.yaml` lists the plugins that are mounted (`acryl-router`, `acryl-db-sqlite`, `routes`, `acryl-node-server`), `src/app.ts` is the one file that names the SQLite provider, and `src/routes.ts` is the app's own plugin: it builds the app on the `db` port and mounts it.
|
|
5
|
+
|
|
6
|
+
- **The dependency rule:** only `src/app.ts` (the `opener` in `package.json`) imports the provider. Every other file takes a `Db` (`import type { Db } from '../plugins/acryl-db/src/index.ts'`) as an argument and never imports `node:sqlite`, `pg`, another driver or `acryl-db-sqlite`. `npm test` and `npm run check` run `acrylblends check-deps`, which names the file and line of a violation; do not move the `opener` entry to silence it.
|
|
7
|
+
- `src/db.ts`: `notesStore(db)`, the queries for notes. Every `db` call returns a promise: `await` it. Use `?` bindings, never string-built SQL. The database is `data/notes.sqlite` (or the file `$NOTES_DB` names), opened by the `db` row with `migrations/` applied on start. See `docs/framework/guides/data.md`.
|
|
8
|
+
- `migrations/`: the shape of the database, one numbered file per change (`001-notes.sql`). A file that has run is never edited; a change is the next file (`002-name.sql`).
|
|
9
|
+
- `src/auth.ts`: scrypt password hashes (`node:crypto`) and the `sessions` table; the cookie `sid` holds a random token.
|
|
10
|
+
- `src/schema.ts`: the rules for a note and for credentials. The HTML forms and the JSON API both use `checkNote`.
|
|
11
|
+
- `src/views.tsx`: the pages, as JSX components rendered to strings, and the small script for the instant filter.
|
|
12
|
+
- `src/client/`: client components, with their own `tsconfig.json` (browser types). `counter.tsx` is the live character counter. `clientModules` in `notes-app.ts` compiles them when the browser asks; `npm run build` compiles them once into `dist/client` for production. See `docs/framework/guides/client-modules.md`.
|
|
13
|
+
- `src/notes-app.ts`: `createApp(db)` (pages and forms, csrf on) and `createApi(db)` (the JSON API under `/api/notes`, chained so `testClient` is typed).
|
|
14
|
+
- `test/notes.test.ts`: a test for every route. It boots the whole app on a database file in a throwaway home (`createApp` of `src/app.ts` with `database`) and takes `app.root.db`. A form POST in a test needs `origin: 'http://localhost'` (csrf) and the `sid` cookie from sign up.
|
|
15
|
+
- Forms answer an invalid input with status 400 and the same page with the messages; a successful POST redirects with 303.
|
|
16
|
+
- To add a column or a table: write the next migration (`migrations/002-name.sql`), then change `notesStore`, `checkNote`, the form and the list in `views.tsx`, and the tests. Do not edit `001-notes.sql`.
|
|
17
|
+
|
|
18
|
+
## Before you ship
|
|
19
|
+
|
|
20
|
+
Read `docs/framework/getting-started/deploy.md`. In short:
|
|
21
|
+
|
|
22
|
+
- Install with `npm ci --omit=dev`, run `npm run build` (it compiles `src/client` once; with `NODE_ENV=production` only `dist/client` is served), and start with `NODE_ENV=production npm start` (`typescript` stays a runtime dependency: it compiles the `.tsx` pages at start).
|
|
23
|
+
- The app listens on `127.0.0.1`; set `HOST=0.0.0.0` only in a container. `PORT` chooses the port.
|
|
24
|
+
- Put the SQLite file outside the code folder (`NOTES_DB=/var/lib/notes/notes.sqlite`) and back it up.
|
|
25
|
+
- Behind an HTTPS reverse proxy set `TRUST_PROXY=1` and `PUBLIC_ORIGIN=https://your.site`, or the cookie is not `Secure` and csrf refuses form posts.
|
|
26
|
+
- `npm test` includes a test that starts the server and stops it with SIGTERM; keep it passing if you change `src/main.ts`.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# The app as data, in ACRYL's Blend format (blends.acryl.dev/v1alpha1): which plugins it mounts, in what order, with what config.
|
|
2
|
+
# Edit this, not app.ts, to switch a plugin off (disabled: true) or change its config. `npm run lock` records what the app is built from.
|
|
3
|
+
apiVersion: blends.acryl.dev/v1alpha1
|
|
4
|
+
kind: Blueprint
|
|
5
|
+
metadata:
|
|
6
|
+
id: acrylblends.__APP_NAME__
|
|
7
|
+
name: __APP_NAME__
|
|
8
|
+
version: 0.1.0
|
|
9
|
+
visibility: private # `npm run save` never pushes a private app to a public remote
|
|
10
|
+
spec:
|
|
11
|
+
runtime: cordis
|
|
12
|
+
parameters:
|
|
13
|
+
# The address the server listens on.
|
|
14
|
+
hostname: { type: string, default: 127.0.0.1 }
|
|
15
|
+
rows:
|
|
16
|
+
- id: router
|
|
17
|
+
name: acryl-router
|
|
18
|
+
# The db port, on one SQLite file. The file and the migrations folder are set in src/app.ts (data/notes.sqlite, or the file the NOTES_DB environment variable names).
|
|
19
|
+
- id: db
|
|
20
|
+
name: acryl-db-sqlite
|
|
21
|
+
- id: routes
|
|
22
|
+
name: routes
|
|
23
|
+
- id: server
|
|
24
|
+
name: acryl-node-server
|
|
25
|
+
config:
|
|
26
|
+
hostname: "{{parameters.hostname}}"
|
|
27
|
+
# 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.
|
|
28
|
+
- id: acryl-door
|
|
29
|
+
name: acryl-door
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
CREATE TABLE users (
|
|
2
|
+
id INTEGER PRIMARY KEY,
|
|
3
|
+
email TEXT NOT NULL UNIQUE,
|
|
4
|
+
password_hash TEXT NOT NULL
|
|
5
|
+
);
|
|
6
|
+
|
|
7
|
+
CREATE TABLE sessions (
|
|
8
|
+
token TEXT PRIMARY KEY,
|
|
9
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
CREATE TABLE notes (
|
|
13
|
+
id INTEGER PRIMARY KEY,
|
|
14
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
15
|
+
title TEXT NOT NULL,
|
|
16
|
+
body TEXT NOT NULL,
|
|
17
|
+
updated_at TEXT NOT NULL
|
|
18
|
+
);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__APP_NAME__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"imports": {
|
|
7
|
+
"#acryl-jsx/jsx-runtime": "./plugins/acryl-router/src/core/jsx/jsx-runtime.ts",
|
|
8
|
+
"#acryl-jsx/jsx-dev-runtime": "./plugins/acryl-router/src/core/jsx/jsx-dev-runtime.ts"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=22.19.0"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node --import ./plugins/acryl-node-server/src/register.ts src/main.ts",
|
|
15
|
+
"dev": "node --watch --import ./plugins/acryl-node-server/src/register.ts src/main.ts",
|
|
16
|
+
"test": "acrylblends check-deps && node --import ./plugins/acryl-node-server/src/register.ts --test \"test/**/*.test.{ts,tsx}\"",
|
|
17
|
+
"check": "tsc --noEmit && tsc --noEmit -p src/client && acrylblends check-deps",
|
|
18
|
+
"build": "acrylblends build-client",
|
|
19
|
+
"lock": "node plugins/manifest/lock.ts",
|
|
20
|
+
"save": "node plugins/save/cli.ts",
|
|
21
|
+
"upgrade": "node plugins/manifest/upgrade-cli.ts"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"acrylblends": "__ACRYLBLENDS_RANGE__",
|
|
25
|
+
"typescript": "6.0.3"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^24.0.0"
|
|
29
|
+
},
|
|
30
|
+
"acryl": {
|
|
31
|
+
"opener": "src/app.ts"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
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 { type AppInstance, instance } from '../plugins/instance/index.ts'
|
|
9
|
+
import {
|
|
10
|
+
blueprintsIn,
|
|
11
|
+
compose,
|
|
12
|
+
loadManifest,
|
|
13
|
+
type ParameterValue,
|
|
14
|
+
} from '../plugins/manifest/index.ts'
|
|
15
|
+
import { doorEnabled } from './door.ts'
|
|
16
|
+
import { routes } from './routes.ts'
|
|
17
|
+
|
|
18
|
+
/** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
|
|
19
|
+
export const registry = {
|
|
20
|
+
'acryl-router': router,
|
|
21
|
+
'acryl-db-sqlite': db,
|
|
22
|
+
'acryl-node-server': nodeServer,
|
|
23
|
+
'acryl-door': door,
|
|
24
|
+
routes,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const projectRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
28
|
+
|
|
29
|
+
/** The manifest next to this app's source. */
|
|
30
|
+
export const MANIFEST = join(projectRoot, 'blend.yaml')
|
|
31
|
+
|
|
32
|
+
/** The manifest, resolved. `createApp` and the tests read it the same way. */
|
|
33
|
+
export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
|
|
34
|
+
return loadManifest(MANIFEST, {
|
|
35
|
+
known: Object.keys(registry),
|
|
36
|
+
getBlueprint: blueprintsIn(join(projectRoot, 'blueprints')),
|
|
37
|
+
...(values ? { values } : {}),
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 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. */
|
|
42
|
+
function without<M extends { plugins: readonly { use: string }[] }>(
|
|
43
|
+
manifest: M,
|
|
44
|
+
...uses: string[]
|
|
45
|
+
): M {
|
|
46
|
+
return {
|
|
47
|
+
...manifest,
|
|
48
|
+
plugins: manifest.plugins.map((row) =>
|
|
49
|
+
uses.includes(row.use) ? { ...row, disabled: true } : row,
|
|
50
|
+
),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface App {
|
|
55
|
+
readonly root: Context
|
|
56
|
+
/** The address the server listens on; empty when the app was made with `listen: false`. */
|
|
57
|
+
readonly url: string
|
|
58
|
+
stop(): Promise<void>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface AppOptions {
|
|
62
|
+
readonly values?: Readonly<Record<string, ParameterValue>>
|
|
63
|
+
/** 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`. */
|
|
64
|
+
readonly database?: string
|
|
65
|
+
/** 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. */
|
|
66
|
+
readonly listen?: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. The `db` row opens the database and applies
|
|
71
|
+
* `migrations/`; the one place that names the SQLite provider is this file (`acrylblends check-deps`), so a different provider is a change here.
|
|
72
|
+
*/
|
|
73
|
+
export async function createApp(chosen: AppInstance, options: AppOptions = {}): Promise<App> {
|
|
74
|
+
const manifest = readManifest(options.values)
|
|
75
|
+
const listen = options.listen
|
|
76
|
+
const file = options.database ?? 'data/notes.sqlite'
|
|
77
|
+
const root = new Context()
|
|
78
|
+
const first = root.plugin(instance, chosen)
|
|
79
|
+
await first.await()
|
|
80
|
+
const fibers: Fiber[] = [
|
|
81
|
+
first,
|
|
82
|
+
...(await compose(
|
|
83
|
+
root,
|
|
84
|
+
listen === false
|
|
85
|
+
? without(manifest, 'acryl-node-server', 'acryl-door')
|
|
86
|
+
: doorEnabled()
|
|
87
|
+
? manifest
|
|
88
|
+
: without(manifest, 'acryl-door'),
|
|
89
|
+
registry,
|
|
90
|
+
{
|
|
91
|
+
db: {
|
|
92
|
+
path: isAbsolute(file) ? file : resolve(projectRoot, file),
|
|
93
|
+
migrations: join(projectRoot, 'migrations'),
|
|
94
|
+
},
|
|
95
|
+
server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
|
|
96
|
+
},
|
|
97
|
+
)),
|
|
98
|
+
]
|
|
99
|
+
return {
|
|
100
|
+
root,
|
|
101
|
+
url: listen === false ? '' : root.nodeServer.url,
|
|
102
|
+
async stop() {
|
|
103
|
+
for (const fiber of [...fibers].reverse()) await fiber.dispose()
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
import type { Db } from '../plugins/acryl-db/src/index.ts'
|
|
4
|
+
|
|
5
|
+
const scryptAsync = promisify(scrypt) as (
|
|
6
|
+
password: string,
|
|
7
|
+
salt: Buffer,
|
|
8
|
+
length: number,
|
|
9
|
+
) => Promise<Buffer>
|
|
10
|
+
|
|
11
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
12
|
+
const salt = randomBytes(16)
|
|
13
|
+
const key = await scryptAsync(password, salt, 64)
|
|
14
|
+
return `${salt.toString('hex')}:${key.toString('hex')}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
|
18
|
+
const [salt, key] = stored.split(':')
|
|
19
|
+
if (!salt || !key) return false
|
|
20
|
+
const expected = Buffer.from(key, 'hex')
|
|
21
|
+
const actual = await scryptAsync(password, Buffer.from(salt, 'hex'), expected.length)
|
|
22
|
+
return timingSafeEqual(actual, expected)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function authStore(db: Db) {
|
|
26
|
+
return {
|
|
27
|
+
findUser(email: string) {
|
|
28
|
+
return db.get<{ id: number; email: string; password_hash: string }>(
|
|
29
|
+
'SELECT id, email, password_hash FROM users WHERE email = ?',
|
|
30
|
+
email,
|
|
31
|
+
)
|
|
32
|
+
},
|
|
33
|
+
async createUser(email: string, password: string): Promise<number> {
|
|
34
|
+
const user = await db.get<{ id: number }>(
|
|
35
|
+
'INSERT INTO users (email, password_hash) VALUES (?, ?) RETURNING id',
|
|
36
|
+
email,
|
|
37
|
+
await hashPassword(password),
|
|
38
|
+
)
|
|
39
|
+
return user!.id
|
|
40
|
+
},
|
|
41
|
+
async startSession(userId: number): Promise<string> {
|
|
42
|
+
const token = randomBytes(32).toString('hex')
|
|
43
|
+
await db.run('INSERT INTO sessions (token, user_id) VALUES (?, ?)', token, userId)
|
|
44
|
+
return token
|
|
45
|
+
},
|
|
46
|
+
async userFor(token: string | undefined) {
|
|
47
|
+
if (!token) return undefined
|
|
48
|
+
return db.get<{ id: number; email: string }>(
|
|
49
|
+
'SELECT users.id AS id, users.email AS email FROM sessions JOIN users ON users.id = sessions.user_id WHERE token = ?',
|
|
50
|
+
token,
|
|
51
|
+
)
|
|
52
|
+
},
|
|
53
|
+
async endSession(token: string): Promise<void> {
|
|
54
|
+
await db.run('DELETE FROM sessions WHERE token = ?', token)
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { render, useEffect, useState } from 'acrylblends/jsx/dom'
|
|
2
|
+
|
|
3
|
+
/** The live character counter of the note form: a client component drawn next to the textarea, compiled on request by clientModules (see src/notes-app.ts). */
|
|
4
|
+
const Count = ({ area }: { area: HTMLTextAreaElement }) => {
|
|
5
|
+
const [length, setLength] = useState(area.value.length)
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const update = () => setLength(area.value.length)
|
|
8
|
+
area.addEventListener('input', update)
|
|
9
|
+
return () => area.removeEventListener('input', update)
|
|
10
|
+
}, [])
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
{length} / {area.maxLength}
|
|
14
|
+
</>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const area = document.getElementById('body')
|
|
19
|
+
const out = document.querySelector('[data-island="counter"]')
|
|
20
|
+
if (area instanceof HTMLTextAreaElement && out instanceof HTMLElement)
|
|
21
|
+
render(<Count area={area} />, out)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2024",
|
|
4
|
+
"lib": ["ES2024", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"jsx": "react-jsx",
|
|
11
|
+
"jsxImportSource": "acrylblends/jsx/dom",
|
|
12
|
+
"types": [],
|
|
13
|
+
"paths": {
|
|
14
|
+
"acrylblends/jsx/dom": ["../../plugins/acryl-router/src/browser/jsx-dom.ts"],
|
|
15
|
+
"acrylblends/jsx/dom/jsx-runtime": [
|
|
16
|
+
"../../plugins/acryl-router/src/core/jsx/dom/jsx-runtime.ts"
|
|
17
|
+
],
|
|
18
|
+
"acrylblends/jsx/dom/jsx-dev-runtime": [
|
|
19
|
+
"../../plugins/acryl-router/src/core/jsx/dom/jsx-dev-runtime.ts"
|
|
20
|
+
],
|
|
21
|
+
"acrylblends/jsx/dom/client": ["../../plugins/acryl-router/src/browser/jsx-dom.ts"],
|
|
22
|
+
"acrylblends/client": ["../../plugins/acryl-router/src/browser/client.ts"]
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"include": ["**/*.ts", "**/*.tsx"]
|
|
26
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Db } from '../plugins/acryl-db/src/index.ts'
|
|
2
|
+
|
|
3
|
+
export interface Note {
|
|
4
|
+
id: number
|
|
5
|
+
user_id: number
|
|
6
|
+
title: string
|
|
7
|
+
body: string
|
|
8
|
+
updated_at: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function notesStore(db: Db) {
|
|
12
|
+
return {
|
|
13
|
+
list(userId: number, q = ''): Promise<Note[]> {
|
|
14
|
+
const like = `%${q.replace(/[\\%_]/g, (m) => `\\${m}`)}%`
|
|
15
|
+
return db.all<Note>(
|
|
16
|
+
`SELECT * FROM notes WHERE user_id = ? AND (title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\') ORDER BY updated_at DESC, id DESC`,
|
|
17
|
+
userId,
|
|
18
|
+
like,
|
|
19
|
+
like,
|
|
20
|
+
)
|
|
21
|
+
},
|
|
22
|
+
get(userId: number, id: number): Promise<Note | undefined> {
|
|
23
|
+
return db.get<Note>('SELECT * FROM notes WHERE id = ? AND user_id = ?', id, userId)
|
|
24
|
+
},
|
|
25
|
+
async create(userId: number, title: string, body: string): Promise<Note> {
|
|
26
|
+
const note = await db.get<Note>(
|
|
27
|
+
'INSERT INTO notes (user_id, title, body, updated_at) VALUES (?, ?, ?, ?) RETURNING *',
|
|
28
|
+
userId,
|
|
29
|
+
title,
|
|
30
|
+
body,
|
|
31
|
+
new Date().toISOString(),
|
|
32
|
+
)
|
|
33
|
+
return note!
|
|
34
|
+
},
|
|
35
|
+
update(userId: number, id: number, title: string, body: string): Promise<Note | undefined> {
|
|
36
|
+
return db.get<Note>(
|
|
37
|
+
'UPDATE notes SET title = ?, body = ?, updated_at = ? WHERE id = ? AND user_id = ? RETURNING *',
|
|
38
|
+
title,
|
|
39
|
+
body,
|
|
40
|
+
new Date().toISOString(),
|
|
41
|
+
id,
|
|
42
|
+
userId,
|
|
43
|
+
)
|
|
44
|
+
},
|
|
45
|
+
async remove(userId: number, id: number): Promise<boolean> {
|
|
46
|
+
const { changes } = await db.run('DELETE FROM notes WHERE id = ? AND user_id = ?', id, userId)
|
|
47
|
+
return changes > 0
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The door (`plugins/acryl-door`) lets an agent working in this folder look at the running page and act on it, through `.acryl/bin/acryl`. It is on in development
|
|
3
|
+
* and off in production (`NODE_ENV=production`) and when `ACRYL_DOOR=off`: `src/app.ts` leaves its row out then, and the pages leave its script tag out. One process
|
|
4
|
+
* at a time serves a folder's door, so the tests that start the real server set `ACRYL_DOOR=off` and a second `npm start` beside a running one does not fail on it.
|
|
5
|
+
*/
|
|
6
|
+
export const doorEnabled = (): boolean =>
|
|
7
|
+
process.env.NODE_ENV !== 'production' && process.env.ACRYL_DOOR !== 'off'
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url'
|
|
2
|
+
import { selectInstance } from '../plugins/instance/index.ts'
|
|
3
|
+
import { createApp } from './app.ts'
|
|
4
|
+
|
|
5
|
+
// The composition root: the one place that reads the environment and chooses where the app lives.
|
|
6
|
+
const chosen = selectInstance({
|
|
7
|
+
projectRoot: fileURLToPath(new URL('..', import.meta.url)),
|
|
8
|
+
// PORT and HOST are the names hosting platforms set; ACRYLBLENDS_PORT and ACRYLBLENDS_HOST win when both are given.
|
|
9
|
+
env: {
|
|
10
|
+
...(process.env.PORT ? { ACRYLBLENDS_PORT: process.env.PORT } : {}),
|
|
11
|
+
...(process.env.HOST ? { ACRYLBLENDS_HOST: process.env.HOST } : {}),
|
|
12
|
+
...process.env,
|
|
13
|
+
},
|
|
14
|
+
})
|
|
15
|
+
const app = await createApp(chosen, {
|
|
16
|
+
...(process.env.NOTES_DB ? { database: process.env.NOTES_DB } : {}),
|
|
17
|
+
})
|
|
18
|
+
console.log(`listening on ${app.url} (data in ${chosen.home})`)
|
|
19
|
+
|
|
20
|
+
// Stop taking requests, let the ones in flight finish, then close the database (the db plugin does, when it is disposed) and exit.
|
|
21
|
+
const stop = async () => {
|
|
22
|
+
const timer = setTimeout(() => process.exit(1), 10_000)
|
|
23
|
+
timer.unref()
|
|
24
|
+
await app.stop()
|
|
25
|
+
process.exit(0)
|
|
26
|
+
}
|
|
27
|
+
process.once('SIGINT', stop)
|
|
28
|
+
process.once('SIGTERM', stop)
|