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
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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,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,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,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
|
+
}
|