@serfab/cadre-host 1.14.0 → 1.16.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.
Files changed (107) hide show
  1. package/README.md +19 -4
  2. package/dist/bin/host.js +32 -20
  3. package/dist/bin/host.js.map +1 -1
  4. package/dist/bin/nat-output.d.ts +5 -0
  5. package/dist/bin/nat-output.js +12 -1
  6. package/dist/bin/nat-output.js.map +1 -1
  7. package/dist/bin/ui-access.d.ts +22 -0
  8. package/dist/bin/ui-access.js +44 -0
  9. package/dist/bin/ui-access.js.map +1 -0
  10. package/dist/hosted/claim-details.d.ts +25 -3
  11. package/dist/hosted/claim-details.js +31 -37
  12. package/dist/hosted/claim-details.js.map +1 -1
  13. package/dist/hosted/hosted-node-service.d.ts +7 -0
  14. package/dist/hosted/hosted-node-service.js +16 -4
  15. package/dist/hosted/hosted-node-service.js.map +1 -1
  16. package/dist/hosted/hosted-node-watcher.d.ts +16 -4
  17. package/dist/hosted/hosted-node-watcher.js +45 -9
  18. package/dist/hosted/hosted-node-watcher.js.map +1 -1
  19. package/dist/hosted/node-status.d.ts +19 -3
  20. package/dist/hosted/node-status.js +21 -5
  21. package/dist/hosted/node-status.js.map +1 -1
  22. package/dist/hosted/types.d.ts +12 -5
  23. package/dist/hosted/types.js.map +1 -1
  24. package/dist/installer/config.d.ts +23 -0
  25. package/dist/installer/config.js +46 -0
  26. package/dist/installer/config.js.map +1 -1
  27. package/dist/installer/index.d.ts +2 -0
  28. package/dist/installer/index.js +7 -16
  29. package/dist/installer/index.js.map +1 -1
  30. package/dist/installer/wizard.d.ts +12 -2
  31. package/dist/installer/wizard.js +15 -7
  32. package/dist/installer/wizard.js.map +1 -1
  33. package/dist/nat/index.d.ts +2 -1
  34. package/dist/nat/index.js +1 -0
  35. package/dist/nat/index.js.map +1 -1
  36. package/dist/nat/nat-service.d.ts +22 -5
  37. package/dist/nat/nat-service.js +75 -26
  38. package/dist/nat/nat-service.js.map +1 -1
  39. package/dist/nat/nat-store.js +11 -0
  40. package/dist/nat/nat-store.js.map +1 -1
  41. package/dist/nat/network-mode.d.ts +19 -0
  42. package/dist/nat/network-mode.js +32 -0
  43. package/dist/nat/network-mode.js.map +1 -0
  44. package/dist/nat/reachability.d.ts +5 -4
  45. package/dist/nat/reachability.js +19 -6
  46. package/dist/nat/reachability.js.map +1 -1
  47. package/dist/nat/types.d.ts +26 -6
  48. package/dist/nat/types.js.map +1 -1
  49. package/dist/orchestrator/host-process-orchestrator.d.ts +3 -3
  50. package/dist/orchestrator/host-process-orchestrator.js +6 -15
  51. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  52. package/dist/package-version.d.ts +1 -0
  53. package/dist/package-version.js +4 -0
  54. package/dist/package-version.js.map +1 -0
  55. package/dist/server/routes/settings.d.ts +1 -0
  56. package/dist/server/routes/settings.js +15 -1
  57. package/dist/server/routes/settings.js.map +1 -1
  58. package/dist/server/routes/status.js +2 -32
  59. package/dist/server/routes/status.js.map +1 -1
  60. package/dist/ui/assets/index-BHd8CWZA.css +1 -0
  61. package/dist/ui/assets/index-Ccr2GjPT.js +23 -0
  62. package/dist/ui/assets/{index-4FWuU8ye.js.map → index-Ccr2GjPT.js.map} +1 -1
  63. package/dist/ui/index.html +2 -2
  64. package/package.json +4 -5
  65. package/src/__tests__/cli.smoke.test.ts +1 -1
  66. package/src/__tests__/orchestrator-claim-secret.test.ts +1 -2
  67. package/src/__tests__/orchestrator-env-scrub.test.ts +160 -163
  68. package/src/__tests__/orchestrator-handle-contract.ts +1 -1
  69. package/src/__tests__/orchestrator-node-identity.test.ts +0 -5
  70. package/src/__tests__/orchestrator-push.test.ts +3 -3
  71. package/src/__tests__/orchestrator.test.ts +3 -5
  72. package/src/__tests__/ui-access.test.ts +30 -0
  73. package/src/bin/host.ts +30 -20
  74. package/src/bin/nat-output.ts +12 -1
  75. package/src/bin/ui-access.ts +50 -0
  76. package/src/hosted/__tests__/fake-orchestrator.test.ts +0 -1
  77. package/src/hosted/__tests__/fake-orchestrator.ts +263 -265
  78. package/src/hosted/__tests__/hosted-node-service.test.ts +708 -609
  79. package/src/hosted/__tests__/hosted-node-supervisor.test.ts +475 -474
  80. package/src/hosted/claim-details.ts +44 -37
  81. package/src/hosted/hosted-node-service.ts +23 -4
  82. package/src/hosted/hosted-node-watcher.ts +288 -250
  83. package/src/hosted/node-status.ts +31 -5
  84. package/src/hosted/types.ts +160 -153
  85. package/src/installer/__tests__/installer.smoke.test.ts +184 -182
  86. package/src/installer/__tests__/wizard.test.ts +76 -56
  87. package/src/installer/config.ts +66 -0
  88. package/src/installer/index.ts +252 -259
  89. package/src/installer/wizard.ts +101 -87
  90. package/src/nat/__tests__/nat-service.test.ts +608 -540
  91. package/src/nat/__tests__/nat-store.test.ts +130 -127
  92. package/src/nat/__tests__/network-mode.test.ts +38 -0
  93. package/src/nat/index.ts +91 -87
  94. package/src/nat/nat-service.ts +79 -26
  95. package/src/nat/nat-store.ts +249 -237
  96. package/src/nat/network-mode.ts +36 -0
  97. package/src/nat/reachability.ts +155 -143
  98. package/src/nat/types.ts +232 -209
  99. package/src/orchestrator/host-process-orchestrator.ts +1089 -1098
  100. package/src/package-version.ts +4 -0
  101. package/src/server/__tests__/fakes.ts +41 -40
  102. package/src/server/__tests__/hosted-nodes-route.test.ts +161 -160
  103. package/src/server/__tests__/settings-route.test.ts +31 -0
  104. package/src/server/routes/settings.ts +128 -115
  105. package/src/server/routes/status.ts +72 -102
  106. package/dist/ui/assets/index-4FWuU8ye.js +0 -19
  107. package/dist/ui/assets/index-Di9Q4d01.css +0 -1
