@_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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @\_linked/server
2
2
 
3
+ ## 3.6.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#200](https://github.com/linked-fw/server/pull/200) [`935d0ca`](https://github.com/linked-fw/server/commit/935d0caf014cae8c71ae3b40963305ac8d43792c) Thanks [@flyon](https://github.com/flyon)! - `POST /admin/sync-shapes` now answers which packages it reloaded (`reloaded`) and the node shapes registered afterwards (`shapes`), refuses (400) package names that are not valid npm names, `@_linked/*` packages, or neither the app nor one of its direct dependencies, and times out a hung sync (`LINKED_SHAPE_SYNC_TIMEOUT_MS`, default 120 s) or source reload (`LINKED_SOURCE_RELOAD_TIMEOUT_MS`, default 60 s) so the queue is released. The README documents the control channel and the side effects of a reload and a restart.
8
+
9
+ ## 3.6.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [#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.
14
+
3
15
  ## 3.5.0
4
16
 
5
17
  ### 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,56 @@ 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
- * (plan-010 T1e.2). Whatever shapes the app package registers (published
109
- * re-exports + generated) get written on boot (and re-run on shape-file HMR).
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
- * Gated to servers whose DEFAULT dataset is a **concrete** materializable store
116
- * (detected via `rawQuery`) — i.e. an app pointing at its app-data FusekiStore.
117
- * CN's default is the context-routing `AppDataRouter` (no `rawQuery`), so CN is
118
- * skipped here — it materializes its own pinned native shapes in its storage
119
- * config.
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: the app itself or its
139
+ * direct dependencies, never `@_linked/*` ({@link assertReloadablePackages}). In
140
+ * dev (Vite) their modules are invalidated and their backends reloaded first
141
+ * ({@link onSourceChange}), so a shape added since the boot is registered before
142
+ * the sync; without Vite (a built app) nothing is reloaded. The answer names the
143
+ * packages reloaded and the node shapes registered after it, so the host can check
144
+ * that the shape it wrote is there. Runs as the system, never as the caller of a
145
+ * request, one at a time; a run that takes longer than
146
+ * `LINKED_SHAPE_SYNC_TIMEOUT_MS` (default 120 s) fails and releases the queue.
147
+ */
148
+ resyncShapes({ packages }?: {
149
+ packages?: string[];
150
+ }): Promise<ShapeSyncResult>;
151
+ /** The packages a host may ask to reload: the app itself and its direct dependencies. */
152
+ private reloadablePackages;
153
+ /**
154
+ * Re-read one package's source from disk (dev only); see {@link resyncShapes}.
155
+ * Resolves whether it reloaded anything.
156
+ */
157
+ private reloadPackageFromDisk;
122
158
  /**
123
159
  * Boot the server and listen. Rejects — and makes the process exit non-zero —
124
160
  * when a provider throws a fatal error while booting (see utils/fatalError).
@@ -214,24 +250,30 @@ export declare class LinkedServer extends Shape {
214
250
  shapeProviders: any[];
215
251
  }>;
216
252
  /**
217
- * Plan-011 phase 2 — HMR re-index entry point.
253
+ * Install the client-shell catch-all as the last ordinary layer on the
254
+ * router stack. See utils/spaFallback.ts for why ordering has to be
255
+ * re-asserted rather than assumed.
256
+ */
257
+ private installSpaFallback;
258
+ /** Tail of the {@link onSourceChange} queue: reloads never interleave. */
259
+ private sourceReloadQueue;
260
+ /**
261
+ * Reload one package's backend providers from source.
218
262
  *
219
263
  * Called by the CLI orchestrator's Vite watcher whenever a `.ts`/`.tsx`
220
- * file inside a workspace package changes. Disposes the package's
221
- * existing providers (so routes, listeners, timers don't accumulate),
222
- * drops them from the registry, and re-runs indexPackageBackendProviders
223
- * to pick up the freshly-imported module.
264
+ * file inside a workspace package changes, and by {@link resyncShapes}.
265
+ * Disposes the package's existing providers (so routes, listeners, timers
266
+ * don't accumulate), drops them from the registry, and re-runs
267
+ * indexPackageBackendProviders to pick up the freshly-imported module.
268
+ * Reloads run one at a time: each re-registers routes on the shared express
269
+ * stack. One that takes longer than `LINKED_SOURCE_RELOAD_TIMEOUT_MS` (default
270
+ * 60 s) rejects and releases the queue.
224
271
  *
225
272
  * Dispose calls have a 5 s soft timeout. A hanging dispose logs a
226
273
  * warning and is abandoned so HMR doesn't stall the whole dev loop.
227
274
  */
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
275
  onSourceChange(pkg: string): Promise<void>;
