@ketvietlab/ketjs 0.1.1
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/LICENSE +21 -0
- package/README.md +36 -0
- package/dist/agent/capabilities.d.ts +62 -0
- package/dist/agent/capabilities.d.ts.map +1 -0
- package/dist/agent/capabilities.js +89 -0
- package/dist/agent/capabilities.js.map +1 -0
- package/dist/agent/permissions.d.ts +56 -0
- package/dist/agent/permissions.d.ts.map +1 -0
- package/dist/agent/permissions.js +209 -0
- package/dist/agent/permissions.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +698 -0
- package/dist/cli.js.map +1 -0
- package/dist/codegen/dts.d.ts +3 -0
- package/dist/codegen/dts.d.ts.map +1 -0
- package/dist/codegen/dts.js +94 -0
- package/dist/codegen/dts.js.map +1 -0
- package/dist/data/adapter.d.ts +4 -0
- package/dist/data/adapter.d.ts.map +1 -0
- package/dist/data/adapter.js +18 -0
- package/dist/data/adapter.js.map +1 -0
- package/dist/data/changeset.d.ts +49 -0
- package/dist/data/changeset.d.ts.map +1 -0
- package/dist/data/changeset.js +206 -0
- package/dist/data/changeset.js.map +1 -0
- package/dist/data/expr.d.ts +59 -0
- package/dist/data/expr.d.ts.map +1 -0
- package/dist/data/expr.js +61 -0
- package/dist/data/expr.js.map +1 -0
- package/dist/data/fleet.d.ts +28 -0
- package/dist/data/fleet.d.ts.map +1 -0
- package/dist/data/fleet.js +82 -0
- package/dist/data/fleet.js.map +1 -0
- package/dist/data/list-search.d.ts +93 -0
- package/dist/data/list-search.d.ts.map +1 -0
- package/dist/data/list-search.js +268 -0
- package/dist/data/list-search.js.map +1 -0
- package/dist/data/migrate.d.ts +75 -0
- package/dist/data/migrate.d.ts.map +1 -0
- package/dist/data/migrate.js +136 -0
- package/dist/data/migrate.js.map +1 -0
- package/dist/data/pool.d.ts +20 -0
- package/dist/data/pool.d.ts.map +1 -0
- package/dist/data/pool.js +92 -0
- package/dist/data/pool.js.map +1 -0
- package/dist/data/query.d.ts +111 -0
- package/dist/data/query.d.ts.map +1 -0
- package/dist/data/query.js +268 -0
- package/dist/data/query.js.map +1 -0
- package/dist/data/sqlite.d.ts +3 -0
- package/dist/data/sqlite.d.ts.map +1 -0
- package/dist/data/sqlite.js +107 -0
- package/dist/data/sqlite.js.map +1 -0
- package/dist/data/time.d.ts +9 -0
- package/dist/data/time.d.ts.map +1 -0
- package/dist/data/time.js +107 -0
- package/dist/data/time.js.map +1 -0
- package/dist/index.d.ts +79 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +51 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel/apps.d.ts +43 -0
- package/dist/kernel/apps.d.ts.map +1 -0
- package/dist/kernel/apps.js +234 -0
- package/dist/kernel/apps.js.map +1 -0
- package/dist/kernel/compose.d.ts +6 -0
- package/dist/kernel/compose.d.ts.map +1 -0
- package/dist/kernel/compose.js +976 -0
- package/dist/kernel/compose.js.map +1 -0
- package/dist/kernel/define.d.ts +9 -0
- package/dist/kernel/define.d.ts.map +1 -0
- package/dist/kernel/define.js +140 -0
- package/dist/kernel/define.js.map +1 -0
- package/dist/kernel/diff.d.ts +11 -0
- package/dist/kernel/diff.d.ts.map +1 -0
- package/dist/kernel/diff.js +106 -0
- package/dist/kernel/diff.js.map +1 -0
- package/dist/kernel/errors.d.ts +17 -0
- package/dist/kernel/errors.d.ts.map +1 -0
- package/dist/kernel/errors.js +43 -0
- package/dist/kernel/errors.js.map +1 -0
- package/dist/kernel/graph.d.ts +3 -0
- package/dist/kernel/graph.d.ts.map +1 -0
- package/dist/kernel/graph.js +56 -0
- package/dist/kernel/graph.js.map +1 -0
- package/dist/kernel/i18n.d.ts +43 -0
- package/dist/kernel/i18n.d.ts.map +1 -0
- package/dist/kernel/i18n.js +79 -0
- package/dist/kernel/i18n.js.map +1 -0
- package/dist/kernel/index.d.ts +7 -0
- package/dist/kernel/index.d.ts.map +1 -0
- package/dist/kernel/index.js +7 -0
- package/dist/kernel/index.js.map +1 -0
- package/dist/kernel/layout.d.ts +17 -0
- package/dist/kernel/layout.d.ts.map +1 -0
- package/dist/kernel/layout.js +77 -0
- package/dist/kernel/layout.js.map +1 -0
- package/dist/kernel/menu.d.ts +27 -0
- package/dist/kernel/menu.d.ts.map +1 -0
- package/dist/kernel/menu.js +65 -0
- package/dist/kernel/menu.js.map +1 -0
- package/dist/kernel/modules.d.ts +31 -0
- package/dist/kernel/modules.d.ts.map +1 -0
- package/dist/kernel/modules.js +322 -0
- package/dist/kernel/modules.js.map +1 -0
- package/dist/kernel/routes.d.ts +24 -0
- package/dist/kernel/routes.d.ts.map +1 -0
- package/dist/kernel/routes.js +98 -0
- package/dist/kernel/routes.js.map +1 -0
- package/dist/kernel/types.d.ts +8 -0
- package/dist/kernel/types.d.ts.map +1 -0
- package/dist/kernel/types.js +67 -0
- package/dist/kernel/types.js.map +1 -0
- package/dist/kernel/workspace.d.ts +62 -0
- package/dist/kernel/workspace.d.ts.map +1 -0
- package/dist/kernel/workspace.js +156 -0
- package/dist/kernel/workspace.js.map +1 -0
- package/dist/scaffold/index.d.ts +2 -0
- package/dist/scaffold/index.d.ts.map +1 -0
- package/dist/scaffold/index.js +49 -0
- package/dist/scaffold/index.js.map +1 -0
- package/dist/scaffold/templates/app.test.ts.tmpl +17 -0
- package/dist/scaffold/templates/biome.json.tmpl +30 -0
- package/dist/scaffold/templates/dev.mjs.tmpl +16 -0
- package/dist/scaffold/templates/gitignore.tmpl +24 -0
- package/dist/scaffold/templates/ket.workspace.ts.tmpl +18 -0
- package/dist/scaffold/templates/module.ts.tmpl +27 -0
- package/dist/scaffold/templates/package.json.tmpl +25 -0
- package/dist/scaffold/templates/tsconfig.json.tmpl +15 -0
- package/dist/server/boot.d.ts +173 -0
- package/dist/server/boot.d.ts.map +1 -0
- package/dist/server/boot.js +659 -0
- package/dist/server/boot.js.map +1 -0
- package/dist/server/config.d.ts +48 -0
- package/dist/server/config.d.ts.map +1 -0
- package/dist/server/config.js +82 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/ctx.d.ts +13 -0
- package/dist/server/ctx.d.ts.map +1 -0
- package/dist/server/ctx.js +496 -0
- package/dist/server/ctx.js.map +1 -0
- package/dist/server/fn.d.ts +34 -0
- package/dist/server/fn.d.ts.map +1 -0
- package/dist/server/fn.js +170 -0
- package/dist/server/fn.js.map +1 -0
- package/dist/server/http.d.ts +79 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +459 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/idem.d.ts +31 -0
- package/dist/server/idem.d.ts.map +1 -0
- package/dist/server/idem.js +68 -0
- package/dist/server/idem.js.map +1 -0
- package/dist/server/jobs.d.ts +5 -0
- package/dist/server/jobs.d.ts.map +1 -0
- package/dist/server/jobs.js +24 -0
- package/dist/server/jobs.js.map +1 -0
- package/dist/server/multipart.d.ts +18 -0
- package/dist/server/multipart.d.ts.map +1 -0
- package/dist/server/multipart.js +183 -0
- package/dist/server/multipart.js.map +1 -0
- package/dist/server/project.d.ts +9 -0
- package/dist/server/project.d.ts.map +1 -0
- package/dist/server/project.js +67 -0
- package/dist/server/project.js.map +1 -0
- package/dist/server/queue.d.ts +80 -0
- package/dist/server/queue.d.ts.map +1 -0
- package/dist/server/queue.js +494 -0
- package/dist/server/queue.js.map +1 -0
- package/dist/server/respond.d.ts +91 -0
- package/dist/server/respond.d.ts.map +1 -0
- package/dist/server/respond.js +99 -0
- package/dist/server/respond.js.map +1 -0
- package/dist/server/runtime.d.ts +14 -0
- package/dist/server/runtime.d.ts.map +1 -0
- package/dist/server/runtime.js +25 -0
- package/dist/server/runtime.js.map +1 -0
- package/dist/server/session.d.ts +55 -0
- package/dist/server/session.d.ts.map +1 -0
- package/dist/server/session.js +172 -0
- package/dist/server/session.js.map +1 -0
- package/dist/server/sessionstore.d.ts +54 -0
- package/dist/server/sessionstore.d.ts.map +1 -0
- package/dist/server/sessionstore.js +232 -0
- package/dist/server/sessionstore.js.map +1 -0
- package/dist/server/storage/index.d.ts +11 -0
- package/dist/server/storage/index.d.ts.map +1 -0
- package/dist/server/storage/index.js +31 -0
- package/dist/server/storage/index.js.map +1 -0
- package/dist/server/storage/local.d.ts +5 -0
- package/dist/server/storage/local.d.ts.map +1 -0
- package/dist/server/storage/local.js +165 -0
- package/dist/server/storage/local.js.map +1 -0
- package/dist/server/storage/s3.d.ts +11 -0
- package/dist/server/storage/s3.d.ts.map +1 -0
- package/dist/server/storage/s3.js +133 -0
- package/dist/server/storage/s3.js.map +1 -0
- package/dist/server/storage/sigv4.d.ts +23 -0
- package/dist/server/storage/sigv4.d.ts.map +1 -0
- package/dist/server/storage/sigv4.js +79 -0
- package/dist/server/storage/sigv4.js.map +1 -0
- package/dist/server/storage/types.d.ts +37 -0
- package/dist/server/storage/types.d.ts.map +1 -0
- package/dist/server/storage/types.js +82 -0
- package/dist/server/storage/types.js.map +1 -0
- package/dist/server/stream.d.ts +43 -0
- package/dist/server/stream.d.ts.map +1 -0
- package/dist/server/stream.js +117 -0
- package/dist/server/stream.js.map +1 -0
- package/dist/server/streamstore.d.ts +29 -0
- package/dist/server/streamstore.d.ts.map +1 -0
- package/dist/server/streamstore.js +140 -0
- package/dist/server/streamstore.js.map +1 -0
- package/dist/server/tenants.d.ts +108 -0
- package/dist/server/tenants.d.ts.map +1 -0
- package/dist/server/tenants.js +172 -0
- package/dist/server/tenants.js.map +1 -0
- package/dist/server/transport/index.d.ts +5 -0
- package/dist/server/transport/index.d.ts.map +1 -0
- package/dist/server/transport/index.js +3 -0
- package/dist/server/transport/index.js.map +1 -0
- package/dist/server/transport/memory.d.ts +14 -0
- package/dist/server/transport/memory.d.ts.map +1 -0
- package/dist/server/transport/memory.js +36 -0
- package/dist/server/transport/memory.js.map +1 -0
- package/dist/server/transport/types.d.ts +44 -0
- package/dist/server/transport/types.d.ts.map +1 -0
- package/dist/server/transport/types.js +79 -0
- package/dist/server/transport/types.js.map +1 -0
- package/dist/server/worker.d.ts +33 -0
- package/dist/server/worker.d.ts.map +1 -0
- package/dist/server/worker.js +428 -0
- package/dist/server/worker.js.map +1 -0
- package/dist/testing.d.ts +146 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +406 -0
- package/dist/testing.js.map +1 -0
- package/dist/theme/contracts.d.ts +4 -0
- package/dist/theme/contracts.d.ts.map +1 -0
- package/dist/theme/contracts.js +87 -0
- package/dist/theme/contracts.js.map +1 -0
- package/dist/theme/index.d.ts +8 -0
- package/dist/theme/index.d.ts.map +1 -0
- package/dist/theme/index.js +7 -0
- package/dist/theme/index.js.map +1 -0
- package/dist/theme/joints.d.ts +19 -0
- package/dist/theme/joints.d.ts.map +1 -0
- package/dist/theme/joints.js +126 -0
- package/dist/theme/joints.js.map +1 -0
- package/dist/theme/ktl/compile.d.ts +26 -0
- package/dist/theme/ktl/compile.d.ts.map +1 -0
- package/dist/theme/ktl/compile.js +226 -0
- package/dist/theme/ktl/compile.js.map +1 -0
- package/dist/theme/ktl/lexer.d.ts +20 -0
- package/dist/theme/ktl/lexer.d.ts.map +1 -0
- package/dist/theme/ktl/lexer.js +56 -0
- package/dist/theme/ktl/lexer.js.map +1 -0
- package/dist/theme/ktl/parser.d.ts +64 -0
- package/dist/theme/ktl/parser.d.ts.map +1 -0
- package/dist/theme/ktl/parser.js +220 -0
- package/dist/theme/ktl/parser.js.map +1 -0
- package/dist/theme/render.d.ts +19 -0
- package/dist/theme/render.d.ts.map +1 -0
- package/dist/theme/render.js +246 -0
- package/dist/theme/render.js.map +1 -0
- package/dist/theme/templates.d.ts +2 -0
- package/dist/theme/templates.d.ts.map +1 -0
- package/dist/theme/templates.js +40 -0
- package/dist/theme/templates.js.map +1 -0
- package/dist/theme/tokens.d.ts +4 -0
- package/dist/theme/tokens.d.ts.map +1 -0
- package/dist/theme/tokens.js +15 -0
- package/dist/theme/tokens.js.map +1 -0
- package/dist/theme/viewmodel.d.ts +6 -0
- package/dist/theme/viewmodel.d.ts.map +1 -0
- package/dist/theme/viewmodel.js +40 -0
- package/dist/theme/viewmodel.js.map +1 -0
- package/dist/types.d.ts +614 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +60 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
// Booting an app: the sequence every deployment repeats, written once.
|
|
2
|
+
//
|
|
3
|
+
// Before this, running a KetSuite-shaped app meant ~150 lines of hand-written boot
|
|
4
|
+
// in the app itself — open a database, migrate, register functions, install a
|
|
5
|
+
// bootstrap set, decide who the request is, build the theme, mount the framework's
|
|
6
|
+
// own routes, print something useful, shut down cleanly. Every one of those lines
|
|
7
|
+
// is app-agnostic, and every second app would have copied them, drift included.
|
|
8
|
+
//
|
|
9
|
+
// What stays with the app is what only the app knows: which modules it ships, which
|
|
10
|
+
// function turns a path into a page, which extra routes it serves, and how to open
|
|
11
|
+
// a datastore that is not SQLite. Those arrive through `AppSpec.serve` as data
|
|
12
|
+
// rather than as a closure the framework has to trust.
|
|
13
|
+
import { createAppRegistry } from '../kernel/apps.js';
|
|
14
|
+
import { translator } from '../kernel/i18n.js';
|
|
15
|
+
import { KetError } from '../kernel/errors.js';
|
|
16
|
+
import { createTheme } from '../theme/render.js';
|
|
17
|
+
import { agentDescriptor } from '../agent/capabilities.js';
|
|
18
|
+
import { migrateOne } from '../data/fleet.js';
|
|
19
|
+
import { callFn } from './fn.js';
|
|
20
|
+
import { createKetServer } from './http.js';
|
|
21
|
+
import { createSessions, dbSessionStore } from './session.js';
|
|
22
|
+
import { createTenants, singleTenant } from './tenants.js';
|
|
23
|
+
import { createJoints } from '../theme/joints.js';
|
|
24
|
+
import { buildMenu } from '../kernel/menu.js';
|
|
25
|
+
import { createAdapterPool } from '../data/pool.js';
|
|
26
|
+
import { document, json, text, withHeaders } from './respond.js';
|
|
27
|
+
import { join, isAbsolute } from 'node:path';
|
|
28
|
+
import { html, each } from '@ketvietlab/ketjs-view';
|
|
29
|
+
import { sqliteStore } from './config.js';
|
|
30
|
+
import { bootRuntime } from './runtime.js';
|
|
31
|
+
import { namespacedStorage, storageFromConfig } from './storage/index.js';
|
|
32
|
+
export { page, fragment, navigablePage, isNavigationRequest, text, bytes, streamed, raw, withHeaders, } from './respond.js';
|
|
33
|
+
export { json } from './respond.js';
|
|
34
|
+
/**
|
|
35
|
+
* Opens, migrates, installs, serves. Returns before listening is announced so a
|
|
36
|
+
* caller can print its own banner, or a test can boot on port 0 and never print.
|
|
37
|
+
*/
|
|
38
|
+
export async function bootApp(spec, o = {}) {
|
|
39
|
+
const serve = spec.serve ?? {};
|
|
40
|
+
const log = o.log ?? console.log;
|
|
41
|
+
const { config, modules, manifest } = await bootRuntime(spec, o);
|
|
42
|
+
const baseStorage = await (serve.openStorage ?? storageFromConfig)(config);
|
|
43
|
+
const storages = new Map();
|
|
44
|
+
const storageFor = (key) => {
|
|
45
|
+
const namespace = key || spec.name;
|
|
46
|
+
let storage = storages.get(namespace);
|
|
47
|
+
if (!storage) {
|
|
48
|
+
storage = namespacedStorage(baseStorage, namespace);
|
|
49
|
+
storages.set(namespace, storage);
|
|
50
|
+
}
|
|
51
|
+
return storage;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* An empty database is not a useful one to look at, so a first run installs
|
|
55
|
+
* enough to see something. A database that has been used is left exactly as it
|
|
56
|
+
* is — and with tenants, "first run" is per tenant rather than per deployment.
|
|
57
|
+
*/
|
|
58
|
+
const bootstrap = config.bootstrapApps ?? serve.bootstrap ?? [];
|
|
59
|
+
const bootstrapInto = async (key, apps) => {
|
|
60
|
+
if (!bootstrap.length || (await apps.enabled()).size !== 0)
|
|
61
|
+
return;
|
|
62
|
+
for (const name of bootstrap)
|
|
63
|
+
await apps.install(name);
|
|
64
|
+
log(` first run${key ? ` [${key}]` : ''}, installed: ${[...(await apps.enabled())].sort().join(', ')}`);
|
|
65
|
+
};
|
|
66
|
+
// Opened here only when there is one. With tenants there is no single datastore,
|
|
67
|
+
// and a nullable field says that more honestly than a default one would.
|
|
68
|
+
const adapter = serve.tenants ? null : await (serve.openStore ?? sqliteStore)(config);
|
|
69
|
+
let apps = null;
|
|
70
|
+
if (adapter) {
|
|
71
|
+
if (config.migrateOnBoot) {
|
|
72
|
+
const ops = await migrateOne(adapter, manifest);
|
|
73
|
+
if (ops.length)
|
|
74
|
+
log(` migrate: ${ops.length} operation(s)`);
|
|
75
|
+
}
|
|
76
|
+
apps = await createAppRegistry(manifest, adapter, { autoInstall: config.autoInstall });
|
|
77
|
+
await bootstrapInto('', apps);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* How a request finds its database. One datastore is the degenerate case of the
|
|
81
|
+
* same interface rather than a second code path — two paths through the thing
|
|
82
|
+
* that decides whose data a request sees is exactly how one of them rots.
|
|
83
|
+
*/
|
|
84
|
+
/**
|
|
85
|
+
* Where logins live.
|
|
86
|
+
*
|
|
87
|
+
* With tenants arriving by subdomain the Host names the tenant before any cookie
|
|
88
|
+
* is read, so each tenant keeps its own sessions in its own database — which is
|
|
89
|
+
* also the isolation you want: a session id from one tenant simply is not in
|
|
90
|
+
* another's table.
|
|
91
|
+
*
|
|
92
|
+
* An app serving every tenant from one domain cannot do that, because reading
|
|
93
|
+
* the session needs the database and knowing the database needs the session. It
|
|
94
|
+
* passes `sessions.store` instead — one shared store, with the tenant recorded
|
|
95
|
+
* on the session. Both work; the framework assumes neither.
|
|
96
|
+
*/
|
|
97
|
+
const sharedStore = serve.sessions?.store ?? null;
|
|
98
|
+
const sessionOpts = serve.sessions
|
|
99
|
+
? {
|
|
100
|
+
...(config.secret ? { secret: config.secret } : {}),
|
|
101
|
+
secure: config.host !== '127.0.0.1' && config.host !== 'localhost',
|
|
102
|
+
...serve.sessions,
|
|
103
|
+
}
|
|
104
|
+
: null;
|
|
105
|
+
const makeSessions = sessionOpts
|
|
106
|
+
? (a) => createSessions({ ...sessionOpts, store: sharedStore ?? dbSessionStore(a) })
|
|
107
|
+
: null;
|
|
108
|
+
// Single datastore: one Sessions, built now. Tenants: one per tenant, built on
|
|
109
|
+
// first touch — unless a shared store was supplied, in which case it is one
|
|
110
|
+
// again and every tenant hands back the same instance.
|
|
111
|
+
const sessions = makeSessions && adapter ? await makeSessions(adapter) : null;
|
|
112
|
+
// Built per tenant, because which templates exist depends on what is installed.
|
|
113
|
+
const islandRegistry = (live) => {
|
|
114
|
+
const disabled = new Set(live.disabledModules ?? []);
|
|
115
|
+
const registry = {};
|
|
116
|
+
for (const module of modules) {
|
|
117
|
+
if (disabled.has(module.name))
|
|
118
|
+
continue;
|
|
119
|
+
for (const [name, definition] of Object.entries(module.islands))
|
|
120
|
+
registry[name] = definition.view;
|
|
121
|
+
}
|
|
122
|
+
return registry;
|
|
123
|
+
};
|
|
124
|
+
const availableThemes = [spec.theme, ...(spec.themes ?? [])].filter((theme) => theme !== undefined);
|
|
125
|
+
const fallbackTheme = spec.theme ?? availableThemes[0];
|
|
126
|
+
const themeFactory = spec.headless || !fallbackTheme
|
|
127
|
+
? {}
|
|
128
|
+
: {
|
|
129
|
+
theme: (live) => createTheme(live, modules, {
|
|
130
|
+
translate: translate(config.defaultLocale),
|
|
131
|
+
theme: fallbackTheme.name,
|
|
132
|
+
}),
|
|
133
|
+
};
|
|
134
|
+
// Fills are KTL, so they translate the way templates do.
|
|
135
|
+
const jointFactory = (live, locale) => createJoints(live, { translate: translate(locale), islands: islandRegistry(live) });
|
|
136
|
+
const tenants = serve.tenants
|
|
137
|
+
? createTenants({
|
|
138
|
+
spec: serve.tenants,
|
|
139
|
+
pool: createAdapterPool({
|
|
140
|
+
create: (key) => {
|
|
141
|
+
const made = serve.tenants.open(key, config);
|
|
142
|
+
// The pool wants an Adapter now; opening is the adapter's own job.
|
|
143
|
+
return made;
|
|
144
|
+
},
|
|
145
|
+
...(serve.tenants.max !== undefined ? { max: serve.tenants.max } : {}),
|
|
146
|
+
...(serve.tenants.idleMs !== undefined ? { idleMs: serve.tenants.idleMs } : {}),
|
|
147
|
+
}),
|
|
148
|
+
manifest,
|
|
149
|
+
autoInstall: config.autoInstall,
|
|
150
|
+
...(config.migrateOnBoot
|
|
151
|
+
? {
|
|
152
|
+
prepare: async (key, a) => {
|
|
153
|
+
const ops = await migrateOne(a, manifest);
|
|
154
|
+
if (ops.length)
|
|
155
|
+
log(` migrate [${key}]: ${ops.length} operation(s)`);
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
: {}),
|
|
159
|
+
onFirstTouch: (key, made) => bootstrapInto(key, made),
|
|
160
|
+
...(makeSessions ? { sessions: makeSessions } : {}),
|
|
161
|
+
joints: jointFactory,
|
|
162
|
+
...themeFactory,
|
|
163
|
+
})
|
|
164
|
+
: singleTenant({
|
|
165
|
+
adapter: adapter,
|
|
166
|
+
apps: apps,
|
|
167
|
+
manifest,
|
|
168
|
+
joints: jointFactory,
|
|
169
|
+
...themeFactory,
|
|
170
|
+
sessions,
|
|
171
|
+
});
|
|
172
|
+
/**
|
|
173
|
+
* Sessions, when the app asks for them. Absent, the header shim stays and the
|
|
174
|
+
* banner keeps saying so — an app that has not wired auth yet should look like
|
|
175
|
+
* one rather than quietly appear to have it.
|
|
176
|
+
*/
|
|
177
|
+
/** This request's tenant's sessions — the same instance for all, when shared. */
|
|
178
|
+
const sessionsOf = (url, req) => sessions ? Promise.resolve(sessions) : tenants.ofRequest(url, req, async (t) => t.sessions);
|
|
179
|
+
// One cookie lookup per request even though scope, permissions and actor all
|
|
180
|
+
// depend on it. With tenant databases this also avoids three separate leases.
|
|
181
|
+
const sessionRecords = new WeakMap();
|
|
182
|
+
const sessionRecordOf = (url, req) => {
|
|
183
|
+
if (!makeSessions)
|
|
184
|
+
return Promise.resolve(null);
|
|
185
|
+
let record = sessionRecords.get(req);
|
|
186
|
+
if (!record) {
|
|
187
|
+
record = sessionsOf(url, req).then(async (manager) => {
|
|
188
|
+
const raw = (await manager?.of(req)) ?? null;
|
|
189
|
+
if (!raw || !manager || !serve.resolveSession)
|
|
190
|
+
return raw;
|
|
191
|
+
const resolved = await tenants.ofRequest(url, req, (tenant) => serve.resolveSession({ adapter: tenant.adapter, manifest: tenant.live, record: raw, url, req }));
|
|
192
|
+
if (!resolved) {
|
|
193
|
+
await manager.store.destroy(raw.id);
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const current = {
|
|
197
|
+
companies: raw.companies,
|
|
198
|
+
company: raw.company,
|
|
199
|
+
branch: raw.branch,
|
|
200
|
+
branches: raw.branches,
|
|
201
|
+
securityVersion: raw.securityVersion,
|
|
202
|
+
};
|
|
203
|
+
if (JSON.stringify(current) === JSON.stringify(resolved))
|
|
204
|
+
return raw;
|
|
205
|
+
return manager.update(raw, resolved);
|
|
206
|
+
});
|
|
207
|
+
sessionRecords.set(req, record);
|
|
208
|
+
}
|
|
209
|
+
return record;
|
|
210
|
+
};
|
|
211
|
+
const actorOf = async (url, req) => (await sessionRecordOf(url, req))?.userId ?? null;
|
|
212
|
+
/**
|
|
213
|
+
* The one place a request's identity is decided — one function since D27,
|
|
214
|
+
* precisely so that replacing headers with a login would be one change.
|
|
215
|
+
*
|
|
216
|
+
* With sessions on the headers are gone entirely rather than kept as a fallback:
|
|
217
|
+
* a system where a header can stand in for a login is a system with no login.
|
|
218
|
+
*
|
|
219
|
+
* Reading a session now costs a tenant lease, because with subdomains the
|
|
220
|
+
* session lives in that tenant's database. That is the price of the isolation:
|
|
221
|
+
* a session id issued by one tenant is not a row in another's table at all.
|
|
222
|
+
*/
|
|
223
|
+
const scopeOf = async (url, req) => {
|
|
224
|
+
if (!makeSessions) {
|
|
225
|
+
const list = (h) => (req.headers[h] ?? '')
|
|
226
|
+
.split(',')
|
|
227
|
+
.map((s) => s.trim())
|
|
228
|
+
.filter(Boolean);
|
|
229
|
+
const company = req.headers['x-ket-company'] ?? config.defaultCompany;
|
|
230
|
+
const companies = list('x-ket-companies');
|
|
231
|
+
return {
|
|
232
|
+
company,
|
|
233
|
+
companies: companies.length ? [...new Set([company, ...companies])] : null,
|
|
234
|
+
branch: req.headers['x-ket-current-branch'] ?? null,
|
|
235
|
+
branches: list('x-ket-branch') || null,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const s = await sessionsOf(url, req);
|
|
239
|
+
return s?.scopeOf(await sessionRecordOf(url, req)) ?? { company: null };
|
|
240
|
+
};
|
|
241
|
+
/**
|
|
242
|
+
* A locale is only ever one the deployment ships a catalogue for.
|
|
243
|
+
*
|
|
244
|
+
* Anything else falls back rather than being passed on: `Accept-Language: *` —
|
|
245
|
+
* which Node's own fetch sends by default — used to reach Intl and throw. It also
|
|
246
|
+
* closes the wider hole: the value reaches the `lang` attribute of every page,
|
|
247
|
+
* and one drawn from a fixed set carries nothing into markup.
|
|
248
|
+
*/
|
|
249
|
+
const known = new Set([
|
|
250
|
+
...Object.keys(manifest.messages ?? {}),
|
|
251
|
+
config.defaultLocale,
|
|
252
|
+
config.fallbackLocale,
|
|
253
|
+
]);
|
|
254
|
+
const localeOf = (url, req) => {
|
|
255
|
+
const asked = [
|
|
256
|
+
url.searchParams.get('lang'),
|
|
257
|
+
...(req.headers['accept-language'] ?? '')
|
|
258
|
+
.split(',')
|
|
259
|
+
.map((part) => part.split(';')[0]?.trim())
|
|
260
|
+
.flatMap((tag) => (tag ? [tag, tag.split('-')[0]] : [])),
|
|
261
|
+
];
|
|
262
|
+
return asked.find((l) => l && known.has(l)) ?? config.defaultLocale;
|
|
263
|
+
};
|
|
264
|
+
const translate = (locale) => translator(manifest, locale, { fallback: config.fallbackLocale });
|
|
265
|
+
/**
|
|
266
|
+
* Every installed module's stylesheets, in dependency order, so a module that
|
|
267
|
+
* extends another loads after it and can override it. The app used to name two
|
|
268
|
+
* files belonging to another module by hand — which meant knowing that module's
|
|
269
|
+
* file layout, and going on linking them after it was uninstalled.
|
|
270
|
+
*/
|
|
271
|
+
const styles = async (req) => tenants.ofRequest(new URL('http://x/'), req, async (t) => {
|
|
272
|
+
const on = await t.apps.enabled();
|
|
273
|
+
const hrefs = manifest.styles.filter((s) => on.has(s.by)).map((s) => s.href);
|
|
274
|
+
return html `${each(hrefs, (h) => h, (h) => html `<link rel="stylesheet" href=${h}>`)}`;
|
|
275
|
+
});
|
|
276
|
+
const ctx = {
|
|
277
|
+
manifest,
|
|
278
|
+
config,
|
|
279
|
+
scopeOf,
|
|
280
|
+
localeOf,
|
|
281
|
+
translate,
|
|
282
|
+
styles,
|
|
283
|
+
sessionsOf,
|
|
284
|
+
storageOf: async (url, req) => storageFor(tenants.keyOf(url, req)),
|
|
285
|
+
document,
|
|
286
|
+
joint: (url, req, key, props) => tenants.ofRequest(url, req, async (t) => t.joints(localeOf(url, req)).render(key, props)),
|
|
287
|
+
jointShows: (url, req, key) => tenants.ofRequest(url, req, async (t) => t.joints(localeOf(url, req)).shows(key)),
|
|
288
|
+
menu: async (url, req) => {
|
|
289
|
+
const allow = await allowFor(url, req);
|
|
290
|
+
const _ = translate(localeOf(url, req));
|
|
291
|
+
// The sidebar's search is in the URL like every other list's, so a filtered
|
|
292
|
+
// menu is a link and the back button walks out of it.
|
|
293
|
+
const q = url.searchParams.get('menu')?.trim() || undefined;
|
|
294
|
+
return tenants.ofRequest(url, req, async (t) => buildMenu(t.live, { allow, translate: (k) => _(k), active: url.pathname, q }));
|
|
295
|
+
},
|
|
296
|
+
live: (req) => tenants.ofRequest(new URL('http://x/'), req, async (t) => t.live),
|
|
297
|
+
appsOf: (req) => tenants.ofRequest(new URL('http://x/'), req, (t) => t.apps.list()),
|
|
298
|
+
callUnchecked: async (name, input, url, req) => {
|
|
299
|
+
const scope = await scopeOf(url, req);
|
|
300
|
+
const actor = await actorOf(url, req);
|
|
301
|
+
return tenants.ofRequest(url, req, async (t) => (await callFn(name, input, {
|
|
302
|
+
adapter: t.adapter,
|
|
303
|
+
manifest: t.live,
|
|
304
|
+
scope,
|
|
305
|
+
actor,
|
|
306
|
+
queueNotify: config.queueNotify,
|
|
307
|
+
})).value);
|
|
308
|
+
},
|
|
309
|
+
call: async (name, input, url, req) => {
|
|
310
|
+
// One lease for the whole call: the scope and the allow-list are resolved
|
|
311
|
+
// outside it, so a session lookup never holds a pooled connection.
|
|
312
|
+
const scope = await scopeOf(url, req);
|
|
313
|
+
const allow = await allowFor(url, req);
|
|
314
|
+
const actor = await actorOf(url, req);
|
|
315
|
+
return tenants.ofRequest(url, req, async (t) => (await callFn(name, input, {
|
|
316
|
+
adapter: t.adapter,
|
|
317
|
+
manifest: t.live,
|
|
318
|
+
scope,
|
|
319
|
+
allow,
|
|
320
|
+
actor,
|
|
321
|
+
queueNotify: config.queueNotify,
|
|
322
|
+
})).value);
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
/**
|
|
326
|
+
* Module-contributed routes and assets.
|
|
327
|
+
*
|
|
328
|
+
* Both are looked up per request against the LIVE manifest rather than mounted
|
|
329
|
+
* once at boot. That costs a set lookup and buys the property the app model
|
|
330
|
+
* claims: switching a module off stops its routes answering and stops its
|
|
331
|
+
* stylesheet being served, without a restart.
|
|
332
|
+
*/
|
|
333
|
+
const routeHandlers = new Map();
|
|
334
|
+
for (const [path, entry] of Object.entries(manifest.routes))
|
|
335
|
+
routeHandlers.set(path, entry.make(ctx));
|
|
336
|
+
const moduleRoutes = {};
|
|
337
|
+
for (const [path, entry] of Object.entries(manifest.routes)) {
|
|
338
|
+
moduleRoutes[path] = async (url, req, params) => {
|
|
339
|
+
const on = await tenants.ofRequest(url, req, (t) => t.apps.enabled());
|
|
340
|
+
if (!on.has(entry.by)) {
|
|
341
|
+
return text(`${path} belongs to "${entry.by}", which is not installed on this database`, {
|
|
342
|
+
status: 404,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
// Closed unless the module said otherwise. A browser is sent to the sign-in
|
|
346
|
+
// page carrying where it was going; anything else gets the status, because a
|
|
347
|
+
// redirect to an HTML form is a useless answer to a fetch().
|
|
348
|
+
if (makeSessions && !entry.anonymous) {
|
|
349
|
+
if (!(await sessionRecordOf(url, req))) {
|
|
350
|
+
const wantsHtml = String(req.headers.accept ?? '').includes('text/html');
|
|
351
|
+
return wantsHtml
|
|
352
|
+
? withHeaders(text('', { status: 303 }), {
|
|
353
|
+
location: `/login?next=${encodeURIComponent(url.pathname + url.search)}`,
|
|
354
|
+
})
|
|
355
|
+
: text('sign in first', { status: 401 });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return routeHandlers.get(path)(url, req, params);
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* A module's assets, resolved per request so that switching the module off stops
|
|
363
|
+
* them being served — without a restart, and without the app knowing where any
|
|
364
|
+
* module keeps its files.
|
|
365
|
+
*/
|
|
366
|
+
const assetMount = {
|
|
367
|
+
prefix: '/_ket/asset/',
|
|
368
|
+
resolve: async (rest, url, req) => {
|
|
369
|
+
const slash = rest.indexOf('/');
|
|
370
|
+
if (slash <= 0)
|
|
371
|
+
return null;
|
|
372
|
+
const owner = rest.slice(0, slash);
|
|
373
|
+
const file = rest.slice(slash + 1);
|
|
374
|
+
const dir = manifest.assets[owner];
|
|
375
|
+
if (!dir || !file || file.startsWith('..') || isAbsolute(file))
|
|
376
|
+
return null;
|
|
377
|
+
if (!(await tenants.ofRequest(url, req, (t) => t.apps.enabled())).has(owner))
|
|
378
|
+
return null;
|
|
379
|
+
return join(dir, file);
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
/**
|
|
383
|
+
* Every function a request with no session may call — which is not "all of them".
|
|
384
|
+
*
|
|
385
|
+
* `allow: null` means unrestricted, and that is correct for an in-process call:
|
|
386
|
+
* a migration, a test, a script. It was also what an anonymous HTTP request got,
|
|
387
|
+
* and that was a hole wide enough to create a user account through. A stranger
|
|
388
|
+
* is not an unrestricted caller; a stranger is a stranger.
|
|
389
|
+
*/
|
|
390
|
+
const anonymousFns = Object.entries(manifest.functions)
|
|
391
|
+
.filter(([, f]) => f.anonymous)
|
|
392
|
+
.map(([k]) => k);
|
|
393
|
+
const allowFor = async (url, req) => {
|
|
394
|
+
if (!makeSessions)
|
|
395
|
+
return null; // no login exists yet; the shim is the identity
|
|
396
|
+
const record = await sessionRecordOf(url, req);
|
|
397
|
+
if (!record)
|
|
398
|
+
return anonymousFns; // a stranger, not an administrator
|
|
399
|
+
if (!serve.permissions)
|
|
400
|
+
return null;
|
|
401
|
+
const granted = await serve.permissions(ctx, record.userId);
|
|
402
|
+
return granted === null ? null : [...new Set([...anonymousFns, ...granted])];
|
|
403
|
+
};
|
|
404
|
+
const pages = serve.pages;
|
|
405
|
+
if (pages && !manifest.functions[pages.resolve]) {
|
|
406
|
+
throw new KetError({
|
|
407
|
+
code: 'E_PAGE_RESOLVER_MISSING',
|
|
408
|
+
module: spec.name,
|
|
409
|
+
message: `app "${spec.name}" resolves pages with "${pages.resolve}", which no installed module declares`,
|
|
410
|
+
hint: `add the module that owns "${pages.resolve.split('.')[0]}" to the app, or drop serve.pages`,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (pages?.siteResolve && !manifest.functions[pages.siteResolve]) {
|
|
414
|
+
throw new KetError({
|
|
415
|
+
code: 'E_SITE_RESOLVER_MISSING',
|
|
416
|
+
module: spec.name,
|
|
417
|
+
message: `app "${spec.name}" resolves sites with "${pages.siteResolve}", which no installed module declares`,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
for (const selected of availableThemes)
|
|
421
|
+
if (pages?.region && !selected.templates[pages.region]) {
|
|
422
|
+
throw new KetError({
|
|
423
|
+
code: 'E_PAGE_REGION_MISSING',
|
|
424
|
+
module: spec.name,
|
|
425
|
+
message: `app "${spec.name}" navigates through region "${pages.region}", which theme "${selected.name}" does not render`,
|
|
426
|
+
hint: `add a "${pages.region}" template, or remove serve.pages.region to keep full navigation`,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
const siteRecords = new WeakMap();
|
|
430
|
+
const requestHost = (url, req) => {
|
|
431
|
+
const raw = String(req.headers.host ?? url.host).trim();
|
|
432
|
+
try {
|
|
433
|
+
return new URL(`http://${raw}`).hostname.replace(/^\[|\]$/g, '');
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return '';
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
const siteOf = (url, req) => {
|
|
440
|
+
if (!pages?.siteResolve)
|
|
441
|
+
return Promise.resolve(null);
|
|
442
|
+
let pending = siteRecords.get(req);
|
|
443
|
+
if (!pending) {
|
|
444
|
+
pending = ctx.call(pages.siteResolve, { host: requestHost(url, req) }, url, req);
|
|
445
|
+
siteRecords.set(req, pending);
|
|
446
|
+
}
|
|
447
|
+
return pending;
|
|
448
|
+
};
|
|
449
|
+
const dynamicThemes = new Map();
|
|
450
|
+
const appRoutes = serve.routes?.(ctx) ?? {};
|
|
451
|
+
for (const path of Object.keys(appRoutes)) {
|
|
452
|
+
if (path.startsWith('/_ket/')) {
|
|
453
|
+
throw new KetError({
|
|
454
|
+
code: 'E_ROUTE_RESERVED',
|
|
455
|
+
module: spec.name,
|
|
456
|
+
message: `app "${spec.name}" claims "${path}", which is reserved`,
|
|
457
|
+
hint: '/_ket/ belongs to the framework: health, the agent descriptor, streams and assets',
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
const owner = manifest.routes[path]?.by;
|
|
461
|
+
if (owner) {
|
|
462
|
+
throw new KetError({
|
|
463
|
+
code: 'E_ROUTE_CLASH',
|
|
464
|
+
module: spec.name,
|
|
465
|
+
message: `module "${owner}" and app "${spec.name}" both serve "${path}"`,
|
|
466
|
+
hint: 'two owners cannot share one path — rename one, or keep the route in its module',
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const server = await createKetServer({
|
|
471
|
+
manifest,
|
|
472
|
+
adapter,
|
|
473
|
+
/**
|
|
474
|
+
* The HTTP layer gets a pool whose leases go through the tenant runtime, not
|
|
475
|
+
* the raw one.
|
|
476
|
+
*
|
|
477
|
+
* Handing it the raw pool looked equivalent and was not: /_ket/fn would lease
|
|
478
|
+
* a datastore that had never been migrated, because migration happens the
|
|
479
|
+
* first time the tenant runtime touches it. The first API call to a new tenant
|
|
480
|
+
* failed with "no such table" while a page request to the same tenant worked.
|
|
481
|
+
* One door, so there is one place preparation can be forgotten.
|
|
482
|
+
*/
|
|
483
|
+
...(tenants.pool
|
|
484
|
+
? {
|
|
485
|
+
pool: {
|
|
486
|
+
...tenants.pool,
|
|
487
|
+
with: (key, fn) => tenants.with(key, (t) => fn(t.adapter)),
|
|
488
|
+
},
|
|
489
|
+
resolveDatastore: (url, req) => tenants.keyOf(url, req),
|
|
490
|
+
}
|
|
491
|
+
: {}),
|
|
492
|
+
resolveLocale: localeOf,
|
|
493
|
+
resolveScope: scopeOf,
|
|
494
|
+
resolveAllow: allowFor,
|
|
495
|
+
resolveActor: actorOf,
|
|
496
|
+
queueNotify: config.queueNotify,
|
|
497
|
+
islandClients: (url, req) => tenants.ofRequest(url, req, async (tenant) => Object.fromEntries(Object.entries(tenant.live.islands)
|
|
498
|
+
.filter(([, island]) => island.client !== undefined)
|
|
499
|
+
.map(([name, island]) => [name, island.client]))),
|
|
500
|
+
assets: serve.assets ? [assetMount, serve.assets] : [assetMount],
|
|
501
|
+
...(spec.headless || !fallbackTheme
|
|
502
|
+
? {}
|
|
503
|
+
: {
|
|
504
|
+
theme: (url, req) => tenants.ofRequest(url, req, async (t) => {
|
|
505
|
+
const site = await siteOf(url, req);
|
|
506
|
+
// A multisite app must never serve its fallback site for an unknown
|
|
507
|
+
// Host header. Apart from leaking tenant content, that turns Host
|
|
508
|
+
// header poisoning into generated links and cached HTML.
|
|
509
|
+
if (pages?.siteResolve && !site)
|
|
510
|
+
return null;
|
|
511
|
+
const chosen = site?.theme ?? fallbackTheme.name;
|
|
512
|
+
const allowed = availableThemes.find((theme) => theme.name === chosen);
|
|
513
|
+
if (!allowed || t.live.disabledModules?.includes(allowed.name))
|
|
514
|
+
return null;
|
|
515
|
+
const locale = site?.locale ?? localeOf(url, req);
|
|
516
|
+
const key = `${t.key}::${t.live.order.join(',')}::${allowed.name}::${locale}`;
|
|
517
|
+
let runtime = dynamicThemes.get(key);
|
|
518
|
+
if (!runtime) {
|
|
519
|
+
runtime = createTheme(t.live, modules, {
|
|
520
|
+
translate: translate(locale),
|
|
521
|
+
theme: allowed.name,
|
|
522
|
+
});
|
|
523
|
+
dynamicThemes.set(key, runtime);
|
|
524
|
+
}
|
|
525
|
+
return runtime;
|
|
526
|
+
}),
|
|
527
|
+
}),
|
|
528
|
+
/**
|
|
529
|
+
* The storefront: a path becomes a page, and a page becomes its sections.
|
|
530
|
+
*
|
|
531
|
+
* The lookup runs through callFn like anything else, so the company filter and
|
|
532
|
+
* the app-installed check apply to a public page exactly as they do to an API
|
|
533
|
+
* call — the front of the site is not a second door with different rules.
|
|
534
|
+
*/
|
|
535
|
+
...(pages
|
|
536
|
+
? {
|
|
537
|
+
...(pages.region ? { pageRegion: pages.region } : {}),
|
|
538
|
+
pageScope: async (url, req) => {
|
|
539
|
+
const resolvedSite = await siteOf(url, req);
|
|
540
|
+
const site = {
|
|
541
|
+
id: resolvedSite?.id,
|
|
542
|
+
title: resolvedSite?.title ?? pages.siteTitle ?? spec.name,
|
|
543
|
+
theme: resolvedSite?.theme ?? fallbackTheme?.name,
|
|
544
|
+
};
|
|
545
|
+
// The theme's layout writes <html lang>, so the locale has to reach it.
|
|
546
|
+
// It was hardcoded there, which made i18n untrue on the first tag of every
|
|
547
|
+
// storefront page.
|
|
548
|
+
const locale = resolvedSite?.locale ?? localeOf(url, req);
|
|
549
|
+
const row = (await ctx.call(pages.resolve, { path: url.pathname, ...(resolvedSite?.id ? { siteId: resolvedSite.id } : {}) }, url, req));
|
|
550
|
+
if (!row) {
|
|
551
|
+
const _ = translate(locale);
|
|
552
|
+
return {
|
|
553
|
+
site,
|
|
554
|
+
locale,
|
|
555
|
+
page: { path: url.pathname, title: pages.notFound ? _(pages.notFound) : 'Not found' },
|
|
556
|
+
sections: [],
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
return {
|
|
560
|
+
site,
|
|
561
|
+
locale,
|
|
562
|
+
page: { id: row.id, path: url.pathname, title: row.title },
|
|
563
|
+
meta: {},
|
|
564
|
+
sections: typeof row.layout === 'string' ? JSON.parse(row.layout) : row.layout,
|
|
565
|
+
};
|
|
566
|
+
},
|
|
567
|
+
}
|
|
568
|
+
: {}),
|
|
569
|
+
routes: {
|
|
570
|
+
...moduleRoutes,
|
|
571
|
+
...appRoutes,
|
|
572
|
+
// The framework's own two, mounted last so an app cannot shadow them by accident.
|
|
573
|
+
// Both answer for the tenant that asked: "which apps are on" has no
|
|
574
|
+
// deployment-wide answer once there is more than one database.
|
|
575
|
+
'/_ket/health': async (url, req) => tenants.ofRequest(url, req, async (t) => json({
|
|
576
|
+
ok: true,
|
|
577
|
+
app: spec.name,
|
|
578
|
+
database: t.adapter.name,
|
|
579
|
+
...(t.key ? { tenant: t.key } : {}),
|
|
580
|
+
apps: [...(await t.apps.enabled())].sort(),
|
|
581
|
+
orphans: await t.apps.orphans(),
|
|
582
|
+
locales: Object.keys(manifest.messages ?? {}),
|
|
583
|
+
})),
|
|
584
|
+
'/_ket/agent': async (url, req) => tenants.ofRequest(url, req, async (t) => json(agentDescriptor(t.live))),
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
const port = await server.listen(config.port);
|
|
588
|
+
const banner = async () => {
|
|
589
|
+
// With tenants there is no deployment-wide list of installed apps, and the
|
|
590
|
+
// banner says which mode it is in rather than inventing one.
|
|
591
|
+
const enabled = apps ? [...(await apps.enabled())].sort() : [];
|
|
592
|
+
const at = `http://${config.host}:${port}`;
|
|
593
|
+
// A "site" row only means something if a path can become a page; an app that
|
|
594
|
+
// declares its own "/" route would otherwise be listed twice, once wrongly.
|
|
595
|
+
const paths = new Map();
|
|
596
|
+
if (pages)
|
|
597
|
+
paths.set('/', 'site');
|
|
598
|
+
// Module routes belong on the banner too, and only while installed — the list
|
|
599
|
+
// is what the deployment actually serves, not what it could serve.
|
|
600
|
+
for (const [p, r] of Object.entries(manifest.routes))
|
|
601
|
+
if (enabled.includes(r.by))
|
|
602
|
+
paths.set(p, p.replace(/^\//, ''));
|
|
603
|
+
for (const p of Object.keys(appRoutes))
|
|
604
|
+
paths.set(p, p.replace(/^\//, '') || 'site');
|
|
605
|
+
const rows = [
|
|
606
|
+
...[...paths].map(([p, label]) => [label, at + p]),
|
|
607
|
+
['health', `${at}/_ket/health`],
|
|
608
|
+
['agent descriptor', `${at}/_ket/agent`],
|
|
609
|
+
['', ''],
|
|
610
|
+
[
|
|
611
|
+
'database',
|
|
612
|
+
adapter
|
|
613
|
+
? adapter.name + (config.databaseUrl ? '' : ` (${config.sqliteFile})`)
|
|
614
|
+
: `${(await tenants.keys()).length} tenant(s), one database each`,
|
|
615
|
+
],
|
|
616
|
+
['apps installed', apps ? enabled.join(', ') || '(none)' : 'per tenant'],
|
|
617
|
+
['locales', Object.keys(manifest.messages ?? {}).join(', ') || '(none)'],
|
|
618
|
+
[
|
|
619
|
+
'identity',
|
|
620
|
+
makeSessions
|
|
621
|
+
? `sessions (${sessions ? sessions.store.name : 'one per tenant'})`
|
|
622
|
+
: 'X-Ket-Company header',
|
|
623
|
+
],
|
|
624
|
+
// Silence here would be the wrong kind: a module that declared install:'auto'
|
|
625
|
+
// and did not arrive should say why, not look broken.
|
|
626
|
+
...(config.autoInstall ? [] : [['auto-install', 'off (KET_AUTO_INSTALL=0)']]),
|
|
627
|
+
];
|
|
628
|
+
const w = Math.max(...rows.map((r) => r[0].length));
|
|
629
|
+
const note = makeSessions
|
|
630
|
+
? (sessions?.ephemeralSecret ?? !config.secret)
|
|
631
|
+
? `\n KET_SECRET is not set, so a signing key was generated for this process.` +
|
|
632
|
+
`\n Sessions will not survive a restart and will not work across pods.`
|
|
633
|
+
: ''
|
|
634
|
+
: `\n No authentication yet: the company comes from the X-Ket-Company header,` +
|
|
635
|
+
`\n defaulting to "${config.defaultCompany}". Fine for development, NOT for production.`;
|
|
636
|
+
return (`\n ${spec.name} is running\n\n` +
|
|
637
|
+
rows.map(([k, v]) => (k ? ` ${k.padEnd(w)} ${v}` : '')).join('\n') +
|
|
638
|
+
`${note}\n`);
|
|
639
|
+
};
|
|
640
|
+
const close = async () => {
|
|
641
|
+
await server.close();
|
|
642
|
+
if (adapter)
|
|
643
|
+
await adapter.close();
|
|
644
|
+
await tenants.close();
|
|
645
|
+
};
|
|
646
|
+
return { name: spec.name, manifest, adapter, apps, tenants, config, port, banner, close };
|
|
647
|
+
}
|
|
648
|
+
/** bootApp, plus the banner and the signal handling a long-running process wants. */
|
|
649
|
+
export async function serveApp(spec, o = {}) {
|
|
650
|
+
const booted = await bootApp(spec, o);
|
|
651
|
+
console.log(await booted.banner());
|
|
652
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
653
|
+
process.on(signal, () => {
|
|
654
|
+
void booted.close().then(() => process.exit(0));
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
return booted;
|
|
658
|
+
}
|
|
659
|
+
//# sourceMappingURL=boot.js.map
|