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,82 @@
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 WebReplModule_1;
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.WebReplModule = void 0;
11
+ const common_1 = require("@nestjs/common");
12
+ const core_1 = require("@nestjs/core");
13
+ const constants_1 = require("./constants");
14
+ const in_memory_web_repl_adapter_1 = require("./adapters/in-memory-web-repl.adapter");
15
+ const web_repl_service_1 = require("./web-repl.service");
16
+ const web_repl_controller_1 = require("./web-repl.controller");
17
+ const build_repl_context_1 = require("./context/build-repl-context");
18
+ const CONTEXT_FACTORY = 'WEB_REPL_CONTEXT_FACTORY';
19
+ let WebReplModule = WebReplModule_1 = class WebReplModule {
20
+ static forRoot(options) {
21
+ if (!options.enabled)
22
+ return { module: WebReplModule_1 };
23
+ return this.assemble([{ provide: constants_1.WEB_REPL_OPTIONS, useValue: options }], options);
24
+ }
25
+ static forRootAsync(async) {
26
+ const optionsProvider = {
27
+ provide: constants_1.WEB_REPL_OPTIONS,
28
+ useFactory: async.useFactory,
29
+ inject: async.inject ?? [],
30
+ };
31
+ // registerController is read from resolved options only at request time
32
+ // in the sense that this module has no way to inspect the value
33
+ // useFactory will eventually produce until it runs -- and providers /
34
+ // controllers must be declared statically. So forRootAsync always
35
+ // registers the default controller; callers who need to omit it should
36
+ // use forRoot() with a statically-known `registerController: false`.
37
+ return {
38
+ module: WebReplModule_1,
39
+ imports: async.imports ?? [],
40
+ providers: [optionsProvider, ...this.sharedProviders()],
41
+ controllers: [web_repl_controller_1.WebReplController],
42
+ // WEB_REPL_OPTIONS must be exported alongside WebReplService: since
43
+ // WebReplController (and any subclass of it, per the README's
44
+ // "Securing it" pattern) now injects WEB_REPL_OPTIONS to enforce
45
+ // `enabled` at runtime (see CRITICAL 1), a caller who mounts their
46
+ // own guarded subclass controller on a SIBLING module (with
47
+ // registerController: false) needs to be able to resolve it too.
48
+ exports: [web_repl_service_1.WebReplService, constants_1.WEB_REPL_OPTIONS],
49
+ };
50
+ }
51
+ static assemble(optionProviders, options) {
52
+ return {
53
+ module: WebReplModule_1,
54
+ providers: [...optionProviders, ...this.sharedProviders()],
55
+ controllers: options.registerController === false ? [] : [web_repl_controller_1.WebReplController],
56
+ exports: [web_repl_service_1.WebReplService, constants_1.WEB_REPL_OPTIONS],
57
+ };
58
+ }
59
+ static sharedProviders() {
60
+ return [
61
+ {
62
+ provide: constants_1.WEB_REPL_ADAPTER,
63
+ useFactory: (opts) => opts.adapter ?? new in_memory_web_repl_adapter_1.InMemoryWebReplAdapter(),
64
+ inject: [constants_1.WEB_REPL_OPTIONS],
65
+ },
66
+ {
67
+ provide: CONTEXT_FACTORY,
68
+ useFactory: (moduleRef) => () => (0, build_repl_context_1.buildReplContext)(moduleRef),
69
+ inject: [core_1.ModuleRef],
70
+ },
71
+ {
72
+ provide: constants_1.WEB_REPL_CLOCK,
73
+ useValue: () => Date.now(),
74
+ },
75
+ web_repl_service_1.WebReplService,
76
+ ];
77
+ }
78
+ };
79
+ exports.WebReplModule = WebReplModule;
80
+ exports.WebReplModule = WebReplModule = WebReplModule_1 = __decorate([
81
+ (0, common_1.Module)({})
82
+ ], WebReplModule);
@@ -0,0 +1,46 @@
1
+ import { type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
2
+ import { Observable } from 'rxjs';
3
+ import type { WebReplAdapter } from './interfaces/web-repl-adapter.interface';
4
+ import type { WebReplModuleOptions } from './interfaces/web-repl-options.interface';
5
+ import type { WebReplEvent } from './interfaces/web-repl-messages.interface';
6
+ type ContextFactory = () => Record<string, unknown>;
7
+ export declare class WebReplService implements OnModuleInit, OnModuleDestroy {
8
+ private readonly options;
9
+ private readonly adapter;
10
+ private readonly buildContext;
11
+ private readonly clock;
12
+ private readonly logger;
13
+ private readonly instanceId;
14
+ private readonly sessionTtl;
15
+ private readonly replayBufferSize;
16
+ private readonly heartbeatInterval;
17
+ private readonly enabled;
18
+ private readonly ownership;
19
+ private readonly ownerSeenAt;
20
+ private readonly ownerHeartbeatInterval;
21
+ private readonly ownerLeaseTtl;
22
+ private ownerHeartbeatTimer?;
23
+ private readonly channels;
24
+ private counter;
25
+ constructor(options: WebReplModuleOptions, adapter: WebReplAdapter, buildContext: ContextFactory, clock?: () => number);
26
+ onModuleInit(): Promise<void>;
27
+ onModuleDestroy(): Promise<void>;
28
+ private heartbeatOwnedChannels;
29
+ dispatch(channel: string, command: string): Promise<{
30
+ commandId: string;
31
+ }>;
32
+ stream(channel: string, lastEventId: number | null): Observable<WebReplEvent>;
33
+ private onCmd;
34
+ private executeCommand;
35
+ private ensureSession;
36
+ private emit;
37
+ private onOut;
38
+ private onSys;
39
+ private onUnsubscribe;
40
+ private maybeEvict;
41
+ private touchTtl;
42
+ private getChannel;
43
+ private safePublish;
44
+ private parse;
45
+ }
46
+ export {};
@@ -0,0 +1,402 @@
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.WebReplService = void 0;
16
+ const common_1 = require("@nestjs/common");
17
+ const rxjs_1 = require("rxjs");
18
+ const operators_1 = require("rxjs/operators");
19
+ const constants_1 = require("./constants");
20
+ const ring_buffer_1 = require("./ring-buffer");
21
+ const repl_session_1 = require("./session/repl-session");
22
+ let WebReplService = class WebReplService {
23
+ constructor(options, adapter, buildContext, clock = () => Date.now()) {
24
+ this.options = options;
25
+ this.adapter = adapter;
26
+ this.buildContext = buildContext;
27
+ this.clock = clock;
28
+ this.logger = new common_1.Logger('WebReplService');
29
+ this.ownership = new Map();
30
+ // Tracks, per channel, the clock() timestamp of the last claim/heartbeat
31
+ // seen for its current owner -- the ownership *lease*. See onCmd's
32
+ // staleness check and heartbeatOwnedChannels().
33
+ this.ownerSeenAt = new Map();
34
+ this.channels = new Map();
35
+ this.counter = 0;
36
+ this.instanceId = options.instanceId ?? `inst_${Math.random().toString(36).slice(2, 10)}`;
37
+ this.sessionTtl = options.sessionTtl ?? constants_1.DEFAULTS.sessionTtl;
38
+ this.replayBufferSize = options.replayBufferSize ?? constants_1.DEFAULTS.replayBufferSize;
39
+ this.heartbeatInterval = options.heartbeatInterval ?? constants_1.DEFAULTS.heartbeatInterval;
40
+ this.enabled = options.enabled;
41
+ this.ownerHeartbeatInterval = options.ownerHeartbeatInterval ?? constants_1.DEFAULTS.ownerHeartbeatInterval;
42
+ const requestedLeaseTtl = options.ownerLeaseTtl ?? constants_1.DEFAULTS.ownerLeaseTtl;
43
+ // Enforce a MARGIN, not just strict inequality: a lease only one tick
44
+ // above the heartbeat interval leaves zero slack for publish/adapter
45
+ // delivery jitter (measured on a PEER's clock) -- a single heartbeat
46
+ // delivered even slightly late could make a live owner look stale to
47
+ // someone else, causing exactly the preemption/split-session/lost-vars
48
+ // failure this feature exists to prevent. Requiring the lease to be at
49
+ // least 2x the heartbeat interval guarantees a live owner always has at
50
+ // least one full heartbeat interval of slack against jitter before it
51
+ // could ever appear stale to a peer.
52
+ const minLeaseTtl = this.ownerHeartbeatInterval * 2;
53
+ if (requestedLeaseTtl < minLeaseTtl) {
54
+ // Clamp rather than throw: this is a debugging tool, it shouldn't fail
55
+ // boot over a misconfigured pair of durations.
56
+ this.ownerLeaseTtl = minLeaseTtl;
57
+ this.logger.warn(`ownerLeaseTtl (${requestedLeaseTtl}ms) must be at least 2x ` +
58
+ `ownerHeartbeatInterval (${this.ownerHeartbeatInterval}ms) so a live owner ` +
59
+ `always has a full heartbeat interval of slack against delivery jitter; ` +
60
+ `clamping the effective lease to ${this.ownerLeaseTtl}ms`);
61
+ }
62
+ else {
63
+ this.ownerLeaseTtl = requestedLeaseTtl;
64
+ }
65
+ }
66
+ async onModuleInit() {
67
+ // CRITICAL: `enabled` is the product's only safety rail. forRoot()
68
+ // already refuses to register anything when disabled, but
69
+ // forRootAsync() resolves options at DI time and always registers this
70
+ // service/controller -- so this runtime check is what keeps a
71
+ // config-driven `enabled: false` (e.g. from forRootAsync) from
72
+ // silently subscribing to the command bus and executing arbitrary code.
73
+ if (!this.enabled)
74
+ return;
75
+ await this.adapter.subscribe(constants_1.TOPICS.cmd, (m) => this.onCmd(this.parse(m)));
76
+ await this.adapter.subscribe(constants_1.TOPICS.out, (m) => this.onOut(this.parse(m)));
77
+ await this.adapter.subscribe(constants_1.TOPICS.sys, (m) => this.onSys(this.parse(m)));
78
+ // Owner-liveness safety net: a live owner must keep renewing its lease
79
+ // on every channel it owns, or another instance may treat that channel
80
+ // as effectively ownerless once the lease expires (see onCmd). Only
81
+ // started when enabled, mirroring the runtime `enabled` enforcement
82
+ // above -- a disabled instance must never touch the adapter, including
83
+ // via this timer.
84
+ this.ownerHeartbeatTimer = setInterval(() => this.heartbeatOwnedChannels(), this.ownerHeartbeatInterval);
85
+ this.ownerHeartbeatTimer.unref?.();
86
+ }
87
+ async onModuleDestroy() {
88
+ if (this.ownerHeartbeatTimer)
89
+ clearInterval(this.ownerHeartbeatTimer);
90
+ for (const state of this.channels.values()) {
91
+ if (state.ttlTimer)
92
+ clearTimeout(state.ttlTimer);
93
+ state.session?.close();
94
+ state.live.complete();
95
+ }
96
+ this.channels.clear();
97
+ await this.adapter.onModuleDestroy?.();
98
+ }
99
+ // Re-announces `claim` for every channel this instance currently owns and
100
+ // still has a live session for -- i.e. is actually still running. This is
101
+ // the heartbeat that keeps a live owner's lease from ever going stale
102
+ // (see onCmd's leaseExpired check). A channel this instance owns per the
103
+ // `ownership` map but no longer has a session for (e.g. torn down by a TTL
104
+ // release already in flight) is intentionally NOT re-claimed here.
105
+ heartbeatOwnedChannels() {
106
+ for (const [channel, state] of this.channels) {
107
+ if (this.ownership.get(channel) !== this.instanceId)
108
+ continue;
109
+ if (!state.session)
110
+ continue;
111
+ void this.safePublish(constants_1.TOPICS.sys, {
112
+ channel,
113
+ kind: 'claim',
114
+ instanceId: this.instanceId,
115
+ });
116
+ }
117
+ }
118
+ async dispatch(channel, command) {
119
+ if (!this.enabled)
120
+ throw new common_1.NotFoundException();
121
+ const commandId = `cmd_${(++this.counter).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
122
+ const msg = { channel, commandId, command, originInstanceId: this.instanceId };
123
+ await this.safePublish(constants_1.TOPICS.cmd, msg);
124
+ return { commandId };
125
+ }
126
+ stream(channel, lastEventId) {
127
+ if (!this.enabled)
128
+ throw new common_1.NotFoundException();
129
+ // Wrapped in defer() so the replay snapshot is taken -- and the
130
+ // subscriber-count bookkeeping runs -- at SUBSCRIBE time rather than
131
+ // when the controller calls stream(). Without this, an event
132
+ // published between the call to stream() and Nest actually attaching
133
+ // the subscription would be missed by both the frozen replay snapshot
134
+ // and the not-yet-attached live subscription (MINOR 4).
135
+ return (0, rxjs_1.defer)(() => {
136
+ const state = this.getChannel(channel);
137
+ state.subscriberCount++;
138
+ const replayed = state.buffer.since(lastEventId);
139
+ const heartbeat = (0, rxjs_1.interval)(this.heartbeatInterval).pipe((0, operators_1.map)(() => ({ id: 0, type: 'system', commandId: null, data: { ping: true } })));
140
+ return (0, rxjs_1.merge)((0, rxjs_1.from)(replayed), state.live.asObservable(), heartbeat).pipe((0, operators_1.finalize)(() => this.onUnsubscribe(channel)));
141
+ });
142
+ }
143
+ async onCmd(msg) {
144
+ const owner = this.ownership.get(msg.channel);
145
+ const iAmOwner = owner === this.instanceId;
146
+ // Lease staleness: an owned channel is only trusted for
147
+ // `ownerLeaseTtl` since the last claim/heartbeat seen for it. A LIVE
148
+ // owner heartbeats itself well within that window (ownerHeartbeatInterval
149
+ // < ownerLeaseTtl is enforced in the constructor), so this never fires
150
+ // for a healthy owner -- only for one that has stopped heartbeating
151
+ // (crashed / killed without a clean shutdown).
152
+ const seenAt = this.ownerSeenAt.get(msg.channel) ?? 0;
153
+ const leaseExpired = owner !== undefined && this.clock() - seenAt > this.ownerLeaseTtl;
154
+ // A channel whose lease expired under a DIFFERENT instance is treated
155
+ // as ownerless for claiming purposes -- but never for the instance that
156
+ // still (per its own map) believes it owns it; that instance keeps
157
+ // executing via the `iAmOwner` branch below regardless of this flag.
158
+ const effectivelyOwnerless = owner === undefined || (leaseExpired && owner !== this.instanceId);
159
+ const isOrigin = msg.originInstanceId === this.instanceId;
160
+ // Only the ORIGIN instance may claim an effectively-ownerless channel --
161
+ // whether it's brand new or a stale takeover -- so every other instance
162
+ // doesn't stampede the same claim.
163
+ if (!iAmOwner && !(effectivelyOwnerless && isOrigin))
164
+ return;
165
+ // Resolve (or create) the channel state and mark a command as pending
166
+ // SYNCHRONOUSLY here, before any `await` below -- i.e. within the same
167
+ // microtask that decided this instance will execute the command. This
168
+ // closes a race with channel eviction (see maybeEvict / IMPORTANT 3):
169
+ // without a `pending` guard set this early, the last SSE subscriber
170
+ // could unsubscribe -- and evict this exact ChannelState from
171
+ // `this.channels` -- in the window between here and
172
+ // executeCommand()'s ensureSession() call. That would orphan the
173
+ // freshly created ReplSession on the now off-map `state` object (never
174
+ // close()d) while later emit() calls re-resolve through getChannel()
175
+ // to a brand new state, mis-tagging this command's output with
176
+ // commandId: null and forcing a second, disconnected ReplSession to be
177
+ // created for the next command on the same channel.
178
+ const state = this.getChannel(msg.channel);
179
+ state.pending++;
180
+ if (!iAmOwner) {
181
+ // effectivelyOwnerless && isOrigin: either a brand-new channel, or a
182
+ // takeover of a channel whose previous owner's lease went stale. On
183
+ // takeover, the dead owner's in-memory session (and its variables) is
184
+ // gone -- this instance starts a fresh session. That's an accepted,
185
+ // documented trade-off: the channel becomes usable again instead of
186
+ // being wedged fleet-wide forever.
187
+ this.ownership.set(msg.channel, this.instanceId);
188
+ this.ownerSeenAt.set(msg.channel, this.clock());
189
+ await this.safePublish(constants_1.TOPICS.sys, {
190
+ channel: msg.channel,
191
+ kind: 'claim',
192
+ instanceId: this.instanceId,
193
+ });
194
+ }
195
+ this.touchTtl(msg.channel, state);
196
+ // Enqueue the whole announce -> eval -> done unit onto the channel's
197
+ // own execution queue. Without this, a second command dispatched to
198
+ // the same channel while the first's async output is still flushing
199
+ // would run its (synchronous) echo + currentCommandId assignment
200
+ // ahead of the first command's still-in-flight eval, mistagging the
201
+ // first command's trailing output with the second command's id. This
202
+ // guarantees currentCommandId is only ever the actively-executing
203
+ // command, and that a queued command's echo is only emitted once it
204
+ // actually starts executing (correct serial-execution semantics).
205
+ //
206
+ // Every link is wrapped so it always resolves, never rejects: if
207
+ // executeCommand throws (e.g. the injected buildContext() factory or
208
+ // the ReplSession constructor throws), an unguarded rejection would
209
+ // permanently poison state.execQueue -- every later
210
+ // `.then(onFulfilled)` chained onto an already-rejected promise is
211
+ // silently skipped, so every subsequent command on the channel would
212
+ // never execute again, with no client-visible error, until process
213
+ // restart. Instead we log it and best-effort surface it to clients as
214
+ // a `system` event tagged with the failing command's id, then let the
215
+ // chain continue so the channel stays usable.
216
+ state.execQueue = state.execQueue.then(() => this.executeCommand(msg, state)
217
+ .catch((err) => {
218
+ this.logger.error(`command ${msg.commandId} on ${msg.channel} failed: ${String(err)}`);
219
+ try {
220
+ return this.emit(msg.channel, 'system', msg.commandId, {
221
+ error: String(err?.message ?? err),
222
+ });
223
+ }
224
+ catch (emitErr) {
225
+ // emit() itself only throws synchronously if JSON.stringify
226
+ // fails on a pathological error message; safePublish already
227
+ // swallows adapter failures. Never let this poison the queue.
228
+ this.logger.error(`failed to report command failure: ${String(emitErr)}`);
229
+ return undefined;
230
+ }
231
+ })
232
+ .finally(() => {
233
+ // Only now -- once this command's announce -> eval -> done (or
234
+ // error) unit has fully settled -- may the channel become
235
+ // eviction-eligible again.
236
+ state.pending--;
237
+ this.maybeEvict(msg.channel, state);
238
+ }));
239
+ await state.execQueue;
240
+ }
241
+ async executeCommand(msg, state) {
242
+ const session = this.ensureSession(msg.channel, state);
243
+ await this.emit(msg.channel, 'command', msg.commandId, {
244
+ command: msg.command,
245
+ instanceId: this.instanceId,
246
+ });
247
+ await session.eval(msg.command);
248
+ // Silent statements (e.g. `const v = 10`) produce zero output chunks,
249
+ // so SSE clients have no way to know the command finished without an
250
+ // explicit completion signal. Emit one system event per executed
251
+ // command, after all of its output chunks.
252
+ await this.emit(msg.channel, 'system', msg.commandId, { done: true });
253
+ }
254
+ ensureSession(channel, state) {
255
+ if (state.session)
256
+ return state.session;
257
+ const session = new repl_session_1.ReplSession({
258
+ context: this.buildContext(),
259
+ onOutput: (chunk) => {
260
+ // Guard against a stray late write from an in-flight eval landing
261
+ // after this channel's session has been disposed/replaced (e.g.
262
+ // by a TTL release, which clears the buffer) -- ReplSession does
263
+ // not guarantee zero writes after close(). Comparing against the
264
+ // session captured in this closure (rather than a boolean flag)
265
+ // also correctly drops output from a session that's since been
266
+ // replaced by a fresh one for the same channel.
267
+ if (state.session !== session)
268
+ return;
269
+ void this.emit(channel, 'output', state.currentCommandId, chunk);
270
+ },
271
+ });
272
+ state.session = session;
273
+ return session;
274
+ }
275
+ async emit(channel, type, commandId, data) {
276
+ const state = this.getChannel(channel);
277
+ if (type === 'command')
278
+ state.currentCommandId = commandId;
279
+ const event = { id: ++state.nextId, type, commandId, data };
280
+ await this.safePublish(constants_1.TOPICS.out, { channel, event });
281
+ }
282
+ onOut(msg) {
283
+ const state = this.getChannel(msg.channel);
284
+ if (msg.event.id > state.nextId)
285
+ state.nextId = msg.event.id;
286
+ state.buffer.push(msg.event);
287
+ state.live.next(msg.event);
288
+ }
289
+ onSys(msg) {
290
+ if (msg.kind === 'claim') {
291
+ this.ownership.set(msg.channel, msg.instanceId);
292
+ this.ownerSeenAt.set(msg.channel, this.clock());
293
+ }
294
+ else {
295
+ this.ownership.delete(msg.channel);
296
+ this.ownerSeenAt.delete(msg.channel);
297
+ const state = this.channels.get(msg.channel);
298
+ if (state?.session) {
299
+ state.session.close();
300
+ state.session = undefined;
301
+ state.buffer.clear();
302
+ }
303
+ if (state)
304
+ this.maybeEvict(msg.channel, state);
305
+ }
306
+ }
307
+ // IMPORTANT 3: stream() (a bare GET) creates and stores a ChannelState
308
+ // for any channel name on first subscribe. Nothing else removes it --
309
+ // TTL release tears down the session+buffer but previously left the
310
+ // ChannelState (and its live Subject) in the map forever. An
311
+ // unauthenticated client looping `GET /repl/<random>` would grow
312
+ // `this.channels` without bound. Evict a channel once it has no live SSE
313
+ // subscribers AND no active session AND an empty replay buffer -- a
314
+ // channel with buffered history but no subscribers is deliberately kept
315
+ // around until TTL release clears its buffer, so a reconnecting client
316
+ // can still replay.
317
+ onUnsubscribe(channel) {
318
+ const state = this.channels.get(channel);
319
+ if (!state)
320
+ return;
321
+ state.subscriberCount = Math.max(0, state.subscriberCount - 1);
322
+ this.maybeEvict(channel, state);
323
+ }
324
+ maybeEvict(channel, state) {
325
+ if (state.pending > 0)
326
+ return;
327
+ if (state.subscriberCount > 0)
328
+ return;
329
+ if (state.session)
330
+ return;
331
+ if (!state.buffer.isEmpty())
332
+ return;
333
+ this.channels.delete(channel);
334
+ // Mirror the channel GC: a channel this instance no longer tracks state
335
+ // for shouldn't keep a stale ownerSeenAt entry lying around (e.g. a
336
+ // non-owner instance that watched a channel via SSE, saw a remote
337
+ // claim/heartbeat for it, then had its own local ChannelState evicted).
338
+ this.ownerSeenAt.delete(channel);
339
+ // Deliberately NOT deleting `ownership` here (unlike the onSys release
340
+ // branch, which deletes both): `ownership` is cluster-wide state --
341
+ // every instance's map should agree on who owns a channel, and that
342
+ // agreement is only supposed to change via an explicit claim/release
343
+ // message, not as a side effect of one instance's local eviction
344
+ // bookkeeping. `ownerSeenAt`, by contrast, is purely local staleness
345
+ // tracking for this instance's own onCmd checks, so it's fine (and
346
+ // necessary, to avoid leaking) to drop it here. In practice this
347
+ // instance only ever reaches eviction-eligibility (no session, empty
348
+ // buffer) for a channel it owns if execution never produced any
349
+ // buffered output at all, which -- given emit() always buffers at least
350
+ // an error/done system event before pending drops to 0 -- shouldn't
351
+ // happen while genuinely owned; if a future refactor changes that
352
+ // invariant, reconsider this asymmetry.
353
+ }
354
+ touchTtl(channel, state) {
355
+ if (state.ttlTimer)
356
+ clearTimeout(state.ttlTimer);
357
+ state.ttlTimer = setTimeout(() => {
358
+ void this.safePublish(constants_1.TOPICS.sys, {
359
+ channel,
360
+ kind: 'release',
361
+ instanceId: this.instanceId,
362
+ });
363
+ }, this.sessionTtl);
364
+ state.ttlTimer.unref?.();
365
+ }
366
+ getChannel(channel) {
367
+ let state = this.channels.get(channel);
368
+ if (!state) {
369
+ state = {
370
+ buffer: new ring_buffer_1.EventRingBuffer(this.replayBufferSize),
371
+ live: new rxjs_1.Subject(),
372
+ nextId: 0,
373
+ currentCommandId: null,
374
+ execQueue: Promise.resolve(),
375
+ subscriberCount: 0,
376
+ pending: 0,
377
+ };
378
+ this.channels.set(channel, state);
379
+ }
380
+ return state;
381
+ }
382
+ async safePublish(topic, message) {
383
+ try {
384
+ await this.adapter.publish(topic, JSON.stringify(message));
385
+ }
386
+ catch (err) {
387
+ this.logger.error(`adapter publish failed on ${topic}: ${String(err)}`);
388
+ }
389
+ }
390
+ parse(message) {
391
+ return JSON.parse(message);
392
+ }
393
+ };
394
+ exports.WebReplService = WebReplService;
395
+ exports.WebReplService = WebReplService = __decorate([
396
+ (0, common_1.Injectable)(),
397
+ __param(0, (0, common_1.Inject)(constants_1.WEB_REPL_OPTIONS)),
398
+ __param(1, (0, common_1.Inject)(constants_1.WEB_REPL_ADAPTER)),
399
+ __param(2, (0, common_1.Inject)('WEB_REPL_CONTEXT_FACTORY')),
400
+ __param(3, (0, common_1.Inject)(constants_1.WEB_REPL_CLOCK)),
401
+ __metadata("design:paramtypes", [Object, Object, Function, Function])
402
+ ], WebReplService);
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "nestjs-web-repl",
3
+ "version": "0.0.0",
4
+ "description": "Expose a live NestJS REPL over the network (HTTP + SSE + Monaco UI).",
5
+ "license": "MIT",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc -p tsconfig.build.json",
13
+ "test": "vitest run --passWithNoTests",
14
+ "test:watch": "vitest",
15
+ "example": "ts-node -T example/main.ts"
16
+ },
17
+ "peerDependencies": {
18
+ "@nestjs/common": "^10 || ^11",
19
+ "@nestjs/core": "^10 || ^11",
20
+ "rxjs": "^7"
21
+ },
22
+ "devDependencies": {
23
+ "@nestjs/common": "^11",
24
+ "@nestjs/core": "^11",
25
+ "@nestjs/platform-express": "^11",
26
+ "@nestjs/testing": "^11",
27
+ "@swc/core": "^1.15.43",
28
+ "@types/node": "^20",
29
+ "@types/supertest": "^6",
30
+ "reflect-metadata": "^0.2",
31
+ "rxjs": "^7",
32
+ "supertest": "^7",
33
+ "ts-node": "^10.9.2",
34
+ "typescript": "^5",
35
+ "unplugin-swc": "^1.5.9",
36
+ "vitest": "^2"
37
+ }
38
+ }