276
+ private reloadPackageSource;
235
277
  processBackendMethodCall(request: any, response: any): Promise<any>;
236
278
  noCache(response: any): void;
237
279
  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';
@@ -38,7 +37,7 @@ import { nativeImport } from '../utils/nativeImport.js';
38
37
  import { createJsonBodyParser } from '../utils/jsonBodyParser.js';
39
38
  import { LinkedStorage } from '@_linked/core/utils/LinkedStorage';
40
39
  import { autoLoadOntologyData } from '@_linked/core/utils/Package';
41
- import { getShapeClass, getSuperShapesClasses, } from '@_linked/core/utils/ShapeClass';
40
+ import { getAllShapeClasses, getShapeClass, getSuperShapesClasses, } from '@_linked/core/utils/ShapeClass';
42
41
  import path from 'path';
43
42
  import { pathToFileURL } from 'node:url';
44
43
  import { isFatalError } from '../utils/fatalError.js';
@@ -140,6 +139,9 @@ 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, assertReloadablePackages, registerAdminRoutes, } from '../utils/adminChannel.js';
143
+ import { envMs, withTimeout } from '../utils/withTimeout.js';
144
+ import { invalidateSourceUnder } from '../utils/reloadSource.js';
143
145
  import { installSpaFallback, repinSpaFallback, } from '../utils/spaFallback.js';
144
146
  import { LincdAPI } from './LincdAPI.js';
145
147
  import { getRpcExposureMode, isGenericRpcDisabled, logSafe, resolveCallable, setRpcExposureMode, warnUndeclaredCall, } from '../utils/rpcExposure.js';
