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,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
|
+
}
|