nestjs-web-repl 0.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.
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EventRingBuffer = void 0;
4
+ class EventRingBuffer {
5
+ constructor(capacity) {
6
+ this.capacity = capacity;
7
+ this.events = [];
8
+ }
9
+ push(event) {
10
+ this.events.push(event);
11
+ if (this.events.length > this.capacity) {
12
+ this.events.splice(0, this.events.length - this.capacity);
13
+ }
14
+ }
15
+ since(lastEventId) {
16
+ if (lastEventId === null)
17
+ return [...this.events];
18
+ return this.events.filter((e) => e.id > lastEventId);
19
+ }
20
+ clear() {
21
+ this.events.length = 0;
22
+ }
23
+ isEmpty() {
24
+ return this.events.length === 0;
25
+ }
26
+ }
27
+ exports.EventRingBuffer = EventRingBuffer;
@@ -0,0 +1,72 @@
1
+ export interface ReplSessionOptions {
2
+ context: Record<string, unknown>;
3
+ onOutput: (chunk: string) => void;
4
+ /**
5
+ * Safety-net timeout (ms) for a single eval(), in case some future input
6
+ * shape defeats both the sentinel-completion signal and the incomplete-
7
+ * input recovery below. Defaults to 30000. A legitimate async command
8
+ * that runs longer than this will be cut off with a timeout error.
9
+ */
10
+ evalTimeoutMs?: number;
11
+ }
12
+ export declare class ReplSession {
13
+ private readonly opts;
14
+ private readonly input;
15
+ private readonly server;
16
+ private readonly evalTimeoutMs;
17
+ private readonly sentinel;
18
+ private readonly bufferedCommandSymbol;
19
+ private queue;
20
+ private buffer;
21
+ private resolveCurrent;
22
+ private watchdog;
23
+ private closed;
24
+ constructor(opts: ReplSessionOptions);
25
+ /**
26
+ * Buffers incoming output text, strips every occurrence of the sentinel
27
+ * before it reaches onOutput, and resolves the in-flight eval() promise
28
+ * (if any) each time a sentinel is found. A trailing partial match of
29
+ * the sentinel is held back across writes so a sentinel split across two
30
+ * stream chunks is never mistakenly forwarded or missed.
31
+ *
32
+ * The bare continuation-prompt marker is filtered out (never forwarded
33
+ * as output) but, on its own, is NOT treated as a "this command is
34
+ * stuck" signal -- a legitimate multi-line command supplied as one
35
+ * eval() string transiently produces this marker for every line that
36
+ * doesn't yet close out a statement before the final line completes it.
37
+ * Reacting to the first occurrence would wrongly abort those valid
38
+ * commands. See checkForStuckIncompleteInput() for the real recovery
39
+ * path.
40
+ */
41
+ private handleOutput;
42
+ /** Length of the longest suffix of `text` that is also a prefix of the sentinel. */
43
+ private partialSentinelSuffixLength;
44
+ /**
45
+ * Called shortly after a command has been written to `input`, once
46
+ * node:repl has had a chance to drain and process every line of it
47
+ * (including every line of a multi-line command written as one
48
+ * string). If the eval already resolved via the sentinel, this is a
49
+ * no-op. Otherwise, reads node:repl's own buffered-command state: a
50
+ * non-empty buffer at this point means the submitted text left a
51
+ * trailing statement that is syntactically incomplete and will never
52
+ * complete on its own (there is no more input coming for it), so we
53
+ * recover instead of waiting forever. An empty buffer means parsing
54
+ * fully consumed the input -- whether it already finished evaluating
55
+ * (sync) or is still running (e.g. a pending top-level await) -- so we
56
+ * leave it alone and let the sentinel (or, failing that, the watchdog)
57
+ * resolve it whenever it's actually done.
58
+ */
59
+ private checkForStuckIncompleteInput;
60
+ /**
61
+ * Discards a syntactically-incomplete buffered command so the session
62
+ * doesn't wait forever for input that will never arrive as a
63
+ * continuation of it, reports the failure, and unblocks the eval queue.
64
+ */
65
+ private recoverFromIncompleteInput;
66
+ /** Resolves the in-flight eval() (if any) and clears its watchdog timer. */
67
+ private settle;
68
+ private format;
69
+ eval(command: string): Promise<void>;
70
+ private runLine;
71
+ close(): void;
72
+ }
@@ -0,0 +1,223 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ReplSession = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const node_repl_1 = require("node:repl");
6
+ const node_stream_1 = require("node:stream");
7
+ const node_util_1 = require("node:util");
8
+ // The non-terminal node:repl writes this exact two-character string, as its
9
+ // own standalone `output` write, for every line that leaves a command
10
+ // syntactically incomplete so far (an unmatched brace/paren/template
11
+ // literal), whether or not the command is eventually completed by a later
12
+ // line in the same batch. It is therefore only ever a *transient* signal,
13
+ // never by itself proof that a command is permanently stuck -- see
14
+ // checkForStuckIncompleteInput() for how genuine incompleteness is
15
+ // determined. Verified empirically on Node v26.
16
+ const CONTINUATION_PROMPT = '| ';
17
+ class ReplSession {
18
+ constructor(opts) {
19
+ this.opts = opts;
20
+ this.input = new node_stream_1.PassThrough();
21
+ // A prompt string unique to this instance (via crypto.randomUUID()) so
22
+ // that no two sessions ever share a sentinel, and collisions with
23
+ // arbitrary user-controlled output are effectively impossible. node:repl
24
+ // (with terminal: false) re-writes the prompt to `output` exactly once
25
+ // per completed top-level statement, and, critically, only after that
26
+ // statement has fully settled: after a synchronous result, after an
27
+ // awaited top-level promise resolves (however long that takes), and
28
+ // after the domain-based uncaught-exception handler finishes reporting a
29
+ // thrown error. That makes "the sentinel appeared in output" a
30
+ // deterministic, timing-independent "this statement is done" signal that
31
+ // works uniformly across all of those cases.
32
+ this.sentinel = `<<webrepl:done:${(0, node_crypto_1.randomUUID)()}>>`;
33
+ this.queue = Promise.resolve();
34
+ this.buffer = '';
35
+ this.resolveCurrent = null;
36
+ this.watchdog = null;
37
+ this.closed = false;
38
+ this.evalTimeoutMs = opts.evalTimeoutMs ?? 30000;
39
+ const output = new node_stream_1.Writable({
40
+ write: (chunk, _enc, cb) => {
41
+ this.handleOutput(chunk.toString());
42
+ cb();
43
+ },
44
+ });
45
+ this.server = (0, node_repl_1.start)({
46
+ input: this.input,
47
+ output,
48
+ terminal: false,
49
+ useColors: false,
50
+ prompt: this.sentinel,
51
+ ignoreUndefined: true,
52
+ });
53
+ this.bufferedCommandSymbol = Object.getOwnPropertySymbols(this.server).find((s) => s.description === 'bufferedCommand');
54
+ for (const key of Object.keys(opts.context)) {
55
+ this.server.context[key] = opts.context[key];
56
+ }
57
+ this.server.context.console = {
58
+ log: (...a) => {
59
+ opts.onOutput(this.format(a) + '\n');
60
+ },
61
+ info: (...a) => {
62
+ opts.onOutput(this.format(a) + '\n');
63
+ },
64
+ warn: (...a) => {
65
+ opts.onOutput(this.format(a) + '\n');
66
+ },
67
+ error: (...a) => {
68
+ opts.onOutput(this.format(a) + '\n');
69
+ },
70
+ debug: (...a) => {
71
+ opts.onOutput(this.format(a) + '\n');
72
+ },
73
+ };
74
+ // The initial prompt is written synchronously inside start(), before
75
+ // this constructor returns and therefore before any eval() call could
76
+ // possibly have registered a resolver yet. handleOutput() strips it
77
+ // (resolveCurrent is still null at this point) and forwards nothing,
78
+ // so it never leaks into onOutput and never spuriously resolves the
79
+ // first real eval().
80
+ }
81
+ /**
82
+ * Buffers incoming output text, strips every occurrence of the sentinel
83
+ * before it reaches onOutput, and resolves the in-flight eval() promise
84
+ * (if any) each time a sentinel is found. A trailing partial match of
85
+ * the sentinel is held back across writes so a sentinel split across two
86
+ * stream chunks is never mistakenly forwarded or missed.
87
+ *
88
+ * The bare continuation-prompt marker is filtered out (never forwarded
89
+ * as output) but, on its own, is NOT treated as a "this command is
90
+ * stuck" signal -- a legitimate multi-line command supplied as one
91
+ * eval() string transiently produces this marker for every line that
92
+ * doesn't yet close out a statement before the final line completes it.
93
+ * Reacting to the first occurrence would wrongly abort those valid
94
+ * commands. See checkForStuckIncompleteInput() for the real recovery
95
+ * path.
96
+ */
97
+ handleOutput(text) {
98
+ if (text === CONTINUATION_PROMPT) {
99
+ return;
100
+ }
101
+ this.buffer += text;
102
+ let idx;
103
+ while ((idx = this.buffer.indexOf(this.sentinel)) !== -1) {
104
+ const before = this.buffer.slice(0, idx);
105
+ if (before.length)
106
+ this.opts.onOutput(before);
107
+ this.buffer = this.buffer.slice(idx + this.sentinel.length);
108
+ this.settle();
109
+ }
110
+ const holdBack = this.partialSentinelSuffixLength(this.buffer);
111
+ if (this.buffer.length > holdBack) {
112
+ const flush = this.buffer.slice(0, this.buffer.length - holdBack);
113
+ this.opts.onOutput(flush);
114
+ this.buffer = this.buffer.slice(this.buffer.length - holdBack);
115
+ }
116
+ }
117
+ /** Length of the longest suffix of `text` that is also a prefix of the sentinel. */
118
+ partialSentinelSuffixLength(text) {
119
+ const max = Math.min(text.length, this.sentinel.length - 1);
120
+ for (let len = max; len > 0; len--) {
121
+ if (text.endsWith(this.sentinel.slice(0, len)))
122
+ return len;
123
+ }
124
+ return 0;
125
+ }
126
+ /**
127
+ * Called shortly after a command has been written to `input`, once
128
+ * node:repl has had a chance to drain and process every line of it
129
+ * (including every line of a multi-line command written as one
130
+ * string). If the eval already resolved via the sentinel, this is a
131
+ * no-op. Otherwise, reads node:repl's own buffered-command state: a
132
+ * non-empty buffer at this point means the submitted text left a
133
+ * trailing statement that is syntactically incomplete and will never
134
+ * complete on its own (there is no more input coming for it), so we
135
+ * recover instead of waiting forever. An empty buffer means parsing
136
+ * fully consumed the input -- whether it already finished evaluating
137
+ * (sync) or is still running (e.g. a pending top-level await) -- so we
138
+ * leave it alone and let the sentinel (or, failing that, the watchdog)
139
+ * resolve it whenever it's actually done.
140
+ */
141
+ checkForStuckIncompleteInput() {
142
+ if (!this.resolveCurrent)
143
+ return;
144
+ if (this.bufferedCommandSymbol === undefined)
145
+ return;
146
+ const buffered = this.server[this.bufferedCommandSymbol];
147
+ if (typeof buffered === 'string' && buffered.length > 0) {
148
+ this.recoverFromIncompleteInput();
149
+ }
150
+ }
151
+ /**
152
+ * Discards a syntactically-incomplete buffered command so the session
153
+ * doesn't wait forever for input that will never arrive as a
154
+ * continuation of it, reports the failure, and unblocks the eval queue.
155
+ */
156
+ recoverFromIncompleteInput() {
157
+ this.server.clearBufferedCommand?.();
158
+ this.opts.onOutput('SyntaxError: incomplete input\n');
159
+ this.settle();
160
+ }
161
+ /** Resolves the in-flight eval() (if any) and clears its watchdog timer. */
162
+ settle() {
163
+ if (this.watchdog) {
164
+ clearTimeout(this.watchdog);
165
+ this.watchdog = null;
166
+ }
167
+ const resolve = this.resolveCurrent;
168
+ this.resolveCurrent = null;
169
+ resolve?.();
170
+ }
171
+ format(args) {
172
+ return args
173
+ .map((a) => (typeof a === 'string' ? a : (0, node_util_1.inspect)(a)))
174
+ .join(' ');
175
+ }
176
+ eval(command) {
177
+ this.queue = this.queue.then(() => this.runLine(command));
178
+ return this.queue;
179
+ }
180
+ runLine(command) {
181
+ if (this.closed) {
182
+ // The session was closed before this queued eval() got its turn.
183
+ // Resolve immediately rather than writing to a destroyed input
184
+ // stream (which would hang or error).
185
+ return Promise.resolve();
186
+ }
187
+ return new Promise((resolve) => {
188
+ // Registering the resolver before writing guarantees we never miss
189
+ // the sentinel that reports this command's completion, however long
190
+ // (or short) evaluation takes.
191
+ this.resolveCurrent = resolve;
192
+ // Defense-in-depth safety net: the drain-check below is the
193
+ // primary, deterministic mechanism for avoiding a permanent hang on
194
+ // incomplete input. This watchdog only matters if some other,
195
+ // unanticipated input shape defeats both the sentinel and the
196
+ // buffered-command check; it should not fire in normal use.
197
+ this.watchdog = setTimeout(() => {
198
+ this.server.clearBufferedCommand?.();
199
+ this.opts.onOutput('Error: eval timed out\n');
200
+ this.settle();
201
+ }, this.evalTimeoutMs);
202
+ this.watchdog.unref?.();
203
+ this.input.write(command + '\n');
204
+ // Give node:repl's readline two macrotask ticks to fully drain and
205
+ // process every line of the command just written (verified
206
+ // empirically sufficient on Node v26, including for multi-line
207
+ // commands where all of the processing actually happens
208
+ // synchronously within the input.write() call above), then check
209
+ // whether it's left holding an incomplete trailing statement.
210
+ setImmediate(() => setImmediate(() => this.checkForStuckIncompleteInput()));
211
+ });
212
+ }
213
+ close() {
214
+ this.closed = true;
215
+ // Resolve any eval() a caller might still be awaiting so shutdown
216
+ // never leaves it hanging.
217
+ this.settle();
218
+ this.input.end();
219
+ this.input.destroy();
220
+ this.server.close();
221
+ }
222
+ }
223
+ exports.ReplSession = ReplSession;
@@ -0,0 +1 @@
1
+ export declare function renderReplUi(channel: string): string;
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderReplUi = renderReplUi;
4
+ function renderReplUi(channel) {
5
+ const safe = channel.replace(/[<>"'&]/g, '');
6
+ // Escapes the channel for safe embedding inside the inline <script> block
7
+ // below. JSON.stringify alone does NOT escape "</script>" or the U+2028/
8
+ // U+2029 line separators, so an attacker-controlled channel like
9
+ // "</script><script>alert(1)</script>" (a URL path segment) would close
10
+ // the script tag early and inject markup the HTML parser executes.
11
+ // Escaping "<" to "<" inside the JS string literal preserves the
12
+ // exact value ("<" IS "<" once JS parses the literal) while ensuring the
13
+ // HTML parser -- which tokenizes the raw bytes before any JS runs --
14
+ // never sees a literal "</script>" sequence.
15
+ const channelLiteral = JSON.stringify(channel)
16
+ .replace(/</g, '\\u003c')
17
+ .replace(/[\u2028\u2029]/g, (ch) => (ch === '\u2028' ? '\\u2028' : '\\u2029'));
18
+ return `<!doctype html>
19
+ <html lang="en">
20
+ <head>
21
+ <meta charset="utf-8" />
22
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
23
+ <title>REPL: ${safe}</title>
24
+ <style>
25
+ html,body{height:100%;margin:0;font-family:ui-monospace,Menlo,Consolas,monospace;background:#1e1e1e;color:#ddd}
26
+ #app{display:flex;flex-direction:column;height:100vh}
27
+ #out{flex:1;overflow:auto;padding:10px;white-space:pre-wrap;font-size:13px;line-height:1.4}
28
+ #out .cmd{color:#4ec9b0}#out .sys{color:#888}#out .err{color:#f14c4c}
29
+ #editor{height:32%;border-top:1px solid #333}
30
+ #bar{display:flex;gap:12px;align-items:center;padding:6px 10px;background:#252526;font-size:12px;border-top:1px solid #333}
31
+ #bar .dot{width:8px;height:8px;border-radius:50%;background:#666;display:inline-block;margin-right:4px}
32
+ #bar .dot.on{background:#3fb950}
33
+ button{background:#0e639c;color:#fff;border:0;padding:4px 12px;border-radius:3px;cursor:pointer}
34
+ </style>
35
+ </head>
36
+ <body>
37
+ <div id="app">
38
+ <div id="out"></div>
39
+ <div id="editor"></div>
40
+ <div id="bar">
41
+ <span><span id="dot" class="dot"></span><span id="state">connecting…</span></span>
42
+ <span>channel: <b>${safe}</b></span>
43
+ <span id="owner"></span>
44
+ <span style="margin-left:auto"><button id="run">Run ▶ (Ctrl+Enter)</button></span>
45
+ </div>
46
+ </div>
47
+ <script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.52.0/min/vs/loader.js"></script>
48
+ <script>
49
+ const channel = ${channelLiteral};
50
+ const channelPath = encodeURIComponent(channel);
51
+ const out = document.getElementById('out');
52
+ const stateEl = document.getElementById('state');
53
+ const dot = document.getElementById('dot');
54
+ const ownerEl = document.getElementById('owner');
55
+ function append(text, cls){ const d=document.createElement('div'); if(cls)d.className=cls; d.textContent=text; out.appendChild(d); out.scrollTop=out.scrollHeight; }
56
+ function appendInline(text, cls){
57
+ let last = out.lastElementChild;
58
+ if(!last || last.dataset.kind !== 'output' || cls){
59
+ last = document.createElement('div');
60
+ last.dataset.kind = 'output';
61
+ if(cls) last.className = cls;
62
+ out.appendChild(last);
63
+ }
64
+ last.textContent += text;
65
+ out.scrollTop = out.scrollHeight;
66
+ }
67
+
68
+ const es = new EventSource('../' + channelPath);
69
+ es.onopen = () => { dot.classList.add('on'); stateEl.textContent='connected'; };
70
+ es.onerror = () => { dot.classList.remove('on'); stateEl.textContent='reconnecting…'; };
71
+ es.onmessage = (m) => {
72
+ const ev = JSON.parse(m.data);
73
+ if(ev.type==='command'){
74
+ // data: { command, instanceId }
75
+ append('> ' + ev.data.command, 'cmd');
76
+ if(ev.data && ev.data.instanceId) ownerEl.textContent='owner: '+ev.data.instanceId;
77
+ }
78
+ else if(ev.type==='output'){
79
+ // data is a raw string chunk, not { chunk }
80
+ appendInline(String(ev.data));
81
+ }
82
+ else if(ev.type==='system'){
83
+ const data = ev.data || {};
84
+ if(data.ping){
85
+ // heartbeat, id:0 -- render nothing
86
+ return;
87
+ }
88
+ if(data.done){
89
+ // command finished -- not rendered as noise
90
+ return;
91
+ }
92
+ if(typeof data.error === 'string'){
93
+ append('! ' + data.error, 'err');
94
+ return;
95
+ }
96
+ // other system notices (e.g. ownership info) -- render dim
97
+ append('[' + JSON.stringify(data) + ']', 'sys');
98
+ if(data.instanceId) ownerEl.textContent='owner: '+data.instanceId;
99
+ }
100
+ };
101
+
102
+ let editor;
103
+ require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.0/min/vs' }});
104
+ require(['vs/editor/editor.main'], function(){
105
+ editor = monaco.editor.create(document.getElementById('editor'), {
106
+ value: '', language: 'typescript', theme: 'vs-dark', minimap:{enabled:false}, automaticLayout:true
107
+ });
108
+ editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, run);
109
+ });
110
+ async function run(){
111
+ const command = editor.getValue().trim();
112
+ if(!command) return;
113
+ editor.setValue('');
114
+ await fetch('../' + channelPath, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ command }) });
115
+ }
116
+ document.getElementById('run').addEventListener('click', run);
117
+ </script>
118
+ </body>
119
+ </html>`;
120
+ }
@@ -0,0 +1,19 @@
1
+ import { type MessageEvent } from '@nestjs/common';
2
+ import { type Observable } from 'rxjs';
3
+ import type { WebReplModuleOptions } from './interfaces/web-repl-options.interface';
4
+ import type { WebReplEvent } from './interfaces/web-repl-messages.interface';
5
+ import { WebReplService } from './web-repl.service';
6
+ export declare function toSseFrame(event: WebReplEvent, lastRealId: number): MessageEvent;
7
+ export declare class WebReplController {
8
+ protected readonly service: WebReplService;
9
+ private readonly options;
10
+ constructor(service: WebReplService, options: WebReplModuleOptions);
11
+ dispatch(channel: string, body: {
12
+ command?: unknown;
13
+ }): Promise<{
14
+ accepted: true;
15
+ commandId: string;
16
+ }>;
17
+ stream(channel: string, lastEventId?: string): Observable<MessageEvent>;
18
+ ui(channel: string): string;
19
+ }
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
12
+ return function (target, key) { decorator(target, key, paramIndex); }
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.WebReplController = void 0;
16
+ exports.toSseFrame = toSseFrame;
17
+ const common_1 = require("@nestjs/common");
18
+ const rxjs_1 = require("rxjs");
19
+ const constants_1 = require("./constants");
20
+ const web_repl_service_1 = require("./web-repl.service");
21
+ const repl_ui_html_1 = require("./ui/repl-ui.html");
22
+ // IMPORTANT 2: the heartbeat is emitted as a real WebReplEvent with the
23
+ // sentinel `id: 0`. Per the SSE spec, any non-null `id` field (including
24
+ // "0") overwrites the browser EventSource's last-event-id -- so if every
25
+ // heartbeat carried `id: "0"`, almost any reconnect after an idle period
26
+ // would send `Last-Event-ID: 0`, and EventRingBuffer.since(0) returns the
27
+ // entire buffer, defeating replay dedup.
28
+ //
29
+ // NOTE: this can't be fixed by simply *omitting* `id` from the MessageEvent
30
+ // -- @nestjs/core's SseStream.writeMessage() auto-assigns its own
31
+ // monotonically increasing id to any message whose `id` is undefined/null
32
+ // (see node_modules/@nestjs/core/router/sse-stream.js), so an "omitted" id
33
+ // still lands on the wire as a small, framework-generated counter value
34
+ // that is just as disconnected from real event ids as the "0" sentinel
35
+ // was -- reproducing the same "reconnect replays everything" bug through a
36
+ // different number. Instead, stamp every heartbeat with the id of the
37
+ // *last real event this connection has actually sent* (or the client's
38
+ // original Last-Event-ID cursor if none has been sent yet on this
39
+ // connection). Per the SSE spec, receiving an id equal to the one the
40
+ // client already has is a no-op for its reconnection cursor, so heartbeats
41
+ // stop perturbing Last-Event-ID once at least one real event has flowed.
42
+ // Exported as a pure function so it's directly unit-testable without
43
+ // asserting on raw SSE wire text.
44
+ function toSseFrame(event, lastRealId) {
45
+ const id = event.id === 0 ? lastRealId : event.id;
46
+ return { id: String(id), type: 'message', data: JSON.stringify(event) };
47
+ }
48
+ let WebReplController = class WebReplController {
49
+ constructor(service, options) {
50
+ this.service = service;
51
+ this.options = options;
52
+ }
53
+ async dispatch(channel, body) {
54
+ // CRITICAL: forRootAsync always registers this controller regardless
55
+ // of the resolved `enabled` value (providers/controllers must be
56
+ // declared statically before useFactory ever runs). Re-check at
57
+ // request time so a disabled async module 404s on every route, just
58
+ // like the sync forRoot(enabled: false) path that registers nothing.
59
+ if (!this.options.enabled)
60
+ throw new common_1.NotFoundException();
61
+ if (typeof body?.command !== 'string' || body.command.trim().length === 0) {
62
+ throw new common_1.BadRequestException('command must be a non-empty string');
63
+ }
64
+ const { commandId } = await this.service.dispatch(channel, body.command);
65
+ return { accepted: true, commandId };
66
+ }
67
+ stream(channel, lastEventId) {
68
+ if (!this.options.enabled)
69
+ throw new common_1.NotFoundException();
70
+ const parsed = lastEventId ? Number(lastEventId) : null;
71
+ const cursor = Number.isNaN(parsed) ? null : parsed;
72
+ // Tracks the id of the last real (non-heartbeat) event sent on THIS
73
+ // connection, so heartbeats can be stamped with it instead of the "0"
74
+ // sentinel -- see toSseFrame() above. Seeded from the client's own
75
+ // reconnect cursor (or 0, meaning "nothing yet") so a heartbeat fired
76
+ // before any real event still reports a harmless/idempotent id.
77
+ let lastRealId = cursor ?? 0;
78
+ return this.service.stream(channel, cursor).pipe((0, rxjs_1.map)((event) => {
79
+ const frame = toSseFrame(event, lastRealId);
80
+ if (event.id !== 0)
81
+ lastRealId = event.id;
82
+ return frame;
83
+ }));
84
+ }
85
+ ui(channel) {
86
+ if (!this.options.enabled)
87
+ throw new common_1.NotFoundException();
88
+ return (0, repl_ui_html_1.renderReplUi)(channel);
89
+ }
90
+ };
91
+ exports.WebReplController = WebReplController;
92
+ __decorate([
93
+ (0, common_1.Post)(':channel'),
94
+ (0, common_1.HttpCode)(202),
95
+ __param(0, (0, common_1.Param)('channel')),
96
+ __param(1, (0, common_1.Body)()),
97
+ __metadata("design:type", Function),
98
+ __metadata("design:paramtypes", [String, Object]),
99
+ __metadata("design:returntype", Promise)
100
+ ], WebReplController.prototype, "dispatch", null);
101
+ __decorate([
102
+ (0, common_1.Sse)(':channel'),
103
+ __param(0, (0, common_1.Param)('channel')),
104
+ __param(1, (0, common_1.Headers)('last-event-id')),
105
+ __metadata("design:type", Function),
106
+ __metadata("design:paramtypes", [String, String]),
107
+ __metadata("design:returntype", Function)
108
+ ], WebReplController.prototype, "stream", null);
109
+ __decorate([
110
+ (0, common_1.Get)(':channel/ui'),
111
+ (0, common_1.Header)('content-type', 'text/html'),
112
+ __param(0, (0, common_1.Param)('channel')),
113
+ __metadata("design:type", Function),
114
+ __metadata("design:paramtypes", [String]),
115
+ __metadata("design:returntype", String)
116
+ ], WebReplController.prototype, "ui", null);
117
+ exports.WebReplController = WebReplController = __decorate([
118
+ (0, common_1.Controller)('repl'),
119
+ __param(1, (0, common_1.Inject)(constants_1.WEB_REPL_OPTIONS)),
120
+ __metadata("design:paramtypes", [web_repl_service_1.WebReplService, Object])
121
+ ], WebReplController);
@@ -0,0 +1,8 @@
1
+ import { type DynamicModule } from '@nestjs/common';
2
+ import type { WebReplModuleOptions, WebReplModuleAsyncOptions } from './interfaces/web-repl-options.interface';
3
+ export declare class WebReplModule {
4
+ static forRoot(options: WebReplModuleOptions): DynamicModule;
5
+ static forRootAsync(async: WebReplModuleAsyncOptions): DynamicModule;
6
+ private static assemble;
7
+ private static sharedProviders;
8
+ }