@_linked/server 3.5.0 → 3.6.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.
@@ -0,0 +1,44 @@
1
+ import type express from 'express';
2
+ /** What one shape sync did. */
3
+ export interface ShapeSyncResult {
4
+ /** Shapes written: those whose stored hash differed from the registered shape. */
5
+ written: number;
6
+ /** The orphan policy the sync ran with (see utils/syncShapes). */
7
+ orphanScope?: 'all' | 'none';
8
+ /** Set when no sync ran: shape sync is turned off, or there is no default dataset. */
9
+ skipped?: 'disabled' | 'no-default-dataset';
10
+ /**
11
+ * The requested packages whose source was reloaded before the sync (dev only: a built
12
+ * app reloads nothing). Set by `POST /admin/sync-shapes`.
13
+ */
14
+ reloaded?: string[];
15
+ /** The node shape IRIs registered in the process after the reload. Set by `POST /admin/sync-shapes`. */
16
+ shapes?: string[];
17
+ }
18
+ /** Whether `name` is a valid npm package name (and so can never be a path like `..`). */
19
+ export declare function isValidPackageName(name: string): boolean;
20
+ /**
21
+ * Refuse a package a host may not ask to reload: not a valid npm name, a framework
22
+ * (`@_linked/*`) package, or not in `allowed` (the app's own name and its direct
23
+ * dependencies). Throws {@link AdminRequestError}.
24
+ */
25
+ export declare function assertReloadablePackages(packages: string[], allowed: ReadonlySet<string>): void;
26
+ /** Thrown by a handler for a request the channel refuses with 400. */
27
+ export declare class AdminRequestError extends Error {
28
+ }
29
+ export interface AdminChannelHandlers {
30
+ /** Exit the process (the host restarts it). Called after the 202 is sent. */
31
+ exit: () => void;
32
+ /** Reload `packages` (when given) and sync the registered shapes into app data. */
33
+ syncShapes: (request: {
34
+ packages: string[];
35
+ }) => Promise<ShapeSyncResult>;
36
+ }
37
+ /** Compare the provided secret with the configured one in constant time. */
38
+ export declare function adminSecretMatches(secret: string | undefined, provided: string | undefined): boolean;
39
+ /** The `packages` of a sync-shapes body: a list of package names, or none. */
40
+ export declare function parseSyncShapesBody(body: unknown): {
41
+ packages: string[];
42
+ };
43
+ /** Mount the control channel's routes on `app`. */
44
+ export declare function registerAdminRoutes(app: express.Express, secret: string | undefined, handlers: AdminChannelHandlers): void;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The host→app control channel: `/admin/*` routes a host (Create Now) uses on an
3
+ * app process it started. Present only when the process was started with a
4
+ * shared secret in `CN_APP_ADMIN_SECRET`; without it every route answers 404, so
5
+ * a standalone app has no control channel. Every request carries the secret in
6
+ * the `x-cn-admin-secret` header.
7
+ *
8
+ * - `GET /admin/health` → 200 `{status: 'ok'}` once the server listens (the boot
9
+ * shape sync has finished by then).
10
+ * - `POST /admin/restart` → 202, then the process exits; the host starts it again.
11
+ * - `POST /admin/sync-shapes` → re-syncs the app's shapes into its app data, as
12
+ * the boot does, and answers 200 with {@link ShapeSyncResult}. Body (optional):
13
+ * `{packages: string[]}`, packages whose source changed on disk: in dev they are
14
+ * reloaded first, so the sync sees shapes added or changed since the boot. Each
15
+ * name must be a valid npm package name and the app itself or one of its direct
16
+ * dependencies, never an `@_linked/*` framework package; anything else answers
17
+ * 400 `{error}`. A failed or timed-out sync answers 500 `{error}`.
18
+ */
19
+ import { timingSafeEqual } from 'crypto';
20
+ /** npm's package-name rule: optional `@scope/`, lower case, URL-safe, no leading `.` or `_` (a scope may start with `_`, as `@_linked` does). */
21
+ const NPM_NAME = /^(?:@[a-z0-9-~_][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
22
+ /** Whether `name` is a valid npm package name (and so can never be a path like `..`). */
23
+ export function isValidPackageName(name) {
24
+ return name.length <= 214 && NPM_NAME.test(name) && !name.split('/').some((part) => part === '.' || part === '..');
25
+ }
26
+ /**
27
+ * Refuse a package a host may not ask to reload: not a valid npm name, a framework
28
+ * (`@_linked/*`) package, or not in `allowed` (the app's own name and its direct
29
+ * dependencies). Throws {@link AdminRequestError}.
30
+ */
31
+ export function assertReloadablePackages(packages, allowed) {
32
+ for (const pkg of packages) {
33
+ if (!isValidPackageName(pkg))
34
+ throw new AdminRequestError(`not a package name: ${JSON.stringify(pkg)}`);
35
+ if (pkg.startsWith('@_linked/'))
36
+ throw new AdminRequestError(`framework packages are not reloaded: ${pkg}`);
37
+ if (!allowed.has(pkg)) {
38
+ throw new AdminRequestError(`unknown package: ${pkg} is neither the app nor one of its direct dependencies`);
39
+ }
40
+ }
41
+ }
42
+ /** Thrown by a handler for a request the channel refuses with 400. */
43
+ export class AdminRequestError extends Error {
44
+ }
45
+ /** Compare the provided secret with the configured one in constant time. */
46
+ export function adminSecretMatches(secret, provided) {
47
+ if (!secret || !provided)
48
+ return false;
49
+ const a = Buffer.from(provided);
50
+ const b = Buffer.from(secret);
51
+ return a.length === b.length && timingSafeEqual(a, b);
52
+ }
53
+ /** The `packages` of a sync-shapes body: a list of package names, or none. */
54
+ export function parseSyncShapesBody(body) {
55
+ if (body === undefined || body === null || body === '')
56
+ return { packages: [] };
57
+ if (typeof body !== 'object') {
58
+ throw new AdminRequestError('the body must be a JSON object');
59
+ }
60
+ const packages = body.packages;
61
+ if (packages === undefined)
62
+ return { packages: [] };
63
+ if (!Array.isArray(packages) || packages.some((p) => typeof p !== 'string' || !p)) {
64
+ throw new AdminRequestError('packages must be a list of package names');
65
+ }
66
+ for (const p of packages) {
67
+ if (!isValidPackageName(p))
68
+ throw new AdminRequestError(`not a package name: ${JSON.stringify(p)}`);
69
+ }
70
+ return { packages: [...new Set(packages)] };
71
+ }
72
+ /** Mount the control channel's routes on `app`. */
73
+ export function registerAdminRoutes(app, secret, handlers) {
74
+ const requireAdmin = (req, res) => {
75
+ if (!secret) {
76
+ res.sendStatus(404); // no control channel in this mode
77
+ return false;
78
+ }
79
+ if (!adminSecretMatches(secret, req.get('x-cn-admin-secret'))) {
80
+ res.sendStatus(401);
81
+ return false;
82
+ }
83
+ return true;
84
+ };
85
+ app.get('/admin/health', (req, res) => {
86
+ if (!requireAdmin(req, res))
87
+ return;
88
+ res.status(200).json({ status: 'ok' });
89
+ });
90
+ app.post('/admin/restart', (req, res) => {
91
+ if (!requireAdmin(req, res))
92
+ return;
93
+ res.sendStatus(202);
94
+ setTimeout(handlers.exit, 50);
95
+ });
96
+ app.post('/admin/sync-shapes', async (req, res) => {
97
+ if (!requireAdmin(req, res))
98
+ return;
99
+ let request;
100
+ try {
101
+ request = parseSyncShapesBody(req.body);
102
+ }
103
+ catch (err) {
104
+ res.status(400).json({ error: err.message });
105
+ return;
106
+ }
107
+ try {
108
+ res.status(200).json(await handlers.syncShapes(request));
109
+ }
110
+ catch (err) {
111
+ const status = err instanceof AdminRequestError ? 400 : 500;
112
+ res.status(status).json({ error: err instanceof Error ? err.message : String(err) });
113
+ }
114
+ });
115
+ }
116
+ //# sourceMappingURL=adminChannel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adminChannel.js","sourceRoot":"","sources":["../../../src/utils/adminChannel.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The host→app control channel: `/admin/*` routes a host (Create Now) uses on an\n * app process it started. Present only when the process was started with a\n * shared secret in `CN_APP_ADMIN_SECRET`; without it every route answers 404, so\n * a standalone app has no control channel. Every request carries the secret in\n * the `x-cn-admin-secret` header.\n *\n * - `GET /admin/health` → 200 `{status: 'ok'}` once the server listens (the boot\n * shape sync has finished by then).\n * - `POST /admin/restart` → 202, then the process exits; the host starts it again.\n * - `POST /admin/sync-shapes` → re-syncs the app's shapes into its app data, as\n * the boot does, and answers 200 with {@link ShapeSyncResult}. Body (optional):\n * `{packages: string[]}`, packages whose source changed on disk: in dev they are\n * reloaded first, so the sync sees shapes added or changed since the boot. Each\n * name must be a valid npm package name and the app itself or one of its direct\n * dependencies, never an `@_linked/*` framework package; anything else answers\n * 400 `{error}`. A failed or timed-out sync answers 500 `{error}`.\n */\nimport { timingSafeEqual } from 'crypto';\nimport type express from 'express';\n\n/** What one shape sync did. */\nexport interface ShapeSyncResult {\n /** Shapes written: those whose stored hash differed from the registered shape. */\n written: number;\n /** The orphan policy the sync ran with (see utils/syncShapes). */\n orphanScope?: 'all' | 'none';\n /** Set when no sync ran: shape sync is turned off, or there is no default dataset. */\n skipped?: 'disabled' | 'no-default-dataset';\n /**\n * The requested packages whose source was reloaded before the sync (dev only: a built\n * app reloads nothing). Set by `POST /admin/sync-shapes`.\n */\n reloaded?: string[];\n /** The node shape IRIs registered in the process after the reload. Set by `POST /admin/sync-shapes`. */\n shapes?: string[];\n}\n\n/** npm's package-name rule: optional `@scope/`, lower case, URL-safe, no leading `.` or `_` (a scope may start with `_`, as `@_linked` does). */\nconst NPM_NAME = /^(?:@[a-z0-9-~_][a-z0-9-._~]*\\/)?[a-z0-9-~][a-z0-9-._~]*$/;\n\n/** Whether `name` is a valid npm package name (and so can never be a path like `..`). */\nexport function isValidPackageName(name: string): boolean {\n return name.length <= 214 && NPM_NAME.test(name) && !name.split('/').some((part) => part === '.' || part === '..');\n}\n\n/**\n * Refuse a package a host may not ask to reload: not a valid npm name, a framework\n * (`@_linked/*`) package, or not in `allowed` (the app's own name and its direct\n * dependencies). Throws {@link AdminRequestError}.\n */\nexport function assertReloadablePackages(packages: string[], allowed: ReadonlySet<string>): void {\n for (const pkg of packages) {\n if (!isValidPackageName(pkg)) throw new AdminRequestError(`not a package name: ${JSON.stringify(pkg)}`);\n if (pkg.startsWith('@_linked/')) throw new AdminRequestError(`framework packages are not reloaded: ${pkg}`);\n if (!allowed.has(pkg)) {\n throw new AdminRequestError(`unknown package: ${pkg} is neither the app nor one of its direct dependencies`);\n }\n }\n}\n\n/** Thrown by a handler for a request the channel refuses with 400. */\nexport class AdminRequestError extends Error {}\n\nexport interface AdminChannelHandlers {\n /** Exit the process (the host restarts it). Called after the 202 is sent. */\n exit: () => void;\n /** Reload `packages` (when given) and sync the registered shapes into app data. */\n syncShapes: (request: { packages: string[] }) => Promise<ShapeSyncResult>;\n}\n\n/** Compare the provided secret with the configured one in constant time. */\nexport function adminSecretMatches(\n secret: string | undefined,\n provided: string | undefined\n): boolean {\n if (!secret || !provided) return false;\n const a = Buffer.from(provided);\n const b = Buffer.from(secret);\n return a.length === b.length && timingSafeEqual(a, b);\n}\n\n/** The `packages` of a sync-shapes body: a list of package names, or none. */\nexport function parseSyncShapesBody(body: unknown): { packages: string[] } {\n if (body === undefined || body === null || body === '') return { packages: [] };\n if (typeof body !== 'object') {\n throw new AdminRequestError('the body must be a JSON object');\n }\n const packages = (body as { packages?: unknown }).packages;\n if (packages === undefined) return { packages: [] };\n if (!Array.isArray(packages) || packages.some((p) => typeof p !== 'string' || !p)) {\n throw new AdminRequestError('packages must be a list of package names');\n }\n for (const p of packages as string[]) {\n if (!isValidPackageName(p)) throw new AdminRequestError(`not a package name: ${JSON.stringify(p)}`);\n }\n return { packages: [...new Set(packages as string[])] };\n}\n\n/** Mount the control channel's routes on `app`. */\nexport function registerAdminRoutes(\n app: express.Express,\n secret: string | undefined,\n handlers: AdminChannelHandlers\n): void {\n const requireAdmin = (req: express.Request, res: express.Response): boolean => {\n if (!secret) {\n res.sendStatus(404); // no control channel in this mode\n return false;\n }\n if (!adminSecretMatches(secret, req.get('x-cn-admin-secret'))) {\n res.sendStatus(401);\n return false;\n }\n return true;\n };\n\n app.get('/admin/health', (req, res) => {\n if (!requireAdmin(req, res)) return;\n res.status(200).json({ status: 'ok' });\n });\n\n app.post('/admin/restart', (req, res) => {\n if (!requireAdmin(req, res)) return;\n res.sendStatus(202);\n setTimeout(handlers.exit, 50);\n });\n\n app.post('/admin/sync-shapes', async (req, res) => {\n if (!requireAdmin(req, res)) return;\n let request: { packages: string[] };\n try {\n request = parseSyncShapesBody(req.body);\n } catch (err) {\n res.status(400).json({ error: (err as Error).message });\n return;\n }\n try {\n res.status(200).json(await handlers.syncShapes(request));\n } catch (err) {\n const status = err instanceof AdminRequestError ? 400 : 500;\n res.status(status).json({ error: err instanceof Error ? err.message : String(err) });\n }\n });\n}\n"]}
@@ -0,0 +1,12 @@
1
+ /** The subset of Vite's (back-compat) module graph this module uses. */
2
+ export interface SourceModuleGraph {
3
+ idToModuleMap: Map<string, {
4
+ file?: string | null;
5
+ }>;
6
+ onFileChange(file: string): void;
7
+ }
8
+ /**
9
+ * Invalidate every module whose file lies under one of `roots` and outside any
10
+ * `node_modules` directory. Returns the files it invalidated.
11
+ */
12
+ export declare function invalidateSourceUnder(graph: SourceModuleGraph, roots: string[]): string[];
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Mark a package's source modules stale in Vite's module graph, so the next
3
+ * `ssrLoadModule` of the package reads them from disk again.
4
+ *
5
+ * Vite's watcher does this too when a file changes, but asynchronously: a host
6
+ * that has just written a file and asks the app to re-sync its shapes cannot rely
7
+ * on the watcher having seen it. Invalidating a module also invalidates its
8
+ * importers, so a changed shapes barrel reaches the package entry that imports it,
9
+ * and a new shape module the barrel now imports is loaded with it.
10
+ */
11
+ import * as path from 'path';
12
+ /**
13
+ * Invalidate every module whose file lies under one of `roots` and outside any
14
+ * `node_modules` directory. Returns the files it invalidated.
15
+ */
16
+ export function invalidateSourceUnder(graph, roots) {
17
+ const prefixes = roots.map((r) => path.resolve(r) + path.sep);
18
+ const files = new Set();
19
+ for (const mod of graph.idToModuleMap.values()) {
20
+ const file = mod === null || mod === void 0 ? void 0 : mod.file;
21
+ if (!file)
22
+ continue;
23
+ if (file.split(path.sep).includes('node_modules'))
24
+ continue;
25
+ if (prefixes.some((p) => file.startsWith(p)))
26
+ files.add(file);
27
+ }
28
+ for (const file of files)
29
+ graph.onFileChange(file);
30
+ return [...files];
31
+ }
32
+ //# sourceMappingURL=reloadSource.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reloadSource.js","sourceRoot":"","sources":["../../../src/utils/reloadSource.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AAQ7B;;;GAGG;AACH,MAAM,UAAU,qBAAqB,CACnC,KAAwB,EACxB,KAAe;IAEf,MAAM,QAAQ,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC;IAC9D,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAC;IAChC,KAAK,MAAM,GAAG,IAAI,KAAK,CAAC,aAAa,CAAC,MAAM,EAAE,EAAE,CAAC;QAC/C,MAAM,IAAI,GAAG,GAAG,aAAH,GAAG,uBAAH,GAAG,CAAE,IAAI,CAAC;QACvB,IAAI,CAAC,IAAI;YAAE,SAAS;QACpB,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,cAAc,CAAC;YAAE,SAAS;QAC5D,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;YAAE,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAChE,CAAC;IACD,KAAK,MAAM,IAAI,IAAI,KAAK;QAAE,KAAK,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;IACnD,OAAO,CAAC,GAAG,KAAK,CAAC,CAAC;AACpB,CAAC","sourcesContent":["/**\n * Mark a package's source modules stale in Vite's module graph, so the next\n * `ssrLoadModule` of the package reads them from disk again.\n *\n * Vite's watcher does this too when a file changes, but asynchronously: a host\n * that has just written a file and asks the app to re-sync its shapes cannot rely\n * on the watcher having seen it. Invalidating a module also invalidates its\n * importers, so a changed shapes barrel reaches the package entry that imports it,\n * and a new shape module the barrel now imports is loaded with it.\n */\nimport * as path from 'path';\n\n/** The subset of Vite's (back-compat) module graph this module uses. */\nexport interface SourceModuleGraph {\n idToModuleMap: Map<string, { file?: string | null }>;\n onFileChange(file: string): void;\n}\n\n/**\n * Invalidate every module whose file lies under one of `roots` and outside any\n * `node_modules` directory. Returns the files it invalidated.\n */\nexport function invalidateSourceUnder(\n graph: SourceModuleGraph,\n roots: string[]\n): string[] {\n const prefixes = roots.map((r) => path.resolve(r) + path.sep);\n const files = new Set<string>();\n for (const mod of graph.idToModuleMap.values()) {\n const file = mod?.file;\n if (!file) continue;\n if (file.split(path.sep).includes('node_modules')) continue;\n if (prefixes.some((p) => file.startsWith(p))) files.add(file);\n }\n for (const file of files) graph.onFileChange(file);\n return [...files];\n}\n"]}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Settle with `promise`, or reject with `message` after `ms`. The operation is not
3
+ * cancelled; the timeout only stops a caller (or a queue) from waiting on it forever.
4
+ */
5
+ export declare function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T>;
6
+ /** A positive integer from the environment, else `fallback`. */
7
+ export declare function envMs(name: string, fallback: number, env?: Record<string, string | undefined>): number;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Settle with `promise`, or reject with `message` after `ms`. The operation is not
3
+ * cancelled; the timeout only stops a caller (or a queue) from waiting on it forever.
4
+ */
5
+ export function withTimeout(promise, ms, message) {
6
+ let timer;
7
+ const timeout = new Promise((_, reject) => {
8
+ timer = setTimeout(() => reject(new Error(message)), ms);
9
+ });
10
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
11
+ }
12
+ /** A positive integer from the environment, else `fallback`. */
13
+ export function envMs(name, fallback, env = process.env) {
14
+ const n = Number(env[name]);
15
+ return Number.isFinite(n) && n > 0 ? n : fallback;
16
+ }
17
+ //# sourceMappingURL=withTimeout.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"withTimeout.js","sourceRoot":"","sources":["../../../src/utils/withTimeout.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,UAAU,WAAW,CAAI,OAAmB,EAAE,EAAU,EAAE,OAAe;IAC7E,IAAI,KAAgD,CAAC;IACrD,MAAM,OAAO,GAAG,IAAI,OAAO,CAAQ,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE;QAC/C,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAC3D,CAAC,CAAC,CAAC;IACH,OAAO,OAAO,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC,CAAC;AAC7E,CAAC;AAED,gEAAgE;AAChE,MAAM,UAAU,KAAK,CAAC,IAAY,EAAE,QAAgB,EAAE,MAA0C,OAAO,CAAC,GAAG;IACzG,MAAM,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC;IAC5B,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC;AACpD,CAAC","sourcesContent":["/**\n * Settle with `promise`, or reject with `message` after `ms`. The operation is not\n * cancelled; the timeout only stops a caller (or a queue) from waiting on it forever.\n */\nexport function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<never>((_, reject) => {\n timer = setTimeout(() => reject(new Error(message)), ms);\n });\n return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));\n}\n\n/** A positive integer from the environment, else `fallback`. */\nexport function envMs(name: string, fallback: number, env: Record<string, string | undefined> = process.env): number {\n const n = Number(env[name]);\n return Number.isFinite(n) && n > 0 ? n : fallback;\n}\n"]}
package/package.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/linked-fw/server.git"
12
12
  },
13
- "version": "3.5.0",
13
+ "version": "3.6.1",
14
14
  "packageManager": "npm@12.2.0",
15
15
  "linkedPackage": true,
16
16
  "type": "module",
package/readme.md CHANGED
@@ -26,6 +26,7 @@ If you want to further adjust the functionality of LincdServer yourself, either
26
26
  - [`ping`](#ping)
27
27
  - [Shape catalog per request](#shape-catalog-per-request)
28
28
  - [API-only servers (`server.apiOnly`)](#api-only-servers-serverapionly)
29
+ - [Host control channel (`/admin/*`)](#host-control-channel-admin)
29
30
  - [Provider lifecycle and HMR](#provider-lifecycle-and-hmr)
30
31
  - [When a provider throws, and refusing to start](#when-a-provider-throws-and-refusing-to-start)
31
32
  - [When to implement `dispose()`](#when-to-implement-dispose)
@@ -313,6 +314,15 @@ semantics are under [Error semantics](#error-semantics). `token` is ignored.
313
314
  code outside any call send no such header. The token still decides which data the store
314
315
  reaches; the data server decides what to do with the end user. `forwardEndUser: false` (or the
315
316
  string `"false"`) turns it off. No other headers are sent.
317
+
318
+ > **Warning: on by default.** Every query of a signed-in user's request sends that user's WebID
319
+ > to the data server at `url`. Point `url` only at a server you would give your users'
320
+ > identities to (for an app CN hosts, CN's gateway). To send none, set it off in the dataset's
321
+ > config:
322
+ >
323
+ > ```json
324
+ > "config": { "url": "${DATA_SERVER_URL:-}", "token": "${DATA_SERVER_TOKEN:-}", "forwardEndUser": false }
325
+ > ```
316
326
  - Trailing slashes are trimmed from `url`. A `url` that is present but empty (an unset
317
327
  `${DATA_SERVER_URL:-}`) never falls back to the no-url form: `init()` and every method throw
318
328
  `RemoteDatasetUnreachableError` with `url: ''`.
@@ -393,6 +403,24 @@ new LinkedServer({
393
403
 
394
404
  With `apiOnly` the server skips page rendering. It installs no SPA catch-all, so a `GET` for a page that no route handles gets express's plain `404` instead of the app shell. The `/call/...` and `/api/...` routes and the routes that providers register work as usual.
395
405
 
406
+ ## Host control channel (`/admin/*`)
407
+
408
+ A host that starts the app process (Create Now) controls it over HTTP routes on the app's own port. They exist only when the process was started with `CN_APP_ADMIN_SECRET` in its environment; without it they answer 404. Every request carries the secret in `x-cn-admin-secret` (401 otherwise).
409
+
410
+ | Route | Answer |
411
+ |---|---|
412
+ | `GET /admin/health` | 200 `{status: 'ok'}` once the server listens (the boot shape sync has finished by then) |
413
+ | `POST /admin/restart` | 202, then the process exits; the host starts it again |
414
+ | `POST /admin/sync-shapes` | 200 `{written, orphanScope, reloaded, shapes}` (or `{written: 0, skipped, …}` when shape sync is off); 400 `{error}` for a malformed body or a package it may not reload; 500 `{error}` when the sync fails or times out |
415
+
416
+ `POST /admin/sync-shapes` re-syncs the registered shapes into the app's data exactly as the boot does (core `syncShapes`: only shapes whose stored `core:shapeHash` differs are written, with the boot's orphan policy and `syncShapesOnBoot` / `LINKED_SYNC_SHAPES_ON_BOOT` switches). The optional body `{packages: [...]}` names packages whose source the host changed; each must be a valid npm package name and the app itself or one of its direct dependencies, never an `@_linked/*` package. In dev their modules are invalidated in Vite and their backends reloaded before the sync, so a shape file written a moment ago is registered; a built app reloads nothing. `reloaded` lists the packages actually reloaded and `shapes` the node shape IRIs registered afterwards, so the host can check that the shape it wrote is there. Syncs run one at a time, as the system; one that takes longer than `LINKED_SHAPE_SYNC_TIMEOUT_MS` (default 120000) fails and releases the queue. A source reload by itself (HMR) never syncs shapes.
417
+
418
+ Side effects to expect:
419
+
420
+ - **A reload briefly disposes the package's providers.** Reloading a package (on HMR, or for `sync-shapes`) runs `dispose()` on its providers and constructs new ones; a request to that package's routes or methods that arrives in between can fail. Reloads run one at a time; one that takes longer than `LINKED_SOURCE_RELOAD_TIMEOUT_MS` (default 60000) rejects and releases the queue.
421
+ - **A restart drops in-flight requests.** `POST /admin/restart` exits the process 50 ms after answering. A host that restarts an app whose server has no `sync-shapes` route (an older `@_linked/server`) to make its boot sync the shapes drops whatever that app was serving.
422
+ - The channel listens on the app's public port, like every other route; only the secret protects it.
423
+
396
424
  ## Provider lifecycle and HMR
397
425
 
398
426
  Every provider — generic (`BackendProvider`) or shape-scoped (`ShapeProvider`) — has a lifecycle the framework drives: