@quolu/lattice 0.52.3 → 0.53.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/LICENSE +147 -147
- package/README.ja.md +355 -355
- package/README.md +258 -258
- package/bin/lattice-bridge.mjs +25 -0
- package/bin/lattice-hub.mjs +67 -0
- package/bin/lattice-mcp.mjs +0 -0
- package/bin/lattice-scripted-adapter.mjs +0 -0
- package/bin/lattice-scripted-worker.mjs +0 -0
- package/bin/lattice-work-order-adapter.mjs +0 -0
- package/bin/lattice.mjs +0 -0
- package/docs/bridge-setup.md +132 -132
- package/docs/schemas/lattice.executor_packet.v1.schema.json +57 -57
- package/docs/schemas/lattice.executor_receipt.v1.schema.json +66 -66
- package/docs/schemas/lattice.phase_todo_revision.v3.schema.json +360 -360
- package/docs/schemas/lattice.plan_create_input.v1.schema.json +56 -56
- package/docs/schemas/lattice.plan_create_input.v2.schema.json +72 -72
- package/docs/schemas/lattice.plan_create_input.v3.schema.json +81 -81
- package/docs/schemas/lattice.plan_create_input.v4.schema.json +85 -85
- package/docs/schemas/lattice.run_request.v1.schema.json +238 -238
- package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -55
- package/docs/schemas/lattice.runtime_adapter_registration_input.v1.schema.json +78 -78
- package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -86
- package/docs/schemas/lattice.todo_extraction.v2.schema.json +298 -298
- package/docs/schemas/lattice.todo_extraction.v3.schema.json +146 -146
- package/docs/schemas/lattice.todo_revision.v2.schema.json +260 -260
- package/docs/schemas/lattice.todo_revision_set.v3.schema.json +363 -363
- package/package.json +103 -103
- package/sensor/LICENSE +21 -21
- package/sensor/NOTICE +19 -19
- package/sensor/dist/bin/lattice-sensor.js +9 -9
- package/sensor/dist/db/index.js +24 -24
- package/sensor/dist/db/migrations.js +41 -41
- package/sensor/dist/db/queries.js +164 -164
- package/sensor/dist/db/schema.sql +205 -205
- package/sensor/dist/directory.js +5 -5
- package/sensor/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cfml.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cfquery.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cfscript.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cobol.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-erlang.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-go.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-java.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-javascript.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-nix.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-python.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-tsx.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-typescript.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-vbnet.wasm +0 -0
- package/sensor/dist/mcp/liveness-watchdog.js +53 -53
- package/sensor/dist/mcp/server-instructions.js +95 -95
- package/sensor/package.json +56 -56
- package/src/artifact-contracts-v2.mjs +325 -325
- package/src/artifact-contracts.mjs +895 -895
- package/src/boundary-compiler.mjs +712 -712
- package/src/boundary-observation-compiler-v2.mjs +344 -344
- package/src/bounded-seam.mjs +230 -230
- package/src/bridge-address.mjs +107 -107
- package/src/bridge-cli.mjs +303 -297
- package/src/bridge-config.mjs +382 -346
- package/src/bridge-daemon.mjs +378 -378
- package/src/bridge-hub-heartbeat.mjs +170 -0
- package/src/bridge-hub-protocol.mjs +198 -0
- package/src/bridge-hub-server.mjs +544 -0
- package/src/bridge-launch-agent.mjs +323 -323
- package/src/bridge-registrar.mjs +102 -102
- package/src/bridge-server.mjs +376 -376
- package/src/cli-help.mjs +308 -308
- package/src/cli-stdio.mjs +40 -40
- package/src/control-compiler.mjs +532 -532
- package/src/dag-chain.mjs +261 -261
- package/src/factory-diagnostics.mjs +188 -188
- package/src/git-process.mjs +72 -0
- package/src/hash-chain.mjs +76 -76
- package/src/hooks-cli.mjs +1057 -1057
- package/src/isolation-runner.mjs +409 -409
- package/src/node-version-guard.mjs +44 -44
- package/src/project-cli.mjs +3 -3
- package/src/project-identity.mjs +130 -130
- package/src/rc1-black-box-oracle.mjs +906 -906
- package/src/rc1-comparison.mjs +154 -154
- package/src/rc1-evidence-bundle.mjs +456 -456
- package/src/rc1-v4-campaign.mjs +708 -708
- package/src/rc1-v4-transform.mjs +467 -467
- package/src/rc1-v5-artifact-set.mjs +855 -855
- package/src/rc1-v5-behavior-evidence.mjs +594 -594
- package/src/rc1-v5-campaign.mjs +797 -797
- package/src/rc1-v5-transform.mjs +421 -421
- package/src/rc1-v6-artifact-set.mjs +807 -807
- package/src/rc1-v6-behavior-evidence.mjs +273 -273
- package/src/rc1-v6-campaign.mjs +623 -623
- package/src/rc1-v6-causal-binding.mjs +473 -473
- package/src/rc1-v6-measurement.mjs +313 -313
- package/src/rc2-artifact-set.mjs +1584 -1584
- package/src/rc2-campaign.mjs +1506 -1506
- package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
- package/src/rc2-delivery-policy-oracle.mjs +134 -134
- package/src/rc2-delivery-policy-transform.mjs +1127 -1127
- package/src/rc2-rc1-transfer-front-end.mjs +511 -511
- package/src/rc3-actual-dogfood.mjs +652 -652
- package/src/rc3-dogfood-scaffold.mjs +315 -315
- package/src/rc3-scripted-campaign.mjs +1383 -1383
- package/src/rc4-stage1-dogfood.mjs +673 -673
- package/src/runtime-adapter-registry.mjs +524 -524
- package/src/runtime-cli.mjs +4588 -4588
- package/src/runtime-contracts.mjs +824 -824
- package/src/runtime-control-store.mjs +604 -604
- package/src/runtime-controller-protocol.mjs +587 -587
- package/src/runtime-decision-verifier.mjs +701 -701
- package/src/runtime-diff-observer.mjs +361 -361
- package/src/runtime-direct-os-observer.mjs +301 -301
- package/src/runtime-driver-state.mjs +166 -166
- package/src/runtime-engine.mjs +779 -779
- package/src/runtime-errors.mjs +356 -356
- package/src/runtime-event-store.mjs +189 -189
- package/src/runtime-front-end.mjs +925 -925
- package/src/runtime-gate-store.mjs +481 -481
- package/src/runtime-hold-recompile.mjs +916 -916
- package/src/runtime-io-sentinel.mjs +391 -391
- package/src/runtime-lifecycle-lock.mjs +294 -294
- package/src/runtime-managed-supervisor.mjs +1490 -1490
- package/src/runtime-multi-epoch-store.mjs +838 -838
- package/src/runtime-projection.mjs +269 -269
- package/src/runtime-pull-intake.mjs +1192 -1192
- package/src/runtime-scripted-adapter-controller.mjs +1160 -1160
- package/src/runtime-scripted-executor.mjs +163 -163
- package/src/runtime-scripted-worktree.mjs +104 -104
- package/src/runtime-seam-resolve.mjs +428 -428
- package/src/runtime-seam-treatment.mjs +173 -173
- package/src/runtime-socket-owner.mjs +125 -125
- package/src/runtime-work-order-contracts.mjs +91 -91
- package/src/runtime-work-order-controller.mjs +1171 -1171
- package/src/runtime-worktree-executor.mjs +199 -199
- package/src/schedulability-compiler-v2.mjs +303 -303
- package/src/schedulability-verifier-v2.mjs +317 -317
- package/src/seam-apply.mjs +549 -549
- package/src/seam-commit-shared.mjs +22 -22
- package/src/seam-commit-transform.mjs +81 -81
- package/src/seam-commit.mjs +18 -18
- package/src/seam-cost.mjs +322 -322
- package/src/seam-derivation.mjs +188 -188
- package/src/seam-gate.mjs +146 -146
- package/src/seam-proposal-contracts.mjs +446 -446
- package/src/seam-proposal-queries.mjs +521 -521
- package/src/seam-proposal.mjs +2011 -2011
- package/src/seam-ref.mjs +33 -33
- package/src/seam-rewrite.mjs +286 -286
- package/src/seam-transform.mjs +554 -554
- package/src/seam-verification.mjs +260 -260
- package/src/sensor-adapter.mjs +432 -432
- package/src/sensor-cli.mjs +139 -139
- package/src/sensor-diff.mjs +661 -661
- package/src/sensor-node-runtime.mjs +53 -53
- package/src/sensor-runtime.mjs +52 -52
- package/src/timestamp-contract.mjs +8 -8
- package/src/todo-audit-pending.mjs +91 -91
- package/src/todo-chain.mjs +178 -178
- package/src/todo-cli.mjs +8 -8
- package/src/todo-contracts.mjs +728 -728
- package/src/todo-dashboard-registry.mjs +573 -573
- package/src/todo-dispatch-shape.mjs +190 -190
- package/src/todo-gantt-html-independence.mjs +239 -239
- package/src/todo-gantt-html-shared.mjs +226 -226
- package/src/todo-gantt-html-style.mjs +131 -131
- package/src/todo-gantt-html.mjs +248 -248
- package/src/todo-gantt-layout.mjs +974 -974
- package/src/todo-gantt-live.mjs +361 -361
- package/src/todo-gantt-nested.mjs +263 -263
- package/src/todo-gantt-presentation.mjs +217 -217
- package/src/todo-gantt-scope.mjs +123 -123
- package/src/todo-gantt-svg.mjs +353 -353
- package/src/todo-independence-contracts.mjs +595 -595
- package/src/todo-independence-guidance.mjs +322 -322
- package/src/todo-independence.mjs +640 -640
- package/src/todo-markdown-renderer.mjs +260 -260
- package/src/todo-migration.mjs +448 -448
- package/src/todo-narrative-anchor.mjs +130 -130
- package/src/todo-note-store.mjs +629 -629
- package/src/todo-parallel-candidates.mjs +114 -114
- package/src/todo-revision.mjs +995 -995
- package/src/todo-split.mjs +472 -472
- package/src/todo-status.mjs +690 -690
- package/src/todo-store-git-transaction.mjs +418 -418
- package/src/todo-store.mjs +42 -21
- package/src/treatment-compiler.mjs +728 -728
- package/src/treatment-runner.mjs +656 -656
- package/src/witness-scaffold.mjs +180 -180
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge hub HTTP server (bh2) — the multi-terminal aggregator that fronts
|
|
3
|
+
* several `lattice bridge` instances behind a single public origin.
|
|
4
|
+
*
|
|
5
|
+
* This module wires I/O (sockets, disk, the clock) around the pure contract
|
|
6
|
+
* in `bridge-hub-protocol.mjs` (bh1). It does not reinvent registration,
|
|
7
|
+
* heartbeat, staleness, or conflict semantics — it only supplies the parts
|
|
8
|
+
* the contract deliberately does not own: an HTTP surface, a locked file
|
|
9
|
+
* store, and a reverse proxy to the owning terminal's bridge.
|
|
10
|
+
*
|
|
11
|
+
* Endpoints:
|
|
12
|
+
* - `POST /__lattice/hub/register` — a terminal's registration/heartbeat call.
|
|
13
|
+
* - `GET /projects/` — the aggregate listing across all registered terminals.
|
|
14
|
+
* - `* /projects/<project_id>/*` — reverse proxy to the owning terminal's
|
|
15
|
+
* bridge, or a typed 404/503 when the project is unknown or offline.
|
|
16
|
+
*
|
|
17
|
+
* Safety posture mirrors `bridge-server.mjs` (Host allow-list, hop-by-hop
|
|
18
|
+
* header stripping, Forwarded header regeneration, origin-form request
|
|
19
|
+
* target validation, streaming proxy via `.pipe()` so SSE is never
|
|
20
|
+
* buffered) without importing from it — `bridge-server.mjs` is owned by a
|
|
21
|
+
* concurrent task and this module must not couple to it mid-flight.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createServer, request as httpRequest } from 'node:http';
|
|
25
|
+
import { isIP } from 'node:net';
|
|
26
|
+
import { randomBytes } from 'node:crypto';
|
|
27
|
+
import { homedir } from 'node:os';
|
|
28
|
+
import {
|
|
29
|
+
mkdir, open, readFile, rename, rm, writeFile,
|
|
30
|
+
} from 'node:fs/promises';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { domainToASCII } from 'node:url';
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
applyBridgeHubRegistration, BRIDGE_HUB_HEARTBEAT_TTL_MS, BridgeHubProtocolError,
|
|
36
|
+
projectBridgeHubRegistry, validateBridgeHubRegistryEntry,
|
|
37
|
+
} from './bridge-hub-protocol.mjs';
|
|
38
|
+
|
|
39
|
+
const LOOPBACK = '127.0.0.1';
|
|
40
|
+
const HUB_HTTP_ERROR_SCHEMA = 'lattice.bridge_hub_http_error.v1';
|
|
41
|
+
const HUB_REGISTRY_DOCUMENT_SCHEMA = 'lattice.bridge_hub_registry_document.v1';
|
|
42
|
+
const HUB_PUBLIC_PROJECT_SCHEMA = 'lattice.bridge_hub_public_project.v1';
|
|
43
|
+
const MAX_REGISTRATION_BODY_BYTES = 65_536;
|
|
44
|
+
const LOCK_ATTEMPTS = 240;
|
|
45
|
+
const LOCK_WAIT_MS = 25;
|
|
46
|
+
const LOCK_STALE_MS = 30_000;
|
|
47
|
+
const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store',
|
|
48
|
+
'x-content-type-options': 'nosniff' };
|
|
49
|
+
const HOP_BY_HOP = new Set([
|
|
50
|
+
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
51
|
+
'te', 'trailer', 'transfer-encoding', 'upgrade',
|
|
52
|
+
]);
|
|
53
|
+
const UNTRUSTED_CLIENT_IP_HEADERS = new Set([
|
|
54
|
+
'cf-connecting-ip', 'client-ip', 'fastly-client-ip', 'true-client-ip',
|
|
55
|
+
'x-cluster-client-ip', 'x-proxyuser-ip',
|
|
56
|
+
]);
|
|
57
|
+
const PROJECT_ROUTE = /^\/projects\/([^/]+)(?:\/.*)?$/u;
|
|
58
|
+
|
|
59
|
+
export class BridgeHubServerError extends Error {
|
|
60
|
+
constructor(code, message, detail = undefined, cause = undefined) {
|
|
61
|
+
super(message, { cause });
|
|
62
|
+
this.name = 'BridgeHubServerError';
|
|
63
|
+
this.code = code;
|
|
64
|
+
if (detail !== undefined) this.detail = detail;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function escapeHtml(value) {
|
|
69
|
+
return String(value).replaceAll('&', '&').replaceAll('<', '<')
|
|
70
|
+
.replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- Host allow-list validation (equivalent to bridge-server.mjs's
|
|
74
|
+
// validatedRequestHost/normalizeBridgeAllowedHost, reimplemented locally so
|
|
75
|
+
// this module does not depend on a file a concurrent task is editing). ---
|
|
76
|
+
|
|
77
|
+
function normalizeHubAllowedHost(value) {
|
|
78
|
+
if (typeof value !== 'string' || value.length === 0 || value !== value.trim()) {
|
|
79
|
+
throw new BridgeHubServerError('BRIDGE_HOST_INVALID', 'allowed host is invalid');
|
|
80
|
+
}
|
|
81
|
+
if (isIP(value) !== 0) return value.toLowerCase();
|
|
82
|
+
const withoutDot = value.endsWith('.') ? value.slice(0, -1) : value;
|
|
83
|
+
const ascii = domainToASCII(withoutDot).toLowerCase();
|
|
84
|
+
if (ascii.length === 0 || ascii.length > 253
|
|
85
|
+
|| !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(ascii)
|
|
86
|
+
|| ascii.split('.').some((label) => label.length === 0 || label.length > 63
|
|
87
|
+
|| label.startsWith('-') || label.endsWith('-'))) {
|
|
88
|
+
throw new BridgeHubServerError('BRIDGE_HOST_INVALID', 'allowed host is invalid');
|
|
89
|
+
}
|
|
90
|
+
return ascii;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function validatedHubHost(value) {
|
|
94
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 512
|
|
95
|
+
|| /[\s\\/@,]/u.test(value)) {
|
|
96
|
+
throw new BridgeHubServerError('BRIDGE_HOST_INVALID', 'request Host is invalid');
|
|
97
|
+
}
|
|
98
|
+
let parsed;
|
|
99
|
+
try { parsed = new URL(`http://${value}`); } catch {
|
|
100
|
+
throw new BridgeHubServerError('BRIDGE_HOST_INVALID', 'request Host is invalid');
|
|
101
|
+
}
|
|
102
|
+
if (parsed.username !== '' || parsed.password !== '' || parsed.pathname !== '/'
|
|
103
|
+
|| parsed.search !== '' || parsed.hash !== '') {
|
|
104
|
+
throw new BridgeHubServerError('BRIDGE_HOST_INVALID', 'request Host is invalid');
|
|
105
|
+
}
|
|
106
|
+
const rawHostname = parsed.hostname.startsWith('[') && parsed.hostname.endsWith(']')
|
|
107
|
+
? parsed.hostname.slice(1, -1) : parsed.hostname;
|
|
108
|
+
const hostname = normalizeHubAllowedHost(rawHostname);
|
|
109
|
+
const authorityHost = hostname.includes(':') ? `[${hostname}]` : hostname;
|
|
110
|
+
return { hostname, authority: parsed.port === '' ? authorityHost : `${authorityHost}:${parsed.port}` };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- Origin-form request-target validation (equivalent to bridge-server.mjs's
|
|
114
|
+
// upstreamUrl guard against absolute-form targets, encoded path bytes, and
|
|
115
|
+
// dot-segment escapes). Applied to every route, not only the proxy path. ---
|
|
116
|
+
|
|
117
|
+
function validatedHubRequestTarget(requestUrl) {
|
|
118
|
+
if (typeof requestUrl !== 'string' || requestUrl.includes('#') || !/^\/(?!\/)[^\s]*$/u.test(requestUrl)) {
|
|
119
|
+
throw new BridgeHubServerError('BRIDGE_HUB_REQUEST_TARGET_INVALID', 'hub requires an origin-form request target');
|
|
120
|
+
}
|
|
121
|
+
const rawPath = requestUrl.split('?', 1)[0];
|
|
122
|
+
if (rawPath.includes('\\') || rawPath.includes('%')) {
|
|
123
|
+
throw new BridgeHubServerError('BRIDGE_HUB_REQUEST_TARGET_INVALID',
|
|
124
|
+
'hub request target contains encoded path bytes or a backslash');
|
|
125
|
+
}
|
|
126
|
+
if (rawPath.split('/').some((segment) => segment === '.' || segment === '..')) {
|
|
127
|
+
throw new BridgeHubServerError('BRIDGE_HUB_REQUEST_TARGET_INVALID', 'hub request target contains a dot segment');
|
|
128
|
+
}
|
|
129
|
+
return requestUrl;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- Proxy header handling (equivalent to bridge-server.mjs's
|
|
133
|
+
// forwardHeaders/forwardedHeaders: strip hop-by-hop and untrusted
|
|
134
|
+
// client-ip headers, then regenerate Forwarded/X-Forwarded-* from the
|
|
135
|
+
// connection hub itself observed). ---
|
|
136
|
+
|
|
137
|
+
function hubConnectionTokens(headers) {
|
|
138
|
+
const value = headers.connection;
|
|
139
|
+
return new Set((Array.isArray(value) ? value.join(',') : value ?? '')
|
|
140
|
+
.split(',').map((entry) => entry.trim().toLowerCase()).filter(Boolean));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hubForwardHeaders(headers) {
|
|
144
|
+
const nominated = hubConnectionTokens(headers);
|
|
145
|
+
return Object.fromEntries(Object.entries(headers)
|
|
146
|
+
.filter(([name, value]) => value !== undefined && name !== 'host'
|
|
147
|
+
&& name.toLowerCase() !== 'forwarded' && name.toLowerCase() !== 'x-real-ip'
|
|
148
|
+
&& !name.toLowerCase().startsWith('x-forwarded-')
|
|
149
|
+
&& !UNTRUSTED_CLIENT_IP_HEADERS.has(name.toLowerCase())
|
|
150
|
+
&& !HOP_BY_HOP.has(name.toLowerCase()) && !nominated.has(name.toLowerCase())));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function hubForwardedHeaders(incoming, host) {
|
|
154
|
+
const remote = incoming.socket.remoteAddress ?? 'unknown';
|
|
155
|
+
const forwardedFor = remote.includes(':') ? `"[${remote}]"` : remote;
|
|
156
|
+
return {
|
|
157
|
+
forwarded: `for=${forwardedFor};host="${host.authority}";proto=http`,
|
|
158
|
+
'x-forwarded-for': remote,
|
|
159
|
+
'x-forwarded-host': host.authority,
|
|
160
|
+
'x-forwarded-proto': 'http',
|
|
161
|
+
'x-real-ip': remote,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- Response rendering. /projects/ listing negotiates JSON on an explicit
|
|
166
|
+
// `Accept: application/json` and defaults to HTML (spec: humans browsing the
|
|
167
|
+
// index). The per-project 404/503 negotiate the other way — JSON unless
|
|
168
|
+
// `Accept` explicitly asks for text/html — matching todo-gantt-live.mjs's
|
|
169
|
+
// notFoundHtml convention, since a bridge/monitoring client is the more
|
|
170
|
+
// likely caller there. ---
|
|
171
|
+
|
|
172
|
+
function respondError(response, status, code, detail = null) {
|
|
173
|
+
if (response.headersSent) { response.destroy(); return; }
|
|
174
|
+
const body = detail === null ? { schema: HUB_HTTP_ERROR_SCHEMA, code } : { schema: HUB_HTTP_ERROR_SCHEMA, code, detail };
|
|
175
|
+
response.writeHead(status, JSON_HEADERS);
|
|
176
|
+
response.end(`${JSON.stringify(body)}\n`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function hubIndexHtml(view) {
|
|
180
|
+
const rows = view.map((project) => {
|
|
181
|
+
const href = `/projects/${encodeURIComponent(project.project_id)}/`;
|
|
182
|
+
const statusLabel = project.status === 'online' ? 'オンライン' : 'オフライン';
|
|
183
|
+
const identity = project.display_name === project.project_id ? '' : `<code>${escapeHtml(project.project_id)}</code>`;
|
|
184
|
+
return `<li><a href="${escapeHtml(href)}"><strong>${escapeHtml(project.display_name)}</strong>`
|
|
185
|
+
+ `${identity}<span>${escapeHtml(statusLabel)}</span></a></li>`;
|
|
186
|
+
}).join('');
|
|
187
|
+
const content = rows.length === 0 ? '<p>登録されているプロジェクトはありません。</p>' : `<ul>${rows}</ul>`;
|
|
188
|
+
return `<!doctype html><html lang="ja"><head><meta charset="utf-8">`
|
|
189
|
+
+ `<meta name="viewport" content="width=device-width,initial-scale=1">`
|
|
190
|
+
+ `<title>登録済みプロジェクト — Lattice hub</title></head>`
|
|
191
|
+
+ `<body><h1>登録済みプロジェクト</h1>${content}</body></html>`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function hubProjectStatusHtml(code, projectId, requestPath, message) {
|
|
195
|
+
return `<!doctype html><html lang="ja"><head><meta charset="utf-8">`
|
|
196
|
+
+ `<title>${escapeHtml(code)} — Lattice hub</title></head>`
|
|
197
|
+
+ `<body><h1>${escapeHtml(message)}</h1><p>project_id: ${escapeHtml(projectId)}</p>`
|
|
198
|
+
+ `<code>${escapeHtml(requestPath)}</code></body></html>`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function respondProjectStatus(incoming, response, status, code, projectId, requestPath, message) {
|
|
202
|
+
const accept = String(incoming.headers.accept ?? '').toLowerCase();
|
|
203
|
+
if (!accept.includes('text/html')) {
|
|
204
|
+
response.writeHead(status, JSON_HEADERS);
|
|
205
|
+
response.end(`${JSON.stringify({ schema: HUB_HTTP_ERROR_SCHEMA, code, project_id: projectId, message })}\n`);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const html = hubProjectStatusHtml(code, projectId, requestPath, message);
|
|
209
|
+
response.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store',
|
|
210
|
+
'x-content-type-options': 'nosniff' });
|
|
211
|
+
response.end(html);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// --- A single async mutex guarding the registration critical section
|
|
215
|
+
// (read -> apply -> write) against two concurrent registration requests
|
|
216
|
+
// racing each other's read before either has written, which would lose one
|
|
217
|
+
// terminal's update. File-level durability (atomic rename, 0600, a lock
|
|
218
|
+
// file) is a separate concern owned by writeBridgeHubRegistry below. ---
|
|
219
|
+
|
|
220
|
+
function createMutex() {
|
|
221
|
+
let queue = Promise.resolve();
|
|
222
|
+
return (task) => {
|
|
223
|
+
const run = queue.then(task, task);
|
|
224
|
+
queue = run.then(() => {}, () => {});
|
|
225
|
+
return run;
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function readRequestBody(incoming, maxBytes) {
|
|
230
|
+
return new Promise((resolve, reject) => {
|
|
231
|
+
const chunks = [];
|
|
232
|
+
let total = 0;
|
|
233
|
+
let settled = false;
|
|
234
|
+
const onData = (chunk) => {
|
|
235
|
+
if (settled) return;
|
|
236
|
+
total += chunk.length;
|
|
237
|
+
if (total > maxBytes) {
|
|
238
|
+
settled = true;
|
|
239
|
+
incoming.off('data', onData);
|
|
240
|
+
incoming.resume();
|
|
241
|
+
reject(new BridgeHubServerError('BRIDGE_HUB_REQUEST_BODY_TOO_LARGE', 'registration request body exceeds limit'));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
chunks.push(chunk);
|
|
245
|
+
};
|
|
246
|
+
incoming.on('data', onData);
|
|
247
|
+
incoming.once('end', () => { if (!settled) { settled = true; resolve(Buffer.concat(chunks)); } });
|
|
248
|
+
incoming.once('error', (error) => { if (!settled) { settled = true; reject(error); } });
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// === File-based registry persistence ===
|
|
253
|
+
// Mirrors todo-dashboard-registry.mjs's withLock/atomicJson pattern
|
|
254
|
+
// (temp-file write + rename, 0600 mode, PID-liveness lock with stale
|
|
255
|
+
// takeover) without importing it — that module's `withLock` is private, and
|
|
256
|
+
// this hub registry lives in its own runtime directory keyed by
|
|
257
|
+
// LATTICE_HUB_RUNTIME_DIR rather than LATTICE_DASHBOARD_RUNTIME_DIR.
|
|
258
|
+
|
|
259
|
+
function hubRuntimeDir(env) {
|
|
260
|
+
const configured = env.LATTICE_HUB_RUNTIME_DIR;
|
|
261
|
+
return typeof configured === 'string' && path.isAbsolute(configured)
|
|
262
|
+
? configured : path.join(homedir(), '.lattice', 'hub');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function hubRuntimePaths(env) {
|
|
266
|
+
const root = hubRuntimeDir(env);
|
|
267
|
+
return { root, registry: path.join(root, 'terminals.json'), lock: path.join(root, 'registry.lock') };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function readJsonDocument(ref, missing) {
|
|
271
|
+
let bytes;
|
|
272
|
+
try { bytes = await readFile(ref, 'utf8'); } catch (error) {
|
|
273
|
+
if (error?.code === 'ENOENT') return missing;
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
try { return JSON.parse(bytes); } catch {
|
|
277
|
+
throw new BridgeHubServerError('BRIDGE_HUB_REGISTRY_FILE_INVALID', `hub registry JSON is invalid: ${ref}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function atomicJson(ref, value) {
|
|
282
|
+
const temporary = `${ref}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
|
|
283
|
+
await writeFile(temporary, `${JSON.stringify(value)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
284
|
+
await rename(temporary, ref);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function withFileLock(lockRef, action) {
|
|
288
|
+
for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt += 1) {
|
|
289
|
+
let handle;
|
|
290
|
+
try {
|
|
291
|
+
handle = await open(lockRef, 'wx', 0o600);
|
|
292
|
+
await handle.writeFile(`${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`);
|
|
293
|
+
try { return await action(); } finally {
|
|
294
|
+
await handle.close();
|
|
295
|
+
await rm(lockRef, { force: true });
|
|
296
|
+
}
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
299
|
+
try {
|
|
300
|
+
const lock = JSON.parse(await readFile(lockRef, 'utf8'));
|
|
301
|
+
let alive = Number.isSafeInteger(lock.pid) && lock.pid > 0;
|
|
302
|
+
if (alive) { try { process.kill(lock.pid, 0); } catch { alive = false; } }
|
|
303
|
+
if (!alive || Date.now() - Date.parse(lock.created_at) > LOCK_STALE_MS) {
|
|
304
|
+
await rm(lockRef, { force: true });
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
} catch (lockError) {
|
|
308
|
+
if (lockError?.code === 'ENOENT') continue;
|
|
309
|
+
}
|
|
310
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_WAIT_MS));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
throw new BridgeHubServerError('BRIDGE_HUB_REGISTRY_BUSY', 'hub registry lock timed out');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function validRegistryDocument(value) {
|
|
317
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
318
|
+
&& value.schema === HUB_REGISTRY_DOCUMENT_SCHEMA && Array.isArray(value.entries)
|
|
319
|
+
&& value.entries.every(validateBridgeHubRegistryEntry);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Read the persisted registry. A missing file is an empty registry, not an error. */
|
|
323
|
+
export async function readBridgeHubRegistry({ env = process.env } = {}) {
|
|
324
|
+
const refs = hubRuntimePaths(env);
|
|
325
|
+
const document = await readJsonDocument(refs.registry, { schema: HUB_REGISTRY_DOCUMENT_SCHEMA, entries: [] });
|
|
326
|
+
if (!validRegistryDocument(document)) {
|
|
327
|
+
throw new BridgeHubServerError('BRIDGE_HUB_REGISTRY_FILE_INVALID', 'hub registry file is invalid');
|
|
328
|
+
}
|
|
329
|
+
return document.entries;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Persist a registry snapshot atomically (temp file + rename, 0600, locked). */
|
|
333
|
+
export async function writeBridgeHubRegistry({ env = process.env, entries }) {
|
|
334
|
+
if (!Array.isArray(entries) || !entries.every(validateBridgeHubRegistryEntry)) {
|
|
335
|
+
throw new BridgeHubServerError('BRIDGE_HUB_REGISTRY_FILE_INVALID', 'hub registry entries are invalid');
|
|
336
|
+
}
|
|
337
|
+
const refs = hubRuntimePaths(env);
|
|
338
|
+
await mkdir(refs.root, { recursive: true, mode: 0o700 });
|
|
339
|
+
await withFileLock(refs.lock, async () => {
|
|
340
|
+
await atomicJson(refs.registry, { schema: HUB_REGISTRY_DOCUMENT_SCHEMA, entries });
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// === HTTP server ===
|
|
345
|
+
|
|
346
|
+
export async function startBridgeHubServer({
|
|
347
|
+
registryStore, port = 0, allowedHosts, env = process.env, fetchImpl = fetch,
|
|
348
|
+
now = () => new Date(), ttlMs = BRIDGE_HUB_HEARTBEAT_TTL_MS, listenAddress = LOOPBACK,
|
|
349
|
+
} = {}) {
|
|
350
|
+
// fetchImpl is accepted for DI parity with the rest of this codebase's bridge
|
|
351
|
+
// modules (see bridge-server.mjs's resolveUpstream) but this server's proxy
|
|
352
|
+
// path streams via node:http (see handleProjectProxy) so SSE is never
|
|
353
|
+
// buffered; fetchImpl has no current call site here.
|
|
354
|
+
void fetchImpl;
|
|
355
|
+
if (!(allowedHosts instanceof Set) || allowedHosts.size === 0) {
|
|
356
|
+
throw new BridgeHubServerError('BRIDGE_HUB_CONFIG_INVALID', 'allowedHosts must be a non-empty Set');
|
|
357
|
+
}
|
|
358
|
+
// Defaults to loopback, matching every other bridge/dashboard server in this
|
|
359
|
+
// codebase. bh5 needs this configurable: on the deployment host, Caddy runs
|
|
360
|
+
// in a Docker bridge network and can only reach the host's docker-bridge
|
|
361
|
+
// gateway address (e.g. 172.18.0.1), never a literal 127.0.0.1 bind — the
|
|
362
|
+
// same reason bridge-server.mjs's own listen address is configurable rather
|
|
363
|
+
// than hardcoded. The safety boundary stays `allowedHosts`, not the bind
|
|
364
|
+
// address; unlike bridge-config.mjs's DHCP-following `resolveBridgeListenAddress`,
|
|
365
|
+
// the hub's own host does not move, so that reconciliation machinery is not
|
|
366
|
+
// ported here.
|
|
367
|
+
if (typeof listenAddress !== 'string' || isIP(listenAddress) === 0) {
|
|
368
|
+
throw new BridgeHubServerError('BRIDGE_HUB_CONFIG_INVALID', 'listenAddress must be an IP literal');
|
|
369
|
+
}
|
|
370
|
+
const normalizedAllowedHosts = new Set([...allowedHosts].map(normalizeHubAllowedHost));
|
|
371
|
+
const store = registryStore ?? {
|
|
372
|
+
read: () => readBridgeHubRegistry({ env }),
|
|
373
|
+
write: (entries) => writeBridgeHubRegistry({ env, entries }),
|
|
374
|
+
};
|
|
375
|
+
const registrationLock = createMutex();
|
|
376
|
+
|
|
377
|
+
async function handleRegister(incoming, response) {
|
|
378
|
+
if (incoming.method !== 'POST') {
|
|
379
|
+
response.setHeader('allow', 'POST');
|
|
380
|
+
respondError(response, 405, 'BRIDGE_HUB_METHOD_NOT_ALLOWED');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
let body;
|
|
384
|
+
try { body = await readRequestBody(incoming, MAX_REGISTRATION_BODY_BYTES); } catch (error) {
|
|
385
|
+
respondError(response, error?.code === 'BRIDGE_HUB_REQUEST_BODY_TOO_LARGE' ? 413 : 400,
|
|
386
|
+
error?.code ?? 'BRIDGE_HUB_REQUEST_BODY_INVALID');
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
let request;
|
|
390
|
+
try { request = JSON.parse(body.toString('utf8')); } catch {
|
|
391
|
+
respondError(response, 400, 'BRIDGE_HUB_REQUEST_BODY_INVALID');
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const remoteAddress = incoming.socket.remoteAddress;
|
|
395
|
+
if (typeof remoteAddress !== 'string' || remoteAddress.length === 0) {
|
|
396
|
+
respondError(response, 500, 'BRIDGE_HUB_REMOTE_ADDRESS_UNAVAILABLE');
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
let result;
|
|
400
|
+
try {
|
|
401
|
+
result = await registrationLock(async () => {
|
|
402
|
+
const entries = await store.read();
|
|
403
|
+
const applied = applyBridgeHubRegistration({ registry: entries, request, remoteAddress, now: now() });
|
|
404
|
+
await store.write(applied.registry);
|
|
405
|
+
return applied.result;
|
|
406
|
+
});
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if (error instanceof BridgeHubProtocolError) {
|
|
409
|
+
const status = error.code === 'BRIDGE_HUB_PROJECT_CONFLICT' ? 409
|
|
410
|
+
: error.code === 'BRIDGE_HUB_REGISTRATION_INVALID' ? 400 : 500;
|
|
411
|
+
respondError(response, status, error.code, error.detail ?? null);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
respondError(response, 500, error?.code ?? 'BRIDGE_HUB_REGISTRATION_FAILED');
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
response.writeHead(200, JSON_HEADERS);
|
|
418
|
+
response.end(`${JSON.stringify(result)}\n`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function handleProjectsIndex(incoming, response) {
|
|
422
|
+
if (incoming.method !== 'GET') {
|
|
423
|
+
response.setHeader('allow', 'GET');
|
|
424
|
+
respondError(response, 405, 'BRIDGE_HUB_METHOD_NOT_ALLOWED');
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const entries = await store.read();
|
|
428
|
+
const projected = projectBridgeHubRegistry({ registry: entries, now: now(), ttlMs });
|
|
429
|
+
// Only the fields humans need for the public index are exposed; internal
|
|
430
|
+
// routing fields (terminal_id, address, port) stay server-side, matching
|
|
431
|
+
// the codebase's existing posture of not leaking topology to public
|
|
432
|
+
// responses (bridge-server.mjs's public bridge-health strips pid/address
|
|
433
|
+
// the same way).
|
|
434
|
+
const view = projected.map((entry) => ({
|
|
435
|
+
schema: HUB_PUBLIC_PROJECT_SCHEMA,
|
|
436
|
+
project_id: entry.project_id,
|
|
437
|
+
display_name: entry.display_name,
|
|
438
|
+
status: entry.status,
|
|
439
|
+
last_seen_at: entry.last_seen_at,
|
|
440
|
+
}));
|
|
441
|
+
const accept = String(incoming.headers.accept ?? '').toLowerCase();
|
|
442
|
+
if (accept.includes('application/json')) {
|
|
443
|
+
response.writeHead(200, JSON_HEADERS);
|
|
444
|
+
response.end(`${JSON.stringify(view)}\n`);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const html = hubIndexHtml(view);
|
|
448
|
+
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store',
|
|
449
|
+
'x-content-type-options': 'nosniff' });
|
|
450
|
+
response.end(html);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function handleProjectProxy(incoming, response, requestUrl, encodedProjectId, host) {
|
|
454
|
+
let projectId;
|
|
455
|
+
try { projectId = decodeURIComponent(encodedProjectId); } catch {
|
|
456
|
+
respondError(response, 400, 'BRIDGE_HUB_PROJECT_ID_INVALID');
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const entries = await store.read();
|
|
460
|
+
const projected = projectBridgeHubRegistry({ registry: entries, now: now(), ttlMs });
|
|
461
|
+
const entry = projected.find((candidate) => candidate.project_id === projectId);
|
|
462
|
+
if (entry === undefined) {
|
|
463
|
+
respondProjectStatus(incoming, response, 404, 'BRIDGE_HUB_PROJECT_NOT_FOUND', projectId, requestUrl,
|
|
464
|
+
'指定されたプロジェクトはhubに登録されていません。');
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (entry.status === 'offline') {
|
|
468
|
+
respondProjectStatus(incoming, response, 503, 'BRIDGE_HUB_PROJECT_OFFLINE', projectId, requestUrl,
|
|
469
|
+
'配信元の端末が現在オフラインです。heartbeatの再送または端末の起動状態を確認してください。');
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const targetHost = entry.address.includes(':') ? `[${entry.address}]` : entry.address;
|
|
473
|
+
const target = new URL(requestUrl, `http://${targetHost}:${entry.port}/`);
|
|
474
|
+
let upstreamResponse = null;
|
|
475
|
+
const proxyRequest = httpRequest(target, {
|
|
476
|
+
method: incoming.method,
|
|
477
|
+
headers: { ...hubForwardHeaders(incoming.headers), ...hubForwardedHeaders(incoming, host), host: target.host },
|
|
478
|
+
}, (incomingResponse) => {
|
|
479
|
+
upstreamResponse = incomingResponse;
|
|
480
|
+
response.writeHead(incomingResponse.statusCode ?? 502, hubForwardHeaders(incomingResponse.headers));
|
|
481
|
+
incomingResponse.once('error', () => response.destroy());
|
|
482
|
+
// Stream, do not buffer: this is the SSE-safety requirement (plan's
|
|
483
|
+
// "known trap" section). incomingResponse.pipe forwards each chunk as
|
|
484
|
+
// it arrives rather than waiting for the upstream response to end.
|
|
485
|
+
incomingResponse.pipe(response);
|
|
486
|
+
});
|
|
487
|
+
proxyRequest.once('error', (error) => respondError(response, 502,
|
|
488
|
+
error?.code === 'ECONNREFUSED' ? 'BRIDGE_HUB_UPSTREAM_REFUSED' : 'BRIDGE_HUB_PROXY_FAILED'));
|
|
489
|
+
incoming.once('aborted', () => proxyRequest.destroy());
|
|
490
|
+
response.once('close', () => {
|
|
491
|
+
if (!response.writableFinished) {
|
|
492
|
+
proxyRequest.destroy();
|
|
493
|
+
upstreamResponse?.destroy();
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
incoming.pipe(proxyRequest);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const handleRequest = async (incoming, response) => {
|
|
500
|
+
let host;
|
|
501
|
+
try { host = validatedHubHost(incoming.headers.host); } catch (error) {
|
|
502
|
+
respondError(response, 400, error?.code ?? 'BRIDGE_HOST_INVALID');
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (!normalizedAllowedHosts.has(host.hostname)) {
|
|
506
|
+
respondError(response, 421, 'BRIDGE_HOST_NOT_ALLOWED');
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
let requestUrl;
|
|
510
|
+
try { requestUrl = validatedHubRequestTarget(incoming.url); } catch (error) {
|
|
511
|
+
respondError(response, 400, error?.code ?? 'BRIDGE_HUB_REQUEST_TARGET_INVALID');
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const rawPath = requestUrl.split('?', 1)[0];
|
|
515
|
+
if (rawPath === '/__lattice/hub/register') { await handleRegister(incoming, response); return; }
|
|
516
|
+
if (rawPath === '/projects/') { await handleProjectsIndex(incoming, response); return; }
|
|
517
|
+
const match = PROJECT_ROUTE.exec(rawPath);
|
|
518
|
+
if (match !== null) { await handleProjectProxy(incoming, response, requestUrl, match[1], host); return; }
|
|
519
|
+
respondError(response, 404, 'BRIDGE_HUB_ROUTE_NOT_FOUND');
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
const server = createServer((incoming, response) => {
|
|
523
|
+
handleRequest(incoming, response).catch((error) => respondError(response, 500,
|
|
524
|
+
error?.code ?? 'BRIDGE_HUB_REQUEST_FAILED'));
|
|
525
|
+
});
|
|
526
|
+
await new Promise((resolve, reject) => {
|
|
527
|
+
server.once('error', reject);
|
|
528
|
+
server.listen({ host: listenAddress, port }, resolve);
|
|
529
|
+
});
|
|
530
|
+
const boundAddress = server.address();
|
|
531
|
+
const actualPort = typeof boundAddress === 'object' && boundAddress !== null ? boundAddress.port : port;
|
|
532
|
+
let closed = false;
|
|
533
|
+
return Object.freeze({
|
|
534
|
+
host: listenAddress,
|
|
535
|
+
port: actualPort,
|
|
536
|
+
close: async () => {
|
|
537
|
+
if (closed) return;
|
|
538
|
+
closed = true;
|
|
539
|
+
const completion = new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
540
|
+
server.closeAllConnections?.();
|
|
541
|
+
await completion;
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
}
|