@_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.
- package/CHANGELOG.md +12 -0
- package/lib/esm/shapes/LinkedServer.d.ts +61 -19
- package/lib/esm/shapes/LinkedServer.js +131 -71
- package/lib/esm/shapes/LinkedServer.js.map +1 -1
- package/lib/esm/utils/adminChannel.d.ts +44 -0
- package/lib/esm/utils/adminChannel.js +116 -0
- package/lib/esm/utils/adminChannel.js.map +1 -0
- package/lib/esm/utils/reloadSource.d.ts +12 -0
- package/lib/esm/utils/reloadSource.js +32 -0
- package/lib/esm/utils/reloadSource.js.map +1 -0
- package/lib/esm/utils/withTimeout.d.ts +7 -0
- package/lib/esm/utils/withTimeout.js +17 -0
- package/lib/esm/utils/withTimeout.js.map +1 -0
- package/package.json +1 -1
- package/readme.md +28 -0
|
@@ -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
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:
|