superdev-cli 0.1.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/.claude-plugin/marketplace.json +33 -0
- package/.claude-plugin/plugin.json +21 -0
- package/.codex-plugin/plugin.json +27 -0
- package/CODE_OF_CONDUCT.md +109 -0
- package/CONTRIBUTING.md +205 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +1051 -0
- package/SECURITY.md +135 -0
- package/THIRD-PARTY-NOTICES.md +253 -0
- package/hooks/hooks.json +61 -0
- package/hooks/run.mjs +88 -0
- package/package.json +65 -0
- package/references/confidentiality.md +48 -0
- package/references/evidence-and-risk.md +73 -0
- package/references/operating-model.md +105 -0
- package/references/platform-capabilities.md +51 -0
- package/references/project-record.md +91 -0
- package/references/provider-contracts.md +102 -0
- package/scripts/doctor/doctor.mjs +539 -0
- package/scripts/privacy/scan-history.mjs +163 -0
- package/scripts/privacy/scan.mjs +368 -0
- package/scripts/validate/README.md +94 -0
- package/scripts/validate/common.mjs +149 -0
- package/scripts/validate/data-model.mjs +225 -0
- package/scripts/validate/dependencies.mjs +69 -0
- package/scripts/validate/docs-templates.mjs +99 -0
- package/scripts/validate/footprint.mjs +78 -0
- package/scripts/validate/imports.mjs +61 -0
- package/scripts/validate/manifests.mjs +173 -0
- package/scripts/validate/markdown.mjs +123 -0
- package/scripts/validate/migrations.mjs +187 -0
- package/scripts/validate/no-tests.mjs +136 -0
- package/scripts/validate/privacy.mjs +115 -0
- package/scripts/validate/record-links.mjs +127 -0
- package/scripts/validate/skill-commands.mjs +190 -0
- package/scripts/validate/skills.mjs +112 -0
- package/scripts/validate/specification.mjs +88 -0
- package/scripts/validate/style.mjs +78 -0
- package/scripts/validate/validate-all.mjs +175 -0
- package/skills/debug/SKILL.md +68 -0
- package/skills/decision/SKILL.md +99 -0
- package/skills/docs/SKILL.md +86 -0
- package/skills/docs/assets/fragments/api/events.md +14 -0
- package/skills/docs/assets/fragments/api/graphql.md +14 -0
- package/skills/docs/assets/fragments/api/local-only.md +13 -0
- package/skills/docs/assets/fragments/api/rest.md +13 -0
- package/skills/docs/assets/fragments/api/rpc.md +13 -0
- package/skills/docs/assets/fragments/async/event-bus.md +13 -0
- package/skills/docs/assets/fragments/async/none.md +11 -0
- package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
- package/skills/docs/assets/fragments/async/queue.md +13 -0
- package/skills/docs/assets/fragments/async/scheduler.md +13 -0
- package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
- package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
- package/skills/docs/assets/fragments/data/document.md +13 -0
- package/skills/docs/assets/fragments/data/external-saas.md +13 -0
- package/skills/docs/assets/fragments/data/key-value.md +13 -0
- package/skills/docs/assets/fragments/data/none.md +12 -0
- package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
- package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
- package/skills/docs/assets/fragments/env/conventional.md +13 -0
- package/skills/docs/assets/fragments/env/envx.md +13 -0
- package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
- package/skills/docs/assets/fragments/env/none.md +11 -0
- package/skills/docs/assets/fragments/ui/api-only.md +13 -0
- package/skills/docs/assets/fragments/ui/cli.md +14 -0
- package/skills/docs/assets/fragments/ui/desktop.md +14 -0
- package/skills/docs/assets/fragments/ui/mobile.md +14 -0
- package/skills/docs/assets/fragments/ui/web.md +14 -0
- package/skills/docs/assets/templates/adr.md +62 -0
- package/skills/docs/assets/templates/api.md +30 -0
- package/skills/docs/assets/templates/architecture.md +52 -0
- package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
- package/skills/docs/assets/templates/compliance.md +29 -0
- package/skills/docs/assets/templates/data-schema.md +43 -0
- package/skills/docs/assets/templates/feature.md +43 -0
- package/skills/docs/assets/templates/foundations.md +55 -0
- package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
- package/skills/docs/assets/templates/module-inventory.md +19 -0
- package/skills/docs/assets/templates/module.md +50 -0
- package/skills/docs/assets/templates/nfr.md +22 -0
- package/skills/docs/assets/templates/observability.md +28 -0
- package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
- package/skills/docs/assets/templates/project-summary.md +44 -0
- package/skills/docs/assets/templates/roles-permissions.md +26 -0
- package/skills/docs/assets/templates/test-plan.md +23 -0
- package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
- package/skills/docs/references/adr-authoring.md +24 -0
- package/skills/docs/references/apis-and-data.md +23 -0
- package/skills/docs/references/capability-fragments.md +25 -0
- package/skills/docs/references/change-tracking.md +62 -0
- package/skills/docs/references/diagrams.md +31 -0
- package/skills/docs/references/discovery.md +59 -0
- package/skills/docs/references/edge-cases.md +45 -0
- package/skills/docs/references/foundations-modules.md +18 -0
- package/skills/docs/references/ingestion.md +52 -0
- package/skills/docs/references/initialize-adopt.md +25 -0
- package/skills/docs/references/module-decomposition.md +38 -0
- package/skills/docs/references/profiles.md +60 -0
- package/skills/docs/references/quality-attributes.md +27 -0
- package/skills/docs/references/reverse-engineer.md +21 -0
- package/skills/docs/references/spec-depths.md +31 -0
- package/skills/docs/references/summarize.md +18 -0
- package/skills/docs/references/surfaces-and-actions.md +24 -0
- package/skills/docs/references/validation.md +47 -0
- package/skills/docs/references/workflows-and-jobs.md +25 -0
- package/skills/docs/scripts/ingest.mjs +984 -0
- package/skills/docs/scripts/profile-detect.mjs +299 -0
- package/skills/docs/scripts/screen.mjs +96 -0
- package/skills/docs/scripts/template-lint.mjs +143 -0
- package/skills/docs/scripts/validate-docs.mjs +363 -0
- package/skills/doctor/SKILL.md +167 -0
- package/skills/feature/SKILL.md +132 -0
- package/skills/init/SKILL.md +137 -0
- package/skills/project/SKILL.md +261 -0
- package/skills/project/references/commands.md +213 -0
- package/skills/resume/SKILL.md +79 -0
- package/skills/review/SKILL.md +91 -0
- package/skills/status/SKILL.md +85 -0
- package/skills/task/SKILL.md +183 -0
- package/src/cli/product-map.mjs +468 -0
- package/src/cli/render.mjs +544 -0
- package/src/cli.mjs +2774 -0
- package/src/cloud/crypto.mjs +118 -0
- package/src/cloud/merge.mjs +186 -0
- package/src/cloud/policy.mjs +115 -0
- package/src/cloud/sync.mjs +512 -0
- package/src/cloud/transport.mjs +116 -0
- package/src/db/connect.mjs +189 -0
- package/src/db/maintenance.mjs +419 -0
- package/src/db/migrate.mjs +188 -0
- package/src/db/migrations/001_initial.sql +1024 -0
- package/src/db/migrations/002_docs_coverage.sql +126 -0
- package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
- package/src/db/migrations/004_task_categories.sql +59 -0
- package/src/db/migrations/005_executable_evidence.sql +32 -0
- package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
- package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
- package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
- package/src/db/migrations/009_retired_documents.sql +54 -0
- package/src/db/migrations/010_test_plan_evidence.sql +20 -0
- package/src/db/migrations/011_criterion_waiver.sql +13 -0
- package/src/db/migrations/012_synchronization.sql +56 -0
- package/src/db/store.mjs +327 -0
- package/src/decisions/record.mjs +167 -0
- package/src/docs/proposals.mjs +871 -0
- package/src/docs/render.mjs +1424 -0
- package/src/docs/templates.mjs +1281 -0
- package/src/features/acceptance.mjs +315 -0
- package/src/features/specify.mjs +248 -0
- package/src/init/discovery.mjs +901 -0
- package/src/init/index.mjs +784 -0
- package/src/init/questions.mjs +562 -0
- package/src/memory/benchmark.mjs +171 -0
- package/src/memory/capture.mjs +135 -0
- package/src/memory/consolidate.mjs +255 -0
- package/src/memory/index.mjs +810 -0
- package/src/model/ids.mjs +185 -0
- package/src/model/screening.mjs +150 -0
- package/src/model/toolkit.mjs +258 -0
- package/src/model/vocabulary.mjs +190 -0
- package/src/product/assumptions.mjs +120 -0
- package/src/product/changes.mjs +180 -0
- package/src/product/test-plans.mjs +183 -0
- package/src/progress/index.mjs +1364 -0
- package/src/runtime/harness.mjs +264 -0
- package/src/runtime/hooks.mjs +1021 -0
- package/src/runtime/identity.mjs +305 -0
- package/src/runtime/resume.mjs +343 -0
- package/src/runtime/session.mjs +679 -0
- package/src/runtime/version.mjs +315 -0
- package/src/service/assets/control-center.html +206 -0
- package/src/service/assets/control-center.manifest.json +7 -0
- package/src/service/manage.mjs +542 -0
- package/src/service/mutations.mjs +860 -0
- package/src/service/read-model.mjs +1557 -0
- package/src/service/server.mjs +783 -0
- package/src/tasks/categories.mjs +154 -0
- package/src/tasks/derive.mjs +977 -0
- package/src/tasks/lifecycle.mjs +830 -0
- package/src/verify/index.mjs +236 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
// The local HTTP service. Brief section 14.2, routes fixed by MODULE_CONTRACT.md.
|
|
2
|
+
//
|
|
3
|
+
// One Node process per project, bound to the loopback interface, serving the
|
|
4
|
+
// compiled control center and the read and mutation API that backs it. No
|
|
5
|
+
// external network is involved at any point.
|
|
6
|
+
//
|
|
7
|
+
// Change detection polls `max(sequence)` from activity_events on a fresh
|
|
8
|
+
// readonly connection about once a second. Two cheaper approaches do not work on
|
|
9
|
+
// this engine and were measured, not assumed: `PRAGMA data_version` is absent,
|
|
10
|
+
// and watching the database file misses commits because they land in the
|
|
11
|
+
// write-ahead log first. A poll on a fresh connection is the only signal that is
|
|
12
|
+
// actually correct, and it costs about 0.3 ms.
|
|
13
|
+
|
|
14
|
+
import { createServer } from "node:http";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
import { query } from "../db/store.mjs";
|
|
21
|
+
import * as readModel from "./read-model.mjs";
|
|
22
|
+
import { applyMutation, ALLOWED_ACTIONS } from "./mutations.mjs";
|
|
23
|
+
|
|
24
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const BUNDLE = join(HERE, "assets", "control-center.html");
|
|
26
|
+
const DATA_PLACEHOLDER = "/*__SUPERDEV_DATA__*/null";
|
|
27
|
+
|
|
28
|
+
export const DEFAULTS = {
|
|
29
|
+
host: "127.0.0.1",
|
|
30
|
+
port: 4317,
|
|
31
|
+
/** How many ports above the requested one to try before giving up. */
|
|
32
|
+
portScan: 20,
|
|
33
|
+
/** Largest mutation body accepted, in bytes. */
|
|
34
|
+
bodyLimit: 1024 * 1024,
|
|
35
|
+
/** How often the change poller asks the database for its latest sequence. */
|
|
36
|
+
pollMs: 1000,
|
|
37
|
+
heartbeatMs: 15_000,
|
|
38
|
+
/** Change events kept for a reconnecting client. Older than this is a resync. */
|
|
39
|
+
replayBuffer: 256,
|
|
40
|
+
/** No request and no events client for this long shuts the service down. */
|
|
41
|
+
idleTimeoutMs: 30 * 60 * 1000,
|
|
42
|
+
idleCheckMs: 30_000,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Consecutive change-poll failures before the stream admits it has gone blind.
|
|
47
|
+
*
|
|
48
|
+
* One failure is another process mid-commit and means nothing. Five in a row,
|
|
49
|
+
* at one second apart, is a fault that is not clearing, and by then a quiet
|
|
50
|
+
* stream is indistinguishable from a quiet project.
|
|
51
|
+
*/
|
|
52
|
+
const DEGRADE_AFTER = 5;
|
|
53
|
+
|
|
54
|
+
const VERSION = (() => {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(readFileSync(join(HERE, "..", "..", "package.json"), "utf8")).version ?? "0.0.0";
|
|
57
|
+
} catch {
|
|
58
|
+
return "0.0.0";
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
|
|
62
|
+
// ------------------------------------------------------------------- helpers
|
|
63
|
+
|
|
64
|
+
// `<` and `>` would let a value close the script element early, and U+2028 and
|
|
65
|
+
// U+2029 are line terminators in JavaScript but ordinary characters in JSON.
|
|
66
|
+
const SCRIPT_UNSAFE = {
|
|
67
|
+
"<": "\\u003c",
|
|
68
|
+
">": "\\u003e",
|
|
69
|
+
"\u2028": "\\u2028",
|
|
70
|
+
"\u2029": "\\u2029",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Make a JSON string safe to place inside a script element. */
|
|
74
|
+
export const escapeForScript = (jsonText) =>
|
|
75
|
+
jsonText.replace(/[<>\u2028\u2029]/g, (c) => SCRIPT_UNSAFE[c]);
|
|
76
|
+
|
|
77
|
+
const NO_STORE = { "cache-control": "no-store" };
|
|
78
|
+
|
|
79
|
+
function sendJson(res, status, body) {
|
|
80
|
+
const text = JSON.stringify(body);
|
|
81
|
+
res.writeHead(status, {
|
|
82
|
+
...NO_STORE,
|
|
83
|
+
"content-type": "application/json; charset=utf-8",
|
|
84
|
+
"content-length": Buffer.byteLength(text),
|
|
85
|
+
});
|
|
86
|
+
res.end(text);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sendHtml(res, status, html, headOnly = false) {
|
|
90
|
+
res.writeHead(status, {
|
|
91
|
+
...NO_STORE,
|
|
92
|
+
"content-type": "text/html; charset=utf-8",
|
|
93
|
+
"content-length": Buffer.byteLength(html),
|
|
94
|
+
});
|
|
95
|
+
// A HEAD reply carries the headers and no body, including the length the
|
|
96
|
+
// body would have had.
|
|
97
|
+
res.end(headOnly ? undefined : html);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Turn any thrown value into the error envelope the interface knows how to
|
|
102
|
+
* render: a stable code and a sentence that says what to do next.
|
|
103
|
+
*/
|
|
104
|
+
function errorBody(err) {
|
|
105
|
+
const code = typeof err?.code === "string" && err.code.startsWith("E_") ? err.code : "E_INTERNAL";
|
|
106
|
+
// An error with no stable code is a defect, not a refusal. The response says
|
|
107
|
+
// so in one sentence; the stack belongs in the log, which is what the log is
|
|
108
|
+
// for and the only place anyone can find it afterwards.
|
|
109
|
+
if (code === "E_INTERNAL") process.stderr.write(`${err?.stack ?? err}\n`);
|
|
110
|
+
const status = {
|
|
111
|
+
E_NOT_FOUND: 404,
|
|
112
|
+
E_UNKNOWN_ACTION: 400,
|
|
113
|
+
E_INVALID_PAYLOAD: 400,
|
|
114
|
+
E_INVALID_TRANSITION: 409,
|
|
115
|
+
E_VERSION_CONFLICT: 409,
|
|
116
|
+
E_ALREADY_CLAIMED: 409,
|
|
117
|
+
E_NOT_CLAIMED: 409,
|
|
118
|
+
E_OPEN_SUBTASKS: 409,
|
|
119
|
+
E_EVIDENCE_MISSING: 409,
|
|
120
|
+
E_EVIDENCE_FAILING: 409,
|
|
121
|
+
E_ACCEPTANCE_UNMET: 409,
|
|
122
|
+
E_TASK_WITHOUT_CONTRACT: 409,
|
|
123
|
+
E_ENABLING_WITHOUT_TARGET: 409,
|
|
124
|
+
E_APPEND_ONLY: 409,
|
|
125
|
+
E_REASON_REQUIRED: 400,
|
|
126
|
+
E_UNKNOWN_TARGET: 400,
|
|
127
|
+
E_STATUS_VIA_TRANSITION: 400,
|
|
128
|
+
E_STYLE_EM_DASH: 400,
|
|
129
|
+
E_STYLE_EMOJI: 400,
|
|
130
|
+
E_SECRET_SHAPED: 400,
|
|
131
|
+
E_ABSOLUTE_HOME_PATH: 400,
|
|
132
|
+
E_MODEL_REASONING_FIELD: 400,
|
|
133
|
+
E_DB_LOCKED: 503,
|
|
134
|
+
E_INTERNAL: 500,
|
|
135
|
+
// A module error carrying a stable code is a refusal the caller can act on,
|
|
136
|
+
// never a crash, so an unlisted one is a conflict rather than a 500.
|
|
137
|
+
}[code] ?? 409;
|
|
138
|
+
return {
|
|
139
|
+
status,
|
|
140
|
+
body: {
|
|
141
|
+
code,
|
|
142
|
+
message:
|
|
143
|
+
code === "E_INTERNAL"
|
|
144
|
+
? "The service hit an unexpected problem handling that request. Check the service log, then try again."
|
|
145
|
+
: String(err.message ?? code),
|
|
146
|
+
...(err?.detail !== undefined ? { details: err.detail } : null),
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A request is same origin when it declares no origin (a direct fetch from the
|
|
153
|
+
* page or a command line client) or declares one of the addresses this service
|
|
154
|
+
* is actually reachable on. Anything else is another site trying to drive the
|
|
155
|
+
* local service through the browser.
|
|
156
|
+
*/
|
|
157
|
+
const LOOPBACK = ["127.0.0.1", "localhost", "[::1]", "::1"];
|
|
158
|
+
|
|
159
|
+
function sameOrigin(req, port) {
|
|
160
|
+
const origin = req.headers.origin;
|
|
161
|
+
if (!origin) return true;
|
|
162
|
+
let url;
|
|
163
|
+
try {
|
|
164
|
+
url = new URL(origin);
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
return LOOPBACK.includes(url.hostname) && url.port === String(port);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Whether the request addressed this service by a loopback name.
|
|
173
|
+
*
|
|
174
|
+
* This is the DNS rebinding defence, and it is the reason the origin check is
|
|
175
|
+
* not enough on its own. A page on another site can point a hostname it owns at
|
|
176
|
+
* 127.0.0.1, and once it resolves there the browser treats the service as that
|
|
177
|
+
* site's own origin: it will send no `Origin` header at all on a plain read, so
|
|
178
|
+
* `sameOrigin` returns true and the project database answers a stranger. What
|
|
179
|
+
* the attacker cannot forge is `Host`, which still carries the name they used.
|
|
180
|
+
* A service bound to loopback is only ever legitimately addressed as loopback.
|
|
181
|
+
*/
|
|
182
|
+
function addressedAsLoopback(req) {
|
|
183
|
+
const header = req.headers.host;
|
|
184
|
+
// No Host at all is HTTP/1.0 or a handmade request, never a browser.
|
|
185
|
+
if (!header) return true;
|
|
186
|
+
const hostname = header.startsWith("[")
|
|
187
|
+
? header.slice(0, header.indexOf("]") + 1)
|
|
188
|
+
: header.split(":")[0];
|
|
189
|
+
return LOOPBACK.includes(hostname);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function readBody(req, limit) {
|
|
193
|
+
return new Promise((resolve, reject) => {
|
|
194
|
+
let size = 0;
|
|
195
|
+
const chunks = [];
|
|
196
|
+
req.on("data", (chunk) => {
|
|
197
|
+
size += chunk.length;
|
|
198
|
+
if (size > limit) {
|
|
199
|
+
// Stop reading rather than tearing the socket down, so the refusal has
|
|
200
|
+
// a chance to reach the caller instead of arriving as a reset.
|
|
201
|
+
req.pause();
|
|
202
|
+
const err = new Error(
|
|
203
|
+
`The request body is larger than ${Math.round(limit / 1024)} KB, which this service refuses. Send a smaller change.`,
|
|
204
|
+
);
|
|
205
|
+
err.code = "E_PAYLOAD_TOO_LARGE";
|
|
206
|
+
reject(err);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
chunks.push(chunk);
|
|
210
|
+
});
|
|
211
|
+
req.on("error", reject);
|
|
212
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const FALLBACK_PAGE = `<!doctype html>
|
|
217
|
+
<html lang="en">
|
|
218
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
219
|
+
<title>Superdev</title>
|
|
220
|
+
<style>
|
|
221
|
+
:root { color-scheme: light dark; }
|
|
222
|
+
body { font: 16px/1.6 system-ui, sans-serif; margin: 0 auto; max-width: 42rem; padding: 3rem 1.5rem; }
|
|
223
|
+
code { background: rgba(127,127,127,.18); border-radius: .25rem; padding: .1em .35em; }
|
|
224
|
+
h1 { font-size: 1.4rem; }
|
|
225
|
+
</style>
|
|
226
|
+
</head>
|
|
227
|
+
<body>
|
|
228
|
+
<h1>The Superdev control center has not been built yet</h1>
|
|
229
|
+
<p>The service is running and the API is answering. The compiled interface is not
|
|
230
|
+
in this installation, so there is nothing to show here yet.</p>
|
|
231
|
+
<p>Build it from the repository root:</p>
|
|
232
|
+
<p><code>npm run ui:build</code></p>
|
|
233
|
+
<p>Then reload this page. The API is available meanwhile at
|
|
234
|
+
<code>/api/overview</code>, and <code>/health</code> reports which project this
|
|
235
|
+
service is pointed at.</p>
|
|
236
|
+
</body>
|
|
237
|
+
</html>
|
|
238
|
+
`;
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------- the service
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Start the service for one project.
|
|
244
|
+
*
|
|
245
|
+
* Returns the port it actually bound (which may not be the one requested) and a
|
|
246
|
+
* `close` that shuts the listener, the timers and every open events client down.
|
|
247
|
+
*/
|
|
248
|
+
export async function serve(root, options = {}) {
|
|
249
|
+
const settings = { ...DEFAULTS, ...options };
|
|
250
|
+
const instanceToken = options.instanceToken ?? randomUUID();
|
|
251
|
+
|
|
252
|
+
const clients = new Set();
|
|
253
|
+
/** Recent change events, newest last, for a client that reconnects. */
|
|
254
|
+
const replay = [];
|
|
255
|
+
let lastSequence = 0;
|
|
256
|
+
let lastRequestAt = Date.now();
|
|
257
|
+
let closing = false;
|
|
258
|
+
/** Mutations running right now, so the change poller can stand out of the way. */
|
|
259
|
+
let writesInFlight = 0;
|
|
260
|
+
/** Consecutive change-poll failures, and whether clients have been told. */
|
|
261
|
+
let pollFailures = 0;
|
|
262
|
+
let degraded = false;
|
|
263
|
+
|
|
264
|
+
const server = createServer((req, res) => {
|
|
265
|
+
lastRequestAt = Date.now();
|
|
266
|
+
handle(req, res).catch((err) => {
|
|
267
|
+
if (res.headersSent) {
|
|
268
|
+
res.end();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const { status, body } = errorBody(err);
|
|
272
|
+
sendJson(res, status, body);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
async function handle(req, res) {
|
|
277
|
+
const url = new URL(req.url, `http://${req.headers.host ?? "127.0.0.1"}`);
|
|
278
|
+
const path = url.pathname.replace(/\/+$/, "") || "/";
|
|
279
|
+
|
|
280
|
+
// Ahead of every route, including the reads and the stream. The write path
|
|
281
|
+
// guarded itself and the read path did not, which made the whole project
|
|
282
|
+
// database readable by any page that could rebind a name to this port. A
|
|
283
|
+
// read surface that leaks the project is not a smaller problem than a write
|
|
284
|
+
// surface that changes it.
|
|
285
|
+
if (settings.host === "127.0.0.1" && !addressedAsLoopback(req)) {
|
|
286
|
+
return sendJson(res, 403, {
|
|
287
|
+
code: "E_FOREIGN_HOST",
|
|
288
|
+
message:
|
|
289
|
+
"That request reached this service under a name that is not loopback. The Superdev service answers only to 127.0.0.1 and localhost on this machine.",
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (path === "/health") {
|
|
294
|
+
return sendJson(res, 200, { ok: true, projectRoot: root, instanceToken, version: VERSION });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Reads and the stream carry project content, so they answer the same
|
|
298
|
+
// question the write path asks: did this come from our own page.
|
|
299
|
+
if (path.startsWith("/api/") && !sameOrigin(req, boundPort)) {
|
|
300
|
+
return sendJson(res, 403, {
|
|
301
|
+
code: "E_CROSS_ORIGIN",
|
|
302
|
+
message:
|
|
303
|
+
"That request came from another origin. The Superdev service only answers its own page on this machine.",
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (path === "/api/events") {
|
|
308
|
+
if (req.method !== "GET") return sendJson(res, 405, methodNotAllowed("GET"));
|
|
309
|
+
return openEventStream(req, res, url);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (path === "/api/mutations") {
|
|
313
|
+
if (req.method !== "POST") return sendJson(res, 405, methodNotAllowed("POST"));
|
|
314
|
+
return handleMutation(req, res);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (path.startsWith("/api/")) {
|
|
318
|
+
if (req.method !== "GET") return sendJson(res, 405, methodNotAllowed("GET"));
|
|
319
|
+
return handleRead(req, res, url, path);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// HEAD is how a person or a script checks the service is serving the
|
|
323
|
+
// interface at all. Answering 404 to it says the page does not exist.
|
|
324
|
+
if (path === "/" && (req.method === "GET" || req.method === "HEAD")) {
|
|
325
|
+
return sendPage(res, req.method === "HEAD");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return sendJson(res, 404, {
|
|
329
|
+
code: "E_NOT_FOUND",
|
|
330
|
+
message: `${req.method} ${path} is not part of this service. The routes are /health, /api/events, /api/mutations and the /api read routes.`,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const methodNotAllowed = (allowed) => ({
|
|
335
|
+
code: "E_METHOD_NOT_ALLOWED",
|
|
336
|
+
message: `That route only answers ${allowed}.`,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ------------------------------------------------------------------- reads
|
|
340
|
+
|
|
341
|
+
async function handleRead(req, res, url, path) {
|
|
342
|
+
const builder = readModel.routes[path];
|
|
343
|
+
if (builder) return sendJson(res, 200, await builder(root));
|
|
344
|
+
|
|
345
|
+
const featureMatch = /^\/api\/features\/([^/]+)$/.exec(path);
|
|
346
|
+
if (featureMatch) {
|
|
347
|
+
return sendJson(res, 200, await readModel.feature(root, decodeURIComponent(featureMatch[1])));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (path === "/api/activity") {
|
|
351
|
+
const numeric = (name) => {
|
|
352
|
+
const raw = url.searchParams.get(name);
|
|
353
|
+
if (raw === null) return undefined;
|
|
354
|
+
const value = Number(raw);
|
|
355
|
+
return Number.isFinite(value) ? value : undefined;
|
|
356
|
+
};
|
|
357
|
+
return sendJson(
|
|
358
|
+
res,
|
|
359
|
+
200,
|
|
360
|
+
await readModel.activity(root, {
|
|
361
|
+
before: numeric("before"),
|
|
362
|
+
limit: numeric("limit"),
|
|
363
|
+
search: url.searchParams.get("search") ?? undefined,
|
|
364
|
+
}),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return sendJson(res, 404, {
|
|
369
|
+
code: "E_NOT_FOUND",
|
|
370
|
+
message: `${path} is not one of this service's read routes.`,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// --------------------------------------------------------------- mutations
|
|
375
|
+
|
|
376
|
+
async function handleMutation(req, res) {
|
|
377
|
+
if (!sameOrigin(req, boundPort)) {
|
|
378
|
+
return sendJson(res, 403, {
|
|
379
|
+
code: "E_CROSS_ORIGIN",
|
|
380
|
+
message:
|
|
381
|
+
"That request came from another origin. The Superdev service only accepts changes from its own page on this machine.",
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
const type = String(req.headers["content-type"] ?? "");
|
|
385
|
+
if (!type.startsWith("application/json")) {
|
|
386
|
+
return sendJson(res, 415, {
|
|
387
|
+
code: "E_UNSUPPORTED_MEDIA_TYPE",
|
|
388
|
+
message: "A mutation has to be sent as application/json.",
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
let raw;
|
|
393
|
+
try {
|
|
394
|
+
raw = await readBody(req, settings.bodyLimit);
|
|
395
|
+
} catch (err) {
|
|
396
|
+
const oversized = err.code === "E_PAYLOAD_TOO_LARGE";
|
|
397
|
+
if (oversized) res.setHeader("connection", "close");
|
|
398
|
+
sendJson(res, oversized ? 413 : 400, {
|
|
399
|
+
code: err.code ?? "E_BAD_REQUEST",
|
|
400
|
+
message: String(err.message),
|
|
401
|
+
});
|
|
402
|
+
if (oversized) req.destroy();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
let parsed;
|
|
407
|
+
try {
|
|
408
|
+
parsed = JSON.parse(raw || "{}");
|
|
409
|
+
} catch {
|
|
410
|
+
return sendJson(res, 400, {
|
|
411
|
+
code: "E_INVALID_PAYLOAD",
|
|
412
|
+
message: "The request body was not valid JSON.",
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
416
|
+
return sendJson(res, 400, {
|
|
417
|
+
code: "E_INVALID_PAYLOAD",
|
|
418
|
+
message: `A mutation is an object with an action and a payload. Accepted actions: ${ALLOWED_ACTIONS.join(", ")}.`,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
let data;
|
|
423
|
+
try {
|
|
424
|
+
data = await runMutation(parsed.action, parsed.payload);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
const { status, body } = errorBody(err);
|
|
427
|
+
return sendJson(res, status, body);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const revision = (await query(root, (db) => db.value("SELECT max(sequence) FROM activity_events"))) ?? 0;
|
|
431
|
+
return sendJson(res, 200, {
|
|
432
|
+
data,
|
|
433
|
+
meta: {
|
|
434
|
+
revision,
|
|
435
|
+
lastEventSequence: revision,
|
|
436
|
+
generatedAt: new Date().toISOString(),
|
|
437
|
+
stale: false,
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The engine refuses to open a write connection while this same process still
|
|
444
|
+
* holds a readonly one, and it reports that as "Resource is read-only" rather
|
|
445
|
+
* than as a lock, so the retry in connect.mjs does not cover it. This service
|
|
446
|
+
* is the one place that reads and writes from a single process, so it owns the
|
|
447
|
+
* problem: the poller stands down while a mutation runs, and a mutation that
|
|
448
|
+
* still lands on top of an in-flight read backs off and tries again. Retrying
|
|
449
|
+
* is safe because the refusal arrives on BEGIN IMMEDIATE, before any statement.
|
|
450
|
+
*/
|
|
451
|
+
async function runMutation(action, payload) {
|
|
452
|
+
writesInFlight += 1;
|
|
453
|
+
try {
|
|
454
|
+
for (let attempt = 0; ; attempt++) {
|
|
455
|
+
try {
|
|
456
|
+
return await applyMutation(root, action, payload);
|
|
457
|
+
} catch (err) {
|
|
458
|
+
if (attempt >= 5 || !/read[- ]?only/i.test(String(err?.message ?? ""))) throw err;
|
|
459
|
+
await new Promise((r) => setTimeout(r, 12 * (attempt + 1)));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
} finally {
|
|
463
|
+
writesInFlight -= 1;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ------------------------------------------------------------------ events
|
|
468
|
+
|
|
469
|
+
function write(client, text) {
|
|
470
|
+
if (client.res.writableEnded) return;
|
|
471
|
+
client.res.write(text);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function openEventStream(req, res, url) {
|
|
475
|
+
res.writeHead(200, {
|
|
476
|
+
...NO_STORE,
|
|
477
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
478
|
+
connection: "keep-alive",
|
|
479
|
+
// Proxies that buffer would defeat the whole point of the stream.
|
|
480
|
+
"x-accel-buffering": "no",
|
|
481
|
+
});
|
|
482
|
+
// A reverse proxy or an idle-socket timeout would otherwise cut a stream
|
|
483
|
+
// that is deliberately quiet between commits.
|
|
484
|
+
res.socket?.setNoDelay(true);
|
|
485
|
+
res.socket?.setTimeout(0);
|
|
486
|
+
|
|
487
|
+
const client = { res };
|
|
488
|
+
clients.add(client);
|
|
489
|
+
|
|
490
|
+
const header = req.headers["last-event-id"];
|
|
491
|
+
const fromQuery = url.searchParams.get("lastEventId");
|
|
492
|
+
const resumeFrom = Number(header ?? fromQuery ?? 0);
|
|
493
|
+
const resume = Number.isFinite(resumeFrom) && resumeFrom > 0 ? Math.floor(resumeFrom) : 0;
|
|
494
|
+
|
|
495
|
+
write(client, `event: hello\ndata: ${JSON.stringify({ sequence: lastSequence })}\n\n`);
|
|
496
|
+
|
|
497
|
+
// Arriving into a stream that is already blind. Saying hello and nothing
|
|
498
|
+
// else would let this client draw the same wrong conclusion as the ones
|
|
499
|
+
// that were here when it broke.
|
|
500
|
+
if (degraded) {
|
|
501
|
+
write(
|
|
502
|
+
client,
|
|
503
|
+
`event: degraded\ndata: ${JSON.stringify({
|
|
504
|
+
sequence: lastSequence,
|
|
505
|
+
reason: "the change poller is not reaching the project database",
|
|
506
|
+
})}\n\n`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (resume > 0 && resume < lastSequence) {
|
|
511
|
+
const oldest = replay.length ? replay[0].sequence : lastSequence + 1;
|
|
512
|
+
if (resume + 1 < oldest) {
|
|
513
|
+
// Their position fell out of the buffer, so an incremental catch-up
|
|
514
|
+
// would silently skip changes. Ask for a full reload instead.
|
|
515
|
+
write(client, `event: resync\ndata: ${JSON.stringify({ sequence: lastSequence })}\n\n`);
|
|
516
|
+
} else {
|
|
517
|
+
for (const event of replay) {
|
|
518
|
+
if (event.sequence > resume) write(client, frame(event));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const done = () => clients.delete(client);
|
|
524
|
+
req.on("close", done);
|
|
525
|
+
req.on("error", done);
|
|
526
|
+
res.on("close", done);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const frame = (event) =>
|
|
530
|
+
`id: ${event.sequence}\nevent: change\ndata: ${JSON.stringify(event)}\n\n`;
|
|
531
|
+
|
|
532
|
+
function broadcast(event) {
|
|
533
|
+
replay.push(event);
|
|
534
|
+
if (replay.length > settings.replayBuffer) replay.splice(0, replay.length - settings.replayBuffer);
|
|
535
|
+
broadcastRaw(frame(event));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** For frames that are about the stream itself rather than a recorded change. */
|
|
539
|
+
function broadcastRaw(text) {
|
|
540
|
+
for (const client of clients) write(client, text);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Announce only what actually moved. Emitting on every poll would make the
|
|
545
|
+
* interface refetch once a second forever, which is exactly the busy-work
|
|
546
|
+
* this stream exists to avoid.
|
|
547
|
+
*/
|
|
548
|
+
async function poll() {
|
|
549
|
+
if (closing || writesInFlight) return;
|
|
550
|
+
let rows;
|
|
551
|
+
try {
|
|
552
|
+
rows = await query(root, async (db) => {
|
|
553
|
+
const latest = (await db.value("SELECT max(sequence) FROM activity_events")) ?? 0;
|
|
554
|
+
if (latest <= lastSequence) return null;
|
|
555
|
+
return db.all(
|
|
556
|
+
`SELECT sequence, event_type, task_id, feature_id, summary
|
|
557
|
+
FROM activity_events WHERE sequence > ? ORDER BY sequence LIMIT ?`,
|
|
558
|
+
lastSequence,
|
|
559
|
+
settings.replayBuffer,
|
|
560
|
+
);
|
|
561
|
+
});
|
|
562
|
+
} catch (error) {
|
|
563
|
+
// A locked or half-written database is a transient state during another
|
|
564
|
+
// process's commit, and the next tick sees the committed result. A fault
|
|
565
|
+
// that keeps returning is not that. Swallowing it forever left the socket
|
|
566
|
+
// open and the interface calling itself live while it had gone blind, so
|
|
567
|
+
// after enough consecutive failures the stream says so.
|
|
568
|
+
pollFailures += 1;
|
|
569
|
+
if (pollFailures === DEGRADE_AFTER) {
|
|
570
|
+
degraded = true;
|
|
571
|
+
broadcastRaw(
|
|
572
|
+
`event: degraded\ndata: ${JSON.stringify({
|
|
573
|
+
sequence: lastSequence,
|
|
574
|
+
reason: String(error?.message ?? error).slice(0, 200),
|
|
575
|
+
})}\n\n`,
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (pollFailures > 0) {
|
|
581
|
+
pollFailures = 0;
|
|
582
|
+
if (degraded) {
|
|
583
|
+
degraded = false;
|
|
584
|
+
broadcastRaw(`event: recovered\ndata: ${JSON.stringify({ sequence: lastSequence })}\n\n`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (!rows?.length) return;
|
|
588
|
+
for (const row of rows) {
|
|
589
|
+
lastSequence = row.sequence;
|
|
590
|
+
broadcast({
|
|
591
|
+
sequence: row.sequence,
|
|
592
|
+
eventType: row.event_type,
|
|
593
|
+
summary: row.summary,
|
|
594
|
+
taskId: row.task_id,
|
|
595
|
+
featureId: row.feature_id,
|
|
596
|
+
scopes: scopesFor(row.event_type),
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ------------------------------------------------------------------- pages
|
|
602
|
+
|
|
603
|
+
async function sendPage(res, headOnly = false) {
|
|
604
|
+
if (!existsSync(BUNDLE)) return sendHtml(res, 200, FALLBACK_PAGE, headOnly);
|
|
605
|
+
let html = readFileSync(BUNDLE, "utf8");
|
|
606
|
+
let bootstrap = "null";
|
|
607
|
+
try {
|
|
608
|
+
// The first paint has real data rather than a spinner. A failure here is
|
|
609
|
+
// never fatal: the page fetches the same route on mount anyway.
|
|
610
|
+
bootstrap = escapeForScript(JSON.stringify(await readModel.overview(root)));
|
|
611
|
+
} catch {
|
|
612
|
+
bootstrap = "null";
|
|
613
|
+
}
|
|
614
|
+
if (html.includes(DATA_PLACEHOLDER)) html = html.replace(DATA_PLACEHOLDER, bootstrap);
|
|
615
|
+
return sendHtml(res, 200, html, headOnly);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ------------------------------------------------------------------ timers
|
|
619
|
+
|
|
620
|
+
const boundPort = await listen(server, settings.host, settings.port, settings.portScan);
|
|
621
|
+
|
|
622
|
+
lastSequence =
|
|
623
|
+
(await query(root, (db) => db.value("SELECT max(sequence) FROM activity_events")).catch(() => 0)) ?? 0;
|
|
624
|
+
|
|
625
|
+
const pollTimer = setInterval(() => {
|
|
626
|
+
poll().catch(() => {});
|
|
627
|
+
}, settings.pollMs);
|
|
628
|
+
pollTimer.unref();
|
|
629
|
+
|
|
630
|
+
const heartbeat = setInterval(() => {
|
|
631
|
+
for (const client of clients) write(client, `: keepalive ${Date.now()}\n\n`);
|
|
632
|
+
}, settings.heartbeatMs);
|
|
633
|
+
heartbeat.unref();
|
|
634
|
+
|
|
635
|
+
let idleTimer = null;
|
|
636
|
+
if (settings.idleTimeoutMs > 0) {
|
|
637
|
+
// A short idle window has to be checked more often than the default, or the
|
|
638
|
+
// configured timeout would silently round up to the check interval.
|
|
639
|
+
const idleCheck = Math.max(1000, Math.min(settings.idleCheckMs, Math.floor(settings.idleTimeoutMs / 4)));
|
|
640
|
+
idleTimer = setInterval(() => {
|
|
641
|
+
// Idle means nobody asked for anything and nobody is listening. A tab
|
|
642
|
+
// holding the events stream open counts as someone using the service.
|
|
643
|
+
if (clients.size) return;
|
|
644
|
+
if (Date.now() - lastRequestAt < settings.idleTimeoutMs) return;
|
|
645
|
+
close().catch(() => {});
|
|
646
|
+
}, idleCheck);
|
|
647
|
+
idleTimer.unref();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function close() {
|
|
651
|
+
if (closing) return;
|
|
652
|
+
closing = true;
|
|
653
|
+
clearInterval(pollTimer);
|
|
654
|
+
clearInterval(heartbeat);
|
|
655
|
+
if (idleTimer) clearInterval(idleTimer);
|
|
656
|
+
for (const client of clients) {
|
|
657
|
+
try {
|
|
658
|
+
client.res.end();
|
|
659
|
+
} catch {
|
|
660
|
+
// The socket is already gone, which is the state we wanted anyway.
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
clients.clear();
|
|
664
|
+
await new Promise((resolve) => server.close(resolve));
|
|
665
|
+
if (typeof settings.onClose === "function") settings.onClose();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return {
|
|
669
|
+
port: boundPort,
|
|
670
|
+
host: settings.host,
|
|
671
|
+
url: `http://${settings.host}:${boundPort}`,
|
|
672
|
+
instanceToken,
|
|
673
|
+
version: VERSION,
|
|
674
|
+
projectRoot: root,
|
|
675
|
+
close,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Which views a change touches, so a listener refetches only what it shows. */
|
|
680
|
+
function scopesFor(eventType) {
|
|
681
|
+
const type = String(eventType ?? "");
|
|
682
|
+
if (type.startsWith("task_")) return ["overview", "tasks", "activity", "product"];
|
|
683
|
+
if (type.startsWith("session_")) return ["overview", "team", "activity"];
|
|
684
|
+
if (type.startsWith("decision_")) return ["decisions", "activity", "readiness"];
|
|
685
|
+
if (type.startsWith("documentation_")) return ["readiness", "activity"];
|
|
686
|
+
if (type === "scope_changed") return ["discovery", "product", "overview", "activity"];
|
|
687
|
+
if (type === "verification_attached") return ["tasks", "features", "readiness", "activity"];
|
|
688
|
+
return ["overview", "activity", "product", "readiness"];
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Bind the requested port, then scan upward. Another project's service on the
|
|
693
|
+
* default port is the ordinary case, not an error, so the second one moves up
|
|
694
|
+
* rather than refusing to start.
|
|
695
|
+
*/
|
|
696
|
+
function listen(server, host, port, scan) {
|
|
697
|
+
return new Promise((resolve, reject) => {
|
|
698
|
+
let candidate = port;
|
|
699
|
+
let attempts = 0;
|
|
700
|
+
|
|
701
|
+
const onError = (err) => {
|
|
702
|
+
if (err.code !== "EADDRINUSE" || attempts >= scan) {
|
|
703
|
+
server.removeListener("error", onError);
|
|
704
|
+
reject(
|
|
705
|
+
err.code === "EADDRINUSE"
|
|
706
|
+
? Object.assign(
|
|
707
|
+
new Error(
|
|
708
|
+
`Ports ${port} to ${port + scan} are all in use on ${host}. Stop another service or start this one with an explicit port.`,
|
|
709
|
+
),
|
|
710
|
+
{ code: "E_NO_FREE_PORT" },
|
|
711
|
+
)
|
|
712
|
+
: err,
|
|
713
|
+
);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
attempts += 1;
|
|
717
|
+
candidate += 1;
|
|
718
|
+
server.listen(candidate, host);
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
server.on("error", onError);
|
|
722
|
+
server.once("listening", () => {
|
|
723
|
+
server.removeListener("error", onError);
|
|
724
|
+
resolve(server.address().port);
|
|
725
|
+
});
|
|
726
|
+
server.listen(candidate, host);
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// --------------------------------------------------------------- entry point
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Started detached by manage.mjs. It writes the lock file with the port it
|
|
734
|
+
* actually bound so the launcher, and later `superdev services`, can find it,
|
|
735
|
+
* and removes the lock on the way out so a crash is the only way to leave one
|
|
736
|
+
* behind.
|
|
737
|
+
*/
|
|
738
|
+
async function main(argv) {
|
|
739
|
+
const arg = (name, fallback = null) => {
|
|
740
|
+
const index = argv.indexOf(`--${name}`);
|
|
741
|
+
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
|
742
|
+
};
|
|
743
|
+
const root = arg("root", process.cwd());
|
|
744
|
+
const port = Number(arg("port", DEFAULTS.port));
|
|
745
|
+
const host = arg("host", DEFAULTS.host);
|
|
746
|
+
const idle = Number(arg("idle-timeout", DEFAULTS.idleTimeoutMs));
|
|
747
|
+
|
|
748
|
+
const { publish, retract } = await import("./manage.mjs");
|
|
749
|
+
const service = await serve(root, {
|
|
750
|
+
port: Number.isFinite(port) ? port : DEFAULTS.port,
|
|
751
|
+
host,
|
|
752
|
+
idleTimeoutMs: Number.isFinite(idle) ? idle : DEFAULTS.idleTimeoutMs,
|
|
753
|
+
instanceToken: process.env.SUPERDEV_INSTANCE_TOKEN || undefined,
|
|
754
|
+
onClose: () => retract(root),
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
publish(root, {
|
|
758
|
+
pid: process.pid,
|
|
759
|
+
port: service.port,
|
|
760
|
+
host: service.host,
|
|
761
|
+
projectRoot: root,
|
|
762
|
+
instanceToken: service.instanceToken,
|
|
763
|
+
version: service.version,
|
|
764
|
+
startedAt: new Date().toISOString(),
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
const shutdown = () => {
|
|
768
|
+
service.close().then(
|
|
769
|
+
() => process.exit(0),
|
|
770
|
+
() => process.exit(1),
|
|
771
|
+
);
|
|
772
|
+
};
|
|
773
|
+
process.on("SIGTERM", shutdown);
|
|
774
|
+
process.on("SIGINT", shutdown);
|
|
775
|
+
process.stdout.write(`superdev service listening on ${service.url} for ${root}\n`);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
779
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
780
|
+
process.stderr.write(`${err?.message ?? err}\n`);
|
|
781
|
+
process.exit(1);
|
|
782
|
+
});
|
|
783
|
+
}
|