@@ -292,6 +294,10 @@ let LinkedServer = class LinkedServer extends Shape {
292
294
  * error is logged and contained.
293
295
  */
294
296
  this.booting = false;
297
+ /** Tail of the {@link resyncShapes} queue: one re-sync at a time. */
298
+ this.shapeSyncQueue = Promise.resolve();
299
+ /** Tail of the {@link onSourceChange} queue: reloads never interleave. */
300
+ this.sourceReloadQueue = Promise.resolve();
295
301
  if (config && typeof config !== 'string' && !('id' in config)) {
296
302
  // The absence of `id` is the runtime discriminator for a LinkedConfig;
297
303
  // the widened `{id?: string}` member of the union keeps TS from narrowing
@@ -360,44 +366,119 @@ let LinkedServer = class LinkedServer extends Shape {
360
366
  /**
361
367
  * Materialize this server's registered shapes into the RDF store as SHACL
362
368
  * (core `syncShapes` → pure `sh:NodeShape` + property shapes), so a running
363
- * app's app-data holds the shapes its instances validate/query against
364
- * (plan-010 T1e.2). Whatever shapes the app package registers (published
365
- * re-exports + generated) get written on boot (and re-run on shape-file HMR).
369
+ * app's app-data holds the shapes its instances validate/query against.
370
+ * Whatever shapes the app package registers (published re-exports +
371
+ * generated) are written on boot, and again when the host asks for it over
372
+ * the control channel ({@link resyncShapes}). A source reload (HMR) does not
373
+ * sync shapes by itself.
374
+ *
375
+ * Core writes only the shapes whose stored hash differs, so a sync of
376
+ * unchanged shapes writes nothing.
366
377
  *
367
378
  * Additive only: store shapes this process did not register are left alone
368
379
  * unless the operator sets `LINKED_SYNC_SHAPES_PRUNE_ORPHANS=true` — see
369
380
  * `bootOrphanScope` in utils/syncShapes for why.
370
381
  *
371
- * Gated to servers whose DEFAULT dataset is a **concrete** materializable store
372
- * (detected via `rawQuery`) — i.e. an app pointing at its app-data FusekiStore.
373
- * CN's default is the context-routing `AppDataRouter` (no `rawQuery`), so CN is
374
- * skipped here — it materializes its own pinned native shapes in its storage
375
- * config.
382
+ * Opt-out via `linked.config` `syncShapesOnBoot` (default true); env
383
+ * LINKED_SYNC_SHAPES_ON_BOOT overrides for deployment. CN sets
384
+ * `syncShapesOnBoot: false` in its own linked.config — its default dataset is
385
+ * a context-router that can't materialize context-free, and it syncs its own
386
+ * pinned shapes separately (linked.backend.storage.js).
376
387
  */
377
388
  async materializeShapesIntoStore() {
389
+ try {
390
+ await this.syncRegisteredShapes();
391
+ }
392
+ catch (err) {
393
+ console.warn('[LinkedServer] shape materialization failed (non-fatal):', err);
394
+ }
395
+ }
396
+ /** One shape sync into the default dataset; throws when it fails. */
397
+ async syncRegisteredShapes() {
378
398
  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
399
  const configFlag = (_a = this.config) === null || _a === void 0 ? void 0 : _a.syncShapesOnBoot;
385
400
  const envFlag = process.env.LINKED_SYNC_SHAPES_ON_BOOT;
386
401
  if (configFlag === false || envFlag === 'false') {
387
- return;
402
+ return { written: 0, skipped: 'disabled' };
388
403
  }
389
404
  const appData = LinkedStorage.getDefaultDataset();
390
405
  if (!appData)
391
- return;
406
+ return { written: 0, skipped: 'no-default-dataset' };
407
+ const { syncShapes } = await import('@_linked/core');
408
+ // Explicit target: materialize EVERY registered shape into the app's own
409
+ // data store regardless of per-shape routing/pins.
410
+ const { count, orphanScope } = await materializeShapes(appData, syncShapes);
411
+ return { written: count, orphanScope };
412
+ }
413
+ /**
414
+ * Re-sync the registered shapes into app data, as the boot does, and report
415
+ * what was written. The host calls it (`POST /admin/sync-shapes`) after it
416
+ * changed the app's shape code, so the app — the only process that loads that
417
+ * code — writes its own shapes and their hashes.
418
+ *
419
+ * `packages` names the packages whose source changed: the app itself or its
420
+ * direct dependencies, never `@_linked/*` ({@link assertReloadablePackages}). In
421
+ * dev (Vite) their modules are invalidated and their backends reloaded first
422
+ * ({@link onSourceChange}), so a shape added since the boot is registered before
423
+ * the sync; without Vite (a built app) nothing is reloaded. The answer names the
424
+ * packages reloaded and the node shapes registered after it, so the host can check
425
+ * that the shape it wrote is there. Runs as the system, never as the caller of a
426
+ * request, one at a time; a run that takes longer than
427
+ * `LINKED_SHAPE_SYNC_TIMEOUT_MS` (default 120 s) fails and releases the queue.
428
+ */
429
+ async resyncShapes({ packages = [] } = {}) {
430
+ assertReloadablePackages(packages, this.reloadablePackages());
431
+ const timeoutMs = envMs('LINKED_SHAPE_SYNC_TIMEOUT_MS', 120000);
432
+ const run = this.shapeSyncQueue.then(() => withTimeout(runAsSystem(async () => {
433
+ const reloaded = [];
434
+ for (const pkg of packages) {
435
+ if (await this.reloadPackageFromDisk(pkg))
436
+ reloaded.push(pkg);
437
+ }
438
+ const result = await this.syncRegisteredShapes();
439
+ return { ...result, reloaded, shapes: [...getAllShapeClasses().keys()].sort() };
440
+ }, 'shape sync requested by the host'), timeoutMs, `the shape sync did not finish within ${timeoutMs} ms`));
441
+ this.shapeSyncQueue = run.catch(() => undefined);
442
+ return run;
443
+ }
444
+ /** The packages a host may ask to reload: the app itself and its direct dependencies. */
445
+ reloadablePackages() {
446
+ var _a, _b;
447
+ const names = new Set();
448
+ if ((_a = this.package) === null || _a === void 0 ? void 0 : _a.name)
449
+ names.add(this.package.name);
392
450
  try {
393
- const { syncShapes } = await import('@_linked/core');
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);
451
+ const json = JSON.parse(fsNative.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'));
452
+ if (json.name)
453
+ names.add(json.name);
454
+ for (const field of ['dependencies', 'devDependencies']) {
455
+ for (const name of Object.keys((_b = json[field]) !== null && _b !== void 0 ? _b : {}))
456
+ names.add(name);
457
+ }
397
458
  }
398
- catch (err) {
399
- console.warn('[LinkedServer] shape materialization failed (non-fatal):', err);
459
+ catch (_c) { }
460
+ return names;
461
+ }
462
+ /**
463
+ * Re-read one package's source from disk (dev only); see {@link resyncShapes}.
464
+ * Resolves whether it reloaded anything.
465
+ */
466
+ async reloadPackageFromDisk(pkg) {
467
+ var _a, _b, _c;
468
+ const root = pkg === ((_a = this.package) === null || _a === void 0 ? void 0 : _a.name) ? process.cwd() : this.packageDirFor(pkg);
469
+ if (!root)
470
+ throw new AdminRequestError(`unknown package: ${pkg}`);
471
+ const graph = (_c = (_b = this.config.server) === null || _b === void 0 ? void 0 : _b.vite) === null || _c === void 0 ? void 0 : _c.moduleGraph;
472
+ if (!graph)
473
+ return false; // a built app: there is no source to reload
474
+ const roots = [root];
475
+ try {
476
+ roots.push(fsNative.realpathSync(root));
400
477
  }
478
+ catch (_d) { }
479
+ invalidateSourceUnder(graph, roots);
480
+ await this.onSourceChange(pkg);
481
+ return true;
401
482
  }
402
483
  // async serveData(req,res) {
403
484
  // let nodeURI = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
@@ -608,42 +689,12 @@ let LinkedServer = class LinkedServer extends Shape {
608
689
  this.server.head('__health', (_req, res) => {
609
690
  res.sendStatus(200);
610
691
  });
611
- // CN control channel (plan-010 T1b.3). Present only when CN spawned this
612
- // process with a shared secret (CN_APP_ADMIN_SECRET); a standalone app run
613
- // without the secret leaves the channel closed (404). Auth is the
614
- // x-cn-admin-secret header matching that env value.
615
- const adminSecret = process.env.CN_APP_ADMIN_SECRET;
616
- // Constant-time compare so the secret can't be recovered by timing the 401
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);
692
+ // The host's control channel (/admin/*), open only when the host started
693
+ // this process with CN_APP_ADMIN_SECRET; see utils/adminChannel.
694
+ registerAdminRoutes(this.server, process.env.CN_APP_ADMIN_SECRET, {
695
+ // The host's provisioner starts the app again after it exits.
696
+ exit: () => process.exit(0),
697
+ syncShapes: (request) => this.resyncShapes(request),
647
698
  });
648
699
  // after controller
649
700
  await this.callGenericBackendProvidersMethod('setupAfterControllers');
@@ -1377,18 +1428,6 @@ let LinkedServer = class LinkedServer extends Shape {
1377
1428
  this.shapeProviders.set(pkg, shapeProviders);
1378
1429
  return { backendProviderExports, shapeProviders };
1379
1430
  }
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
1431
  /**
1393
1432
  * Install the client-shell catch-all as the last ordinary layer on the
1394
1433
  * router stack. See utils/spaFallback.ts for why ordering has to be
@@ -1401,7 +1440,28 @@ let LinkedServer = class LinkedServer extends Shape {
1401
1440
  this.render(req, res);
1402
1441
  }));
1403
1442
  }
1443
+ /**
1444
+ * Reload one package's backend providers from source.
1445
+ *
1446
+ * Called by the CLI orchestrator's Vite watcher whenever a `.ts`/`.tsx`
1447
+ * file inside a workspace package changes, and by {@link resyncShapes}.
1448
+ * Disposes the package's existing providers (so routes, listeners, timers
1449
+ * don't accumulate), drops them from the registry, and re-runs
1450
+ * indexPackageBackendProviders to pick up the freshly-imported module.
1451
+ * Reloads run one at a time: each re-registers routes on the shared express
1452
+ * stack. One that takes longer than `LINKED_SOURCE_RELOAD_TIMEOUT_MS` (default
1453
+ * 60 s) rejects and releases the queue.
1454
+ *
1455
+ * Dispose calls have a 5 s soft timeout. A hanging dispose logs a
1456
+ * warning and is abandoned so HMR doesn't stall the whole dev loop.
1457
+ */
1404
1458
  async onSourceChange(pkg) {
1459
+ const timeoutMs = envMs('LINKED_SOURCE_RELOAD_TIMEOUT_MS', 60000);
1460
+ const run = this.sourceReloadQueue.then(() => withTimeout(this.reloadPackageSource(pkg), timeoutMs, `the reload of ${pkg} did not finish within ${timeoutMs} ms`));
1461
+ this.sourceReloadQueue = run.catch(() => undefined);
1462
+ return run;
1463
+ }
1464
+ async reloadPackageSource(pkg) {
1405
1465
  var _a, _b, _c, _d, _e;
1406
1466
  const disposeWithTimeout = async (p, label) => {
1407
1467
  if (!(p === null || p === void 0 ? void 0 : p.dispose))