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.
- package/LICENSE +21 -0
- package/README.md +360 -0
- package/dist/adapters/in-memory-web-repl.adapter.d.ts +8 -0
- package/dist/adapters/in-memory-web-repl.adapter.js +20 -0
- package/dist/constants.d.ts +15 -0
- package/dist/constants.js +23 -0
- package/dist/context/build-repl-context.d.ts +28 -0
- package/dist/context/build-repl-context.js +43 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +11 -0
- package/dist/interfaces/web-repl-adapter.interface.d.ts +5 -0
- package/dist/interfaces/web-repl-adapter.interface.js +2 -0
- package/dist/interfaces/web-repl-messages.interface.d.ts +22 -0
- package/dist/interfaces/web-repl-messages.interface.js +2 -0
- package/dist/interfaces/web-repl-options.interface.d.ts +40 -0
- package/dist/interfaces/web-repl-options.interface.js +2 -0
- package/dist/ring-buffer.d.ts +10 -0
- package/dist/ring-buffer.js +27 -0
- package/dist/session/repl-session.d.ts +72 -0
- package/dist/session/repl-session.js +223 -0
- package/dist/ui/repl-ui.html.d.ts +1 -0
- package/dist/ui/repl-ui.html.js +120 -0
- package/dist/web-repl.controller.d.ts +19 -0
- package/dist/web-repl.controller.js +121 -0
- package/dist/web-repl.module.d.ts +8 -0
- package/dist/web-repl.module.js +82 -0
- package/dist/web-repl.service.d.ts +46 -0
- package/dist/web-repl.service.js +402 -0
- package/package.json +38 -0
|
@@ -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
|
+
}
|