@@ -1,115 +1,128 @@
1
- /**
2
- * /api/settings — host-level settings UI surface.
3
- *
4
- * The persisted shape is `host.config.json` (managed by the installer).
5
- * This route is a thin passthrough plus a write-whitelist:
6
- *
7
- * - uiPort rejected (restart required; only edit at install time)
8
- * - upnpEnabled accepted → also propagated to NatService.putSettings
9
- * - updates.autoApply accepted → propagated to UpdateService.putSettings
10
- * - updates.manifestUrl accepted → propagated to UpdateService.putSettings
11
- * - anything else 400 invalid_setting
12
- *
13
- * GET returns the full `HostConfigFile` (the SPA wants the full shape so it
14
- * can render a read-only view of the rejected keys).
15
- */
16
-
17
- import type { FastifyInstance } from 'fastify';
18
-
19
- import type { NatService } from '../../nat/index.js';
20
- import type { UpdateService } from '../../update/index.js';
21
- import type { HostSettingsStore } from '../settings-store.js';
22
-
23
- const FORBIDDEN_KEYS = new Set<string>(['uiPort', 'dataDir', 'installId', 'installedAt', 'installerVersion', 'version']);
24
-
25
- const WRITABLE_TOP_KEYS = new Set<string>(['upnpEnabled', 'updates']);
26
- const WRITABLE_UPDATES_KEYS = new Set<string>(['autoApply', 'manifestUrl']);
27
-
28
- export interface SettingsRoutesOptions {
29
- settingsStore: HostSettingsStore;
30
- /** An `upnpEnabled` write takes effect on it at once. */
31
- nat: NatService;
32
- update?: UpdateService;
33
- }
34
-
35
- export function registerSettingsRoutes(app: FastifyInstance, opts: SettingsRoutesOptions): void {
36
- const { settingsStore, nat, update } = opts;
37
-
38
- app.get('/api/settings', async () => {
39
- return { ok: true, data: settingsStore.read() };
40
- });
41
-
42
- app.put('/api/settings', async (request, reply) => {
43
- const body = (request.body ?? {}) as Record<string, unknown>;
44
- const validation = validatePatch(body);
45
- if (validation.error) {
46
- return reply.code(400).send({
47
- ok: false,
48
- error: { code: 'invalid_setting', message: validation.error },
49
- });
50
- }
51
-
52
- const current = settingsStore.read();
53
- const patch: { upnpEnabled?: boolean; updates?: typeof current.updates } = {};
54
-
55
- if ('upnpEnabled' in body) {
56
- patch.upnpEnabled = body.upnpEnabled as boolean;
57
- // Propagate the UPnP toggle into the running NatService so the change
58
- // takes effect immediately. NatService persists into nat.json — that's
59
- // independent of host.config.json's `upnpEnabled` (which the installer
60
- // uses on first run to seed nat.json). Update both for consistency.
61
- await nat.putSettings({ upnpEnabled: body.upnpEnabled as boolean });
62
- }
63
- if ('updates' in body) {
64
- const updates = body.updates as { autoApply?: boolean; manifestUrl?: string };
65
- const nextUpdates = { ...current.updates };
66
- if ('autoApply' in updates) nextUpdates.autoApply = updates.autoApply as boolean;
67
- if ('manifestUrl' in updates) nextUpdates.manifestUrl = updates.manifestUrl as string;
68
- patch.updates = nextUpdates;
69
- if (update) {
70
- const propagate: { autoApply?: boolean; manifestUrl?: string } = {};
71
- if ('autoApply' in updates) propagate.autoApply = updates.autoApply as boolean;
72
- if ('manifestUrl' in updates) propagate.manifestUrl = updates.manifestUrl as string;
73
- await update.putSettings(propagate);
74
- }
75
- }
76
-
77
- const next = settingsStore.update(patch);
78
- return { ok: true, data: next };
79
- });
80
- }
81
-
82
- function validatePatch(body: Record<string, unknown>): { error?: string } {
83
- for (const key of Object.keys(body)) {
84
- if (FORBIDDEN_KEYS.has(key)) {
85
- return { error: `Setting "${key}" is not editable at runtime; change it in host.config.json and restart cadre-host.` };
86
- }
87
- if (!WRITABLE_TOP_KEYS.has(key)) {
88
- return { error: `Unknown setting "${key}"` };
89
- }
90
- }
91
- if ('upnpEnabled' in body && typeof body.upnpEnabled !== 'boolean') {
92
- return { error: '"upnpEnabled" must be a boolean' };
93
- }
94
- if ('updates' in body) {
95
- if (!body.updates || typeof body.updates !== 'object') {
96
- return { error: '"updates" must be an object' };
97
- }
98
- const updates = body.updates as Record<string, unknown>;
99
- for (const key of Object.keys(updates)) {
100
- if (!WRITABLE_UPDATES_KEYS.has(key)) {
101
- return { error: `Unknown setting "updates.${key}"` };
102
- }
103
- }
104
- if ('autoApply' in updates && typeof updates.autoApply !== 'boolean') {
105
- return { error: '"updates.autoApply" must be a boolean' };
106
- }
107
- if (
108
- 'manifestUrl' in updates &&
109
- typeof updates.manifestUrl !== 'string'
110
- ) {
111
- return { error: '"updates.manifestUrl" must be a string' };
112
- }
113
- }
114
- return {};
115
- }
1
+ /**
2
+ * /api/settings — host-level settings UI surface.
3
+ *
4
+ * The persisted shape is `host.config.json` (managed by the installer).
5
+ * This route is a thin passthrough plus a write-whitelist:
6
+ *
7
+ * - uiPort rejected (restart required; only edit at install time)
8
+ * - upnpEnabled accepted → also propagated to NatService.putSettings
9
+ * - updates.autoApply accepted → propagated to UpdateService.putSettings
10
+ * - updates.manifestUrl accepted → propagated to UpdateService.putSettings
11
+ * - claimAddresses accepted (replaced whole) → read by the next claim code
12
+ * - anything else 400 invalid_setting
13
+ *
14
+ * GET returns the full `HostConfigFile` (the SPA wants the full shape so it
15
+ * can render a read-only view of the rejected keys).
16
+ */
17
+
18
+ import type { FastifyInstance } from 'fastify';
19
+
20
+ import type { NatService } from '../../nat/index.js';
21
+ import type { UpdateService } from '../../update/index.js';
22
+ import type { HostSettingsStore } from '../settings-store.js';
23
+ import { claimAddressSettingsProblem, type ClaimAddressSettings } from '../../installer/config.js';
24
+
25
+ const FORBIDDEN_KEYS = new Set<string>(['uiPort', 'dataDir', 'installId', 'installedAt', 'installerVersion', 'version']);
26
+
27
+ const WRITABLE_TOP_KEYS = new Set<string>(['upnpEnabled', 'updates', 'claimAddresses']);
28
+ const WRITABLE_UPDATES_KEYS = new Set<string>(['autoApply', 'manifestUrl']);
29
+
30
+ export interface SettingsRoutesOptions {
31
+ settingsStore: HostSettingsStore;
32
+ /** An `upnpEnabled` write takes effect on it at once. */
33
+ nat: NatService;
34
+ update?: UpdateService;
35
+ }
36
+
37
+ export function registerSettingsRoutes(app: FastifyInstance, opts: SettingsRoutesOptions): void {
38
+ const { settingsStore, nat, update } = opts;
39
+
40
+ app.get('/api/settings', async () => {
41
+ return { ok: true, data: settingsStore.read() };
42
+ });
43
+
44
+ app.put('/api/settings', async (request, reply) => {
45
+ const body = (request.body ?? {}) as Record<string, unknown>;
46
+ const validation = validatePatch(body);
47
+ if (validation.error) {
48
+ return reply.code(400).send({
49
+ ok: false,
50
+ error: { code: 'invalid_setting', message: validation.error },
51
+ });
52
+ }
53
+
54
+ const current = settingsStore.read();
55
+ const patch: { upnpEnabled?: boolean; updates?: typeof current.updates; claimAddresses?: ClaimAddressSettings } = {};
56
+
57
+ if ('upnpEnabled' in body) {
58
+ patch.upnpEnabled = body.upnpEnabled as boolean;
59
+ // Propagate the UPnP toggle into the running NatService so the change
60
+ // takes effect immediately. NatService persists into nat.json — that's
61
+ // independent of host.config.json's `upnpEnabled` (which the installer
62
+ // uses on first run to seed nat.json). Update both for consistency.
63
+ await nat.putSettings({ upnpEnabled: body.upnpEnabled as boolean });
64
+ }
65
+ if ('updates' in body) {
66
+ const updates = body.updates as { autoApply?: boolean; manifestUrl?: string };
67
+ const nextUpdates = { ...current.updates };
68
+ if ('autoApply' in updates) nextUpdates.autoApply = updates.autoApply as boolean;
69
+ if ('manifestUrl' in updates) nextUpdates.manifestUrl = updates.manifestUrl as string;
70
+ patch.updates = nextUpdates;
71
+ if (update) {
72
+ const propagate: { autoApply?: boolean; manifestUrl?: string } = {};
73
+ if ('autoApply' in updates) propagate.autoApply = updates.autoApply as boolean;
74
+ if ('manifestUrl' in updates) propagate.manifestUrl = updates.manifestUrl as string;
75
+ await update.putSettings(propagate);
76
+ }
77
+ }
78
+
79
+ if ('claimAddresses' in body) {
80
+ const { lan, addrs } = body.claimAddresses as ClaimAddressSettings;
81
+ patch.claimAddresses = {
82
+ ...(lan !== undefined ? { lan } : {}),
83
+ ...(addrs !== undefined ? { addrs: addrs.map((a) => a.trim()).filter((a) => a.length > 0) } : {}),
84
+ };
85
+ }
86
+ const next = settingsStore.update(patch);
87
+ return { ok: true, data: next };
88
+ });
89
+ }
90
+
91
+ function validatePatch(body: Record<string, unknown>): { error?: string } {
92
+ for (const key of Object.keys(body)) {
93
+ if (FORBIDDEN_KEYS.has(key)) {
94
+ return { error: `Setting "${key}" is not editable at runtime; change it in host.config.json and restart cadre-host.` };
95
+ }
96
+ if (!WRITABLE_TOP_KEYS.has(key)) {
97
+ return { error: `Unknown setting "${key}"` };
98
+ }
99
+ }
100
+ if ('upnpEnabled' in body && typeof body.upnpEnabled !== 'boolean') {
101
+ return { error: '"upnpEnabled" must be a boolean' };
102
+ }
103
+ if ('claimAddresses' in body) {
104
+ const problem = claimAddressSettingsProblem(body.claimAddresses);
105
+ if (problem) return { error: problem };
106
+ }
107
+ if ('updates' in body) {
108
+ if (!body.updates || typeof body.updates !== 'object') {
109
+ return { error: '"updates" must be an object' };
110
+ }
111
+ const updates = body.updates as Record<string, unknown>;
112
+ for (const key of Object.keys(updates)) {
113
+ if (!WRITABLE_UPDATES_KEYS.has(key)) {
114
+ return { error: `Unknown setting "updates.${key}"` };
115
+ }
116
+ }
117
+ if ('autoApply' in updates && typeof updates.autoApply !== 'boolean') {
118
+ return { error: '"updates.autoApply" must be a boolean' };
119
+ }
120
+ if (
121
+ 'manifestUrl' in updates &&
122
+ typeof updates.manifestUrl !== 'string'
123
+ ) {
124
+ return { error: '"updates.manifestUrl" must be a string' };
125
+ }
126
+ }
127
+ return {};
128
+ }
@@ -1,102 +1,72 @@
1
- /**
2
- * GET /api/status — aggregated health snapshot used by the SPA's dashboard.
3
- *
4
- * All inputs are in-memory subsystem state — no disk reads on each call.
5
- * The response budget is < 5 ms; favour `getStatus()` over `testReachability()`.
6
- */
7
-
8
- import { readFileSync } from 'node:fs';
9
- import { dirname, resolve } from 'node:path';
10
- import { fileURLToPath } from 'node:url';
11
-
12
- import type { FastifyInstance } from 'fastify';
13
-
14
- import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
15
- import type { ManagedNodeInfo } from '../../orchestrator/types.js';
16
- import type { NatService } from '../../nat/index.js';
17
- import type { NatStatusSnapshot } from '../../nat/types.js';
18
- import type { UpdateService } from '../../update/index.js';
19
-
20
- const PROCESS_STARTED_AT = process.hrtime.bigint();
21
-
22
- export interface StatusRouteOptions {
23
- orchestrator: HostProcessOrchestrator;
24
- nat: NatService;
25
- update?: UpdateService;
26
- }
27
-
28
- export interface StatusResponse {
29
- service: { name: 'cadre-host'; version: string; uptimeSeconds: number };
30
- nodes: Array<{
31
- id: string;
32
- partyId: string;
33
- status: ManagedNodeInfo['status'];
34
- profile: ManagedNodeInfo['profile'];
35
- }>;
36
- connectivity: NatStatusSnapshot;
37
- update?: { available?: string; lastChecked?: string };
38
- }
39
-
40
- export function registerStatusRoute(app: FastifyInstance, opts: StatusRouteOptions): void {
41
- const version = readPackageVersion();
42
-
43
- app.get('/api/status', async (): Promise<StatusResponse> => {
44
- const nodes = opts.orchestrator.listNodes().map((n) => ({
45
- id: n.id,
46
- partyId: n.partyId,
47
- status: n.status,
48
- profile: n.profile,
49
- }));
50
-
51
- const response: StatusResponse = {
52
- service: {
53
- name: 'cadre-host',
54
- version,
55
- uptimeSeconds: secondsSince(PROCESS_STARTED_AT),
56
- },
57
- nodes,
58
- connectivity: opts.nat.getStatus(),
59
- };
60
-
61
- if (opts.update) {
62
- const state = await opts.update.getState();
63
- const update: { available?: string; lastChecked?: string } = {};
64
- if (state.available?.version) update.available = state.available.version;
65
- if (state.lastChecked) update.lastChecked = state.lastChecked;
66
- response.update = update;
67
- }
68
-
69
- return response;
70
- });
71
- }
72
-
73
- function secondsSince(start: bigint): number {
74
- const now = process.hrtime.bigint();
75
- const elapsedNs = now - start;
76
- return Number(elapsedNs / 1_000_000_000n);
77
- }
78
-
79
- let cachedVersion: string | null = null;
80
- function readPackageVersion(): string {
81
- if (cachedVersion) return cachedVersion;
82
- try {
83
- // dist/server/routes/status.js -> ../../../package.json
84
- // src/server/routes/status.ts -> ../../../package.json (vitest)
85
- const here = dirname(fileURLToPath(import.meta.url));
86
- const candidates = [
87
- resolve(here, '..', '..', '..', 'package.json'),
88
- resolve(here, '..', '..', 'package.json'),
89
- ];
90
- for (const p of candidates) {
91
- try {
92
- const parsed = JSON.parse(readFileSync(p, 'utf8')) as { version?: string };
93
- if (parsed.version) {
94
- cachedVersion = parsed.version;
95
- return cachedVersion;
96
- }
97
- } catch { /* try next */ }
98
- }
99
- } catch { /* ignore */ }
100
- cachedVersion = '0.0.0-unknown';
101
- return cachedVersion;
102
- }
1
+ /**
2
+ * GET /api/status — aggregated health snapshot used by the SPA's dashboard.
3
+ *
4
+ * All inputs are in-memory subsystem state — no disk reads on each call.
5
+ * The response budget is < 5 ms; favour `getStatus()` over `testReachability()`.
6
+ */
7
+
8
+ import type { FastifyInstance } from 'fastify';
9
+
10
+ import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
11
+ import type { ManagedNodeInfo } from '../../orchestrator/types.js';
12
+ import type { NatService } from '../../nat/index.js';
13
+ import type { NatStatusSnapshot } from '../../nat/types.js';
14
+ import type { UpdateService } from '../../update/index.js';
15
+ import { packageVersion } from '../../package-version.js';
16
+
17
+ const PROCESS_STARTED_AT = process.hrtime.bigint();
18
+
19
+ export interface StatusRouteOptions {
20
+ orchestrator: HostProcessOrchestrator;
21
+ nat: NatService;
22
+ update?: UpdateService;
23
+ }
24
+
25
+ export interface StatusResponse {
26
+ service: { name: 'cadre-host'; version: string; uptimeSeconds: number };
27
+ nodes: Array<{
28
+ id: string;
29
+ partyId: string;
30
+ status: ManagedNodeInfo['status'];
31
+ profile: ManagedNodeInfo['profile'];
32
+ }>;
33
+ connectivity: NatStatusSnapshot;
34
+ update?: { available?: string; lastChecked?: string };
35
+ }
36
+
37
+ export function registerStatusRoute(app: FastifyInstance, opts: StatusRouteOptions): void {
38
+ app.get('/api/status', async (): Promise<StatusResponse> => {
39
+ const nodes = opts.orchestrator.listNodes().map((n) => ({
40
+ id: n.id,
41
+ partyId: n.partyId,
42
+ status: n.status,
43
+ profile: n.profile,
44
+ }));
45
+
46
+ const response: StatusResponse = {
47
+ service: {
48
+ name: 'cadre-host',
49
+ version: packageVersion,
50
+ uptimeSeconds: secondsSince(PROCESS_STARTED_AT),
51
+ },
52
+ nodes,
53
+ connectivity: opts.nat.getStatus(),
54
+ };
55
+
56
+ if (opts.update) {
57
+ const state = await opts.update.getState();
58
+ const update: { available?: string; lastChecked?: string } = {};
59
+ if (state.available?.version) update.available = state.available.version;
60
+ if (state.lastChecked) update.lastChecked = state.lastChecked;
61
+ response.update = update;
62
+ }
63
+
64
+ return response;
65
+ });
66
+ }
67
+
68
+ function secondsSince(start: bigint): number {
69
+ const now = process.hrtime.bigint();
70
+ const elapsedNs = now - start;
71
+ return Number(elapsedNs / 1_000_000_000n);
72
+ }