@_linked/server 3.5.0 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/lib/esm/shapes/LinkedServer.d.ts +51 -19
- package/lib/esm/shapes/LinkedServer.js +96 -71
- package/lib/esm/shapes/LinkedServer.js.map +1 -1
- package/lib/esm/utils/adminChannel.d.ts +29 -0
- package/lib/esm/utils/adminChannel.js +88 -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/package.json +1 -1
- package/readme.md +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @\_linked/server
|
|
2
2
|
|
|
3
|
+
## 3.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#198](https://github.com/linked-fw/server/pull/198) [`8ff798a`](https://github.com/linked-fw/server/commit/8ff798a59104a1283c5a5ec9312a29e56f103c03) Thanks [@flyon](https://github.com/flyon)! - Add `POST /admin/sync-shapes` to the host control channel (`CN_APP_ADMIN_SECRET`). It re-syncs the app's registered shapes into its app data exactly as the boot does (core `syncShapes`, writing only shapes whose stored hash differs) and answers `{written, orphanScope}`; a failed sync answers 500 `{error}`. The optional body `{packages: [...]}` names packages whose source changed on disk: in dev their modules are invalidated in Vite and their backends reloaded first, so a shape file the host has just written is registered before the sync. Source reloads (`onSourceChange`) now run one at a time, and `LinkedServer.resyncShapes()` is available to code in the process.
|
|
8
|
+
|
|
3
9
|
## 3.5.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
|
@@ -15,6 +15,7 @@ import { Shape } from '@_linked/core/shapes/Shape';
|
|
|
15
15
|
* needs; a plain function, an arrow function or a non-function value is not.
|
|
16
16
|
*/
|
|
17
17
|
export declare function isProviderClass(value: unknown): boolean;
|
|
18
|
+
import { type ShapeSyncResult } from '../utils/adminChannel.js';
|
|
18
19
|
/**
|
|
19
20
|
* Serve the local upload folder at `/uploads` (where `LocalFileStore` files are
|
|
20
21
|
* reachable), with the headers for untrusted files.
|
|
@@ -104,21 +105,47 @@ export declare class LinkedServer extends Shape {
|
|
|
104
105
|
/**
|
|
105
106
|
* Materialize this server's registered shapes into the RDF store as SHACL
|
|
106
107
|
* (core `syncShapes` → pure `sh:NodeShape` + property shapes), so a running
|
|
107
|
-
* app's app-data holds the shapes its instances validate/query against
|
|
108
|
-
*
|
|
109
|
-
*
|
|
108
|
+
* app's app-data holds the shapes its instances validate/query against.
|
|
109
|
+
* Whatever shapes the app package registers (published re-exports +
|
|
110
|
+
* generated) are written on boot, and again when the host asks for it over
|
|
111
|
+
* the control channel ({@link resyncShapes}). A source reload (HMR) does not
|
|
112
|
+
* sync shapes by itself.
|
|
113
|
+
*
|
|
114
|
+
* Core writes only the shapes whose stored hash differs, so a sync of
|
|
115
|
+
* unchanged shapes writes nothing.
|
|
110
116
|
*
|
|
111
117
|
* Additive only: store shapes this process did not register are left alone
|
|
112
118
|
* unless the operator sets `LINKED_SYNC_SHAPES_PRUNE_ORPHANS=true` — see
|
|
113
119
|
* `bootOrphanScope` in utils/syncShapes for why.
|
|
114
120
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
121
|
+
* Opt-out via `linked.config` `syncShapesOnBoot` (default true); env
|
|
122
|
+
* LINKED_SYNC_SHAPES_ON_BOOT overrides for deployment. CN sets
|
|
123
|
+
* `syncShapesOnBoot: false` in its own linked.config — its default dataset is
|
|
124
|
+
* a context-router that can't materialize context-free, and it syncs its own
|
|
125
|
+
* pinned shapes separately (linked.backend.storage.js).
|
|
120
126
|
*/
|
|
121
127
|
private materializeShapesIntoStore;
|
|
128
|
+
/** One shape sync into the default dataset; throws when it fails. */
|
|
129
|
+
private syncRegisteredShapes;
|
|
130
|
+
/** Tail of the {@link resyncShapes} queue: one re-sync at a time. */
|
|
131
|
+
private shapeSyncQueue;
|
|
132
|
+
/**
|
|
133
|
+
* Re-sync the registered shapes into app data, as the boot does, and report
|
|
134
|
+
* what was written. The host calls it (`POST /admin/sync-shapes`) after it
|
|
135
|
+
* changed the app's shape code, so the app — the only process that loads that
|
|
136
|
+
* code — writes its own shapes and their hashes.
|
|
137
|
+
*
|
|
138
|
+
* `packages` names the packages whose source changed. In dev (Vite) their
|
|
139
|
+
* modules are invalidated and their backends reloaded first
|
|
140
|
+
* ({@link onSourceChange}), so a shape added since the boot is registered
|
|
141
|
+
* before the sync; without Vite (a built app) the sync covers the shapes the
|
|
142
|
+
* process loaded. Runs as the system, never as the caller of a request.
|
|
143
|
+
*/
|
|
144
|
+
resyncShapes({ packages }?: {
|
|
145
|
+
packages?: string[];
|
|
146
|
+
}): Promise<ShapeSyncResult>;
|
|
147
|
+
/** Re-read one package's source from disk (dev only); see {@link resyncShapes}. */
|
|
148
|
+
private reloadPackageFromDisk;
|
|
122
149
|
/**
|
|
123
150
|
* Boot the server and listen. Rejects — and makes the process exit non-zero —
|
|
124
151
|
* when a provider throws a fatal error while booting (see utils/fatalError).
|
|
@@ -214,24 +241,29 @@ export declare class LinkedServer extends Shape {
|
|
|
214
241
|
shapeProviders: any[];
|
|
215
242
|
}>;
|
|
216
243
|
/**
|
|
217
|
-
*
|
|
244
|
+
* Install the client-shell catch-all as the last ordinary layer on the
|
|
245
|
+
* router stack. See utils/spaFallback.ts for why ordering has to be
|
|
246
|
+
* re-asserted rather than assumed.
|
|
247
|
+
*/
|
|
248
|
+
private installSpaFallback;
|
|
249
|
+
/** Tail of the {@link onSourceChange} queue: reloads never interleave. */
|
|
250
|
+
private sourceReloadQueue;
|
|
251
|
+
/**
|
|
252
|
+
* Reload one package's backend providers from source.
|
|
218
253
|
*
|
|
219
254
|
* Called by the CLI orchestrator's Vite watcher whenever a `.ts`/`.tsx`
|
|
220
|
-
* file inside a workspace package changes
|
|
221
|
-
* existing providers (so routes, listeners, timers
|
|
222
|
-
* drops them from the registry, and re-runs
|
|
223
|
-
* to pick up the freshly-imported module.
|
|
255
|
+
* file inside a workspace package changes, and by {@link resyncShapes}.
|
|
256
|
+
* Disposes the package's existing providers (so routes, listeners, timers
|
|
257
|
+
* don't accumulate), drops them from the registry, and re-runs
|
|
258
|
+
* indexPackageBackendProviders to pick up the freshly-imported module.
|
|
259
|
+
* Reloads run one at a time: each re-registers routes on the shared express
|
|
260
|
+
* stack.
|
|
224
261
|
*
|
|
225
262
|
* Dispose calls have a 5 s soft timeout. A hanging dispose logs a
|
|
226
263
|
* warning and is abandoned so HMR doesn't stall the whole dev loop.
|
|
227
264
|
*/
|
|
228
|
-
/**
|
|
229
|
-
* Install the client-shell catch-all as the last ordinary layer on the
|
|
230
|
-
* router stack. See utils/spaFallback.ts for why ordering has to be
|
|
231
|
-
* re-asserted rather than assumed.
|
|
232
|
-
*/
|
|
233
|
-
private installSpaFallback;
|
|
234
265
|
onSourceChange(pkg: string): Promise<void>;
|
|
266
|
+
private reloadPackageSource;
|
|
235
267
|
processBackendMethodCall(request: any, response: any): Promise<any>;
|
|
236
268
|
noCache(response: any): void;
|
|
237
269
|
processAPICall(request: any, response: any, method: 'get' | 'post' | 'put' | 'delete'): Promise<void>;
|
|
@@ -8,7 +8,6 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
8
8
|
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
9
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
10
|
};
|
|
11
|
-
import { timingSafeEqual } from 'crypto';
|
|
12
11
|
import events from 'events';
|
|
13
12
|
import express from 'express';
|
|
14
13
|
import fetchCookie from 'fetch-cookie';
|
|
@@ -140,6 +139,8 @@ import { resolveBootstrapEntry, resolveViteMainEntry, resolveViteServingMode, }
|
|
|
140
139
|
import { resolveRouteAssets } from '../utils/routeAssets.js';
|
|
141
140
|
import { resolveRouteComponent, ssrCssInlineUrls, ssrCssScope, } from '../utils/ssrCss.js';
|
|
142
141
|
import { materializeShapes } from '../utils/syncShapes.js';
|
|
142
|
+
import { AdminRequestError, registerAdminRoutes, } from '../utils/adminChannel.js';
|
|
143
|
+
import { invalidateSourceUnder } from '../utils/reloadSource.js';
|
|
143
144
|
import { installSpaFallback, repinSpaFallback, } from '../utils/spaFallback.js';
|
|
144
145
|
import { LincdAPI } from './LincdAPI.js';
|
|
145
146
|
import { getRpcExposureMode, isGenericRpcDisabled, logSafe, resolveCallable, setRpcExposureMode, warnUndeclaredCall, } from '../utils/rpcExposure.js';
|
|
@@ -292,6 +293,10 @@ let LinkedServer = class LinkedServer extends Shape {
|
|
|
292
293
|
* error is logged and contained.
|
|
293
294
|
*/
|
|
294
295
|
this.booting = false;
|
|
296
|
+
/** Tail of the {@link resyncShapes} queue: one re-sync at a time. */
|
|
297
|
+
this.shapeSyncQueue = Promise.resolve();
|
|
298
|
+
/** Tail of the {@link onSourceChange} queue: reloads never interleave. */
|
|
299
|
+
this.sourceReloadQueue = Promise.resolve();
|
|
295
300
|
if (config && typeof config !== 'string' && !('id' in config)) {
|
|
296
301
|
// The absence of `id` is the runtime discriminator for a LinkedConfig;
|
|
297
302
|
// the widened `{id?: string}` member of the union keeps TS from narrowing
|
|
@@ -360,44 +365,87 @@ let LinkedServer = class LinkedServer extends Shape {
|
|
|
360
365
|
/**
|
|
361
366
|
* Materialize this server's registered shapes into the RDF store as SHACL
|
|
362
367
|
* (core `syncShapes` → pure `sh:NodeShape` + property shapes), so a running
|
|
363
|
-
* app's app-data holds the shapes its instances validate/query against
|
|
364
|
-
*
|
|
365
|
-
*
|
|
368
|
+
* app's app-data holds the shapes its instances validate/query against.
|
|
369
|
+
* Whatever shapes the app package registers (published re-exports +
|
|
370
|
+
* generated) are written on boot, and again when the host asks for it over
|
|
371
|
+
* the control channel ({@link resyncShapes}). A source reload (HMR) does not
|
|
372
|
+
* sync shapes by itself.
|
|
373
|
+
*
|
|
374
|
+
* Core writes only the shapes whose stored hash differs, so a sync of
|
|
375
|
+
* unchanged shapes writes nothing.
|
|
366
376
|
*
|
|
367
377
|
* Additive only: store shapes this process did not register are left alone
|
|
368
378
|
* unless the operator sets `LINKED_SYNC_SHAPES_PRUNE_ORPHANS=true` — see
|
|
369
379
|
* `bootOrphanScope` in utils/syncShapes for why.
|
|
370
380
|
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
381
|
+
* Opt-out via `linked.config` `syncShapesOnBoot` (default true); env
|
|
382
|
+
* LINKED_SYNC_SHAPES_ON_BOOT overrides for deployment. CN sets
|
|
383
|
+
* `syncShapesOnBoot: false` in its own linked.config — its default dataset is
|
|
384
|
+
* a context-router that can't materialize context-free, and it syncs its own
|
|
385
|
+
* pinned shapes separately (linked.backend.storage.js).
|
|
376
386
|
*/
|
|
377
387
|
async materializeShapesIntoStore() {
|
|
388
|
+
try {
|
|
389
|
+
await this.syncRegisteredShapes();
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
console.warn('[LinkedServer] shape materialization failed (non-fatal):', err);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/** One shape sync into the default dataset; throws when it fails. */
|
|
396
|
+
async syncRegisteredShapes() {
|
|
378
397
|
var _a;
|
|
379
|
-
// Opt-out via `linked.config` `syncShapesOnBoot` (default true); env
|
|
380
|
-
// LINKED_SYNC_SHAPES_ON_BOOT overrides for deployment. CN sets
|
|
381
|
-
// `syncShapesOnBoot: false` in its own linked.config — its default dataset is
|
|
382
|
-
// a context-router that can't materialize context-free, and it syncs its own
|
|
383
|
-
// pinned shapes separately (linked.backend.storage.js).
|
|
384
398
|
const configFlag = (_a = this.config) === null || _a === void 0 ? void 0 : _a.syncShapesOnBoot;
|
|
385
399
|
const envFlag = process.env.LINKED_SYNC_SHAPES_ON_BOOT;
|
|
386
400
|
if (configFlag === false || envFlag === 'false') {
|
|
387
|
-
return;
|
|
401
|
+
return { written: 0, skipped: 'disabled' };
|
|
388
402
|
}
|
|
389
403
|
const appData = LinkedStorage.getDefaultDataset();
|
|
390
404
|
if (!appData)
|
|
391
|
-
return;
|
|
405
|
+
return { written: 0, skipped: 'no-default-dataset' };
|
|
406
|
+
const { syncShapes } = await import('@_linked/core');
|
|
407
|
+
// Explicit target: materialize EVERY registered shape into the app's own
|
|
408
|
+
// data store regardless of per-shape routing/pins.
|
|
409
|
+
const { count, orphanScope } = await materializeShapes(appData, syncShapes);
|
|
410
|
+
return { written: count, orphanScope };
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Re-sync the registered shapes into app data, as the boot does, and report
|
|
414
|
+
* what was written. The host calls it (`POST /admin/sync-shapes`) after it
|
|
415
|
+
* changed the app's shape code, so the app — the only process that loads that
|
|
416
|
+
* code — writes its own shapes and their hashes.
|
|
417
|
+
*
|
|
418
|
+
* `packages` names the packages whose source changed. In dev (Vite) their
|
|
419
|
+
* modules are invalidated and their backends reloaded first
|
|
420
|
+
* ({@link onSourceChange}), so a shape added since the boot is registered
|
|
421
|
+
* before the sync; without Vite (a built app) the sync covers the shapes the
|
|
422
|
+
* process loaded. Runs as the system, never as the caller of a request.
|
|
423
|
+
*/
|
|
424
|
+
async resyncShapes({ packages = [] } = {}) {
|
|
425
|
+
const run = this.shapeSyncQueue.then(() => runAsSystem(async () => {
|
|
426
|
+
for (const pkg of packages)
|
|
427
|
+
await this.reloadPackageFromDisk(pkg);
|
|
428
|
+
return this.syncRegisteredShapes();
|
|
429
|
+
}, 'shape sync requested by the host'));
|
|
430
|
+
this.shapeSyncQueue = run.catch(() => undefined);
|
|
431
|
+
return run;
|
|
432
|
+
}
|
|
433
|
+
/** Re-read one package's source from disk (dev only); see {@link resyncShapes}. */
|
|
434
|
+
async reloadPackageFromDisk(pkg) {
|
|
435
|
+
var _a, _b, _c;
|
|
436
|
+
const root = pkg === ((_a = this.package) === null || _a === void 0 ? void 0 : _a.name) ? process.cwd() : this.packageDirFor(pkg);
|
|
437
|
+
if (!root)
|
|
438
|
+
throw new AdminRequestError(`unknown package: ${pkg}`);
|
|
439
|
+
const graph = (_c = (_b = this.config.server) === null || _b === void 0 ? void 0 : _b.vite) === null || _c === void 0 ? void 0 : _c.moduleGraph;
|
|
440
|
+
if (!graph)
|
|
441
|
+
return; // a built app: there is no source to reload
|
|
442
|
+
const roots = [root];
|
|
392
443
|
try {
|
|
393
|
-
|
|
394
|
-
// Explicit target: materialize EVERY registered shape into the app's own
|
|
395
|
-
// data store regardless of per-shape routing/pins.
|
|
396
|
-
await materializeShapes(appData, syncShapes);
|
|
397
|
-
}
|
|
398
|
-
catch (err) {
|
|
399
|
-
console.warn('[LinkedServer] shape materialization failed (non-fatal):', err);
|
|
444
|
+
roots.push(fsNative.realpathSync(root));
|
|
400
445
|
}
|
|
446
|
+
catch (_d) { }
|
|
447
|
+
invalidateSourceUnder(graph, roots);
|
|
448
|
+
await this.onSourceChange(pkg);
|
|
401
449
|
}
|
|
402
450
|
// async serveData(req,res) {
|
|
403
451
|
// let nodeURI = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
|
|
@@ -608,42 +656,12 @@ let LinkedServer = class LinkedServer extends Shape {
|
|
|
608
656
|
this.server.head('__health', (_req, res) => {
|
|
609
657
|
res.sendStatus(200);
|
|
610
658
|
});
|
|
611
|
-
//
|
|
612
|
-
// process with
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
// response. timingSafeEqual requires equal-length buffers, so guard length.
|
|
618
|
-
const secretMatches = (provided) => {
|
|
619
|
-
if (!adminSecret || !provided)
|
|
620
|
-
return false;
|
|
621
|
-
const a = Buffer.from(provided);
|
|
622
|
-
const b = Buffer.from(adminSecret);
|
|
623
|
-
return a.length === b.length && timingSafeEqual(a, b);
|
|
624
|
-
};
|
|
625
|
-
const requireAdmin = (req, res) => {
|
|
626
|
-
if (!adminSecret) {
|
|
627
|
-
res.sendStatus(404); // no control channel in this mode
|
|
628
|
-
return false;
|
|
629
|
-
}
|
|
630
|
-
if (!secretMatches(req.get('x-cn-admin-secret'))) {
|
|
631
|
-
res.sendStatus(401);
|
|
632
|
-
return false;
|
|
633
|
-
}
|
|
634
|
-
return true;
|
|
635
|
-
};
|
|
636
|
-
this.server.get('/admin/health', (req, res) => {
|
|
637
|
-
if (!requireAdmin(req, res))
|
|
638
|
-
return;
|
|
639
|
-
res.status(200).json({ status: 'ok' });
|
|
640
|
-
});
|
|
641
|
-
this.server.post('/admin/restart', (req, res) => {
|
|
642
|
-
if (!requireAdmin(req, res))
|
|
643
|
-
return;
|
|
644
|
-
res.sendStatus(202);
|
|
645
|
-
// CN's provisioner respawns on exit (see ChildProcessProvisioner).
|
|
646
|
-
setTimeout(() => process.exit(0), 50);
|
|
659
|
+
// The host's control channel (/admin/*), open only when the host started
|
|
660
|
+
// this process with CN_APP_ADMIN_SECRET; see utils/adminChannel.
|
|
661
|
+
registerAdminRoutes(this.server, process.env.CN_APP_ADMIN_SECRET, {
|
|
662
|
+
// The host's provisioner starts the app again after it exits.
|
|
663
|
+
exit: () => process.exit(0),
|
|
664
|
+
syncShapes: (request) => this.resyncShapes(request),
|
|
647
665
|
});
|
|
648
666
|
// after controller
|
|
649
667
|
await this.callGenericBackendProvidersMethod('setupAfterControllers');
|
|
@@ -1377,18 +1395,6 @@ let LinkedServer = class LinkedServer extends Shape {
|
|
|
1377
1395
|
this.shapeProviders.set(pkg, shapeProviders);
|
|
1378
1396
|
return { backendProviderExports, shapeProviders };
|
|
1379
1397
|
}
|
|
1380
|
-
/**
|
|
1381
|
-
* Plan-011 phase 2 — HMR re-index entry point.
|
|
1382
|
-
*
|
|
1383
|
-
* Called by the CLI orchestrator's Vite watcher whenever a `.ts`/`.tsx`
|
|
1384
|
-
* file inside a workspace package changes. Disposes the package's
|
|
1385
|
-
* existing providers (so routes, listeners, timers don't accumulate),
|
|
1386
|
-
* drops them from the registry, and re-runs indexPackageBackendProviders
|
|
1387
|
-
* to pick up the freshly-imported module.
|
|
1388
|
-
*
|
|
1389
|
-
* Dispose calls have a 5 s soft timeout. A hanging dispose logs a
|
|
1390
|
-
* warning and is abandoned so HMR doesn't stall the whole dev loop.
|
|
1391
|
-
*/
|
|
1392
1398
|
/**
|
|
1393
1399
|
* Install the client-shell catch-all as the last ordinary layer on the
|
|
1394
1400
|
* router stack. See utils/spaFallback.ts for why ordering has to be
|
|
@@ -1401,7 +1407,26 @@ let LinkedServer = class LinkedServer extends Shape {
|
|
|
1401
1407
|
this.render(req, res);
|
|
1402
1408
|
}));
|
|
1403
1409
|
}
|
|
1410
|
+
/**
|
|
1411
|
+
* Reload one package's backend providers from source.
|
|
1412
|
+
*
|
|
1413
|
+
* Called by the CLI orchestrator's Vite watcher whenever a `.ts`/`.tsx`
|
|
1414
|
+
* file inside a workspace package changes, and by {@link resyncShapes}.
|
|
1415
|
+
* Disposes the package's existing providers (so routes, listeners, timers
|
|
1416
|
+
* don't accumulate), drops them from the registry, and re-runs
|
|
1417
|
+
* indexPackageBackendProviders to pick up the freshly-imported module.
|
|
1418
|
+
* Reloads run one at a time: each re-registers routes on the shared express
|
|
1419
|
+
* stack.
|
|
1420
|
+
*
|
|
1421
|
+
* Dispose calls have a 5 s soft timeout. A hanging dispose logs a
|
|
1422
|
+
* warning and is abandoned so HMR doesn't stall the whole dev loop.
|
|
1423
|
+
*/
|
|
1404
1424
|
async onSourceChange(pkg) {
|
|
1425
|
+
const run = this.sourceReloadQueue.then(() => this.reloadPackageSource(pkg));
|
|
1426
|
+
this.sourceReloadQueue = run.catch(() => undefined);
|
|
1427
|
+
return run;
|
|
1428
|
+
}
|
|
1429
|
+
async reloadPackageSource(pkg) {
|
|
1405
1430
|
var _a, _b, _c, _d, _e;
|
|
1406
1431
|
const disposeWithTimeout = async (p, label) => {
|
|
1407
1432
|
if (!(p === null || p === void 0 ? void 0 : p.dispose))
|