@forzalabs/remora-preview 1.0.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/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @forzalabs/remora-preview
2
+
3
+ The read-only viewer for a [Remora](https://www.npmjs.com/package/@forzalabs/remora) project: the
4
+ lineage graph of what feeds what, and a click-through table inspector for every source, producer,
5
+ consumer and schema.
6
+
7
+ It is normally used through the CLI, which hosts it and reloads it as you edit:
8
+
9
+ ```bash
10
+ remora ui-preview # http://127.0.0.1:5070, opens your browser
11
+ remora ui-preview -p 8080 --no-open
12
+ ```
13
+
14
+ You can also serve a snapshot on its own — no CLI, no licence, no project on disk:
15
+
16
+ ```bash
17
+ remora graph -f json > project.json # on a machine that has the project
18
+ npx @forzalabs/remora-preview project.json
19
+ ```
20
+
21
+ ## It cannot read your data
22
+
23
+ This package has no drivers, no credentials and no configuration reader; its only runtime dependency
24
+ is `express`, and it binds to loopback. It receives one versioned JSON document — the snapshot — and
25
+ renders it. Sampling live data is a capability of the CLI that hosts it, never of this viewer.
26
+
27
+ ## API
28
+
29
+ ```ts
30
+ import { serve, fromSnapshot } from '@forzalabs/remora-preview'
31
+
32
+ const server = await serve({ provider: fromSnapshot(snapshot), port: 5070, open: true })
33
+ server.notifyChange() // tell every open page to refetch
34
+ await server.close()
35
+ ```
36
+
37
+ A host that reads a live project implements `IPreviewProvider` directly, so views are built on demand
38
+ rather than serialised up front.
39
+
40
+ ## Versioning
41
+
42
+ The snapshot carries a `formatVersion`. This viewer renders the versions it knows and refuses a newer
43
+ one with a message saying so, rather than drawing it wrong.
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The repo's precondition helper, re-stated locally.
3
+ *
4
+ * `@remora/app-core` would be the import everywhere else, but this package publishes to npm on its
5
+ * own and must keep `express` as its only runtime dependency (docs/PREVIEW_PLAN.md §2). Six lines of
6
+ * copy is the cheaper side of that trade.
7
+ */
8
+ export declare const affirm: (value: unknown, message: string) => void;
package/dist/Affirm.js ADDED
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.affirm = void 0;
4
+ /**
5
+ * The repo's precondition helper, re-stated locally.
6
+ *
7
+ * `@remora/app-core` would be the import everywhere else, but this package publishes to npm on its
8
+ * own and must keep `express` as its only runtime dependency (docs/PREVIEW_PLAN.md §2). Six lines of
9
+ * copy is the cheaper side of that trade.
10
+ */
11
+ const affirm = (value, message) => {
12
+ if (!value)
13
+ throw new Error('Affirm failed: ' + message);
14
+ };
15
+ exports.affirm = affirm;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Opens the page in the default browser. Never throws and never blocks: a headless box, a locked-down
3
+ * desktop or a missing `xdg-open` is a reason to print the URL, not to fail the command.
4
+ */
5
+ export declare const openBrowser: (url: string) => boolean;
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.openBrowser = void 0;
4
+ const node_child_process_1 = require("node:child_process");
5
+ /** One opener per platform. An unknown platform simply does not open anything. */
6
+ const OPENERS = {
7
+ darwin: 'open',
8
+ win32: 'start ""',
9
+ linux: 'xdg-open'
10
+ };
11
+ /**
12
+ * Opens the page in the default browser. Never throws and never blocks: a headless box, a locked-down
13
+ * desktop or a missing `xdg-open` is a reason to print the URL, not to fail the command.
14
+ */
15
+ const openBrowser = (url) => {
16
+ const opener = OPENERS[process.platform];
17
+ if (!opener)
18
+ return false;
19
+ try {
20
+ (0, node_child_process_1.exec)(`${opener} "${url}"`, () => { });
21
+ return true;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ };
27
+ exports.openBrowser = openBrowser;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The CLI ↔ viewer contract, version 1.
3
+ *
4
+ * A HAND COPY of `packages/definitions/src/Preview.ts` and `Lineage.ts`, on purpose. This package is
5
+ * published to npm on its own and the workspace packages are not, so it cannot import them; and the
6
+ * contract between the two sides is meant to be one versioned JSON document rather than the whole
7
+ * resource surface (docs/PREVIEW_PLAN.md §2). `ContractParity.ts` type-checks this copy against the
8
+ * originals at build time, so the two cannot drift silently.
9
+ *
10
+ * Nothing here describes a remora resource — only tables, cells and nodes. That is what lets the page
11
+ * render a project it knows nothing about.
12
+ */
13
+ /** The highest `formatVersion` this viewer can render. A newer snapshot is refused, not mis-drawn. */
14
+ export declare const SUPPORTED_FORMAT_VERSION = 1;
15
+ export interface IPreviewSnapshot {
16
+ formatVersion: number;
17
+ project: string;
18
+ generatedBy: string;
19
+ graph: ILineageGraph;
20
+ diagnostics: IPreviewDiagnostic[];
21
+ /** Inlined when the whole document is emitted at once; otherwise fetched per node from `/api/resource/:id`. */
22
+ resources?: IResourceView[];
23
+ /** What the host can do beyond rendering this document. Absent in a snapshot read from a file. */
24
+ capabilities?: IPreviewCapabilities;
25
+ }
26
+ /**
27
+ * Additive: a capability the viewer does not know about is one it never asks for, so no version bump.
28
+ * Each is something the HOST can do and this package cannot; a snapshot read from a file has none.
29
+ */
30
+ export interface IPreviewCapabilities {
31
+ /** The host can read rows from a real data source: `/api/sample/:id` answers. */
32
+ sample?: boolean;
33
+ /** The host can execute a consumer: `POST /api/run/:id` answers. */
34
+ run?: boolean;
35
+ /** The host can read and write the authored configuration file: `/api/file/:id` answers. */
36
+ edit?: boolean;
37
+ /** The host can tail its own log file: `/api/logs` answers. */
38
+ logs?: boolean;
39
+ }
40
+ /** The authored configuration file behind a node — the file on disk, never the loaded object. */
41
+ export interface IPreviewFile {
42
+ /** Project-relative path, shown so what is being edited is never in doubt. */
43
+ path: string;
44
+ /** The file's text, exactly as it is on disk. */
45
+ content: string;
46
+ }
47
+ /** What a save did, or refused to do. */
48
+ export interface IPreviewSaveResult {
49
+ saved: boolean;
50
+ message?: string;
51
+ }
52
+ /** A run the host accepted. Progress is not streamed: the run overlay is the progress. */
53
+ export interface IPreviewRunResult {
54
+ started: boolean;
55
+ /** What was started, or why it was refused. */
56
+ message?: string;
57
+ }
58
+ /** A tail of the host's log file. */
59
+ export interface IPreviewLogs {
60
+ /** Project-relative path, or a note when nothing is being written to a file. */
61
+ path: string;
62
+ /** Oldest first, so the page renders it the way a terminal does. */
63
+ lines: string[];
64
+ truncated?: IViewTruncation;
65
+ }
66
+ export interface ILineageGraph {
67
+ nodes: ILineageNode[];
68
+ edges: ILineageEdge[];
69
+ }
70
+ export interface ILineageNode {
71
+ id: string;
72
+ kind: LineageNodeKind;
73
+ name: string;
74
+ description?: string;
75
+ badges?: string[];
76
+ status?: LineageStatus;
77
+ /** Referenced by another resource but never declared. Drawn anyway — that is the point. */
78
+ missing?: boolean;
79
+ /** The most recent execution of this resource, when the host knows of one. */
80
+ run?: ILineageRun;
81
+ }
82
+ /** One execution of a resource, as the graph shows it: the outcome, when, how long, how much. */
83
+ export interface ILineageRun {
84
+ status: LineageRunStatus;
85
+ /** ISO start time. */
86
+ startedAt: string;
87
+ /** Wall-clock duration in ms. Absent while the run is still going. */
88
+ elapsedMS?: number;
89
+ /** Rows written. Absent, or -1, when the run never got far enough to know. */
90
+ itemsCount?: number;
91
+ /** Why it failed. Absent on anything but a failure. */
92
+ error?: string;
93
+ /** Remora release that ran it. */
94
+ engineVersion?: string;
95
+ }
96
+ export interface ILineageEdge {
97
+ id: string;
98
+ from: string;
99
+ to: string;
100
+ kind: LineageEdgeKind;
101
+ label?: string;
102
+ }
103
+ export type LineageNodeKind = 'source' | 'producer' | 'consumer' | 'schema' | 'destination';
104
+ export type LineageEdgeKind = 'reads' | 'feeds' | 'writes' | 'validates' | 'triggers';
105
+ export type LineageStatus = 'ok' | 'warn' | 'error';
106
+ /** `started` is a run that has not reported a result: still going, or wedged. */
107
+ export type LineageRunStatus = 'success' | 'failed' | 'started';
108
+ export interface IPreviewDiagnostic {
109
+ severity: 'error' | 'warning';
110
+ origin: 'load' | 'lineage';
111
+ node?: string;
112
+ file?: string;
113
+ message: string;
114
+ }
115
+ export interface IResourceView {
116
+ id: string;
117
+ kind: LineageNodeKind;
118
+ name: string;
119
+ description?: string;
120
+ summary: IViewField[];
121
+ tables: IViewTable[];
122
+ raw?: unknown;
123
+ }
124
+ export interface IViewField {
125
+ label: string;
126
+ value: string | number | boolean | null;
127
+ link?: string;
128
+ }
129
+ export interface IViewTable {
130
+ title: string;
131
+ columns: IViewColumn[];
132
+ rows: IViewCell[][];
133
+ truncated?: IViewTruncation;
134
+ }
135
+ export interface IViewColumn {
136
+ key: string;
137
+ label: string;
138
+ align?: 'left' | 'right';
139
+ }
140
+ export interface IViewCell {
141
+ value: string | number | boolean | null;
142
+ /** A node id. Resolved server-side, per cell — the page never interprets remora semantics. */
143
+ link?: string;
144
+ muted?: boolean;
145
+ status?: LineageStatus;
146
+ }
147
+ export interface IViewTruncation {
148
+ shown: number;
149
+ total: number;
150
+ }
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ /**
3
+ * The CLI ↔ viewer contract, version 1.
4
+ *
5
+ * A HAND COPY of `packages/definitions/src/Preview.ts` and `Lineage.ts`, on purpose. This package is
6
+ * published to npm on its own and the workspace packages are not, so it cannot import them; and the
7
+ * contract between the two sides is meant to be one versioned JSON document rather than the whole
8
+ * resource surface (docs/PREVIEW_PLAN.md §2). `ContractParity.ts` type-checks this copy against the
9
+ * originals at build time, so the two cannot drift silently.
10
+ *
11
+ * Nothing here describes a remora resource — only tables, cells and nodes. That is what lets the page
12
+ * render a project it knows nothing about.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.SUPPORTED_FORMAT_VERSION = void 0;
16
+ /** The highest `formatVersion` this viewer can render. A newer snapshot is refused, not mis-drawn. */
17
+ exports.SUPPORTED_FORMAT_VERSION = 1;
@@ -0,0 +1,31 @@
1
+ import { IPreviewProvider } from './Snapshot';
2
+ export interface IServeOptions {
3
+ /** Where the graph, the diagnostics and the resource views come from. */
4
+ provider: IPreviewProvider;
5
+ /** Preferred port; the next `PORT_ATTEMPTS` are tried when it is in use. */
6
+ port?: number;
7
+ /** Bind address. Overriding the loopback default exposes an unauthenticated page. */
8
+ bind?: string;
9
+ /** Open the default browser once the server is listening. */
10
+ open?: boolean;
11
+ }
12
+ export interface IPreviewServer {
13
+ url: string;
14
+ port: number;
15
+ /**
16
+ * The session token this server injected into the page. Every state-changing request must carry
17
+ * it in `x-remora-preview-token`; it is generated per session and never leaves the machine.
18
+ */
19
+ token: string;
20
+ /** Tell every open page the project changed; each one refetches what it is showing. */
21
+ notifyChange: () => void;
22
+ close: () => Promise<void>;
23
+ }
24
+ /**
25
+ * Serves the prebuilt page and a small read-only API over one preview provider.
26
+ *
27
+ * The server holds no remora concepts at all: it hands out whatever documents the provider builds.
28
+ * That is what keeps the viewer publishable on its own, and what makes it structurally incapable of
29
+ * reaching a data source (docs/PREVIEW_PLAN.md §2).
30
+ */
31
+ export declare const serve: (options: IServeOptions) => Promise<IPreviewServer>;
package/dist/Server.js ADDED
@@ -0,0 +1,314 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.serve = void 0;
7
+ const node_crypto_1 = __importDefault(require("node:crypto"));
8
+ const express_1 = __importDefault(require("express"));
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_http_1 = __importDefault(require("node:http"));
11
+ const node_path_1 = __importDefault(require("node:path"));
12
+ const Affirm_1 = require("./Affirm");
13
+ const Browser_1 = require("./Browser");
14
+ /** Clear of the backend (5069) and of the frontend dev server (3000). */
15
+ const DEFAULT_PORT = 5070;
16
+ /** Ports past the requested one to try when it is taken, before giving up with the reason. */
17
+ const PORT_ATTEMPTS = 20;
18
+ /** An SSE client that never disconnects is a leak with a view of the project. Bound it. */
19
+ const MAX_CLIENTS = 32;
20
+ /** Under every browser and proxy idle timeout there is, so a quiet session stays connected. */
21
+ const HEARTBEAT_MS = 20000;
22
+ /** Loopback only. The viewer has no authentication because it is never reachable off this machine. */
23
+ const DEFAULT_BIND = '127.0.0.1';
24
+ /** Rows a sample request may ask for. A sample is a look at the data, never an export of it. */
25
+ const DEFAULT_SAMPLE_ROWS = 10;
26
+ const MAX_SAMPLE_ROWS = 100;
27
+ /** Log lines a tail may ask for. A log is a stream, not a document. */
28
+ const DEFAULT_LOG_LINES = 200;
29
+ const MAX_LOG_LINES = 2000;
30
+ /**
31
+ * The header a state-changing request must carry, holding the session token injected into the page.
32
+ *
33
+ * A custom header is load-bearing beyond the token: a cross-origin request that sets one triggers a
34
+ * CORS preflight, and this server answers no preflight, so a page on another origin cannot make the
35
+ * request at all — before the token is even looked at.
36
+ */
37
+ const TOKEN_HEADER = 'x-remora-preview-token';
38
+ /** Methods that change something and therefore need the token. */
39
+ const MUTATING_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
40
+ /**
41
+ * Host names the server will answer on, whatever DNS says.
42
+ *
43
+ * Binding to loopback does not stop a BROWSER: every site the developer visits can address
44
+ * 127.0.0.1, and a hostname that resolves there (DNS rebinding) walks past a naive check. The `Host`
45
+ * header is therefore the boundary, not the bind address alone.
46
+ */
47
+ const LOOPBACK_HOSTNAMES = ['127.0.0.1', 'localhost', '::1', '[::1]'];
48
+ /** A configuration file larger than this is not a configuration file. */
49
+ const MAX_EDIT_BYTES = '2mb';
50
+ /**
51
+ * Serves the prebuilt page and a small read-only API over one preview provider.
52
+ *
53
+ * The server holds no remora concepts at all: it hands out whatever documents the provider builds.
54
+ * That is what keeps the viewer publishable on its own, and what makes it structurally incapable of
55
+ * reaching a data source (docs/PREVIEW_PLAN.md §2).
56
+ */
57
+ const serve = async (options) => {
58
+ (0, Affirm_1.affirm)(options, 'Missing preview server options');
59
+ (0, Affirm_1.affirm)(options.provider, 'Missing preview provider');
60
+ const pagePath = node_path_1.default.join(__dirname, 'page');
61
+ (0, Affirm_1.affirm)(node_fs_1.default.existsSync(node_path_1.default.join(pagePath, 'index.html')), `The preview page is not built (${pagePath}). Run: npm run build --workspace=@forzalabs/remora-preview`);
62
+ const clients = new Set();
63
+ const app = (0, express_1.default)();
64
+ const bind = options.bind ?? DEFAULT_BIND;
65
+ // Per session, so a token that leaks is worthless the moment the preview is closed.
66
+ const token = node_crypto_1.default.randomBytes(24).toString('hex');
67
+ app.disable('x-powered-by');
68
+ app.use(guard(bind, token));
69
+ // Before the static middleware, because this is the same document with the token injected.
70
+ app.get(['/', '/index.html'], servePage(pagePath, token));
71
+ registerApi(app, options.provider, clients);
72
+ app.use(express_1.default.static(pagePath, { index: false }));
73
+ const server = await listen(app, options.port ?? DEFAULT_PORT, bind, PORT_ATTEMPTS);
74
+ const heartbeat = setInterval(() => clients.forEach(client => client.write(': ping\n\n')), HEARTBEAT_MS);
75
+ const port = server.address().port;
76
+ const url = `http://${bind}:${port}`;
77
+ if (options.open)
78
+ (0, Browser_1.openBrowser)(url);
79
+ return {
80
+ url,
81
+ port,
82
+ token,
83
+ notifyChange: () => clients.forEach(client => client.write('event: change\ndata: {}\n\n')),
84
+ close: () => close(server, clients, heartbeat)
85
+ };
86
+ };
87
+ exports.serve = serve;
88
+ /**
89
+ * Refuses anything that is not this page asking.
90
+ *
91
+ * Loopback is why the page needs no login, but loopback alone does not make it private: every site
92
+ * the developer visits can address 127.0.0.1 from their browser, and once this server can RUN a
93
+ * consumer or REWRITE a config file, that stops being a curiosity. Three layers, cheapest first:
94
+ *
95
+ * - the `Host` header must be one this server answers on, which is what closes DNS rebinding;
96
+ * - a request carrying a cross-origin `Origin` is refused outright;
97
+ * - anything that changes state must also carry the session token from the page.
98
+ *
99
+ * Applied to READS as well, deliberately: a website quietly reading someone's project graph was
100
+ * always possible and was never wanted either.
101
+ */
102
+ const guard = (bind, token) => (req, res, next) => {
103
+ if (!isAllowedHost(req.headers.host, bind))
104
+ return res.status(403).json({ message: `Refused: unexpected Host header "${req.headers.host ?? ''}"` });
105
+ const origin = req.headers.origin;
106
+ if (origin && !isAllowedHost(authorityOf(origin), bind))
107
+ return res.status(403).json({ message: `Refused: cross-origin request from ${origin}` });
108
+ if (MUTATING_METHODS.includes(req.method) && req.headers[TOKEN_HEADER] !== token)
109
+ return res.status(403).json({ message: 'Refused: this request needs the preview session token' });
110
+ next();
111
+ };
112
+ /** @param host a `Host` header or an origin's authority — with or without a port. */
113
+ const isAllowedHost = (host, bind) => {
114
+ if (!host)
115
+ return false;
116
+ const hostname = stripPort(host);
117
+ return LOOPBACK_HOSTNAMES.includes(hostname) || hostname === bind;
118
+ };
119
+ /** Splits the port off an authority, leaving an IPv6 literal's own colons alone. */
120
+ const stripPort = (host) => {
121
+ if (host.startsWith('['))
122
+ return host.slice(0, host.indexOf(']') + 1);
123
+ const separator = host.lastIndexOf(':');
124
+ return separator > 0 ? host.slice(0, separator) : host;
125
+ };
126
+ /** An origin's `host:port`, or null when it is not a URL at all. */
127
+ const authorityOf = (origin) => {
128
+ try {
129
+ return new URL(origin).host;
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ };
135
+ /**
136
+ * The prebuilt page, with the session token injected.
137
+ *
138
+ * Injected into the document rather than handed out by a route: a page on another origin cannot read
139
+ * this response body, which is the whole reason the token is worth anything. Read once — the page is
140
+ * prebuilt and does not change while a session is open.
141
+ */
142
+ const servePage = (pagePath, token) => {
143
+ const html = node_fs_1.default.readFileSync(node_path_1.default.join(pagePath, 'index.html'), 'utf8');
144
+ const script = `<script>window.__REMORA_PREVIEW_TOKEN__ = ${JSON.stringify(token)}</script>`;
145
+ const injected = html.includes('</head>') ? html.replace('</head>', `${script}</head>`) : script + html;
146
+ return (req, res) => res.type('html').send(injected);
147
+ };
148
+ /**
149
+ * The routes.
150
+ *
151
+ * The reads are always there. Everything a host can DO — sample, run, edit, tail its logs — is
152
+ * registered only when that host can actually do it, so a standalone viewer has no such route rather
153
+ * than a route that refuses. That absence is the capability check. docs/PREVIEW_PLAN.md §2.
154
+ */
155
+ const registerApi = (app, provider, clients) => {
156
+ // A cached snapshot would show a project as it was two saves ago, which is the one thing live
157
+ // reload exists to prevent.
158
+ app.use('/api', (req, res, next) => {
159
+ res.setHeader('Cache-Control', 'no-store');
160
+ next();
161
+ });
162
+ app.get('/api/snapshot', (req, res) => send(res, () => provider.snapshot()));
163
+ app.get('/api/resource/:id', (req, res) => send(res, () => {
164
+ const view = provider.resource(req.params.id);
165
+ if (!view) {
166
+ res.status(404);
167
+ return { message: `No resource view for "${req.params.id}"` };
168
+ }
169
+ return view;
170
+ }));
171
+ app.get('/api/events', (req, res) => subscribe(req, res, clients));
172
+ if (provider.sample)
173
+ registerSample(app, provider);
174
+ if (provider.run)
175
+ registerRun(app, provider);
176
+ if (provider.file)
177
+ registerFile(app, provider);
178
+ if (provider.logs)
179
+ registerLogs(app, provider);
180
+ };
181
+ const registerSample = (app, provider) => {
182
+ app.get('/api/sample/:id', (req, res) => sendAsync(res, async () => {
183
+ const rows = readRows(req.query.rows);
184
+ const table = await provider.sample(req.params.id, rows);
185
+ if (!table) {
186
+ res.status(404);
187
+ return { message: `No data to sample for "${req.params.id}"` };
188
+ }
189
+ return table;
190
+ }));
191
+ };
192
+ /**
193
+ * Executing a resource. `POST`, because it changes the world, which also means it needs the token.
194
+ *
195
+ * It answers as soon as the run has STARTED. A run takes minutes and holding a request open for one
196
+ * would make the page's progress display a second mechanism — where the run overlay already is one.
197
+ */
198
+ const registerRun = (app, provider) => {
199
+ app.post('/api/run/:id', (req, res) => sendAsync(res, async () => {
200
+ const result = await provider.run(req.params.id);
201
+ if (!result?.started)
202
+ res.status(409);
203
+ return result ?? { started: false, message: `Nothing to run for "${req.params.id}"` };
204
+ }));
205
+ };
206
+ /**
207
+ * Reading and writing the file behind a node. The write is registered only when the host offers one,
208
+ * so a host can expose the authored file without accepting changes to it.
209
+ */
210
+ const registerFile = (app, provider) => {
211
+ app.get('/api/file/:id', (req, res) => send(res, () => {
212
+ const file = provider.file(req.params.id);
213
+ if (!file) {
214
+ res.status(404);
215
+ return { message: `No configuration file for "${req.params.id}"` };
216
+ }
217
+ return file;
218
+ }));
219
+ if (!provider.save)
220
+ return;
221
+ app.put('/api/file/:id', express_1.default.json({ limit: MAX_EDIT_BYTES }), (req, res) => sendAsync(res, async () => {
222
+ const content = req.body?.content;
223
+ if (typeof content !== 'string') {
224
+ res.status(400);
225
+ return { saved: false, message: 'A save must carry the file as { "content": "..." }' };
226
+ }
227
+ // A refusal is the expected outcome here — invalid JSON, a schema violation — so it comes
228
+ // back as the reason with a 422, never as a 500 the page can only call an error.
229
+ const result = await provider.save(req.params.id, content);
230
+ if (!result?.saved)
231
+ res.status(422);
232
+ return result ?? { saved: false, message: `Nothing to save for "${req.params.id}"` };
233
+ }));
234
+ };
235
+ const registerLogs = (app, provider) => {
236
+ app.get('/api/logs', (req, res) => send(res, () => provider.logs(readLines(req.query.lines))));
237
+ };
238
+ /** Whatever a URL carries, a sample stays a sample: a bad value reads as the default, never as more. */
239
+ const readRows = (value) => {
240
+ const rows = parseInt(String(value ?? ''), 10);
241
+ if (isNaN(rows) || rows < 1)
242
+ return DEFAULT_SAMPLE_ROWS;
243
+ return Math.min(rows, MAX_SAMPLE_ROWS);
244
+ };
245
+ /** The same rule for a log tail: bounded above, whatever was asked for. */
246
+ const readLines = (value) => {
247
+ const lines = parseInt(String(value ?? ''), 10);
248
+ if (isNaN(lines) || lines < 1)
249
+ return DEFAULT_LOG_LINES;
250
+ return Math.min(lines, MAX_LOG_LINES);
251
+ };
252
+ /**
253
+ * A provider reads a configuration that is being edited, so it is allowed to fail. Returning the
254
+ * reason as JSON lets the page say what happened instead of blanking out.
255
+ */
256
+ const send = (res, build) => {
257
+ try {
258
+ res.json(build());
259
+ }
260
+ catch (error) {
261
+ res.status(500).json({ message: error.message });
262
+ }
263
+ };
264
+ /**
265
+ * The same contract as `send`, for the one route that reaches a data source and so cannot answer
266
+ * synchronously. A source that is unreachable or unauthorised is the expected outcome here, not an
267
+ * exceptional one, so its reason is what the page gets to show.
268
+ */
269
+ const sendAsync = async (res, build) => {
270
+ try {
271
+ res.json(await build());
272
+ }
273
+ catch (error) {
274
+ if (res.headersSent)
275
+ return;
276
+ res.status(500).json({ message: error.message });
277
+ }
278
+ };
279
+ /** Server-sent events: the page is told a change happened and refetches; nothing is pushed to it. */
280
+ const subscribe = (req, res, clients) => {
281
+ if (clients.size >= MAX_CLIENTS) {
282
+ res.status(503).json({ message: `Too many open preview sessions (${MAX_CLIENTS})` });
283
+ return;
284
+ }
285
+ res.writeHead(200, {
286
+ 'Content-Type': 'text/event-stream',
287
+ 'Cache-Control': 'no-store',
288
+ Connection: 'keep-alive'
289
+ });
290
+ res.write('retry: 2000\n\n');
291
+ clients.add(res);
292
+ req.on('close', () => clients.delete(res));
293
+ };
294
+ /**
295
+ * Listens on the first free port at or after `port`. A developer running two previews should get the
296
+ * second one on 5071, not an EADDRINUSE stack trace — and the bound port is reported back, so the
297
+ * URL printed is always the URL that works.
298
+ */
299
+ const listen = (app, port, bind, attempts) => new Promise((resolve, reject) => {
300
+ const server = node_http_1.default.createServer(app);
301
+ server.once('error', (error) => {
302
+ if (error.code !== 'EADDRINUSE' || attempts <= 1)
303
+ return reject(error);
304
+ listen(app, port + 1, bind, attempts - 1).then(resolve, reject);
305
+ });
306
+ server.listen(port, bind, () => resolve(server));
307
+ });
308
+ const close = (server, clients, heartbeat) => {
309
+ clearInterval(heartbeat);
310
+ // An open event stream keeps the socket alive, so close() would otherwise never call back.
311
+ clients.forEach(client => client.end());
312
+ clients.clear();
313
+ return new Promise(resolve => server.close(() => resolve()));
314
+ };
@@ -0,0 +1,43 @@
1
+ import { IPreviewFile, IPreviewLogs, IPreviewRunResult, IPreviewSaveResult, IPreviewSnapshot, IResourceView, IViewTable } from './Contract';
2
+ /**
3
+ * What the server reads the project through. A host that can reload — the CLI watching a directory —
4
+ * implements it over a live environment; a fixed document is adapted by `fromSnapshot`.
5
+ *
6
+ * One input instead of a `snapshot`-or-`provider` pair, so the server has a single code path.
7
+ */
8
+ export interface IPreviewProvider {
9
+ /** Graph and diagnostics. Called per request, so a reloading host needs no invalidation. */
10
+ snapshot: () => IPreviewSnapshot;
11
+ /** The inspector view of one node id, or null when the node is unknown. */
12
+ resource: (nodeId: string) => IResourceView | null;
13
+ /**
14
+ * OPTIONAL, and the reason this viewer can be published on its own: reading real rows needs
15
+ * drivers and credentials, which live in the host. A host that has them offers this and the
16
+ * server routes to it; one that does not — `fromSnapshot`, and so `remora-preview <file>` — has
17
+ * no route to offer, rather than a route that is refused. docs/PREVIEW_PLAN.md §2.
18
+ *
19
+ * @returns the rows as a table, or null when the node holds no data to read.
20
+ */
21
+ sample?: (nodeId: string, rows: number) => Promise<IViewTable | null>;
22
+ /**
23
+ * OPTIONAL. Executes a resource. Returns as soon as the run has STARTED, never when it finishes:
24
+ * a run takes minutes, and the run overlay is already the progress display — the execution writes
25
+ * a `started` record that the host's next poll turns into a repaint.
26
+ */
27
+ run?: (nodeId: string) => Promise<IPreviewRunResult>;
28
+ /**
29
+ * OPTIONAL, and paired with `save`. The resource's file AS AUTHORED. Never the loaded object: a
30
+ * loaded producer has had its groups expanded, so writing that back would persist the expansion.
31
+ */
32
+ file?: (nodeId: string) => IPreviewFile | null;
33
+ /** OPTIONAL. Writes the file back, or refuses with the reason. Registered only alongside `file`. */
34
+ save?: (nodeId: string, content: string) => Promise<IPreviewSaveResult>;
35
+ /** OPTIONAL. The tail of the host's own log file. */
36
+ logs?: (lines: number) => IPreviewLogs;
37
+ }
38
+ /**
39
+ * Serves one already-built document — the standalone `remora-preview <file>` entrypoint, and any
40
+ * static export. Resource views come from the snapshot's own `resources`, so a document generated
41
+ * without them shows the graph and reports that the detail was not included.
42
+ */
43
+ export declare const fromSnapshot: (snapshot: IPreviewSnapshot) => IPreviewProvider;