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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Petar Popov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,360 @@
1
+ # nestjs-web-repl
2
+
3
+ Expose a live NestJS REPL over HTTP — command intake, an SSE output stream, and a
4
+ Monaco-based browser UI. Under the hood it drives a real `node:repl` session wired
5
+ into your app's Nest DI container, the same way `nest start --entrypoint repl` does,
6
+ so `get(SomeService)`, `resolve(...)`, `select(...)`, and friends all work exactly as
7
+ they do in the local REPL — except reachable over HTTP, from anywhere, against a
8
+ running server.
9
+
10
+ > ## ⚠️ Security warning — read this before enabling anything
11
+ >
12
+ > **These endpoints execute arbitrary code inside your running application.**
13
+ > A command like `require('child_process').execSync('...')` runs with the full
14
+ > privileges of your Node process. This library ships with **no authentication,
15
+ > no authorization, and no rate limiting**. The `enabled` option is the *only*
16
+ > built-in safety rail, and it is a blunt boolean — it does not check who is
17
+ > asking.
18
+ >
19
+ > Do not expose these routes on a public-facing port. Do not enable this in
20
+ > production unless you have put your own auth in front of it (see
21
+ > [Securing it](#securing-it) below). Treat this exactly like you would treat
22
+ > giving someone a shell on your server — because that is what it is.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ npm install nestjs-web-repl
28
+ ```
29
+
30
+ ## Quick start
31
+
32
+ ```ts
33
+ import { Module } from '@nestjs/common';
34
+ import { WebReplModule } from 'nestjs-web-repl';
35
+
36
+ @Module({
37
+ imports: [
38
+ WebReplModule.forRoot({
39
+ enabled: process.env.REPL_ENABLED === 'true',
40
+ }),
41
+ ],
42
+ })
43
+ export class AppModule {}
44
+ ```
45
+
46
+ Boot the app with `REPL_ENABLED=true` and open `http://localhost:3000/repl/dev/ui`
47
+ (`dev` here is just a channel name — see [Endpoints](#endpoints)). Type a command
48
+ and press `Ctrl+Enter`. The REPL context is app-wide: `get(SomeProviderFromAnyModule)`
49
+ resolves from the whole DI container, not just the module that imports
50
+ `WebReplModule`.
51
+
52
+ A runnable example lives in [`example/`](./example): `example/cat.service.ts`
53
+ registers a trivial `CatService`, `example/app.module.ts` wires up
54
+ `WebReplModule.forRoot(...)`, and `example/main.ts` boots it. Run it with:
55
+
56
+ ```bash
57
+ REPL_ENABLED=true PORT=3000 npx ts-node -T example/main.ts
58
+ # or, from a checkout of this repo: REPL_ENABLED=true PORT=3000 npm run example
59
+ ```
60
+
61
+ > Use `ts-node` (not `tsx`/esbuild-based runners) to run TypeScript sources
62
+ > directly: Nest's DI resolves constructor parameter types from
63
+ > `emitDecoratorMetadata` output, and esbuild-based transpilers do not emit
64
+ > it the way `tsc`/`ts-node` do, which breaks provider injection at runtime.
65
+
66
+ then, in the UI (or via `curl`, see below), run:
67
+
68
+ ```ts
69
+ get(CatService).findAll()
70
+ // -> [ 'Tom', 'Felix' ]
71
+ ```
72
+
73
+ ## Endpoints
74
+
75
+ Every endpoint is namespaced under a `:channel` path segment. A channel is an
76
+ arbitrary string you choose (`dev`, `prod-debug`, your username — whatever); each
77
+ channel gets its own isolated REPL session (its own variables, its own history),
78
+ and is how multiple people/tabs can share or separate REPL state.
79
+
80
+ - **`POST /repl/:channel`** — body `{ "command": "get(CatService).findAll()" }`.
81
+ Dispatches the command for execution and returns immediately:
82
+ `202 { "accepted": true, "commandId": "cmd_..." }`. The actual result arrives
83
+ asynchronously over the SSE stream below.
84
+ - **`GET /repl/:channel`** — a Server-Sent-Events stream of what happens on that
85
+ channel. Supports `Last-Event-ID` for replay (a bounded ring buffer, default
86
+ 200 events, backs each channel) so a reconnecting client doesn't miss output.
87
+ Each SSE message is JSON with `{ id, type, commandId, data }`, where `type` is
88
+ one of:
89
+ - **`command`** — echoes a dispatched command back out. `data` is
90
+ `{ command, instanceId }` (the instance that is about to run it).
91
+ - **`output`** — a chunk of REPL output. **`data` is the raw output string**
92
+ (not `{ chunk: ... }` — just the string itself), exactly as `node:repl`
93
+ wrote it (including `console.log` output and the inspected return value).
94
+ - **`system`** — control/status notices. `data` varies by shape:
95
+ - `{ ping: true }` — a heartbeat, `id: 0`, sent on `heartbeatInterval` (default
96
+ 15s) purely to keep the connection alive. Not buffered for replay.
97
+ - `{ done: true }` — sent once after a command's output finishes, since
98
+ silent statements (`const v = 10`) produce no `output` events at all and
99
+ clients otherwise have no way to know a command has finished.
100
+ - `{ error: string }` — a command failed to execute (e.g. the REPL context
101
+ factory threw); the channel stays usable afterward.
102
+ - **`GET /repl/:channel/ui`** — an HTML page: an output pane fed by the SSE
103
+ stream above, plus a Monaco editor for composing/sending commands
104
+ (`Ctrl+Enter` or the Run button posts to the endpoint above).
105
+
106
+ ### Try it with curl
107
+
108
+ ```bash
109
+ # stream (leave running in one terminal)
110
+ curl -N http://localhost:3000/repl/dev
111
+
112
+ # in another terminal, dispatch a command
113
+ curl -X POST http://localhost:3000/repl/dev \
114
+ -H 'content-type: application/json' \
115
+ -d '{"command":"get(CatService).findAll()"}'
116
+ ```
117
+
118
+ ## Securing it
119
+
120
+ Because the module ships no auth, the supported way to lock this down is to
121
+ disable the built-in controller and register your own subclass with guards:
122
+
123
+ ```ts
124
+ import { Controller, UseGuards } from '@nestjs/common';
125
+ import { WebReplController } from 'nestjs-web-repl';
126
+ import { AdminGuard } from './admin.guard';
127
+
128
+ @Controller('internal/repl')
129
+ @UseGuards(AdminGuard)
130
+ export class SecureReplController extends WebReplController {}
131
+ ```
132
+
133
+ ```ts
134
+ @Module({
135
+ imports: [
136
+ WebReplModule.forRoot({
137
+ enabled: process.env.REPL_ENABLED === 'true',
138
+ registerController: false, // don't register the unguarded default controller
139
+ }),
140
+ ],
141
+ controllers: [SecureReplController], // register yours instead
142
+ })
143
+ export class AppModule {}
144
+ ```
145
+
146
+ > **Note:** `registerController: false` only takes effect via the synchronous
147
+ > `forRoot(...)`. `forRootAsync(...)` always registers the default,
148
+ > **unguarded** `WebReplController`, because whether to declare a controller is
149
+ > a static, module-definition-time decision in Nest, and `forRootAsync`'s
150
+ > options aren't resolved until DI runs (after controllers are already fixed).
151
+ > If you need async configuration (e.g. options from a `ConfigService`) *and*
152
+ > a guarded controller, resolve your options synchronously up front (read env
153
+ > vars / config directly) and call `forRoot(...)`, or put a guard in front of
154
+ > the route at the HTTP-adapter/middleware level instead.
155
+
156
+ ## Adapter / multi-instance
157
+
158
+ If you run more than one instance of your app (multiple processes, pods,
159
+ etc.), each instance would otherwise get its own isolated in-memory REPL —
160
+ confusing if you dispatch a command from one browser tab and it lands on a
161
+ different instance than the one holding your session's variables. Web-repl
162
+ solves this with an **ownership + fan-out** protocol:
163
+
164
+ - The **first instance** to see a command for a given channel claims
165
+ ownership of that channel (broadcasting an internal `claim` message on the
166
+ `webrepl:sys` adapter topic — not a client-visible SSE event; see
167
+ [Endpoints](#endpoints)) and is the only instance that actually runs
168
+ commands on it from then on.
169
+ - Every instance still receives and displays that channel's `output` events
170
+ (via fan-out), so any tab watching that channel's SSE stream sees the same
171
+ output regardless of which instance it's connected to.
172
+ - A channel's ownership is released after `sessionTtl` (default 30 minutes)
173
+ of inactivity, freeing it to be re-claimed by whichever instance next
174
+ receives a command for it.
175
+ - Ownership is also a **lease**: the owning instance re-announces `claim`
176
+ for every channel it owns every `ownerHeartbeatInterval` (default 10s). If
177
+ no claim/heartbeat has been seen for a channel's owner in `ownerLeaseTtl`
178
+ (default 30s) — because that instance crashed or was killed without a
179
+ clean shutdown — the channel is treated as effectively ownerless, and the
180
+ origin instance of the next command for it takes over. `ownerLeaseTtl` is
181
+ enforced to be at least `ownerHeartbeatInterval * 2` (clamped up with a
182
+ warning otherwise), so a live owner always has a full heartbeat interval
183
+ of slack against publish/delivery jitter — a live, heartbeating owner is
184
+ never preempted this way. Takeover loses that channel's in-memory
185
+ variables (the dead owner's session is gone) but restores availability
186
+ instead of leaving the channel wedged fleet-wide (see
187
+ [Limitations](#limitations-v1)).
188
+ - Because ownership is decided by whichever instance's `onCmd` handler runs
189
+ first, two instances racing to claim the same brand-new channel at the
190
+ same instant resolve **last-claim-wins** (see [Limitations](#limitations-v1)).
191
+
192
+ By default this coordination happens via `InMemoryWebReplAdapter`, which only
193
+ works within a single process (fine for local dev / single-instance
194
+ deployments). For real multi-instance deployments, provide your own adapter
195
+ that implements:
196
+
197
+ ```ts
198
+ export interface WebReplAdapter {
199
+ publish(topic: string, message: string): Promise<void>;
200
+ subscribe(topic: string, handler: (message: string) => void): Promise<void>;
201
+ onModuleDestroy?(): void | Promise<void>;
202
+ }
203
+ ```
204
+
205
+ `message` is always a JSON string (already serialized by the library — your
206
+ adapter just needs to move opaque strings around, not parse them). Three
207
+ fixed topics are used: `webrepl:cmd`, `webrepl:out`, `webrepl:sys`. The
208
+ `webrepl:sys` topic carries internal `claim`/`release` ownership-coordination
209
+ messages between instances — these are never forwarded to SSE clients (they
210
+ are distinct from, and not to be confused with, the client-visible `system`
211
+ *SSE event type* documented under [Endpoints](#endpoints), which only ever
212
+ carries `{ping}`/`{done}`/`{error}`).
213
+
214
+ ### Redis adapter sketch
215
+
216
+ ```ts
217
+ import { Injectable, OnModuleDestroy } from '@nestjs/common';
218
+ import Redis from 'ioredis';
219
+ import type { WebReplAdapter } from 'nestjs-web-repl';
220
+
221
+ @Injectable()
222
+ export class RedisWebReplAdapter implements WebReplAdapter, OnModuleDestroy {
223
+ private readonly pub = new Redis(process.env.REDIS_URL);
224
+ private readonly sub = new Redis(process.env.REDIS_URL);
225
+
226
+ async publish(topic: string, message: string): Promise<void> {
227
+ await this.pub.publish(topic, message);
228
+ }
229
+
230
+ async subscribe(topic: string, handler: (message: string) => void): Promise<void> {
231
+ await this.sub.subscribe(topic);
232
+ this.sub.on('message', (channel, message) => {
233
+ if (channel === topic) handler(message);
234
+ });
235
+ }
236
+
237
+ async onModuleDestroy(): Promise<void> {
238
+ await this.pub.quit();
239
+ await this.sub.quit();
240
+ }
241
+ }
242
+ ```
243
+
244
+ ```ts
245
+ WebReplModule.forRoot({
246
+ enabled: process.env.REPL_ENABLED === 'true',
247
+ adapter: new RedisWebReplAdapter(),
248
+ instanceId: process.env.HOSTNAME, // shows up in `command` SSE events and
249
+ // internal webrepl:sys claim messages
250
+ });
251
+ ```
252
+
253
+ ## Options (`WebReplModuleOptions`)
254
+
255
+ | Option | Type | Default | Notes |
256
+ | ------------------- | ---------------- | --------------------------- | --------------------------------------------------- |
257
+ | `enabled` | `boolean` | *(required)* | When `false`, the module registers nothing at all. |
258
+ | `adapter` | `WebReplAdapter` | `InMemoryWebReplAdapter` | Provide for multi-instance coordination. |
259
+ | `instanceId` | `string` | random `inst_xxxxxxxx` | Shown in `command` SSE events and internal `webrepl:sys` claim/release messages. |
260
+ | `sessionTtl` | `number` (ms) | `1_800_000` (30 min) | Idle time before a channel's ownership is released. |
261
+ | `replayBufferSize` | `number` | `200` | Events kept per channel for SSE `Last-Event-ID` replay. |
262
+ | `heartbeatInterval` | `number` (ms) | `15_000` | SSE `system` `{ ping: true }` interval. |
263
+ | `ownerHeartbeatInterval` | `number` (ms) | `10_000` | How often an instance re-announces `claim` for each channel it owns, keeping its ownership lease alive. |
264
+ | `ownerLeaseTtl` | `number` (ms) | `30_000` | How long an ownership record is trusted since the last claim/heartbeat, before a stale owner's channel may be taken over. Enforced minimum `ownerHeartbeatInterval * 2` (a live owner always keeps a full heartbeat interval of slack against delivery jitter); if the configured value is below that, it's clamped up to `ownerHeartbeatInterval * 2` and a warning is logged (never throws). |
265
+ | `registerController`| `boolean` | `true` | Set `false` to omit the default controller (see [Securing it](#securing-it)). `forRoot` only. |
266
+
267
+ `WebReplModule.forRootAsync({ useFactory, inject, imports })` is also available
268
+ for options that need DI (e.g. reading a `ConfigService`); see the
269
+ `registerController` caveat above.
270
+
271
+ ## Exports
272
+
273
+ `WebReplModule`, `WebReplController`, `WebReplService`, `InMemoryWebReplAdapter`,
274
+ and the types `WebReplAdapter`, `WebReplModuleOptions`, `WebReplModuleAsyncOptions`,
275
+ `WebReplEvent`, `SseEventType`.
276
+
277
+ ## Limitations (v1)
278
+
279
+ - **Monaco loads from a CDN** (`cdn.jsdelivr.net`) inside the `/ui` page — the
280
+ *browser* needs internet access to load the editor; the server side has no
281
+ such dependency.
282
+ - **No autocomplete / IntelliSense** against your actual providers — Monaco
283
+ is configured for plain TypeScript syntax highlighting only, not a live
284
+ language service.
285
+ - **No session persistence.** REPL sessions (and their variables) live only
286
+ in process memory; a restart of the owning instance loses all channel
287
+ state, including the replay buffer.
288
+ - **Ownership races are last-claim-wins.** If two instances receive the very
289
+ first command for a brand-new channel at nearly the same time, both may
290
+ briefly believe they own it; whichever internal `claim` message (on the
291
+ `webrepl:sys` adapter topic) is processed last by the group determines the
292
+ actual owner going forward. This is a narrow window (first command on a
293
+ channel only) but is not fully resolved by the protocol as implemented.
294
+ - **A crashed/restarted owner's channel is taken over, not wedged forever,
295
+ but loses its in-memory variables.** Ownership is a lease (see
296
+ "Multi-instance ownership" above): a live owner keeps it alive with
297
+ `claim` heartbeats every `ownerHeartbeatInterval`. If the instance that
298
+ owns a channel crashes or is restarted instead of shutting down cleanly,
299
+ it stops heartbeating, and after `ownerLeaseTtl` the origin instance of
300
+ the next command for that channel takes over — starting a fresh session.
301
+ Any variables declared in the dead owner's session are gone; the channel
302
+ itself becomes usable again rather than being wedged fleet-wide. This is
303
+ strictly better than a permanent wedge, but it is still a data loss on
304
+ unclean owner death, and (like last-claim-wins above) a narrow multi-
305
+ instance edge case worth knowing about.
306
+ - **Relies on a deep import of `@nestjs/core` internals**
307
+ (`@nestjs/core/nest-application-context`, `@nestjs/core/repl/repl-context`)
308
+ to build an app-wide REPL context, since only the `repl()` bootstrap
309
+ function itself is part of `@nestjs/core`'s public entrypoint. This is
310
+ pinned by the package's `@nestjs/core` peer range; a future `@nestjs/core`
311
+ major that relocates these modules could break it.
312
+
313
+ ## How this was built (transparency)
314
+
315
+ This library was built with AI assistance — specifically, an agent (Claude
316
+ Code) driving a plan-first, test-driven workflow under human direction: a
317
+ written spec and implementation plan, then task-by-task implementation where
318
+ each task was implemented, independently reviewed by a separate agent, and
319
+ fixed before the next, followed by a whole-repository review.
320
+
321
+ We tell you this because the honest thing to do is let you judge the code on
322
+ its merits rather than guess at its origins. If you are skeptical of AI-written
323
+ code, here is what to actually look at:
324
+
325
+ - **The tests.** 62 automated tests, including a two-instance end-to-end test
326
+ that proves cross-instance command routing and output fan-out, and an
327
+ execution-proof test that resolves a real provider through the live REPL
328
+ context. `npm test`, `npm run build`, and `npx tsc --noEmit` are all green.
329
+ - **The commit history.** The real TDD trail is preserved — failing test,
330
+ implementation, fixes — including several rounds where review caught genuine
331
+ defects (the trickiest: `node:repl` completion detection on modern Node, and
332
+ a runtime-vs-registration security bug where `forRootAsync` could have
333
+ shipped the arbitrary-code endpoint live with `enabled: false`).
334
+ - **The security invariants** are documented in [`AGENTS.md`](./AGENTS.md) and
335
+ enforced by tests, not left as prose.
336
+
337
+ AI assistance does not exempt the code from scrutiny — it raises the bar for
338
+ it. Issues and fixes are welcome from anyone who finds something we missed.
339
+
340
+ ## AI usage & training
341
+
342
+ The source here is published so people and their tools can **use** it — read
343
+ it, run it, debug against it, integrate it. It is not offered as training data.
344
+ [`robots.txt`](./robots.txt) and [`ai.txt`](./ai.txt) record a request that AI
345
+ model *training* crawlers (GPTBot, ClaudeBot, CCBot, Google-Extended, and
346
+ others) not ingest this repository. Those files are advisory — they express
347
+ intent and do not, and cannot, technically enforce anything, nor do they bind
348
+ GitHub's own hosting. Using an AI coding assistant to help you *work with* this
349
+ library is entirely fine and expected.
350
+
351
+ ## Contributing
352
+
353
+ Contributions are welcome — see [`CONTRIBUTING.md`](./CONTRIBUTING.md). AI-
354
+ assisted PRs are fine; we just ask you to disclose the assistance and to
355
+ understand what you submit. Agents working in this repo should start with
356
+ [`AGENTS.md`](./AGENTS.md).
357
+
358
+ ## License
359
+
360
+ [MIT](./LICENSE) © Petar Popov
@@ -0,0 +1,8 @@
1
+ import type { WebReplAdapter } from '../interfaces/web-repl-adapter.interface';
2
+ export declare class InMemoryWebReplAdapter implements WebReplAdapter {
3
+ private readonly emitter;
4
+ constructor();
5
+ publish(topic: string, message: string): Promise<void>;
6
+ subscribe(topic: string, handler: (message: string) => void): Promise<void>;
7
+ onModuleDestroy(): void;
8
+ }
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.InMemoryWebReplAdapter = void 0;
4
+ const node_events_1 = require("node:events");
5
+ class InMemoryWebReplAdapter {
6
+ constructor() {
7
+ this.emitter = new node_events_1.EventEmitter();
8
+ this.emitter.setMaxListeners(0);
9
+ }
10
+ async publish(topic, message) {
11
+ queueMicrotask(() => this.emitter.emit(topic, message));
12
+ }
13
+ async subscribe(topic, handler) {
14
+ this.emitter.on(topic, handler);
15
+ }
16
+ onModuleDestroy() {
17
+ this.emitter.removeAllListeners();
18
+ }
19
+ }
20
+ exports.InMemoryWebReplAdapter = InMemoryWebReplAdapter;
@@ -0,0 +1,15 @@
1
+ export declare const WEB_REPL_OPTIONS: unique symbol;
2
+ export declare const WEB_REPL_ADAPTER: unique symbol;
3
+ export declare const WEB_REPL_CLOCK: unique symbol;
4
+ export declare const TOPICS: {
5
+ readonly cmd: "webrepl:cmd";
6
+ readonly out: "webrepl:out";
7
+ readonly sys: "webrepl:sys";
8
+ };
9
+ export declare const DEFAULTS: {
10
+ readonly sessionTtl: 1800000;
11
+ readonly replayBufferSize: 200;
12
+ readonly heartbeatInterval: 15000;
13
+ readonly ownerHeartbeatInterval: 10000;
14
+ readonly ownerLeaseTtl: 30000;
15
+ };
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULTS = exports.TOPICS = exports.WEB_REPL_CLOCK = exports.WEB_REPL_ADAPTER = exports.WEB_REPL_OPTIONS = void 0;
4
+ exports.WEB_REPL_OPTIONS = Symbol('WEB_REPL_OPTIONS');
5
+ exports.WEB_REPL_ADAPTER = Symbol('WEB_REPL_ADAPTER');
6
+ exports.WEB_REPL_CLOCK = Symbol('WEB_REPL_CLOCK');
7
+ exports.TOPICS = {
8
+ cmd: 'webrepl:cmd',
9
+ out: 'webrepl:out',
10
+ sys: 'webrepl:sys',
11
+ };
12
+ exports.DEFAULTS = {
13
+ sessionTtl: 1_800_000,
14
+ replayBufferSize: 200,
15
+ heartbeatInterval: 15_000,
16
+ // How often a live owner re-announces `claim` for the channels it owns.
17
+ ownerHeartbeatInterval: 10_000,
18
+ // How long an ownership record is trusted since the last claim/heartbeat
19
+ // seen for it. Must stay strictly greater than ownerHeartbeatInterval --
20
+ // see WebReplService's constructor clamp -- so a live owner never looks
21
+ // stale between its own heartbeats.
22
+ ownerLeaseTtl: 30_000,
23
+ };
@@ -0,0 +1,28 @@
1
+ import type { ModuleRef } from '@nestjs/core';
2
+ /**
3
+ * Builds a live NestJS REPL global scope (`get`, `$`, `resolve`, `select`,
4
+ * `debug`, `methods`, `help`, ...) bound to the running application's DI
5
+ * container.
6
+ *
7
+ * We deliberately do NOT hand `ReplContext` the injected `ModuleRef`
8
+ * directly, even though `ReplContext` only reads `app.container` in its
9
+ * constructor: the REPL's built-in `get`/`resolve`/`select` native
10
+ * functions call `app.get()`, `app.resolve()`, `app.select()` at command
11
+ * time (see @nestjs/core/repl/native-functions/*.js). A `ModuleRef`'s own
12
+ * `get()`/`resolve()` default to `strict: true` (host-module-only lookup)
13
+ * and it has no `select()` at all -- so `get(SomeService)` would silently
14
+ * fail to resolve providers registered in modules other than the one that
15
+ * hosts WebReplModule, which is exactly the common case for a REPL wired
16
+ * in from a shared library module.
17
+ *
18
+ * Instead we pull the process-wide `NestContainer` off the injected
19
+ * `ModuleRef` (every `ModuleRef`, whatever module it belongs to, shares the
20
+ * same container instance -- see `createModuleReferenceType` in
21
+ * @nestjs/core/injector/module.js, `super(self.container)`) and construct a
22
+ * real `NestApplicationContext` around it: the exact class
23
+ * `NestFactory.createApplicationContext`/`NestApplication` build `app`
24
+ * from. That gives the REPL the same app-wide, non-strict
25
+ * `get`/`resolve`/`select` behavior a real `nest start --entrypoint repl`
26
+ * REPL has, regardless of which module happens to host WebReplModule.
27
+ */
28
+ export declare function buildReplContext(moduleRef: ModuleRef): Record<string, unknown>;
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildReplContext = buildReplContext;
4
+ // Deep imports: neither of these is re-exported from @nestjs/core's public
5
+ // entrypoint (only the `repl()` bootstrap function is public). Both are
6
+ // pinned by this package's `@nestjs/core` peerDependency range and
7
+ // exercised end-to-end by src/web-repl.module.spec.ts; if a future
8
+ // @nestjs/core major relocates them, this is the one file to fix.
9
+ const nest_application_context_1 = require("@nestjs/core/nest-application-context");
10
+ const repl_context_1 = require("@nestjs/core/repl/repl-context");
11
+ /**
12
+ * Builds a live NestJS REPL global scope (`get`, `$`, `resolve`, `select`,
13
+ * `debug`, `methods`, `help`, ...) bound to the running application's DI
14
+ * container.
15
+ *
16
+ * We deliberately do NOT hand `ReplContext` the injected `ModuleRef`
17
+ * directly, even though `ReplContext` only reads `app.container` in its
18
+ * constructor: the REPL's built-in `get`/`resolve`/`select` native
19
+ * functions call `app.get()`, `app.resolve()`, `app.select()` at command
20
+ * time (see @nestjs/core/repl/native-functions/*.js). A `ModuleRef`'s own
21
+ * `get()`/`resolve()` default to `strict: true` (host-module-only lookup)
22
+ * and it has no `select()` at all -- so `get(SomeService)` would silently
23
+ * fail to resolve providers registered in modules other than the one that
24
+ * hosts WebReplModule, which is exactly the common case for a REPL wired
25
+ * in from a shared library module.
26
+ *
27
+ * Instead we pull the process-wide `NestContainer` off the injected
28
+ * `ModuleRef` (every `ModuleRef`, whatever module it belongs to, shares the
29
+ * same container instance -- see `createModuleReferenceType` in
30
+ * @nestjs/core/injector/module.js, `super(self.container)`) and construct a
31
+ * real `NestApplicationContext` around it: the exact class
32
+ * `NestFactory.createApplicationContext`/`NestApplication` build `app`
33
+ * from. That gives the REPL the same app-wide, non-strict
34
+ * `get`/`resolve`/`select` behavior a real `nest start --entrypoint repl`
35
+ * REPL has, regardless of which module happens to host WebReplModule.
36
+ */
37
+ function buildReplContext(moduleRef) {
38
+ const container = moduleRef.container;
39
+ const root = container.getModules().values().next().value;
40
+ const app = new nest_application_context_1.NestApplicationContext(container, {}, root);
41
+ const ctx = new repl_context_1.ReplContext(app);
42
+ return ctx.globalScope;
43
+ }
@@ -0,0 +1,7 @@
1
+ export { WebReplModule } from './web-repl.module';
2
+ export { WebReplController } from './web-repl.controller';
3
+ export { WebReplService } from './web-repl.service';
4
+ export { InMemoryWebReplAdapter } from './adapters/in-memory-web-repl.adapter';
5
+ export type { WebReplAdapter } from './interfaces/web-repl-adapter.interface';
6
+ export type { WebReplModuleOptions, WebReplModuleAsyncOptions, } from './interfaces/web-repl-options.interface';
7
+ export type { WebReplEvent, SseEventType, } from './interfaces/web-repl-messages.interface';
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.InMemoryWebReplAdapter = exports.WebReplService = exports.WebReplController = exports.WebReplModule = void 0;
4
+ var web_repl_module_1 = require("./web-repl.module");
5
+ Object.defineProperty(exports, "WebReplModule", { enumerable: true, get: function () { return web_repl_module_1.WebReplModule; } });
6
+ var web_repl_controller_1 = require("./web-repl.controller");
7
+ Object.defineProperty(exports, "WebReplController", { enumerable: true, get: function () { return web_repl_controller_1.WebReplController; } });
8
+ var web_repl_service_1 = require("./web-repl.service");
9
+ Object.defineProperty(exports, "WebReplService", { enumerable: true, get: function () { return web_repl_service_1.WebReplService; } });
10
+ var in_memory_web_repl_adapter_1 = require("./adapters/in-memory-web-repl.adapter");
11
+ Object.defineProperty(exports, "InMemoryWebReplAdapter", { enumerable: true, get: function () { return in_memory_web_repl_adapter_1.InMemoryWebReplAdapter; } });
@@ -0,0 +1,5 @@
1
+ export interface WebReplAdapter {
2
+ publish(topic: string, message: string): Promise<void>;
3
+ subscribe(topic: string, handler: (message: string) => void): Promise<void>;
4
+ onModuleDestroy?(): void | Promise<void>;
5
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,22 @@
1
+ export type SseEventType = 'command' | 'output' | 'system';
2
+ export interface WebReplEvent {
3
+ id: number;
4
+ type: SseEventType;
5
+ commandId: string | null;
6
+ data: unknown;
7
+ }
8
+ export interface CmdMessage {
9
+ channel: string;
10
+ commandId: string;
11
+ command: string;
12
+ originInstanceId: string;
13
+ }
14
+ export interface OutMessage {
15
+ channel: string;
16
+ event: WebReplEvent;
17
+ }
18
+ export interface SysMessage {
19
+ channel: string;
20
+ kind: 'claim' | 'release';
21
+ instanceId: string;
22
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,40 @@
1
+ import type { ModuleMetadata } from '@nestjs/common';
2
+ import type { WebReplAdapter } from './web-repl-adapter.interface';
3
+ export interface WebReplModuleOptions {
4
+ /** Required. When false the module registers nothing. */
5
+ enabled: boolean;
6
+ /** Multi-instance coordination. Defaults to InMemoryWebReplAdapter. */
7
+ adapter?: WebReplAdapter;
8
+ /** Identifies this instance in ownership/system messages. Auto-generated if omitted. */
9
+ instanceId?: string;
10
+ /** Idle ms before a session is disposed. Default 30 min. */
11
+ sessionTtl?: number;
12
+ /** Events kept per channel for SSE replay. Default 200. */
13
+ replayBufferSize?: number;
14
+ /** SSE ping-comment interval in ms. Default 15000. */
15
+ heartbeatInterval?: number;
16
+ /**
17
+ * How often an instance re-announces `claim` for each channel it currently
18
+ * owns, keeping its ownership lease alive. Default 10000.
19
+ */
20
+ ownerHeartbeatInterval?: number;
21
+ /**
22
+ * How long an ownership record is trusted since the last claim/heartbeat
23
+ * seen for it, before another instance may take over a channel whose owner
24
+ * has gone silent (e.g. crashed or was killed without a clean shutdown).
25
+ * Default 30000. Enforced minimum: `ownerHeartbeatInterval * 2` -- a live
26
+ * owner must always have at least one full heartbeat interval of slack
27
+ * against publish/delivery jitter, or a single late-arriving heartbeat
28
+ * could make it look stale to a peer and get preempted. If the configured
29
+ * value is below that minimum, the effective lease is clamped up to
30
+ * `ownerHeartbeatInterval * 2` and a warning is logged (this never
31
+ * throws).
32
+ */
33
+ ownerLeaseTtl?: number;
34
+ /** When false, the default controller is not registered (user registers a subclass). Default true. */
35
+ registerController?: boolean;
36
+ }
37
+ export interface WebReplModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
38
+ useFactory: (...args: any[]) => WebReplModuleOptions | Promise<WebReplModuleOptions>;
39
+ inject?: any[];
40
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,10 @@
1
+ import type { WebReplEvent } from './interfaces/web-repl-messages.interface';
2
+ export declare class EventRingBuffer {
3
+ private readonly capacity;
4
+ private readonly events;
5
+ constructor(capacity: number);
6
+ push(event: WebReplEvent): void;
7
+ since(lastEventId: number | null): WebReplEvent[];
8
+ clear(): void;
9
+ isEmpty(): boolean;
10
+ }