@_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 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
- * (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. 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
- * Plan-011 phase 2 — HMR re-index entry point.
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. 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.
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
- * (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).
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
- * 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.
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
- 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);
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
- // 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);
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))