@luckystack/devkit 0.4.0 → 0.5.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/CLAUDE.md +125 -125
- package/LICENSE +21 -21
- package/README.md +44 -44
- package/dist/{chunk-BZTCJ7JY.js → chunk-EU2RFSLO.js} +1 -1
- package/dist/chunk-EU2RFSLO.js.map +1 -0
- package/dist/cli/validateDeploy.js +1 -1
- package/dist/cli/validateDeploy.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/supervisor.js +0 -0
- package/dist/supervisor.js.map +1 -1
- package/dist/templates/api.template.ts +54 -54
- package/dist/templates/page_dashboard.template.tsx +35 -35
- package/dist/templates/page_plain.template.tsx +32 -32
- package/dist/templates/sync_client.template.ts +40 -40
- package/dist/templates/sync_client_paired.template.ts +38 -38
- package/dist/templates/sync_client_standalone.template.ts +36 -36
- package/dist/templates/sync_server.template.ts +64 -64
- package/docs/cli.md +245 -245
- package/docs/hot-reload.md +365 -365
- package/docs/loader-pipeline.md +324 -324
- package/docs/runtime-type-resolver.md +258 -258
- package/docs/supervisor.md +292 -292
- package/docs/ts-program-cache.md +199 -199
- package/docs/type-map-generation.md +392 -392
- package/package.json +7 -2
- package/dist/chunk-BZTCJ7JY.js.map +0 -1
package/docs/supervisor.md
CHANGED
|
@@ -1,292 +1,292 @@
|
|
|
1
|
-
# Supervisor (`supervisor.ts`)
|
|
2
|
-
|
|
3
|
-
> Dev-only. The supervisor is the parent process that watches files which cannot be hot-reloaded in-process (config, bootstrap, sockets setup, auth wiring) and restarts the LuckyStack server child whenever one of them changes. In production it skips the watcher entirely and just keeps the child crash-restarted.
|
|
4
|
-
|
|
5
|
-
The supervisor is a single Node script (`packages/devkit/src/supervisor.ts`) compiled into the devkit `dist/` and invoked by consumer projects via their own `npm run dev` script (typically `tsx node_modules/@luckystack/devkit/dist/supervisor.js` or equivalent).
|
|
6
|
-
|
|
7
|
-
It owns one child process at a time. The child runs `tsx` against `server/server.ts` and inherits stdio so its logs surface in the parent's terminal as-is.
|
|
8
|
-
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
## Process model
|
|
12
|
-
|
|
13
|
-
```typescript
|
|
14
|
-
const tsxCliPath = path.resolve(process.cwd(), 'node_modules', 'tsx', 'dist', 'cli.mjs');
|
|
15
|
-
const childArgs = [tsxCliPath, 'server/server.ts'];
|
|
16
|
-
|
|
17
|
-
const startChild = () => {
|
|
18
|
-
if (isShuttingDown) return;
|
|
19
|
-
childBootStartedAt = performance.now();
|
|
20
|
-
childProcess = spawn(process.execPath, childArgs, {
|
|
21
|
-
stdio: 'inherit',
|
|
22
|
-
env: {
|
|
23
|
-
...process.env,
|
|
24
|
-
LUCKYSTACK_CORE_SUPERVISED: 'true',
|
|
25
|
-
},
|
|
26
|
-
});
|
|
27
|
-
// ... attach 'exit' handler ...
|
|
28
|
-
};
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
Key points:
|
|
32
|
-
|
|
33
|
-
- `process.execPath` is the running Node binary, so the child uses the same Node version as the supervisor.
|
|
34
|
-
- `tsx` is invoked as a Node script (`node node_modules/tsx/dist/cli.mjs`) rather than via a shell — fewer cross-platform surprises (no PATH dependency, no `.cmd` shim on Windows).
|
|
35
|
-
- `stdio: 'inherit'` means child logs go directly to the parent's TTY. Backpressure is handled by Node, the supervisor doesn't touch the streams.
|
|
36
|
-
- `LUCKYSTACK_CORE_SUPERVISED=true` is set in the child env so framework code can detect that it is running under the supervisor (useful for adjusting log prefixes or skipping its own crash-restart logic).
|
|
37
|
-
|
|
38
|
-
The supervisor stores the active child handle on a module-level variable:
|
|
39
|
-
|
|
40
|
-
```typescript
|
|
41
|
-
let childProcess: ChildProcess | null = null;
|
|
42
|
-
let pendingRestart = false;
|
|
43
|
-
let restartTimer: NodeJS.Timeout | null = null;
|
|
44
|
-
let crashRestartTimer: NodeJS.Timeout | null = null;
|
|
45
|
-
let childBootStartedAt = 0;
|
|
46
|
-
let isShuttingDown = false;
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
This is intentionally global to the file — the supervisor is a single-purpose entry script, not a library.
|
|
50
|
-
|
|
51
|
-
---
|
|
52
|
-
|
|
53
|
-
## `CORE_WATCH_GLOBS`
|
|
54
|
-
|
|
55
|
-
```typescript
|
|
56
|
-
const CORE_WATCH_GLOBS = [
|
|
57
|
-
'config.ts',
|
|
58
|
-
'.env',
|
|
59
|
-
'.env.local',
|
|
60
|
-
'server/server.ts',
|
|
61
|
-
'server/bootstrap/**/*.ts',
|
|
62
|
-
'server/auth/**/*.ts',
|
|
63
|
-
'server/functions/db.ts',
|
|
64
|
-
'server/functions/redis.ts',
|
|
65
|
-
'server/functions/sentry.ts',
|
|
66
|
-
];
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
These are files that cannot be hot-reloaded inside the same Node process — they own framework-wide singletons (Prisma client, Redis client, Sentry SDK, Socket.io server, OAuth providers). Changing any of them requires a fresh boot.
|
|
70
|
-
|
|
71
|
-
Everything not in this list — API/sync files, page components, shared helpers, locale `.json`, `_components/*` — is hot-reloaded by `setupWatchers()` running inside the child (see `hot-reload.md`).
|
|
72
|
-
|
|
73
|
-
Adding new globs here should be deliberate. The rule of thumb: if mutation can be reflected by reassigning a module-level binding inside `@luckystack/core` (the `registerXxx` extension points), it can hot-reload; if it needs to re-run a top-level `new SomeClient(...)`, it needs a supervisor restart.
|
|
74
|
-
|
|
75
|
-
---
|
|
76
|
-
|
|
77
|
-
## Restart debouncing
|
|
78
|
-
|
|
79
|
-
```typescript
|
|
80
|
-
const RESTART_DEBOUNCE_MS = 150;
|
|
81
|
-
const CRASH_RESTART_DELAY_MS = 300;
|
|
82
|
-
|
|
83
|
-
const scheduleRestart = ({ event, changedPath }: { event: string; changedPath: string }) => {
|
|
84
|
-
if (restartTimer) clearTimeout(restartTimer);
|
|
85
|
-
|
|
86
|
-
restartTimer = setTimeout(() => {
|
|
87
|
-
restartTimer = null;
|
|
88
|
-
pendingRestart = true;
|
|
89
|
-
|
|
90
|
-
console.log(`[Supervisor] Core change detected (${event}): ${changedPath}. Restarting server`, 'yellow');
|
|
91
|
-
|
|
92
|
-
if (!childProcess) {
|
|
93
|
-
pendingRestart = false;
|
|
94
|
-
startChild();
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
childProcess.kill('SIGTERM');
|
|
99
|
-
}, RESTART_DEBOUNCE_MS);
|
|
100
|
-
};
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
Two constants:
|
|
104
|
-
|
|
105
|
-
- **`RESTART_DEBOUNCE_MS = 150`** — coalesces bursts of save events (e.g. a project-wide rename) into a single restart.
|
|
106
|
-
- **`CRASH_RESTART_DELAY_MS = 300`** — delay before re-spawning after a non-zero exit, so a syntax error doesn't restart-loop at full speed.
|
|
107
|
-
|
|
108
|
-
The flow on a save:
|
|
109
|
-
|
|
110
|
-
1. Watcher fires `all` -> `scheduleRestart`.
|
|
111
|
-
2. Existing timer cleared; new timer queued at 150 ms.
|
|
112
|
-
3. Timer expires: `pendingRestart = true`, then either start a fresh child (no current) or `kill('SIGTERM')` the running one.
|
|
113
|
-
4. The child's `exit` handler sees `pendingRestart === true` and respawns.
|
|
114
|
-
|
|
115
|
-
`pendingRestart` is the bridge between the kill and the respawn. It's needed because the child exit handler runs asynchronously; the supervisor must remember that this particular exit was intentional.
|
|
116
|
-
|
|
117
|
-
---
|
|
118
|
-
|
|
119
|
-
## State machine
|
|
120
|
-
|
|
121
|
-
```
|
|
122
|
-
+----------------+
|
|
123
|
-
| no child |
|
|
124
|
-
+-------+--------+
|
|
125
|
-
| startChild()
|
|
126
|
-
v
|
|
127
|
-
+----------------+
|
|
128
|
-
file change ---> | running |
|
|
129
|
-
+-------+--------+
|
|
130
|
-
|
|
|
131
|
-
+---------------+----------------+
|
|
132
|
-
| | |
|
|
133
|
-
v v v
|
|
134
|
-
SIGTERM exit 0 exit code != 0
|
|
135
|
-
(intentional) (graceful, (crash)
|
|
136
|
-
| user Ctrl+C
|
|
137
|
-
v | |
|
|
138
|
-
respawn after stop (or schedule respawn
|
|
139
|
-
150ms debounce process.exit after 300ms
|
|
140
|
-
coalesce if isShuttingDown)
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
Key transitions in the `exit` handler:
|
|
144
|
-
|
|
145
|
-
```typescript
|
|
146
|
-
childProcess.on('exit', (code, signal) => {
|
|
147
|
-
const uptimeMs = Math.round(performance.now() - childBootStartedAt);
|
|
148
|
-
const shouldRestart = pendingRestart;
|
|
149
|
-
childProcess = null;
|
|
150
|
-
|
|
151
|
-
if (isShuttingDown) {
|
|
152
|
-
process.exit(0);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (shouldRestart) {
|
|
156
|
-
pendingRestart = false;
|
|
157
|
-
console.log(`[Supervisor] Restarting server after ${String(uptimeMs)}ms uptime`, 'yellow');
|
|
158
|
-
startChild();
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (signal === 'SIGTERM' || signal === 'SIGINT') {
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
if (typeof code === 'number' && code !== 0) {
|
|
167
|
-
console.log(`[Supervisor] Server crashed with code ${String(code)}. Restarting in ${String(CRASH_RESTART_DELAY_MS)}ms`, 'red');
|
|
168
|
-
crashRestartTimer = setTimeout(() => {
|
|
169
|
-
crashRestartTimer = null;
|
|
170
|
-
startChild();
|
|
171
|
-
}, CRASH_RESTART_DELAY_MS);
|
|
172
|
-
}
|
|
173
|
-
});
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
Cases handled:
|
|
177
|
-
|
|
178
|
-
| Exit signal/code | Behavior |
|
|
179
|
-
|---|---|
|
|
180
|
-
| `isShuttingDown` | `process.exit(0)` — Ctrl+C raced with a crash, do not respawn |
|
|
181
|
-
| `pendingRestart === true` | Respawn immediately, log uptime |
|
|
182
|
-
| Signal `SIGTERM` / `SIGINT` (not from us) | Treat as ordered shutdown, do not respawn |
|
|
183
|
-
| `code === 0` | Clean exit, do not respawn |
|
|
184
|
-
| `code !== 0` | Schedule a `setTimeout(startChild, 300)` respawn |
|
|
185
|
-
|
|
186
|
-
The `isShuttingDown` guard at the top is critical: without it, a crash that raced with Ctrl+C would re-spawn indefinitely because `pendingRestart` is independent of the shutdown flag.
|
|
187
|
-
|
|
188
|
-
---
|
|
189
|
-
|
|
190
|
-
## Shutdown handling
|
|
191
|
-
|
|
192
|
-
```typescript
|
|
193
|
-
const shutdownSupervisor = () => {
|
|
194
|
-
isShuttingDown = true;
|
|
195
|
-
|
|
196
|
-
if (restartTimer) {
|
|
197
|
-
clearTimeout(restartTimer);
|
|
198
|
-
restartTimer = null;
|
|
199
|
-
}
|
|
200
|
-
if (crashRestartTimer) {
|
|
201
|
-
clearTimeout(crashRestartTimer);
|
|
202
|
-
crashRestartTimer = null;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
pendingRestart = false;
|
|
206
|
-
|
|
207
|
-
if (childProcess) {
|
|
208
|
-
childProcess.kill('SIGTERM');
|
|
209
|
-
setTimeout(() => process.exit(0), 1500).unref();
|
|
210
|
-
} else {
|
|
211
|
-
process.exit(0);
|
|
212
|
-
}
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
const handleSignal = (signalName: string) => {
|
|
216
|
-
if (isShuttingDown) {
|
|
217
|
-
process.exit(1); // second Ctrl+C — hard exit
|
|
218
|
-
}
|
|
219
|
-
isShuttingDown = true;
|
|
220
|
-
console.log(`[Supervisor] Received ${signalName}, shutting down`, 'yellow');
|
|
221
|
-
void watcher.close().then(() => {
|
|
222
|
-
shutdownSupervisor();
|
|
223
|
-
}).catch(() => {
|
|
224
|
-
shutdownSupervisor();
|
|
225
|
-
});
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
process.on('SIGINT', () => handleSignal('SIGINT'));
|
|
229
|
-
process.on('SIGTERM', () => handleSignal('SIGTERM'));
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
Notes:
|
|
233
|
-
|
|
234
|
-
- **`isShuttingDown` is set synchronously** at the top of `handleSignal` so any in-flight child `exit` handler running BEFORE `watcher.close()` resolves can't schedule a new spawn.
|
|
235
|
-
- **Second Ctrl+C exits with code 1** immediately, bypassing the graceful watcher close. The user is impatient; this is the right move.
|
|
236
|
-
- **`setTimeout(process.exit, 1500).unref()`** is the force-exit grace period. On Windows, child processes sometimes ignore `SIGTERM`; the unrefed timer guarantees the supervisor exits even if the child sits.
|
|
237
|
-
- **Watcher close is awaited** so chokidar releases file handles before exit (matters on Windows where unreleased handles block subsequent processes).
|
|
238
|
-
|
|
239
|
-
---
|
|
240
|
-
|
|
241
|
-
## Production mode
|
|
242
|
-
|
|
243
|
-
```typescript
|
|
244
|
-
if (env.NODE_ENV === 'production') {
|
|
245
|
-
startChild();
|
|
246
|
-
} else {
|
|
247
|
-
const watcher = watch(CORE_WATCH_GLOBS, {
|
|
248
|
-
ignoreInitial: true,
|
|
249
|
-
awaitWriteFinish: {
|
|
250
|
-
stabilityThreshold: 120,
|
|
251
|
-
pollInterval: 20,
|
|
252
|
-
},
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
watcher.on('all', (event, changedPath) => {
|
|
256
|
-
scheduleRestart({ event, changedPath });
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
process.on('SIGINT', () => handleSignal('SIGINT'));
|
|
260
|
-
process.on('SIGTERM', () => handleSignal('SIGTERM'));
|
|
261
|
-
|
|
262
|
-
startChild();
|
|
263
|
-
}
|
|
264
|
-
```
|
|
265
|
-
|
|
266
|
-
In production:
|
|
267
|
-
|
|
268
|
-
- No file watcher.
|
|
269
|
-
- Only `startChild()` is called.
|
|
270
|
-
- The crash-restart timer still fires on non-zero exit (the `exit` handler always runs), so the supervisor doubles as a minimal process keeper for prod boots — useful when running directly without a system supervisor (PM2, systemd, etc.).
|
|
271
|
-
- Signal handlers are NOT registered in the prod branch above; the child receives signals directly (or via the system supervisor), and crash-restart is the only behavior.
|
|
272
|
-
|
|
273
|
-
`env.NODE_ENV` is read through `@luckystack/core`'s env helper, not directly from `process.env`, so any project-level env preprocessing (e.g. loading `.env.local` overrides) applies before the branch.
|
|
274
|
-
|
|
275
|
-
---
|
|
276
|
-
|
|
277
|
-
## Edge cases
|
|
278
|
-
|
|
279
|
-
- **Child exits between two debounce ticks.** The first tick set `pendingRestart = true` and killed the child. The child's exit handler respawns. The second tick fires, but `childProcess` is already a fresh process — it kills the new child too. This is correct: both saves are part of the same coalesced restart from the user's perspective.
|
|
280
|
-
- **Watcher event arrives during shutdown.** `handleSignal` sets `isShuttingDown = true` synchronously; `scheduleRestart` does not check this flag, but its eventual `startChild()` call DOES (`if (isShuttingDown) return;`). The kill+respawn sequence is short-circuited.
|
|
281
|
-
- **SIGTERM ignored by Windows child.** `shutdownSupervisor` schedules a 1500 ms force-exit via `setTimeout(process.exit, 1500).unref()`. The unref means the timer doesn't keep the event loop alive if the child does exit cleanly.
|
|
282
|
-
- **Rapid Ctrl+C double-tap.** First Ctrl+C: `handleSignal` flags shutdown, calls `watcher.close()`. Second Ctrl+C: `handleSignal` sees `isShuttingDown === true` and calls `process.exit(1)` immediately — no waiting for watchers or children.
|
|
283
|
-
- **Respawn loop after a config syntax error.** Child crashes with non-zero exit -> 300 ms crash-restart timer -> child crashes again. The supervisor will loop until the user fixes the config or kills it. There is no exponential backoff currently.
|
|
284
|
-
|
|
285
|
-
---
|
|
286
|
-
|
|
287
|
-
## Operational guidance
|
|
288
|
-
|
|
289
|
-
- The supervisor is NOT the place to add type-map regeneration — that lives in `setupWatchers` inside the child (`hot-reload.md`). The supervisor only handles restarts.
|
|
290
|
-
- Add new globs to `CORE_WATCH_GLOBS` only when a file change cannot be picked up by hot reload. Most additions are unnecessary — hot reload handles `_api/`, `_sync/`, components, helpers, locales, and the import dependency graph for shared modules.
|
|
291
|
-
- The supervisor doesn't manage ports. The child binds whatever port the framework / config specifies. If the previous child exits cleanly on SIGTERM, the port is released before the new child starts; if it doesn't, the new child's `EADDRINUSE` is a config bug, not a supervisor bug.
|
|
292
|
-
- Logs from the supervisor are prefixed `[Supervisor]` and colored cyan/yellow/red. They appear alongside child logs because of `stdio: 'inherit'`.
|
|
1
|
+
# Supervisor (`supervisor.ts`)
|
|
2
|
+
|
|
3
|
+
> Dev-only. The supervisor is the parent process that watches files which cannot be hot-reloaded in-process (config, bootstrap, sockets setup, auth wiring) and restarts the LuckyStack server child whenever one of them changes. In production it skips the watcher entirely and just keeps the child crash-restarted.
|
|
4
|
+
|
|
5
|
+
The supervisor is a single Node script (`packages/devkit/src/supervisor.ts`) compiled into the devkit `dist/` and invoked by consumer projects via their own `npm run dev` script (typically `tsx node_modules/@luckystack/devkit/dist/supervisor.js` or equivalent).
|
|
6
|
+
|
|
7
|
+
It owns one child process at a time. The child runs `tsx` against `server/server.ts` and inherits stdio so its logs surface in the parent's terminal as-is.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Process model
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
const tsxCliPath = path.resolve(process.cwd(), 'node_modules', 'tsx', 'dist', 'cli.mjs');
|
|
15
|
+
const childArgs = [tsxCliPath, 'server/server.ts'];
|
|
16
|
+
|
|
17
|
+
const startChild = () => {
|
|
18
|
+
if (isShuttingDown) return;
|
|
19
|
+
childBootStartedAt = performance.now();
|
|
20
|
+
childProcess = spawn(process.execPath, childArgs, {
|
|
21
|
+
stdio: 'inherit',
|
|
22
|
+
env: {
|
|
23
|
+
...process.env,
|
|
24
|
+
LUCKYSTACK_CORE_SUPERVISED: 'true',
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
// ... attach 'exit' handler ...
|
|
28
|
+
};
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Key points:
|
|
32
|
+
|
|
33
|
+
- `process.execPath` is the running Node binary, so the child uses the same Node version as the supervisor.
|
|
34
|
+
- `tsx` is invoked as a Node script (`node node_modules/tsx/dist/cli.mjs`) rather than via a shell — fewer cross-platform surprises (no PATH dependency, no `.cmd` shim on Windows).
|
|
35
|
+
- `stdio: 'inherit'` means child logs go directly to the parent's TTY. Backpressure is handled by Node, the supervisor doesn't touch the streams.
|
|
36
|
+
- `LUCKYSTACK_CORE_SUPERVISED=true` is set in the child env so framework code can detect that it is running under the supervisor (useful for adjusting log prefixes or skipping its own crash-restart logic).
|
|
37
|
+
|
|
38
|
+
The supervisor stores the active child handle on a module-level variable:
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
let childProcess: ChildProcess | null = null;
|
|
42
|
+
let pendingRestart = false;
|
|
43
|
+
let restartTimer: NodeJS.Timeout | null = null;
|
|
44
|
+
let crashRestartTimer: NodeJS.Timeout | null = null;
|
|
45
|
+
let childBootStartedAt = 0;
|
|
46
|
+
let isShuttingDown = false;
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This is intentionally global to the file — the supervisor is a single-purpose entry script, not a library.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## `CORE_WATCH_GLOBS`
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
const CORE_WATCH_GLOBS = [
|
|
57
|
+
'config.ts',
|
|
58
|
+
'.env',
|
|
59
|
+
'.env.local',
|
|
60
|
+
'server/server.ts',
|
|
61
|
+
'server/bootstrap/**/*.ts',
|
|
62
|
+
'server/auth/**/*.ts',
|
|
63
|
+
'server/functions/db.ts',
|
|
64
|
+
'server/functions/redis.ts',
|
|
65
|
+
'server/functions/sentry.ts',
|
|
66
|
+
];
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
These are files that cannot be hot-reloaded inside the same Node process — they own framework-wide singletons (Prisma client, Redis client, Sentry SDK, Socket.io server, OAuth providers). Changing any of them requires a fresh boot.
|
|
70
|
+
|
|
71
|
+
Everything not in this list — API/sync files, page components, shared helpers, locale `.json`, `_components/*` — is hot-reloaded by `setupWatchers()` running inside the child (see `hot-reload.md`).
|
|
72
|
+
|
|
73
|
+
Adding new globs here should be deliberate. The rule of thumb: if mutation can be reflected by reassigning a module-level binding inside `@luckystack/core` (the `registerXxx` extension points), it can hot-reload; if it needs to re-run a top-level `new SomeClient(...)`, it needs a supervisor restart.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Restart debouncing
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
const RESTART_DEBOUNCE_MS = 150;
|
|
81
|
+
const CRASH_RESTART_DELAY_MS = 300;
|
|
82
|
+
|
|
83
|
+
const scheduleRestart = ({ event, changedPath }: { event: string; changedPath: string }) => {
|
|
84
|
+
if (restartTimer) clearTimeout(restartTimer);
|
|
85
|
+
|
|
86
|
+
restartTimer = setTimeout(() => {
|
|
87
|
+
restartTimer = null;
|
|
88
|
+
pendingRestart = true;
|
|
89
|
+
|
|
90
|
+
console.log(`[Supervisor] Core change detected (${event}): ${changedPath}. Restarting server`, 'yellow');
|
|
91
|
+
|
|
92
|
+
if (!childProcess) {
|
|
93
|
+
pendingRestart = false;
|
|
94
|
+
startChild();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
childProcess.kill('SIGTERM');
|
|
99
|
+
}, RESTART_DEBOUNCE_MS);
|
|
100
|
+
};
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Two constants:
|
|
104
|
+
|
|
105
|
+
- **`RESTART_DEBOUNCE_MS = 150`** — coalesces bursts of save events (e.g. a project-wide rename) into a single restart.
|
|
106
|
+
- **`CRASH_RESTART_DELAY_MS = 300`** — delay before re-spawning after a non-zero exit, so a syntax error doesn't restart-loop at full speed.
|
|
107
|
+
|
|
108
|
+
The flow on a save:
|
|
109
|
+
|
|
110
|
+
1. Watcher fires `all` -> `scheduleRestart`.
|
|
111
|
+
2. Existing timer cleared; new timer queued at 150 ms.
|
|
112
|
+
3. Timer expires: `pendingRestart = true`, then either start a fresh child (no current) or `kill('SIGTERM')` the running one.
|
|
113
|
+
4. The child's `exit` handler sees `pendingRestart === true` and respawns.
|
|
114
|
+
|
|
115
|
+
`pendingRestart` is the bridge between the kill and the respawn. It's needed because the child exit handler runs asynchronously; the supervisor must remember that this particular exit was intentional.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## State machine
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
+----------------+
|
|
123
|
+
| no child |
|
|
124
|
+
+-------+--------+
|
|
125
|
+
| startChild()
|
|
126
|
+
v
|
|
127
|
+
+----------------+
|
|
128
|
+
file change ---> | running |
|
|
129
|
+
+-------+--------+
|
|
130
|
+
|
|
|
131
|
+
+---------------+----------------+
|
|
132
|
+
| | |
|
|
133
|
+
v v v
|
|
134
|
+
SIGTERM exit 0 exit code != 0
|
|
135
|
+
(intentional) (graceful, (crash)
|
|
136
|
+
| user Ctrl+C
|
|
137
|
+
v | |
|
|
138
|
+
respawn after stop (or schedule respawn
|
|
139
|
+
150ms debounce process.exit after 300ms
|
|
140
|
+
coalesce if isShuttingDown)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Key transitions in the `exit` handler:
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
childProcess.on('exit', (code, signal) => {
|
|
147
|
+
const uptimeMs = Math.round(performance.now() - childBootStartedAt);
|
|
148
|
+
const shouldRestart = pendingRestart;
|
|
149
|
+
childProcess = null;
|
|
150
|
+
|
|
151
|
+
if (isShuttingDown) {
|
|
152
|
+
process.exit(0);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (shouldRestart) {
|
|
156
|
+
pendingRestart = false;
|
|
157
|
+
console.log(`[Supervisor] Restarting server after ${String(uptimeMs)}ms uptime`, 'yellow');
|
|
158
|
+
startChild();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (signal === 'SIGTERM' || signal === 'SIGINT') {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (typeof code === 'number' && code !== 0) {
|
|
167
|
+
console.log(`[Supervisor] Server crashed with code ${String(code)}. Restarting in ${String(CRASH_RESTART_DELAY_MS)}ms`, 'red');
|
|
168
|
+
crashRestartTimer = setTimeout(() => {
|
|
169
|
+
crashRestartTimer = null;
|
|
170
|
+
startChild();
|
|
171
|
+
}, CRASH_RESTART_DELAY_MS);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Cases handled:
|
|
177
|
+
|
|
178
|
+
| Exit signal/code | Behavior |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `isShuttingDown` | `process.exit(0)` — Ctrl+C raced with a crash, do not respawn |
|
|
181
|
+
| `pendingRestart === true` | Respawn immediately, log uptime |
|
|
182
|
+
| Signal `SIGTERM` / `SIGINT` (not from us) | Treat as ordered shutdown, do not respawn |
|
|
183
|
+
| `code === 0` | Clean exit, do not respawn |
|
|
184
|
+
| `code !== 0` | Schedule a `setTimeout(startChild, 300)` respawn |
|
|
185
|
+
|
|
186
|
+
The `isShuttingDown` guard at the top is critical: without it, a crash that raced with Ctrl+C would re-spawn indefinitely because `pendingRestart` is independent of the shutdown flag.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Shutdown handling
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
const shutdownSupervisor = () => {
|
|
194
|
+
isShuttingDown = true;
|
|
195
|
+
|
|
196
|
+
if (restartTimer) {
|
|
197
|
+
clearTimeout(restartTimer);
|
|
198
|
+
restartTimer = null;
|
|
199
|
+
}
|
|
200
|
+
if (crashRestartTimer) {
|
|
201
|
+
clearTimeout(crashRestartTimer);
|
|
202
|
+
crashRestartTimer = null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
pendingRestart = false;
|
|
206
|
+
|
|
207
|
+
if (childProcess) {
|
|
208
|
+
childProcess.kill('SIGTERM');
|
|
209
|
+
setTimeout(() => process.exit(0), 1500).unref();
|
|
210
|
+
} else {
|
|
211
|
+
process.exit(0);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const handleSignal = (signalName: string) => {
|
|
216
|
+
if (isShuttingDown) {
|
|
217
|
+
process.exit(1); // second Ctrl+C — hard exit
|
|
218
|
+
}
|
|
219
|
+
isShuttingDown = true;
|
|
220
|
+
console.log(`[Supervisor] Received ${signalName}, shutting down`, 'yellow');
|
|
221
|
+
void watcher.close().then(() => {
|
|
222
|
+
shutdownSupervisor();
|
|
223
|
+
}).catch(() => {
|
|
224
|
+
shutdownSupervisor();
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
process.on('SIGINT', () => handleSignal('SIGINT'));
|
|
229
|
+
process.on('SIGTERM', () => handleSignal('SIGTERM'));
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Notes:
|
|
233
|
+
|
|
234
|
+
- **`isShuttingDown` is set synchronously** at the top of `handleSignal` so any in-flight child `exit` handler running BEFORE `watcher.close()` resolves can't schedule a new spawn.
|
|
235
|
+
- **Second Ctrl+C exits with code 1** immediately, bypassing the graceful watcher close. The user is impatient; this is the right move.
|
|
236
|
+
- **`setTimeout(process.exit, 1500).unref()`** is the force-exit grace period. On Windows, child processes sometimes ignore `SIGTERM`; the unrefed timer guarantees the supervisor exits even if the child sits.
|
|
237
|
+
- **Watcher close is awaited** so chokidar releases file handles before exit (matters on Windows where unreleased handles block subsequent processes).
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## Production mode
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
if (env.NODE_ENV === 'production') {
|
|
245
|
+
startChild();
|
|
246
|
+
} else {
|
|
247
|
+
const watcher = watch(CORE_WATCH_GLOBS, {
|
|
248
|
+
ignoreInitial: true,
|
|
249
|
+
awaitWriteFinish: {
|
|
250
|
+
stabilityThreshold: 120,
|
|
251
|
+
pollInterval: 20,
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
watcher.on('all', (event, changedPath) => {
|
|
256
|
+
scheduleRestart({ event, changedPath });
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
process.on('SIGINT', () => handleSignal('SIGINT'));
|
|
260
|
+
process.on('SIGTERM', () => handleSignal('SIGTERM'));
|
|
261
|
+
|
|
262
|
+
startChild();
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
In production:
|
|
267
|
+
|
|
268
|
+
- No file watcher.
|
|
269
|
+
- Only `startChild()` is called.
|
|
270
|
+
- The crash-restart timer still fires on non-zero exit (the `exit` handler always runs), so the supervisor doubles as a minimal process keeper for prod boots — useful when running directly without a system supervisor (PM2, systemd, etc.).
|
|
271
|
+
- Signal handlers are NOT registered in the prod branch above; the child receives signals directly (or via the system supervisor), and crash-restart is the only behavior.
|
|
272
|
+
|
|
273
|
+
`env.NODE_ENV` is read through `@luckystack/core`'s env helper, not directly from `process.env`, so any project-level env preprocessing (e.g. loading `.env.local` overrides) applies before the branch.
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## Edge cases
|
|
278
|
+
|
|
279
|
+
- **Child exits between two debounce ticks.** The first tick set `pendingRestart = true` and killed the child. The child's exit handler respawns. The second tick fires, but `childProcess` is already a fresh process — it kills the new child too. This is correct: both saves are part of the same coalesced restart from the user's perspective.
|
|
280
|
+
- **Watcher event arrives during shutdown.** `handleSignal` sets `isShuttingDown = true` synchronously; `scheduleRestart` does not check this flag, but its eventual `startChild()` call DOES (`if (isShuttingDown) return;`). The kill+respawn sequence is short-circuited.
|
|
281
|
+
- **SIGTERM ignored by Windows child.** `shutdownSupervisor` schedules a 1500 ms force-exit via `setTimeout(process.exit, 1500).unref()`. The unref means the timer doesn't keep the event loop alive if the child does exit cleanly.
|
|
282
|
+
- **Rapid Ctrl+C double-tap.** First Ctrl+C: `handleSignal` flags shutdown, calls `watcher.close()`. Second Ctrl+C: `handleSignal` sees `isShuttingDown === true` and calls `process.exit(1)` immediately — no waiting for watchers or children.
|
|
283
|
+
- **Respawn loop after a config syntax error.** Child crashes with non-zero exit -> 300 ms crash-restart timer -> child crashes again. The supervisor will loop until the user fixes the config or kills it. There is no exponential backoff currently.
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Operational guidance
|
|
288
|
+
|
|
289
|
+
- The supervisor is NOT the place to add type-map regeneration — that lives in `setupWatchers` inside the child (`hot-reload.md`). The supervisor only handles restarts.
|
|
290
|
+
- Add new globs to `CORE_WATCH_GLOBS` only when a file change cannot be picked up by hot reload. Most additions are unnecessary — hot reload handles `_api/`, `_sync/`, components, helpers, locales, and the import dependency graph for shared modules.
|
|
291
|
+
- The supervisor doesn't manage ports. The child binds whatever port the framework / config specifies. If the previous child exits cleanly on SIGTERM, the port is released before the new child starts; if it doesn't, the new child's `EADDRINUSE` is a config bug, not a supervisor bug.
|
|
292
|
+
- Logs from the supervisor are prefixed `[Supervisor]` and colored cyan/yellow/red. They appear alongside child logs because of `stdio: 'inherit'`.
|