rcf-lite 0.0.1 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +288 -0
- package/LICENSE +202 -0
- package/README.md +92 -4
- package/bin/rcf-verify.js +122 -0
- package/bin/rcf.js +174 -0
- package/bin/view-supervisor-child.mjs +14 -0
- package/fixtures/canary-manifest.json +103 -0
- package/guidance/README.md +25 -0
- package/guidance/build-cycle-playbook.md +492 -0
- package/guidance/build-cycle.md +40 -0
- package/guidance/document-model.md +45 -0
- package/guidance/elicitation-playbook.md +442 -0
- package/guidance/harness-template.md +246 -0
- package/guidance/managed/README.md +63 -0
- package/guidance/managed/agent-instructions-block.hash +1 -0
- package/guidance/managed/agent-instructions-block.md +220 -0
- package/guidance/managed/legacy-fragment-hashes.json +22 -0
- package/guidance/manifest.json +21 -0
- package/guidance/overview.md +48 -0
- package/guidance/persona-programme.md +44 -0
- package/package.json +69 -8
- package/rcf/adrs/adr-001.json +25 -0
- package/rcf/adrs/adr-002.json +25 -0
- package/rcf/adrs/adr-003.json +25 -0
- package/rcf/adrs/adr-004.json +20 -0
- package/rcf/adrs/adr-005.json +20 -0
- package/rcf/adrs/adr-006.json +25 -0
- package/rcf/adrs/adr-007.json +25 -0
- package/rcf/adrs/adr-008.json +25 -0
- package/rcf/adrs/adr-009.json +25 -0
- package/rcf/build-sequence.json +11 -0
- package/rcf/code-nodes/cn-001.json +14 -0
- package/rcf/code-nodes/cn-002.json +14 -0
- package/rcf/code-nodes/cn-003.json +15 -0
- package/rcf/code-nodes/cn-004.json +17 -0
- package/rcf/code-nodes/cn-005.json +19 -0
- package/rcf/code-nodes/cn-006.json +17 -0
- package/rcf/code-nodes/cn-007.json +16 -0
- package/rcf/code-nodes/cn-008.json +19 -0
- package/rcf/code-nodes/cn-009.json +16 -0
- package/rcf/code-nodes/cn-010.json +18 -0
- package/rcf/code-nodes/cn-011.json +18 -0
- package/rcf/code-nodes/cn-012.json +18 -0
- package/rcf/code-nodes/cn-013.json +19 -0
- package/rcf/code-nodes/cn-014.json +16 -0
- package/rcf/code-nodes/cn-015.json +16 -0
- package/rcf/code-nodes/cn-016.json +16 -0
- package/rcf/code-nodes/cn-017.json +16 -0
- package/rcf/code-nodes/cn-018.json +15 -0
- package/rcf/code-nodes/cn-019.json +16 -0
- package/rcf/code-nodes/cn-020.json +16 -0
- package/rcf/code-nodes/cn-021.json +16 -0
- package/rcf/code-nodes/cn-022.json +16 -0
- package/rcf/code-nodes/cn-023.json +16 -0
- package/rcf/code-nodes/cn-024.json +16 -0
- package/rcf/code-nodes/cn-025.json +16 -0
- package/rcf/code-nodes/cn-026.json +16 -0
- package/rcf/code-nodes/cn-027.json +16 -0
- package/rcf/code-nodes/cn-028.json +16 -0
- package/rcf/code-nodes/cn-029.json +19 -0
- package/rcf/code-nodes/cn-030.json +14 -0
- package/rcf/code-nodes/cn-031.json +14 -0
- package/rcf/code-nodes/cn-032.json +14 -0
- package/rcf/code-nodes/cn-033.json +14 -0
- package/rcf/code-nodes/cn-034.json +14 -0
- package/rcf/code-nodes/cn-035.json +14 -0
- package/rcf/code-nodes/cn-036.json +14 -0
- package/rcf/code-nodes/cn-037.json +14 -0
- package/rcf/code-nodes/cn-038.json +14 -0
- package/rcf/code-nodes/cn-039.json +14 -0
- package/rcf/code-nodes/cn-040.json +14 -0
- package/rcf/code-nodes/cn-041.json +14 -0
- package/rcf/code-nodes/cn-042.json +14 -0
- package/rcf/code-nodes/cn-043.json +14 -0
- package/rcf/code-nodes/cn-044.json +14 -0
- package/rcf/code-nodes/cn-045.json +14 -0
- package/rcf/code-nodes/cn-046.json +14 -0
- package/rcf/code-nodes/cn-047.json +14 -0
- package/rcf/code-nodes/cn-048.json +14 -0
- package/rcf/code-nodes/cn-049.json +14 -0
- package/rcf/code-nodes/cn-050.json +14 -0
- package/rcf/code-nodes/cn-051.json +14 -0
- package/rcf/code-nodes/cn-052.json +14 -0
- package/rcf/code-nodes/cn-053.json +14 -0
- package/rcf/code-nodes/cn-054.json +14 -0
- package/rcf/code-nodes/cn-055.json +14 -0
- package/rcf/code-nodes/cn-056.json +14 -0
- package/rcf/code-nodes/cn-057.json +14 -0
- package/rcf/fbs/fbs-001.json +49 -0
- package/rcf/fbs/fbs-002.json +42 -0
- package/rcf/fbs/fbs-003.json +37 -0
- package/rcf/fbs/fbs-004.json +39 -0
- package/rcf/fbs/fbs-005.json +38 -0
- package/rcf/fbs/fbs-006.json +48 -0
- package/rcf/fbs/fbs-007.json +39 -0
- package/rcf/fbs/fbs-008.json +40 -0
- package/rcf/fbs/fbs-009.json +36 -0
- package/rcf/fbs/fbs-010.json +41 -0
- package/rcf/fbs/fbs-011.json +36 -0
- package/rcf/fbs/fbs-012.json +46 -0
- package/rcf/fbs/fbs-013.json +42 -0
- package/rcf/fbs/fbs-014.json +49 -0
- package/rcf/fbs/fbs-015.json +32 -0
- package/rcf/manifest.json +17 -0
- package/rcf/prd.json +47 -0
- package/rcf/requirements/req-001.json +19 -0
- package/rcf/requirements/req-002.json +19 -0
- package/rcf/requirements/req-003.json +19 -0
- package/rcf/requirements/req-004.json +19 -0
- package/rcf/requirements/req-005.json +19 -0
- package/rcf/requirements/req-006.json +19 -0
- package/rcf/requirements/req-007.json +19 -0
- package/rcf/requirements/req-008.json +19 -0
- package/rcf/requirements/req-009.json +19 -0
- package/rcf/tacs/tac-001.json +45 -0
- package/rcf/tacs/tac-002.json +109 -0
- package/rcf/tacs/tac-003.json +40 -0
- package/rcf/tacs/tac-004.json +51 -0
- package/rcf/tacs/tac-005.json +52 -0
- package/rcf/tacs/tac-006.json +104 -0
- package/rcf/tacs/tac-007.json +38 -0
- package/rcf/tacs/tac-008.json +51 -0
- package/rcf/tad.json +51 -0
- package/rcf/test-suites/PENDING.md +23 -0
- package/rcf/test-suites/ts-001.json +38 -0
- package/rcf/test-suites/ts-002.json +38 -0
- package/rcf/test-suites/ts-003.json +43 -0
- package/rcf/test-suites/ts-004.json +44 -0
- package/rcf/test-suites/ts-005.json +30 -0
- package/rcf/test-suites/ts-006.json +36 -0
- package/rcf/test-suites/ts-007.json +43 -0
- package/rcf/test-suites/ts-008.json +37 -0
- package/rcf/test-suites/ts-009.json +38 -0
- package/rcf/test-suites/ts-010.json +38 -0
- package/rcf/test-suites/ts-011.json +44 -0
- package/rcf/test-suites/ts-012.json +36 -0
- package/rcf/test-suites/ts-013.json +38 -0
- package/rcf/test-suites/ts-014.json +38 -0
- package/rcf/test-suites/ts-015.json +38 -0
- package/rcf/test-suites/ts-016.json +37 -0
- package/rcf/test-suites/ts-017.json +38 -0
- package/rcf/test-suites/ts-018.json +38 -0
- package/rcf/test-suites/ts-019.json +37 -0
- package/rcf/test-suites/ts-020.json +46 -0
- package/rcf/test-suites/ts-021.json +46 -0
- package/rcf/test-suites/ts-022.json +46 -0
- package/rcf/test-suites/ts-023.json +46 -0
- package/rcf/test-suites/ts-024.json +46 -0
- package/rcf/test-suites/ts-025.json +52 -0
- package/rcf/user-stories/us-101.json +40 -0
- package/rcf/user-stories/us-102.json +40 -0
- package/rcf/user-stories/us-201.json +40 -0
- package/rcf/user-stories/us-202.json +40 -0
- package/rcf/user-stories/us-203.json +32 -0
- package/rcf/user-stories/us-301.json +40 -0
- package/rcf/user-stories/us-302.json +40 -0
- package/rcf/user-stories/us-303.json +40 -0
- package/rcf/user-stories/us-304.json +40 -0
- package/rcf/user-stories/us-401.json +40 -0
- package/rcf/user-stories/us-402.json +40 -0
- package/rcf/user-stories/us-403.json +40 -0
- package/rcf/user-stories/us-501.json +40 -0
- package/rcf/user-stories/us-502.json +40 -0
- package/rcf/user-stories/us-503.json +40 -0
- package/rcf/user-stories/us-601.json +40 -0
- package/rcf/user-stories/us-602.json +40 -0
- package/rcf/user-stories/us-701.json +40 -0
- package/rcf/user-stories/us-702.json +40 -0
- package/rcf/user-stories/us-801.json +49 -0
- package/rcf/user-stories/us-802.json +49 -0
- package/rcf/user-stories/us-803.json +49 -0
- package/rcf/user-stories/us-804.json +49 -0
- package/rcf/user-stories/us-805.json +49 -0
- package/rcf/user-stories/us-901.json +40 -0
- package/src/.gitkeep +0 -0
- package/src/browser-verify/auth-smoke.js +109 -0
- package/src/browser-verify/index.js +29 -0
- package/src/browser-verify/invariants.js +336 -0
- package/src/browser-verify/manifest-writer.js +189 -0
- package/src/browser-verify/runner.js +171 -0
- package/src/build/bundle.js +198 -0
- package/src/build/formatters/json.js +18 -0
- package/src/build/formatters/markdown.js +406 -0
- package/src/build/index.js +14 -0
- package/src/build/mark.js +177 -0
- package/src/build/queue.js +285 -0
- package/src/cli/browser-verify.js +231 -0
- package/src/cli/build.js +584 -0
- package/src/cli/coverage.js +219 -0
- package/src/cli/create.js +355 -0
- package/src/cli/delete.js +127 -0
- package/src/cli/design.js +353 -0
- package/src/cli/doctor.js +548 -0
- package/src/cli/fbs.js +171 -0
- package/src/cli/finalise.js +377 -0
- package/src/cli/guidance.js +206 -0
- package/src/cli/help.js +156 -0
- package/src/cli/impact.js +119 -0
- package/src/cli/init.js +282 -0
- package/src/cli/intake.js +153 -0
- package/src/cli/link.js +128 -0
- package/src/cli/mcp.js +160 -0
- package/src/cli/preflight.js +220 -0
- package/src/cli/read.js +162 -0
- package/src/cli/req-baseline.js +269 -0
- package/src/cli/req-classify.js +135 -0
- package/src/cli/review.js +295 -0
- package/src/cli/test-suite.js +216 -0
- package/src/cli/trace.js +172 -0
- package/src/cli/ui-baseline.js +292 -0
- package/src/cli/ui-classify.js +108 -0
- package/src/cli/update.js +197 -0
- package/src/cli/validate.js +168 -0
- package/src/cli/view.js +452 -0
- package/src/core/baseline-catalog/data/auth.json +42 -0
- package/src/core/baseline-catalog/data/http-api.json +42 -0
- package/src/core/baseline-catalog/data/notifications.json +33 -0
- package/src/core/baseline-catalog/data/persistence.json +33 -0
- package/src/core/baseline-catalog/data/web-ui.json +60 -0
- package/src/core/baseline-catalog/index.js +121 -0
- package/src/core/errors/index.js +167 -0
- package/src/core/fixtures/register-canary/canary-prompt-01.json +18 -0
- package/src/core/fixtures/register-canary/canary-prompt-02.json +21 -0
- package/src/core/fixtures/register-canary/canary-prompt-03.json +17 -0
- package/src/core/isolation/index.js +60 -0
- package/src/core/mcp/framing.js +103 -0
- package/src/core/mcp/index.js +8 -0
- package/src/core/mcp/server.js +228 -0
- package/src/core/patterns/register-canary.js +209 -0
- package/src/core/patterns/req-shapes.js +158 -0
- package/src/core/patterns/services.js +358 -0
- package/src/core/patterns/ui-shapes.js +166 -0
- package/src/core/store/cn-resolve.js +134 -0
- package/src/core/store/derive-deps.js +93 -0
- package/src/core/store/ids.js +78 -0
- package/src/core/store/index.js +20 -0
- package/src/core/store/init.js +255 -0
- package/src/core/store/loader.js +211 -0
- package/src/core/store/tp-resolve.js +176 -0
- package/src/core/store/validator.js +191 -0
- package/src/core/store/walker.js +898 -0
- package/src/core/store/writer.js +1849 -0
- package/src/design/index.js +11 -0
- package/src/design/writer.js +271 -0
- package/src/finalise/detect.js +129 -0
- package/src/finalise/index.js +17 -0
- package/src/finalise/ingest.js +119 -0
- package/src/finalise/install.js +119 -0
- package/src/finalise/ship-without-verified.js +131 -0
- package/src/finalise/spawn.js +84 -0
- package/src/intake/fidelity.js +105 -0
- package/src/intake/index.js +6 -0
- package/src/intake/manifest-writer.js +100 -0
- package/src/intake/orchestrator.js +138 -0
- package/src/intake/validate.js +80 -0
- package/src/mcp/map-errors.js +131 -0
- package/src/mcp/prompts.js +49 -0
- package/src/mcp/resources.js +244 -0
- package/src/mcp/tools.js +1204 -0
- package/src/preflight/design-shapes.js +185 -0
- package/src/preflight/index.js +90 -0
- package/src/preflight/manifest-writer.js +264 -0
- package/src/preflight/scanner.js +206 -0
- package/src/preflight/secrets.js +134 -0
- package/src/preflight/session.js +246 -0
- package/src/query/attestation.js +285 -0
- package/src/query/coverage.js +308 -0
- package/src/query/formatters/json.js +21 -0
- package/src/query/formatters/mermaid.js +209 -0
- package/src/query/formatters/table.js +203 -0
- package/src/query/impact.js +173 -0
- package/src/query/index.js +9 -0
- package/src/query/trace.js +345 -0
- package/src/register-canary/fixture-loader.js +87 -0
- package/src/register-canary/index.js +10 -0
- package/src/register-canary/record-writer.js +132 -0
- package/src/register-canary/runner.js +156 -0
- package/src/req-baseline/gate.js +86 -0
- package/src/req-baseline/index.js +27 -0
- package/src/req-baseline/open-candidates.js +143 -0
- package/src/req-baseline/opt-out.js +195 -0
- package/src/req-baseline/sweep.js +230 -0
- package/src/req-detection/classifier.js +181 -0
- package/src/req-detection/index.js +9 -0
- package/src/req-detection/persist.js +55 -0
- package/src/review/index.js +325 -0
- package/src/review/mutation.js +117 -0
- package/src/review/ui-baseline-drift.js +138 -0
- package/src/server/index.js +178 -0
- package/src/server/routes.js +110 -0
- package/src/server/sse.js +118 -0
- package/src/setup/agent-setup.js +362 -0
- package/src/setup/identity-seed.js +104 -0
- package/src/setup/knowledge-seed.js +123 -0
- package/src/setup/managed-block.js +193 -0
- package/src/setup/managed-gitignore.js +166 -0
- package/src/setup/managed-markers.js +49 -0
- package/src/ui-baseline/defaults.js +119 -0
- package/src/ui-baseline/index.js +25 -0
- package/src/ui-baseline/manifest-writer.js +282 -0
- package/src/ui-baseline/session.js +178 -0
- package/src/ui-detection/classifier.js +192 -0
- package/src/verify/chain/index.js +190 -0
- package/src/verify/cli/cleanup.js +61 -0
- package/src/verify/cli/help.js +56 -0
- package/src/verify/cli/mcp.js +98 -0
- package/src/verify/cli/provision.js +71 -0
- package/src/verify/cli/report.js +71 -0
- package/src/verify/cli/run.js +155 -0
- package/src/verify/engine/brief.js +87 -0
- package/src/verify/engine/index.js +177 -0
- package/src/verify/engine/launcher.js +307 -0
- package/src/verify/mcp/tools.js +107 -0
- package/src/verify/profile/index.js +146 -0
- package/src/verify/provision/index.js +256 -0
- package/src/verify/report/index.js +139 -0
- package/src/verify/report/renderer.js +118 -0
- package/src/verify/verdict/index.js +246 -0
- package/src/view/doc-renderers/adr.js +44 -0
- package/src/view/doc-renderers/build-sequence.js +40 -0
- package/src/view/doc-renderers/fbs.js +128 -0
- package/src/view/doc-renderers/helpers.js +159 -0
- package/src/view/doc-renderers/index.js +12 -0
- package/src/view/doc-renderers/prd.js +45 -0
- package/src/view/doc-renderers/req.js +43 -0
- package/src/view/doc-renderers/tac.js +38 -0
- package/src/view/doc-renderers/tad.js +74 -0
- package/src/view/doc-renderers/test-suite.js +45 -0
- package/src/view/doc-renderers/user-story.js +63 -0
- package/src/view/html-page.js +462 -0
- package/src/view/index.js +63 -0
- package/src/view/live-client.js +338 -0
- package/src/view/mermaid-diagram.js +178 -0
- package/src/view/style.css +735 -0
- package/src/view/tree-model.js +152 -0
- package/src/view/vendored/mermaid.min.js +2607 -0
- package/src/view-supervisor/index.js +26 -0
- package/src/view-supervisor/logs.js +32 -0
- package/src/view-supervisor/manifest-writer.js +178 -0
- package/src/view-supervisor/persist-until.js +85 -0
- package/src/view-supervisor/supervisor.js +276 -0
- package/src/watch/index.js +152 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// HTTP + SSE server for the live-view surface. Wires the watch primitive
|
|
2
|
+
// to the walker to the SSE hub: any change under `rcf/` triggers a full
|
|
3
|
+
// re-walk (D4) and a `tree-update` broadcast (D12). Binds 127.0.0.1 only
|
|
4
|
+
// (D11); EADDRINUSE is a hard failure (D10).
|
|
5
|
+
//
|
|
6
|
+
// Public surface:
|
|
7
|
+
// startServer({ projectRoot, port, host, log, heartbeatMs, watchImpl })
|
|
8
|
+
// -> Promise<{ url, port, close, hub, currentState }>
|
|
9
|
+
// close() drains SSE with a `shutdown` event and releases the port. The
|
|
10
|
+
// caller (bin/rcf-view.js) is responsible for the 2s force-exit budget
|
|
11
|
+
// on top; the server itself does not `process.exit`.
|
|
12
|
+
|
|
13
|
+
import { createServer } from 'node:http';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { LIVE_CLIENT_PATH, STYLE_CSS_PATH, VENDORED_MERMAID_PATH, renderModelToPage } from '../view/index.js';
|
|
17
|
+
import { watch as defaultWatch } from '../watch/index.js';
|
|
18
|
+
import { createRouter } from './routes.js';
|
|
19
|
+
import { createSseHub } from './sse.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {object} StartServerOptions
|
|
23
|
+
* @property {string} projectRoot
|
|
24
|
+
* @property {number} [port=4373]
|
|
25
|
+
* @property {string} [host='127.0.0.1']
|
|
26
|
+
* @property {number} [heartbeatMs=30000]
|
|
27
|
+
* @property {number} [debounceMs=50]
|
|
28
|
+
* @property {(line: string) => void} [log] - stderr sink
|
|
29
|
+
* @property {typeof defaultWatch} [watchImpl] - injectable watch primitive for tests
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {StartServerOptions} args
|
|
34
|
+
* @returns {Promise<{
|
|
35
|
+
* url: string,
|
|
36
|
+
* port: number,
|
|
37
|
+
* host: string,
|
|
38
|
+
* close: () => Promise<void>,
|
|
39
|
+
* currentState: () => { version: number, fullPageHtml: string, contentHtml: string } | null,
|
|
40
|
+
* sse: ReturnType<typeof createSseHub>,
|
|
41
|
+
* rewalk: () => Promise<void>,
|
|
42
|
+
* }>}
|
|
43
|
+
*/
|
|
44
|
+
export async function startServer(args) {
|
|
45
|
+
const projectRoot = args.projectRoot;
|
|
46
|
+
const port = typeof args.port === 'number' ? args.port : 4373;
|
|
47
|
+
const host = typeof args.host === 'string' ? args.host : '127.0.0.1';
|
|
48
|
+
const heartbeatMs = typeof args.heartbeatMs === 'number' ? args.heartbeatMs : 30000;
|
|
49
|
+
const debounceMs = typeof args.debounceMs === 'number' ? args.debounceMs : 50;
|
|
50
|
+
const log = typeof args.log === 'function' ? args.log : () => {};
|
|
51
|
+
const watchImpl = typeof args.watchImpl === 'function' ? args.watchImpl : defaultWatch;
|
|
52
|
+
|
|
53
|
+
/** @type {{ version: number, fullPageHtml: string, contentHtml: string, errors: import('#core/errors').RcfError[] } | null} */
|
|
54
|
+
let state = null;
|
|
55
|
+
let version = 0;
|
|
56
|
+
let rewalkInFlight = null;
|
|
57
|
+
let closed = false;
|
|
58
|
+
|
|
59
|
+
const sse = createSseHub({ heartbeatMs, log });
|
|
60
|
+
|
|
61
|
+
async function rewalk() {
|
|
62
|
+
if (closed) return;
|
|
63
|
+
if (rewalkInFlight) {
|
|
64
|
+
// Coalesce concurrent walks. The trailing one will pick up the
|
|
65
|
+
// final on-disk state; a middle one adds nothing.
|
|
66
|
+
return rewalkInFlight;
|
|
67
|
+
}
|
|
68
|
+
rewalkInFlight = (async () => {
|
|
69
|
+
try {
|
|
70
|
+
const result = await renderModelToPage({ projectRoot });
|
|
71
|
+
if (closed) return;
|
|
72
|
+
version += 1;
|
|
73
|
+
state = {
|
|
74
|
+
version,
|
|
75
|
+
fullPageHtml: result.fullPageHtml,
|
|
76
|
+
contentHtml: result.contentHtml,
|
|
77
|
+
errors: result.errors,
|
|
78
|
+
};
|
|
79
|
+
sse.broadcast('tree-update', { version, contentHtml: result.contentHtml });
|
|
80
|
+
if (result.errors && result.errors.length > 0) {
|
|
81
|
+
sse.broadcast('walker-error', { errors: result.errors });
|
|
82
|
+
}
|
|
83
|
+
} catch (err) {
|
|
84
|
+
log(`[server] walker failed: ${/** @type {Error} */ (err).message}`);
|
|
85
|
+
sse.broadcast('walker-error', {
|
|
86
|
+
errors: [{ kind: 'ioFailure', message: /** @type {Error} */ (err).message }],
|
|
87
|
+
});
|
|
88
|
+
} finally {
|
|
89
|
+
rewalkInFlight = null;
|
|
90
|
+
}
|
|
91
|
+
})();
|
|
92
|
+
return rewalkInFlight;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Initial walk before we bind, so the first HTTP GET / has content ready
|
|
96
|
+
// and the first SSE connect gets a real payload.
|
|
97
|
+
await rewalk();
|
|
98
|
+
|
|
99
|
+
const router = createRouter({
|
|
100
|
+
currentState: () => state,
|
|
101
|
+
sse,
|
|
102
|
+
stylePath: STYLE_CSS_PATH,
|
|
103
|
+
mermaidPath: VENDORED_MERMAID_PATH,
|
|
104
|
+
liveClientPath: LIVE_CLIENT_PATH,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const server = createServer(router);
|
|
108
|
+
|
|
109
|
+
// Watcher: any .json change under rcf/ triggers a re-walk. Debounced
|
|
110
|
+
// per D4. Non-JSON files are filtered in the primitive itself.
|
|
111
|
+
const watchDir = join(projectRoot, 'rcf');
|
|
112
|
+
const watcher = watchImpl({
|
|
113
|
+
paths: [watchDir],
|
|
114
|
+
onChange: () => { rewalk().catch(() => {}); },
|
|
115
|
+
debounceMs,
|
|
116
|
+
onError: (err) => { log(`[watch] ${err.message}`); },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Bind. EADDRINUSE surfaces as a rejected Promise so the caller can
|
|
120
|
+
// print a clear message and exit 2 (D10). On bind failure we also tear
|
|
121
|
+
// down the watcher and close the (unbound) http.Server so the process
|
|
122
|
+
// has no lingering handles.
|
|
123
|
+
try {
|
|
124
|
+
await new Promise((resolve, reject) => {
|
|
125
|
+
function onError(err) {
|
|
126
|
+
server.off('listening', onListening);
|
|
127
|
+
reject(err);
|
|
128
|
+
}
|
|
129
|
+
function onListening() {
|
|
130
|
+
server.off('error', onError);
|
|
131
|
+
resolve();
|
|
132
|
+
}
|
|
133
|
+
server.once('error', onError);
|
|
134
|
+
server.once('listening', onListening);
|
|
135
|
+
server.listen(port, host);
|
|
136
|
+
});
|
|
137
|
+
} catch (err) {
|
|
138
|
+
try { watcher.close(); } catch { /* swallow */ }
|
|
139
|
+
try { sse.close(); } catch { /* swallow */ }
|
|
140
|
+
try { server.close(); } catch { /* swallow */ }
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const boundPort = /** @type {import('node:net').AddressInfo} */ (server.address()).port;
|
|
145
|
+
|
|
146
|
+
async function close() {
|
|
147
|
+
if (closed) return;
|
|
148
|
+
closed = true;
|
|
149
|
+
try { watcher.close(); } catch { /* swallow */ }
|
|
150
|
+
await sse.drain('shutdown');
|
|
151
|
+
// Kick any lingering keep-alive sockets out first so `server.close()`
|
|
152
|
+
// resolves quickly. Node 18.2+ exposes both APIs.
|
|
153
|
+
try { server.closeIdleConnections?.(); } catch { /* swallow */ }
|
|
154
|
+
try { server.closeAllConnections?.(); } catch { /* swallow */ }
|
|
155
|
+
await new Promise((resolve) => {
|
|
156
|
+
let done = false;
|
|
157
|
+
server.close(() => { if (!done) { done = true; resolve(); } });
|
|
158
|
+
// Backstop: never let the promise hang beyond the bin's 2s budget.
|
|
159
|
+
const t = setTimeout(() => {
|
|
160
|
+
if (done) return;
|
|
161
|
+
done = true;
|
|
162
|
+
try { server.closeAllConnections?.(); } catch { /* swallow */ }
|
|
163
|
+
resolve();
|
|
164
|
+
}, 500);
|
|
165
|
+
if (typeof t.unref === 'function') t.unref();
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
url: `http://${host}:${boundPort}/`,
|
|
171
|
+
port: boundPort,
|
|
172
|
+
host,
|
|
173
|
+
close,
|
|
174
|
+
currentState: () => state,
|
|
175
|
+
sse,
|
|
176
|
+
rewalk,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// HTTP route table for the live-view server. Only five routes are served:
|
|
2
|
+
// GET / -> rendered page (text/html)
|
|
3
|
+
// GET /events -> SSE stream (text/event-stream)
|
|
4
|
+
// GET /style.css -> shipped stylesheet
|
|
5
|
+
// GET /mermaid.min.js-> vendored mermaid runtime
|
|
6
|
+
// GET /live-client.js-> phase 3.8 live client script
|
|
7
|
+
// Everything else -> 404 text/plain.
|
|
8
|
+
//
|
|
9
|
+
// No CORS headers, no cache headers on static assets beyond what the
|
|
10
|
+
// browser derives from same-origin/localhost trust (D5, D11).
|
|
11
|
+
|
|
12
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
13
|
+
|
|
14
|
+
const MIME = {
|
|
15
|
+
html: 'text/html; charset=utf-8',
|
|
16
|
+
css: 'text/css; charset=utf-8',
|
|
17
|
+
js: 'application/javascript; charset=utf-8',
|
|
18
|
+
txt: 'text/plain; charset=utf-8',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {object} RouterDeps
|
|
23
|
+
* @property {() => { fullPageHtml: string, contentHtml: string, version: number } | null} currentState
|
|
24
|
+
* @property {ReturnType<typeof import('./sse.js').createSseHub>} sse
|
|
25
|
+
* @property {string} stylePath
|
|
26
|
+
* @property {string} mermaidPath
|
|
27
|
+
* @property {string} liveClientPath
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create a request handler bound to the current state provider and the
|
|
32
|
+
* SSE hub. The returned function has signature `(req, res) -> void` and
|
|
33
|
+
* is ready to pass into `http.createServer`.
|
|
34
|
+
*
|
|
35
|
+
* @param {RouterDeps} deps
|
|
36
|
+
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => void}
|
|
37
|
+
*/
|
|
38
|
+
export function createRouter(deps) {
|
|
39
|
+
return function handle(req, res) {
|
|
40
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
41
|
+
res.writeHead(405, { 'content-type': MIME.txt, allow: 'GET, HEAD' });
|
|
42
|
+
res.end('method not allowed\n');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
46
|
+
const path = url.pathname;
|
|
47
|
+
|
|
48
|
+
if (path === '/' || path === '/index.html') {
|
|
49
|
+
const state = deps.currentState();
|
|
50
|
+
if (!state) {
|
|
51
|
+
res.writeHead(503, { 'content-type': MIME.txt });
|
|
52
|
+
res.end('view server initialising\n');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
res.writeHead(200, { 'content-type': MIME.html });
|
|
56
|
+
res.end(state.fullPageHtml);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (path === '/events') {
|
|
60
|
+
const state = deps.currentState();
|
|
61
|
+
const payload = state ? { version: state.version, contentHtml: state.contentHtml } : null;
|
|
62
|
+
deps.sse.handle(req, res, payload);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (path === '/style.css') {
|
|
66
|
+
serveFile(res, deps.stylePath, MIME.css).catch((err) => fail(res, err));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (path === '/mermaid.min.js') {
|
|
70
|
+
serveFile(res, deps.mermaidPath, MIME.js).catch((err) => fail(res, err));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (path === '/live-client.js') {
|
|
74
|
+
serveFile(res, deps.liveClientPath, MIME.js).catch((err) => fail(res, err));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
res.writeHead(404, { 'content-type': MIME.txt });
|
|
79
|
+
res.end('not found\n');
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function serveFile(res, path, contentType) {
|
|
84
|
+
let body;
|
|
85
|
+
let size;
|
|
86
|
+
try {
|
|
87
|
+
const [buf, s] = await Promise.all([readFile(path), stat(path)]);
|
|
88
|
+
body = buf;
|
|
89
|
+
size = s.size;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') {
|
|
92
|
+
res.writeHead(404, { 'content-type': MIME.txt });
|
|
93
|
+
res.end('not found\n');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
res.writeHead(200, {
|
|
99
|
+
'content-type': contentType,
|
|
100
|
+
'content-length': String(size),
|
|
101
|
+
});
|
|
102
|
+
res.end(body);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fail(res, err) {
|
|
106
|
+
try {
|
|
107
|
+
res.writeHead(500, { 'content-type': MIME.txt });
|
|
108
|
+
res.end(`internal error: ${err && err.message ? err.message : 'unknown'}\n`);
|
|
109
|
+
} catch { /* swallow */ }
|
|
110
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// SSE (Server-Sent Events) connection manager. Tracks the open long-lived
|
|
2
|
+
// `/events` responses, broadcasts payload events to all of them, keeps a
|
|
3
|
+
// heartbeat loop, and drains gracefully on server close (D16).
|
|
4
|
+
//
|
|
5
|
+
// D5: private surface between the server and its own browser client.
|
|
6
|
+
// Neither payload shape nor route path is a stable contract; downstream
|
|
7
|
+
// consumers (e.g. `rcf query`) get their own wire when the time comes.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} SseHubOptions
|
|
11
|
+
* @property {number} [heartbeatMs=30000] - heartbeat interval in ms
|
|
12
|
+
* @property {(msg: string) => void} [log] - optional stderr sink
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {SseHubOptions} [opts]
|
|
17
|
+
* @returns {{
|
|
18
|
+
* handle: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, current: { version: number, contentHtml: string } | null) => void,
|
|
19
|
+
* broadcast: (event: string, data: object) => void,
|
|
20
|
+
* heartbeatOnce: () => void,
|
|
21
|
+
* drain: (event?: string) => Promise<void>,
|
|
22
|
+
* size: () => number,
|
|
23
|
+
* close: () => void,
|
|
24
|
+
* }}
|
|
25
|
+
*/
|
|
26
|
+
export function createSseHub(opts = {}) {
|
|
27
|
+
const heartbeatMs = typeof opts.heartbeatMs === 'number' && opts.heartbeatMs > 0 ? opts.heartbeatMs : 30000;
|
|
28
|
+
const log = typeof opts.log === 'function' ? opts.log : () => {};
|
|
29
|
+
/** @type {Set<import('node:http').ServerResponse>} */
|
|
30
|
+
const clients = new Set();
|
|
31
|
+
let heartbeatTimer = null;
|
|
32
|
+
let closed = false;
|
|
33
|
+
|
|
34
|
+
function frame(event, data) {
|
|
35
|
+
const payload = typeof data === 'string' ? data : JSON.stringify(data ?? {});
|
|
36
|
+
return `event: ${event}\ndata: ${payload}\n\n`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function writeTo(res, event, data) {
|
|
40
|
+
try {
|
|
41
|
+
res.write(frame(event, data));
|
|
42
|
+
} catch (err) {
|
|
43
|
+
log(`[sse] write failed: ${/** @type {Error} */ (err).message}`);
|
|
44
|
+
try { res.end(); } catch { /* swallow */ }
|
|
45
|
+
clients.delete(res);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function startHeartbeat() {
|
|
50
|
+
if (heartbeatTimer || closed) return;
|
|
51
|
+
heartbeatTimer = setInterval(() => {
|
|
52
|
+
if (clients.size === 0) return;
|
|
53
|
+
const ts = new Date().toISOString();
|
|
54
|
+
for (const res of clients) writeTo(res, 'heartbeat', { ts });
|
|
55
|
+
}, heartbeatMs);
|
|
56
|
+
if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function stopHeartbeat() {
|
|
60
|
+
if (!heartbeatTimer) return;
|
|
61
|
+
clearInterval(heartbeatTimer);
|
|
62
|
+
heartbeatTimer = null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
handle(req, res, current) {
|
|
67
|
+
if (closed) {
|
|
68
|
+
res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
|
|
69
|
+
res.end('server shutting down\n');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
res.writeHead(200, {
|
|
73
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
74
|
+
'cache-control': 'no-cache, no-transform',
|
|
75
|
+
'connection': 'keep-alive',
|
|
76
|
+
'x-accel-buffering': 'no',
|
|
77
|
+
});
|
|
78
|
+
// Prime the stream so proxies don't buffer the initial event.
|
|
79
|
+
try { res.write(': ok\n\n'); } catch { /* swallow */ }
|
|
80
|
+
clients.add(res);
|
|
81
|
+
req.on('close', () => {
|
|
82
|
+
clients.delete(res);
|
|
83
|
+
});
|
|
84
|
+
// First event on connect carries the current state so the client
|
|
85
|
+
// is in sync without a separate resume protocol (D12).
|
|
86
|
+
if (current) writeTo(res, 'tree-update', current);
|
|
87
|
+
startHeartbeat();
|
|
88
|
+
},
|
|
89
|
+
broadcast(event, data) {
|
|
90
|
+
if (closed) return;
|
|
91
|
+
for (const res of clients) writeTo(res, event, data);
|
|
92
|
+
},
|
|
93
|
+
heartbeatOnce() {
|
|
94
|
+
if (clients.size === 0) return;
|
|
95
|
+
const ts = new Date().toISOString();
|
|
96
|
+
for (const res of clients) writeTo(res, 'heartbeat', { ts });
|
|
97
|
+
},
|
|
98
|
+
async drain(event = 'shutdown') {
|
|
99
|
+
if (closed) return;
|
|
100
|
+
closed = true;
|
|
101
|
+
stopHeartbeat();
|
|
102
|
+
for (const res of clients) {
|
|
103
|
+
writeTo(res, event, {});
|
|
104
|
+
try { res.end(); } catch { /* swallow */ }
|
|
105
|
+
}
|
|
106
|
+
clients.clear();
|
|
107
|
+
},
|
|
108
|
+
size() { return clients.size; },
|
|
109
|
+
close() {
|
|
110
|
+
closed = true;
|
|
111
|
+
stopHeartbeat();
|
|
112
|
+
for (const res of clients) {
|
|
113
|
+
try { res.end(); } catch { /* swallow */ }
|
|
114
|
+
}
|
|
115
|
+
clients.clear();
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|