@luckystack/devkit 0.13.0 → 0.15.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/AGENTS.md +1 -1
- package/CHANGELOG.md +27 -0
- package/dist/chunk-FWE6YJ3G.js +17 -0
- package/dist/chunk-FWE6YJ3G.js.map +1 -0
- package/dist/devServerRestart.d.ts +12 -0
- package/dist/index.js +157 -118
- package/dist/index.js.map +1 -1
- package/dist/supervisor.d.ts +5 -0
- package/dist/supervisor.js +17 -2
- package/dist/supervisor.js.map +1 -1
- package/dist/templateInjector.d.ts +5 -1
- package/dist/templates/api.template.ts +2 -1
- package/dist/templates/page_dashboard.template.tsx +3 -7
- package/dist/templates/page_plain.template.tsx +4 -9
- package/dist/templates/sync_client.template.ts +3 -2
- package/dist/templates/sync_client_paired.template.ts +2 -1
- package/dist/templates/sync_client_standalone.template.ts +2 -1
- package/dist/templates/sync_server.template.ts +2 -1
- package/docs/hot-reload.md +2 -2
- package/docs/supervisor.md +1 -1
- package/package.json +2 -2
package/dist/supervisor.d.ts
CHANGED
|
@@ -23,3 +23,8 @@ export type ResolveChildSpawnResult = {
|
|
|
23
23
|
message: string;
|
|
24
24
|
};
|
|
25
25
|
export declare const resolveChildSpawn: ({ isBun, execPath, npmUserAgent, npmExecPath, tsxCliPath: tsxCli, tsconfigServerArgs: tsconfigArgs, entry, fileExists, }: ResolveChildSpawnInput) => ResolveChildSpawnResult;
|
|
26
|
+
export declare const startChild: () => void;
|
|
27
|
+
export declare const scheduleRestart: ({ event, changedPath, }: {
|
|
28
|
+
event: string;
|
|
29
|
+
changedPath: string;
|
|
30
|
+
}) => void;
|
package/dist/supervisor.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
isDevRestartMessage
|
|
4
|
+
} from "./chunk-FWE6YJ3G.js";
|
|
2
5
|
|
|
3
6
|
// src/supervisor.ts
|
|
4
7
|
import { spawn } from "child_process";
|
|
@@ -93,11 +96,17 @@ var consecutiveFastCrashes = 0;
|
|
|
93
96
|
var isShuttingDown = false;
|
|
94
97
|
var startChild = () => {
|
|
95
98
|
if (isShuttingDown) return;
|
|
99
|
+
if (crashRestartTimer) {
|
|
100
|
+
clearTimeout(crashRestartTimer);
|
|
101
|
+
crashRestartTimer = null;
|
|
102
|
+
}
|
|
103
|
+
if (childProcess) return;
|
|
96
104
|
if (!resolvedChildSpawn.ok) return;
|
|
97
105
|
const { command: childCommand, args: childArgs, runtime: childRuntime } = resolvedChildSpawn.spec;
|
|
106
|
+
const withIpc = childRuntime === "node" || process.platform !== "win32";
|
|
98
107
|
childBootStartedAt = performance.now();
|
|
99
108
|
const spawned = spawn(childCommand, childArgs, {
|
|
100
|
-
stdio: "inherit",
|
|
109
|
+
stdio: withIpc ? ["inherit", "inherit", "inherit", "ipc"] : "inherit",
|
|
101
110
|
//? `process.env` is guaranteed `.env`-free here (see the invariant at the
|
|
102
111
|
//? top of this file), so the child loads `.env` fresh on every restart and
|
|
103
112
|
//? picks up edited values — mirroring a cold `npm run server` boot.
|
|
@@ -111,6 +120,10 @@ var startChild = () => {
|
|
|
111
120
|
`[Supervisor] Started server process (pid: ${String(spawned.pid)}, runtime: ${childRuntime})`
|
|
112
121
|
);
|
|
113
122
|
let handled = false;
|
|
123
|
+
spawned.on("message", (message) => {
|
|
124
|
+
if (childProcess !== spawned || !isDevRestartMessage(message)) return;
|
|
125
|
+
scheduleRestart({ event: "route dependency change", changedPath: message.reason });
|
|
126
|
+
});
|
|
114
127
|
spawned.on("error", (err) => {
|
|
115
128
|
console.log(`[Supervisor] Failed to spawn server process: ${String(err)}`, "red");
|
|
116
129
|
if (handled) return;
|
|
@@ -254,6 +267,8 @@ var bootSupervisor = () => {
|
|
|
254
267
|
};
|
|
255
268
|
if (!process.env.LUCKYSTACK_SUPERVISOR_IMPORT_ONLY) bootSupervisor();
|
|
256
269
|
export {
|
|
257
|
-
resolveChildSpawn
|
|
270
|
+
resolveChildSpawn,
|
|
271
|
+
scheduleRestart,
|
|
272
|
+
startChild
|
|
258
273
|
};
|
|
259
274
|
//# sourceMappingURL=supervisor.js.map
|
package/dist/supervisor.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/supervisor.ts"],"sourcesContent":["#!/usr/bin/env node\n//? CRITICAL INVARIANT: this process must NEVER merge `.env` files into its own\n//? `process.env`. The child is spawned with `{ ...process.env }`, and the\n//? child's own `loadEnvFiles()` loads `.env` with `override: false` — any\n//? `.env`-derived value that leaks into the supervisor's env would therefore\n//? WIN over freshly edited file values on every restart (stale-env bug).\n//? That is why this file imports NOTHING from `@luckystack/core` (core runs\n//? `bootstrapEnv()` as an import side-effect) and reads env files via\n//? `dotenv.parse` (pure — no `process.env` mutation). The previous design\n//? (an `ambientEnvSnapshot` module imported first) broke as soon as tsup\n//? inlined the snapshot into the entry body: ESM imports are hoisted, so\n//? core's side-effect ran BEFORE the snapshot line.\nimport { spawn, type ChildProcess } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport { performance } from 'node:perf_hooks';\nimport path from 'node:path';\nimport { watch } from 'chokidar';\nimport { parse as parseDotenv } from 'dotenv';\n\nconst RESTART_DEBOUNCE_MS = 150;\nconst CRASH_RESTART_DELAY_MS = 300;\n//? Rapid-crash-loop breaker. A child that exits non-zero within\n//? FAST_CRASH_THRESHOLD_MS of booting counts as a \"fast crash\" (it died during\n//? startup rather than after serving). MAX_CONSECUTIVE_FAST_CRASHES of those in\n//? a row means the startup error is not transient (a port already in use, a\n//? syntax error, a missing env) — restarting forever just spams the console, so\n//? we give up and print an actionable message. A child that survives longer than\n//? the threshold resets the counter, so a normal restart loop is unaffected.\nconst FAST_CRASH_THRESHOLD_MS = 3000;\nconst MAX_CONSECUTIVE_FAST_CRASHES = 4;\n//? Force-exit grace: how long we wait for the child to honour SIGTERM before\n//? the supervisor hard-exits. Windows does not deliver SIGKILL so the only\n//? lever is this timer. 1 500 ms is intentionally short (dev restarts should\n//? be fast); increase via LUCKYSTACK_SUPERVISOR_GRACE_MS for slow servers.\nconst SHUTDOWN_GRACE_MS = Number(process.env.LUCKYSTACK_SUPERVISOR_GRACE_MS) || 1500;\n\n//? Mirrors `getEnvFiles()` from @luckystack/core — duplicated on purpose so the\n//? supervisor never imports core (see the invariant above). Keep in sync.\nconst DEFAULT_ENV_FILES = ['.env', '.env.local'];\nconst getEnvFiles = (): string[] => {\n const override = process.env.LUCKYSTACK_ENV_FILES;\n if (override) {\n const list = override.split(',').map((entry) => entry.trim()).filter(Boolean);\n if (list.length > 0) return list;\n }\n return DEFAULT_ENV_FILES;\n};\n\n//? Resolve NODE_ENV without mutating `process.env`: a real ambient env var\n//? wins; otherwise read the env files via the pure `dotenv.parse` (\"later\n//? overrides earlier\", matching loadEnvFiles' file order).\nconst resolveNodeEnv = (): string => {\n if (process.env.NODE_ENV) return process.env.NODE_ENV;\n let fromFiles: string | undefined;\n for (const file of getEnvFiles()) {\n const filePath = path.resolve(process.cwd(), file);\n if (!existsSync(filePath)) continue;\n const parsed = parseDotenv(readFileSync(filePath, 'utf8'));\n if (typeof parsed.NODE_ENV === 'string' && parsed.NODE_ENV.length > 0) fromFiles = parsed.NODE_ENV;\n }\n return fromFiles ?? 'development';\n};\n\n//? chokidar v5 removed glob support — a `server/bootstrap/**/*.ts` pattern is no\n//? longer expanded; it is watched as a LITERAL path that never exists, so changes\n//? under `server/bootstrap` / `server/auth` silently never triggered a restart.\n//? Watch the concrete files directly, and the bootstrap/auth DIRECTORIES\n//? recursively (chokidar's default for a directory), then filter events down to\n//? the file types the old globs targeted in the `all` handler below.\nconst CORE_WATCH_FILES = [\n 'config.ts',\n ...getEnvFiles(),\n 'server/server.ts',\n 'server/functions/db.ts',\n 'server/functions/redis.ts',\n 'server/functions/sentry.ts',\n];\n//? Only watch dirs that exist — chokidar watching a recursive non-existent\n//? directory is wasteful/odd; a dir created later is picked up on next boot.\nconst CORE_WATCH_DIRS = ['server/bootstrap', 'server/auth'].filter((dir) =>\n existsSync(path.resolve(process.cwd(), dir)),\n);\nconst CORE_WATCH_TARGETS = [...CORE_WATCH_FILES, ...CORE_WATCH_DIRS];\n\nconst tsxCliPath = path.resolve(process.cwd(), 'node_modules', 'tsx', 'dist', 'cli.mjs');\n//? Scaffolded projects carry a dedicated server tsconfig — pass it when present\n//? so the child matches a manual `tsx --tsconfig tsconfig.server.json` run.\nconst tsconfigServerArgs = existsSync(path.resolve(process.cwd(), 'tsconfig.server.json'))\n ? ['--tsconfig', 'tsconfig.server.json']\n : [];\n\n//? ---------------------------------------------------------------------------\n//? Runtime honouring — `bun run server` must ACTUALLY run Bun\n//? ---------------------------------------------------------------------------\n//? A LuckyStack project must work on Node AND Bun. On Windows that is not\n//? automatic and the failure is SILENT: npm generates a `.cmd` shim per bin\n//? (`node_modules/.bin/luckystack-dev.cmd`) which hardcodes a `node` call, so\n//? `bun run server` dutifully launches NODE while every log line still looks\n//? green. Windows has no shebang to intercept, so the shim always wins.\n//? Verified empirically on bun 1.3.14 / Windows:\n//? `bun run server` -> child ran C:\\Program Files\\nodejs\\node.exe\n//? `bun --bun run server` -> child ran Bun (via a node-shim Bun injects at\n//? %TEMP%\\bun-node-<hash>\\node.exe)\n//? Bun does leave fingerprints even when it hands off to Node:\n//? npm_config_user_agent = \"bun/1.3.14 npm/? node/v24.3.0 win32 x64\"\n//? npm_execpath = \"<abs>/bun.exe\"\n//? That lets us tell \"the developer typed `bun run`\" from \"the developer typed\n//? `npm run`\", and `npm_execpath` hands us the real bun binary to correct\n//? course with. The CHILD is what actually serves the app, so the child is\n//? where the intended runtime gets honoured. This keeps the promise honest\n//? with NO new runtime choice: no extra script name, no wizard question, no\n//? manifest dimension — `npm run server` stays Node, `bun run server` is Bun.\nconst BUN_BINARY_PATTERN = /(?:^|[\\\\/])bun(?:\\.exe)?$/i;\n\nexport type SupervisorRuntime = 'node' | 'bun';\n\nexport interface ChildSpawnSpec {\n command: string;\n args: string[];\n runtime: SupervisorRuntime;\n}\n\nexport interface ResolveChildSpawnInput {\n //? `'Bun' in globalThis` at the call site — passed in so the branch is testable.\n isBun: boolean;\n execPath: string;\n npmUserAgent: string | undefined;\n npmExecPath: string | undefined;\n tsxCliPath: string;\n tsconfigServerArgs: string[];\n //? MUST be absolute — see the fork-bomb note in the bun re-exec branch.\n entry: string;\n fileExists: (candidate: string) => boolean;\n}\n\n//? Result shape rather than a throw: this module may not import\n//? `@luckystack/core` (see the invariant at the top of this file), so the\n//? framework's `tryCatch` is unavailable here and a bare throw at module scope\n//? would surface as an ugly unhandled stack instead of an actionable message.\nexport type ResolveChildSpawnResult =\n | { ok: true; spec: ChildSpawnSpec }\n | { ok: false; message: string };\n\nexport const resolveChildSpawn = ({\n isBun,\n execPath,\n npmUserAgent,\n npmExecPath,\n tsxCliPath: tsxCli,\n tsconfigServerArgs: tsconfigArgs,\n entry,\n fileExists,\n}: ResolveChildSpawnInput): ResolveChildSpawnResult => {\n //? Already executing under Bun (`bun --bun run server`, or `bun server/server.ts`).\n //? `process.execPath` is then either bun itself or the node-shim Bun injects for\n //? bin scripts — BOTH re-enter Bun (verified on 1.3.14/Windows), so spawning\n //? execPath keeps the child on Bun. tsx is dropped deliberately: Bun compiles\n //? TypeScript natively, so tsx would only add a redundant transpile hop, and\n //? `--tsconfig` is not a Bun flag at all (Bun reads `tsconfig.json` itself).\n if (isBun) return { ok: true, spec: { command: execPath, args: [entry], runtime: 'bun' } };\n\n const launchedByBun =\n (npmUserAgent ?? '').startsWith('bun/') || BUN_BINARY_PATTERN.test(npmExecPath ?? '');\n\n //? Plain `npm run server` — the canonical Node path, unchanged. Node cannot\n //? run the whole TypeScript server tree on its own, so tsx stays.\n if (!launchedByBun) {\n return {\n ok: true,\n spec: { command: execPath, args: [tsxCli, ...tsconfigArgs, entry], runtime: 'node' },\n };\n }\n\n //? `bun run server` on Windows: WE are Node (the .cmd shim won), but the\n //? developer asked for Bun. Re-exec the child through the real bun binary.\n const bunBinary = npmExecPath ?? '';\n if (!BUN_BINARY_PATTERN.test(bunBinary) || !fileExists(bunBinary)) {\n //? Fail LOUD rather than quietly serving from Node. Silently continuing is\n //? the exact trap this branch exists to remove: it would look green while\n //? proving nothing about Bun compatibility.\n return {\n ok: false,\n message:\n 'Detected a `bun run` launch, but could not locate the bun binary to run the server with ' +\n `(npm_execpath=${npmExecPath ?? '<unset>'}). Refusing to silently fall back to Node — ` +\n 'that would look like a working Bun boot while actually running Node. ' +\n 'Run `npm run server` for the Node path, or reinstall/repair Bun so it is on PATH.',\n };\n }\n\n //? `entry` MUST be absolute: `bun run <name>` resolves a package.json SCRIPT\n //? before a file, so a relative `server/server.ts` could re-enter the `server`\n //? script and fork-bomb. An absolute path can never be read as a script name.\n return { ok: true, spec: { command: bunBinary, args: ['--bun', 'run', entry], runtime: 'bun' } };\n};\n\nconst resolvedChildSpawn = resolveChildSpawn({\n isBun: 'Bun' in globalThis,\n execPath: process.execPath,\n npmUserAgent: process.env.npm_config_user_agent,\n npmExecPath: process.env.npm_execpath,\n tsxCliPath,\n tsconfigServerArgs,\n entry: path.resolve(process.cwd(), 'server', 'server.ts'),\n fileExists: existsSync,\n});\n\n\nlet childProcess: ChildProcess | null = null;\nlet pendingRestart = false;\nlet restartTimer: NodeJS.Timeout | null = null;\nlet crashRestartTimer: NodeJS.Timeout | null = null;\nlet childBootStartedAt = 0;\nlet consecutiveFastCrashes = 0;\nlet isShuttingDown = false;\n\nconst startChild = () => {\n if (isShuttingDown) return;\n //? Defensive: `bootSupervisor()` already aborts on an unresolved spawn, so this\n //? is unreachable in practice — it exists to narrow the result type here.\n if (!resolvedChildSpawn.ok) return;\n const { command: childCommand, args: childArgs, runtime: childRuntime } = resolvedChildSpawn.spec;\n childBootStartedAt = performance.now();\n const spawned = spawn(childCommand, childArgs, {\n stdio: 'inherit',\n //? `process.env` is guaranteed `.env`-free here (see the invariant at the\n //? top of this file), so the child loads `.env` fresh on every restart and\n //? picks up edited values — mirroring a cold `npm run server` boot.\n env: {\n ...process.env,\n LUCKYSTACK_CORE_SUPERVISED: 'true',\n },\n });\n childProcess = spawned;\n\n //? Name the runtime explicitly. `bun run server` silently serving from Node is\n //? the bug this file guards against, so the runtime must be observable rather\n //? than assumed — a green boot log is exactly what made the old trap invisible.\n console.log(\n `[Supervisor] Started server process (pid: ${String(spawned.pid)}, runtime: ${childRuntime})`,\n );\n\n //? `'error'` and `'exit'` can BOTH fire for a single spawn (a failed spawn\n //? emits `'error'`, and some platforms then emit `'exit'` too). This flag\n //? resolves THIS child's lifecycle exactly once regardless of event order, so\n //? we never double-schedule a restart.\n let handled = false;\n\n //? A failed `spawn` (ENOENT/EACCES — e.g. a missing `tsx`) emits `'error'`,\n //? NOT `'exit'`. Without this listener Node re-throws it as an uncaught\n //? exception that kills the SUPERVISOR instead of retrying — the dev wrapper\n //? would just disappear. Route it through the same crash-restart delay.\n spawned.on('error', (err) => {\n console.log(`[Supervisor] Failed to spawn server process: ${String(err)}`, 'red');\n if (handled) return;\n handled = true;\n childProcess = null;\n\n if (isShuttingDown) {\n process.exit(1);\n }\n\n console.log(`[Supervisor] Retrying server spawn in ${String(CRASH_RESTART_DELAY_MS)}ms`);\n crashRestartTimer = setTimeout(() => {\n crashRestartTimer = null;\n startChild();\n }, CRASH_RESTART_DELAY_MS);\n });\n\n spawned.on('exit', (code, signal) => {\n const uptimeMs = Math.round(performance.now() - childBootStartedAt);\n const shouldRestart = pendingRestart;\n\n if (handled) return;\n handled = true;\n childProcess = null;\n\n //? Once the user has asked us to stop, never spawn another child — no matter\n //? what the previous one's exit reason was. Without this guard a crash that\n //? races with Ctrl+C re-spawns indefinitely.\n if (isShuttingDown) {\n process.exit(0);\n }\n\n if (shouldRestart) {\n pendingRestart = false;\n //? A watcher-driven restart is an explicit edit to a watched file — give\n //? the new code a clean slate instead of inheriting a previous crash burst.\n consecutiveFastCrashes = 0;\n console.log(`[Supervisor] Restarting server after ${String(uptimeMs)}ms uptime`);\n startChild();\n return;\n }\n\n if (signal === 'SIGTERM' || signal === 'SIGINT') {\n return;\n }\n\n if (typeof code === 'number' && code !== 0) {\n //? Rapid-crash-loop breaker: a child that died within the boot window\n //? counts toward the consecutive-fast-crash tally; one that lived longer\n //? proves the startup error cleared, so reset the tally.\n if (uptimeMs < FAST_CRASH_THRESHOLD_MS) {\n consecutiveFastCrashes += 1;\n } else {\n consecutiveFastCrashes = 0;\n }\n\n if (consecutiveFastCrashes >= MAX_CONSECUTIVE_FAST_CRASHES) {\n console.log(\n `[Supervisor] Server crashed ${String(consecutiveFastCrashes)} times within ` +\n `${String(FAST_CRASH_THRESHOLD_MS / 1000)}s of starting — giving up. Fix the startup ` +\n `error above (e.g. a port already in use), then re-run \\`npm run server\\`.`,\n );\n process.exit(1);\n }\n\n console.log(`[Supervisor] Server crashed with code ${String(code)}. Restarting in ${String(CRASH_RESTART_DELAY_MS)}ms`);\n crashRestartTimer = setTimeout(() => {\n crashRestartTimer = null;\n startChild();\n }, CRASH_RESTART_DELAY_MS);\n }\n });\n};\n\nconst scheduleRestart = ({\n event,\n changedPath,\n}: {\n event: string;\n changedPath: string;\n}) => {\n if (restartTimer) {\n clearTimeout(restartTimer);\n }\n\n restartTimer = setTimeout(() => {\n restartTimer = null;\n pendingRestart = true;\n\n console.log(`[Supervisor] Core change detected (${event}): ${changedPath}. Restarting server`);\n\n if (!childProcess) {\n pendingRestart = false;\n startChild();\n return;\n }\n\n childProcess.kill('SIGTERM');\n }, RESTART_DEBOUNCE_MS);\n};\n\nconst shutdownSupervisor = () => {\n isShuttingDown = true;\n\n if (restartTimer) {\n clearTimeout(restartTimer);\n restartTimer = null;\n }\n if (crashRestartTimer) {\n clearTimeout(crashRestartTimer);\n crashRestartTimer = null;\n }\n\n pendingRestart = false;\n\n if (childProcess) {\n //? Child is still alive — its 'exit' handler will see isShuttingDown\n //? and call process.exit(0) once the child is gone. Force-exit after a\n //? short grace period in case the child ignores SIGTERM (Windows).\n childProcess.kill('SIGTERM');\n setTimeout(() => process.exit(0), SHUTDOWN_GRACE_MS).unref();\n } else {\n //? No child running (e.g. we were between crashes, sitting in the\n //? crash-restart setTimeout we just cleared above) — exit immediately.\n process.exit(0);\n }\n};\n\nconst bootSupervisor = () => {\n //? Fail loud before anything is watched or spawned: we were asked for Bun but\n //? cannot deliver it, and Node-in-disguise is not an acceptable substitute.\n if (!resolvedChildSpawn.ok) {\n console.error(`[Supervisor] ${resolvedChildSpawn.message}`);\n process.exit(1);\n }\n\n if (resolveNodeEnv() === 'production') {\n //? In production the supervisor has no file watcher, but it still needs to\n //? forward shutdown signals so the child gets a chance to drain (e.g. flush\n //? error trackers, close DB connections). Without these handlers a SIGTERM\n //? from a process manager (systemd, Docker) kills the supervisor instantly\n //? and the child may never exit cleanly.\n process.on('SIGINT', () => {\n isShuttingDown = true;\n shutdownSupervisor();\n });\n process.on('SIGTERM', () => {\n isShuttingDown = true;\n shutdownSupervisor();\n });\n startChild();\n } else {\n const watcher = watch(CORE_WATCH_TARGETS, {\n ignoreInitial: true,\n awaitWriteFinish: {\n stabilityThreshold: 120,\n pollInterval: 20,\n },\n });\n\n watcher.on('all', (event, changedPath) => {\n //? Ignore pure directory churn from the recursively-watched bootstrap/auth dirs.\n if (event === 'addDir' || event === 'unlinkDir') return;\n //? Restore the old `**/*.ts` glob intent: within the watched directories only\n //? TS sources matter. A `.ts` suffix covers every concrete watched TS file too\n //? (config.ts, server.ts, functions/*.ts); the non-TS watched files are the\n //? `.env*` set, matched explicitly. A stray non-TS file under a watched dir is\n //? correctly ignored.\n const normalized = changedPath.replaceAll('\\\\', '/');\n const relevant = normalized.endsWith('.ts') || CORE_WATCH_FILES.some((file) => normalized.endsWith(file));\n if (!relevant) return;\n scheduleRestart({ event, changedPath });\n });\n\n const handleSignal = (signalName: string) => {\n //? Set the flag synchronously so any in-flight child 'exit' handler\n //? running BEFORE watcher.close() resolves won't schedule a new spawn.\n if (isShuttingDown) {\n //? Second Ctrl+C — user is impatient. Hard-exit immediately.\n process.exit(1);\n }\n isShuttingDown = true;\n console.log(`[Supervisor] Received ${signalName}, shutting down`);\n void watcher.close().then(() => {\n shutdownSupervisor();\n }).catch(() => {\n shutdownSupervisor();\n });\n };\n\n process.on('SIGINT', () => { handleSignal('SIGINT'); });\n process.on('SIGTERM', () => { handleSignal('SIGTERM'); });\n\n startChild();\n }\n};\n\n//? Importing this module must not boot a server: the unit tests import\n//? `resolveChildSpawn` to exercise every runtime branch, and this file is an\n//? ENTRY (the `luckystack-dev` bin + the repo's `server/dev/supervisor.ts`\n//? shim both rely on the import side-effect, so a `main`-module check would\n//? break the shim). Default is unchanged — boot unless explicitly told not to.\nif (!process.env.LUCKYSTACK_SUPERVISOR_IMPORT_ONLY) bootSupervisor();\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/supervisor.ts"],"sourcesContent":["#!/usr/bin/env node\n//? CRITICAL INVARIANT: this process must NEVER merge `.env` files into its own\n//? `process.env`. The child is spawned with `{ ...process.env }`, and the\n//? child's `loadEnvFiles()` lets any value already in `process.env` beat every\n//? env file — any `.env`-derived value that leaks into the supervisor's env would therefore\n//? WIN over freshly edited file values on every restart (stale-env bug).\n//? That is why this file imports NOTHING from `@luckystack/core` (core runs\n//? `bootstrapEnv()` as an import side-effect) and reads env files via\n//? `dotenv.parse` (pure — no `process.env` mutation). The previous design\n//? (an `ambientEnvSnapshot` module imported first) broke as soon as tsup\n//? inlined the snapshot into the entry body: ESM imports are hoisted, so\n//? core's side-effect ran BEFORE the snapshot line.\nimport { spawn, type ChildProcess } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport { performance } from 'node:perf_hooks';\nimport path from 'node:path';\nimport { watch } from 'chokidar';\nimport { parse as parseDotenv } from 'dotenv';\nimport { isDevRestartMessage } from './devServerRestart';\n\nconst RESTART_DEBOUNCE_MS = 150;\nconst CRASH_RESTART_DELAY_MS = 300;\n//? Rapid-crash-loop breaker. A child that exits non-zero within\n//? FAST_CRASH_THRESHOLD_MS of booting counts as a \"fast crash\" (it died during\n//? startup rather than after serving). MAX_CONSECUTIVE_FAST_CRASHES of those in\n//? a row means the startup error is not transient (a port already in use, a\n//? syntax error, a missing env) — restarting forever just spams the console, so\n//? we give up and print an actionable message. A child that survives longer than\n//? the threshold resets the counter, so a normal restart loop is unaffected.\nconst FAST_CRASH_THRESHOLD_MS = 3000;\nconst MAX_CONSECUTIVE_FAST_CRASHES = 4;\n//? Force-exit grace: how long we wait for the child to honour SIGTERM before\n//? the supervisor hard-exits. Windows does not deliver SIGKILL so the only\n//? lever is this timer. 1 500 ms is intentionally short (dev restarts should\n//? be fast); increase via LUCKYSTACK_SUPERVISOR_GRACE_MS for slow servers.\nconst SHUTDOWN_GRACE_MS = Number(process.env.LUCKYSTACK_SUPERVISOR_GRACE_MS) || 1500;\n\n//? Mirrors `getEnvFiles()` from @luckystack/core — duplicated on purpose so the\n//? supervisor never imports core (see the invariant above). Keep in sync.\nconst DEFAULT_ENV_FILES = ['.env', '.env.local'];\nconst getEnvFiles = (): string[] => {\n const override = process.env.LUCKYSTACK_ENV_FILES;\n if (override) {\n const list = override.split(',').map((entry) => entry.trim()).filter(Boolean);\n if (list.length > 0) return list;\n }\n return DEFAULT_ENV_FILES;\n};\n\n//? Resolve NODE_ENV without mutating `process.env`: a real ambient env var\n//? wins; otherwise read the env files via the pure `dotenv.parse` (\"later\n//? overrides earlier\", matching loadEnvFiles' file order).\nconst resolveNodeEnv = (): string => {\n if (process.env.NODE_ENV) return process.env.NODE_ENV;\n let fromFiles: string | undefined;\n for (const file of getEnvFiles()) {\n const filePath = path.resolve(process.cwd(), file);\n if (!existsSync(filePath)) continue;\n const parsed = parseDotenv(readFileSync(filePath, 'utf8'));\n if (typeof parsed.NODE_ENV === 'string' && parsed.NODE_ENV.length > 0) fromFiles = parsed.NODE_ENV;\n }\n return fromFiles ?? 'development';\n};\n\n//? chokidar v5 removed glob support — a `server/bootstrap/**/*.ts` pattern is no\n//? longer expanded; it is watched as a LITERAL path that never exists, so changes\n//? under `server/bootstrap` / `server/auth` silently never triggered a restart.\n//? Watch the concrete files directly, and the bootstrap/auth DIRECTORIES\n//? recursively (chokidar's default for a directory), then filter events down to\n//? the file types the old globs targeted in the `all` handler below.\nconst CORE_WATCH_FILES = [\n 'config.ts',\n ...getEnvFiles(),\n 'server/server.ts',\n 'server/functions/db.ts',\n 'server/functions/redis.ts',\n 'server/functions/sentry.ts',\n];\n//? Only watch dirs that exist — chokidar watching a recursive non-existent\n//? directory is wasteful/odd; a dir created later is picked up on next boot.\nconst CORE_WATCH_DIRS = ['server/bootstrap', 'server/auth'].filter((dir) =>\n existsSync(path.resolve(process.cwd(), dir)),\n);\nconst CORE_WATCH_TARGETS = [...CORE_WATCH_FILES, ...CORE_WATCH_DIRS];\n\nconst tsxCliPath = path.resolve(process.cwd(), 'node_modules', 'tsx', 'dist', 'cli.mjs');\n//? Scaffolded projects carry a dedicated server tsconfig — pass it when present\n//? so the child matches a manual `tsx --tsconfig tsconfig.server.json` run.\nconst tsconfigServerArgs = existsSync(path.resolve(process.cwd(), 'tsconfig.server.json'))\n ? ['--tsconfig', 'tsconfig.server.json']\n : [];\n\n//? ---------------------------------------------------------------------------\n//? Runtime honouring — `bun run server` must ACTUALLY run Bun\n//? ---------------------------------------------------------------------------\n//? A LuckyStack project must work on Node AND Bun. On Windows that is not\n//? automatic and the failure is SILENT: npm generates a `.cmd` shim per bin\n//? (`node_modules/.bin/luckystack-dev.cmd`) which hardcodes a `node` call, so\n//? `bun run server` dutifully launches NODE while every log line still looks\n//? green. Windows has no shebang to intercept, so the shim always wins.\n//? Verified empirically on bun 1.3.14 / Windows:\n//? `bun run server` -> child ran C:\\Program Files\\nodejs\\node.exe\n//? `bun --bun run server` -> child ran Bun (via a node-shim Bun injects at\n//? %TEMP%\\bun-node-<hash>\\node.exe)\n//? Bun does leave fingerprints even when it hands off to Node:\n//? npm_config_user_agent = \"bun/1.3.14 npm/? node/v24.3.0 win32 x64\"\n//? npm_execpath = \"<abs>/bun.exe\"\n//? That lets us tell \"the developer typed `bun run`\" from \"the developer typed\n//? `npm run`\", and `npm_execpath` hands us the real bun binary to correct\n//? course with. The CHILD is what actually serves the app, so the child is\n//? where the intended runtime gets honoured. This keeps the promise honest\n//? with NO new runtime choice: no extra script name, no wizard question, no\n//? manifest dimension — `npm run server` stays Node, `bun run server` is Bun.\nconst BUN_BINARY_PATTERN = /(?:^|[\\\\/])bun(?:\\.exe)?$/i;\n\nexport type SupervisorRuntime = 'node' | 'bun';\n\nexport interface ChildSpawnSpec {\n command: string;\n args: string[];\n runtime: SupervisorRuntime;\n}\n\nexport interface ResolveChildSpawnInput {\n //? `'Bun' in globalThis` at the call site — passed in so the branch is testable.\n isBun: boolean;\n execPath: string;\n npmUserAgent: string | undefined;\n npmExecPath: string | undefined;\n tsxCliPath: string;\n tsconfigServerArgs: string[];\n //? MUST be absolute — see the fork-bomb note in the bun re-exec branch.\n entry: string;\n fileExists: (candidate: string) => boolean;\n}\n\n//? Result shape rather than a throw: this module may not import\n//? `@luckystack/core` (see the invariant at the top of this file), so the\n//? framework's `tryCatch` is unavailable here and a bare throw at module scope\n//? would surface as an ugly unhandled stack instead of an actionable message.\nexport type ResolveChildSpawnResult =\n | { ok: true; spec: ChildSpawnSpec }\n | { ok: false; message: string };\n\nexport const resolveChildSpawn = ({\n isBun,\n execPath,\n npmUserAgent,\n npmExecPath,\n tsxCliPath: tsxCli,\n tsconfigServerArgs: tsconfigArgs,\n entry,\n fileExists,\n}: ResolveChildSpawnInput): ResolveChildSpawnResult => {\n //? Already executing under Bun (`bun --bun run server`, or `bun server/server.ts`).\n //? `process.execPath` is then either bun itself or the node-shim Bun injects for\n //? bin scripts — BOTH re-enter Bun (verified on 1.3.14/Windows), so spawning\n //? execPath keeps the child on Bun. tsx is dropped deliberately: Bun compiles\n //? TypeScript natively, so tsx would only add a redundant transpile hop, and\n //? `--tsconfig` is not a Bun flag at all (Bun reads `tsconfig.json` itself).\n if (isBun) return { ok: true, spec: { command: execPath, args: [entry], runtime: 'bun' } };\n\n const launchedByBun =\n (npmUserAgent ?? '').startsWith('bun/') || BUN_BINARY_PATTERN.test(npmExecPath ?? '');\n\n //? Plain `npm run server` — the canonical Node path, unchanged. Node cannot\n //? run the whole TypeScript server tree on its own, so tsx stays.\n if (!launchedByBun) {\n return {\n ok: true,\n spec: { command: execPath, args: [tsxCli, ...tsconfigArgs, entry], runtime: 'node' },\n };\n }\n\n //? `bun run server` on Windows: WE are Node (the .cmd shim won), but the\n //? developer asked for Bun. Re-exec the child through the real bun binary.\n const bunBinary = npmExecPath ?? '';\n if (!BUN_BINARY_PATTERN.test(bunBinary) || !fileExists(bunBinary)) {\n //? Fail LOUD rather than quietly serving from Node. Silently continuing is\n //? the exact trap this branch exists to remove: it would look green while\n //? proving nothing about Bun compatibility.\n return {\n ok: false,\n message:\n 'Detected a `bun run` launch, but could not locate the bun binary to run the server with ' +\n `(npm_execpath=${npmExecPath ?? '<unset>'}). Refusing to silently fall back to Node — ` +\n 'that would look like a working Bun boot while actually running Node. ' +\n 'Run `npm run server` for the Node path, or reinstall/repair Bun so it is on PATH.',\n };\n }\n\n //? `entry` MUST be absolute: `bun run <name>` resolves a package.json SCRIPT\n //? before a file, so a relative `server/server.ts` could re-enter the `server`\n //? script and fork-bomb. An absolute path can never be read as a script name.\n return { ok: true, spec: { command: bunBinary, args: ['--bun', 'run', entry], runtime: 'bun' } };\n};\n\nconst resolvedChildSpawn = resolveChildSpawn({\n isBun: 'Bun' in globalThis,\n execPath: process.execPath,\n npmUserAgent: process.env.npm_config_user_agent,\n npmExecPath: process.env.npm_execpath,\n tsxCliPath,\n tsconfigServerArgs,\n entry: path.resolve(process.cwd(), 'server', 'server.ts'),\n fileExists: existsSync,\n});\n\n\nlet childProcess: ChildProcess | null = null;\nlet pendingRestart = false;\nlet restartTimer: NodeJS.Timeout | null = null;\nlet crashRestartTimer: NodeJS.Timeout | null = null;\nlet childBootStartedAt = 0;\nlet consecutiveFastCrashes = 0;\nlet isShuttingDown = false;\n\nexport const startChild = () => {\n if (isShuttingDown) return;\n //? One child at a time. A watcher restart can start a child while a crash\n //? restart is still pending; that timer would then start a SECOND child and\n //? overwrite `childProcess`, orphaning the first: it keeps the port and is\n //? never sent SIGTERM on shutdown.\n if (crashRestartTimer) {\n clearTimeout(crashRestartTimer);\n crashRestartTimer = null;\n }\n if (childProcess) return;\n //? Defensive: `bootSupervisor()` already aborts on an unresolved spawn, so this\n //? is unreachable in practice — it exists to narrow the result type here.\n if (!resolvedChildSpawn.ok) return;\n const { command: childCommand, args: childArgs, runtime: childRuntime } = resolvedChildSpawn.spec;\n //? The IPC channel carries the child's restart request (`devServerRestart.ts`).\n //? Verified for Node and for Bun on Linux; Bun on Windows is unverified, so it\n //? keeps plain stdio and its dev server falls back to a \"restart needed\" log.\n const withIpc = childRuntime === 'node' || process.platform !== 'win32';\n childBootStartedAt = performance.now();\n const spawned = spawn(childCommand, childArgs, {\n stdio: withIpc ? ['inherit', 'inherit', 'inherit', 'ipc'] : 'inherit',\n //? `process.env` is guaranteed `.env`-free here (see the invariant at the\n //? top of this file), so the child loads `.env` fresh on every restart and\n //? picks up edited values — mirroring a cold `npm run server` boot.\n env: {\n ...process.env,\n LUCKYSTACK_CORE_SUPERVISED: 'true',\n },\n });\n childProcess = spawned;\n\n //? Name the runtime explicitly. `bun run server` silently serving from Node is\n //? the bug this file guards against, so the runtime must be observable rather\n //? than assumed — a green boot log is exactly what made the old trap invisible.\n console.log(\n `[Supervisor] Started server process (pid: ${String(spawned.pid)}, runtime: ${childRuntime})`,\n );\n\n //? `'error'` and `'exit'` can BOTH fire for a single spawn (a failed spawn\n //? emits `'error'`, and some platforms then emit `'exit'` too). This flag\n //? resolves THIS child's lifecycle exactly once regardless of event order, so\n //? we never double-schedule a restart.\n let handled = false;\n\n //? A route dependency changed in the child. Ignore a message from a child\n //? that has already been replaced: restarting would kill its successor.\n spawned.on('message', (message) => {\n if (childProcess !== spawned || !isDevRestartMessage(message)) return;\n scheduleRestart({ event: 'route dependency change', changedPath: message.reason });\n });\n\n //? A failed `spawn` (ENOENT/EACCES — e.g. a missing `tsx`) emits `'error'`,\n //? NOT `'exit'`. Without this listener Node re-throws it as an uncaught\n //? exception that kills the SUPERVISOR instead of retrying — the dev wrapper\n //? would just disappear. Route it through the same crash-restart delay.\n spawned.on('error', (err) => {\n console.log(`[Supervisor] Failed to spawn server process: ${String(err)}`, 'red');\n if (handled) return;\n handled = true;\n childProcess = null;\n\n if (isShuttingDown) {\n process.exit(1);\n }\n\n console.log(`[Supervisor] Retrying server spawn in ${String(CRASH_RESTART_DELAY_MS)}ms`);\n crashRestartTimer = setTimeout(() => {\n crashRestartTimer = null;\n startChild();\n }, CRASH_RESTART_DELAY_MS);\n });\n\n spawned.on('exit', (code, signal) => {\n const uptimeMs = Math.round(performance.now() - childBootStartedAt);\n const shouldRestart = pendingRestart;\n\n if (handled) return;\n handled = true;\n childProcess = null;\n\n //? Once the user has asked us to stop, never spawn another child — no matter\n //? what the previous one's exit reason was. Without this guard a crash that\n //? races with Ctrl+C re-spawns indefinitely.\n if (isShuttingDown) {\n process.exit(0);\n }\n\n if (shouldRestart) {\n pendingRestart = false;\n //? A watcher-driven restart is an explicit edit to a watched file — give\n //? the new code a clean slate instead of inheriting a previous crash burst.\n consecutiveFastCrashes = 0;\n console.log(`[Supervisor] Restarting server after ${String(uptimeMs)}ms uptime`);\n startChild();\n return;\n }\n\n if (signal === 'SIGTERM' || signal === 'SIGINT') {\n return;\n }\n\n if (typeof code === 'number' && code !== 0) {\n //? Rapid-crash-loop breaker: a child that died within the boot window\n //? counts toward the consecutive-fast-crash tally; one that lived longer\n //? proves the startup error cleared, so reset the tally.\n if (uptimeMs < FAST_CRASH_THRESHOLD_MS) {\n consecutiveFastCrashes += 1;\n } else {\n consecutiveFastCrashes = 0;\n }\n\n if (consecutiveFastCrashes >= MAX_CONSECUTIVE_FAST_CRASHES) {\n console.log(\n `[Supervisor] Server crashed ${String(consecutiveFastCrashes)} times within ` +\n `${String(FAST_CRASH_THRESHOLD_MS / 1000)}s of starting — giving up. Fix the startup ` +\n `error above (e.g. a port already in use), then re-run \\`npm run server\\`.`,\n );\n process.exit(1);\n }\n\n console.log(`[Supervisor] Server crashed with code ${String(code)}. Restarting in ${String(CRASH_RESTART_DELAY_MS)}ms`);\n crashRestartTimer = setTimeout(() => {\n crashRestartTimer = null;\n startChild();\n }, CRASH_RESTART_DELAY_MS);\n }\n });\n};\n\nexport const scheduleRestart = ({\n event,\n changedPath,\n}: {\n event: string;\n changedPath: string;\n}) => {\n if (restartTimer) {\n clearTimeout(restartTimer);\n }\n\n restartTimer = setTimeout(() => {\n restartTimer = null;\n pendingRestart = true;\n\n console.log(`[Supervisor] Core change detected (${event}): ${changedPath}. Restarting server`);\n\n if (!childProcess) {\n pendingRestart = false;\n startChild();\n return;\n }\n\n childProcess.kill('SIGTERM');\n }, RESTART_DEBOUNCE_MS);\n};\n\nconst shutdownSupervisor = () => {\n isShuttingDown = true;\n\n if (restartTimer) {\n clearTimeout(restartTimer);\n restartTimer = null;\n }\n if (crashRestartTimer) {\n clearTimeout(crashRestartTimer);\n crashRestartTimer = null;\n }\n\n pendingRestart = false;\n\n if (childProcess) {\n //? Child is still alive — its 'exit' handler will see isShuttingDown\n //? and call process.exit(0) once the child is gone. Force-exit after a\n //? short grace period in case the child ignores SIGTERM (Windows).\n childProcess.kill('SIGTERM');\n setTimeout(() => process.exit(0), SHUTDOWN_GRACE_MS).unref();\n } else {\n //? No child running (e.g. we were between crashes, sitting in the\n //? crash-restart setTimeout we just cleared above) — exit immediately.\n process.exit(0);\n }\n};\n\nconst bootSupervisor = () => {\n //? Fail loud before anything is watched or spawned: we were asked for Bun but\n //? cannot deliver it, and Node-in-disguise is not an acceptable substitute.\n if (!resolvedChildSpawn.ok) {\n console.error(`[Supervisor] ${resolvedChildSpawn.message}`);\n process.exit(1);\n }\n\n if (resolveNodeEnv() === 'production') {\n //? In production the supervisor has no file watcher, but it still needs to\n //? forward shutdown signals so the child gets a chance to drain (e.g. flush\n //? error trackers, close DB connections). Without these handlers a SIGTERM\n //? from a process manager (systemd, Docker) kills the supervisor instantly\n //? and the child may never exit cleanly.\n process.on('SIGINT', () => {\n isShuttingDown = true;\n shutdownSupervisor();\n });\n process.on('SIGTERM', () => {\n isShuttingDown = true;\n shutdownSupervisor();\n });\n startChild();\n } else {\n const watcher = watch(CORE_WATCH_TARGETS, {\n ignoreInitial: true,\n awaitWriteFinish: {\n stabilityThreshold: 120,\n pollInterval: 20,\n },\n });\n\n watcher.on('all', (event, changedPath) => {\n //? Ignore pure directory churn from the recursively-watched bootstrap/auth dirs.\n if (event === 'addDir' || event === 'unlinkDir') return;\n //? Restore the old `**/*.ts` glob intent: within the watched directories only\n //? TS sources matter. A `.ts` suffix covers every concrete watched TS file too\n //? (config.ts, server.ts, functions/*.ts); the non-TS watched files are the\n //? `.env*` set, matched explicitly. A stray non-TS file under a watched dir is\n //? correctly ignored.\n const normalized = changedPath.replaceAll('\\\\', '/');\n const relevant = normalized.endsWith('.ts') || CORE_WATCH_FILES.some((file) => normalized.endsWith(file));\n if (!relevant) return;\n scheduleRestart({ event, changedPath });\n });\n\n const handleSignal = (signalName: string) => {\n //? Set the flag synchronously so any in-flight child 'exit' handler\n //? running BEFORE watcher.close() resolves won't schedule a new spawn.\n if (isShuttingDown) {\n //? Second Ctrl+C — user is impatient. Hard-exit immediately.\n process.exit(1);\n }\n isShuttingDown = true;\n console.log(`[Supervisor] Received ${signalName}, shutting down`);\n void watcher.close().then(() => {\n shutdownSupervisor();\n }).catch(() => {\n shutdownSupervisor();\n });\n };\n\n process.on('SIGINT', () => { handleSignal('SIGINT'); });\n process.on('SIGTERM', () => { handleSignal('SIGTERM'); });\n\n startChild();\n }\n};\n\n//? Importing this module must not boot a server: the unit tests import\n//? `resolveChildSpawn` to exercise every runtime branch, and this file is an\n//? ENTRY (the `luckystack-dev` bin + the repo's `server/dev/supervisor.ts`\n//? shim both rely on the import side-effect, so a `main`-module check would\n//? break the shim). Default is unchanged — boot unless explicitly told not to.\nif (!process.env.LUCKYSTACK_SUPERVISOR_IMPORT_ONLY) bootSupervisor();\n"],"mappings":"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","names":[]}
|
|
@@ -21,6 +21,10 @@ export declare const extractSyncPagePath: (filePath: string) => string;
|
|
|
21
21
|
* Extract sync name from a sync file path (e.g., "test" from "src/examples/_sync/test_server_v1.ts")
|
|
22
22
|
*/
|
|
23
23
|
export declare const extractSyncName: (filePath: string) => string;
|
|
24
|
+
/**
|
|
25
|
+
* Extract sync version from a sync file path (e.g., "v1" from "src/examples/_sync/test_server_v1.ts")
|
|
26
|
+
*/
|
|
27
|
+
export declare const extractSyncVersion: (filePath: string) => string;
|
|
24
28
|
/**
|
|
25
29
|
* Extract clientInput type body from a sync file's SyncParams interface
|
|
26
30
|
* Returns the content between the braces of clientInput: { ... }
|
|
@@ -30,7 +34,7 @@ export declare const extractClientInputFromFile: (filePath: string) => string |
|
|
|
30
34
|
* Extract clientInput type from the generated apiTypes.generated.ts file
|
|
31
35
|
* Used when the server file is already deleted but we need to migrate types to client
|
|
32
36
|
*/
|
|
33
|
-
export declare const extractClientInputFromGeneratedTypes: (pagePath: string, syncName: string) => string | null;
|
|
37
|
+
export declare const extractClientInputFromGeneratedTypes: (pagePath: string, syncName: string, version: string) => string | null;
|
|
34
38
|
/**
|
|
35
39
|
* Calculate the relative path prefix (e.g., '../../../') to reach project root from a file
|
|
36
40
|
* @param filePath - Absolute or relative path to the file
|
|
@@ -37,7 +37,8 @@ export interface ApiParams {
|
|
|
37
37
|
flushPressure: (options?: { thresholdBytes?: number }) => Promise<void>;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
// Take what you need from the parameters, e.g. ({ data, user, functions }: ApiParams)
|
|
41
|
+
export const main = (): MaybePromise<ApiResponse> => {
|
|
41
42
|
// Stream payload types are generated from your stream(...) calls.
|
|
42
43
|
// stream({ phase: 'started', progress: 0 });
|
|
43
44
|
// stream({ phase: 'done', progress: 100, done: true });
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
//? sane default — adjust the role check or remove the middleware if this
|
|
7
7
|
//? page is public.
|
|
8
8
|
|
|
9
|
-
//@ts-ignore We replace {{REL_PATH}} with the relative path to the project root at scaffold time.
|
|
10
9
|
import type { PageMiddleware } from '@luckystack/core/client';
|
|
11
10
|
//@ts-ignore We replace {{REL_PATH}} with the relative path to the project root at scaffold time.
|
|
12
11
|
import type { SessionLayout } from '{{REL_PATH}}config';
|
|
@@ -20,12 +19,9 @@ export const middleware: PageMiddleware<SessionLayout> = ({ session }) => {
|
|
|
20
19
|
return { success: true };
|
|
21
20
|
};
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export default function Page({ params, searchParams }: PageProps) {
|
|
22
|
+
//? Route and query values arrive as props when the page needs them:
|
|
23
|
+
//? ({ params, searchParams }: { params: Record<string, string | undefined>; searchParams: Record<string, string> })
|
|
24
|
+
export default function Page() {
|
|
29
25
|
return (
|
|
30
26
|
<div className='flex flex-col gap-4 p-6 w-full h-full'>
|
|
31
27
|
<h1 className='text-2xl font-semibold text-title'>{/* TODO: page title */}</h1>
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
//? the default surrounding layout (`'plain'`) is what you want. Per-page
|
|
6
6
|
//? middleware is OPTIONAL — uncomment when this route needs a guard.
|
|
7
7
|
|
|
8
|
-
//@ts-ignore We replace {{REL_PATH}} with the relative path to the project root at scaffold time.
|
|
9
8
|
// import type { PageMiddleware } from '@luckystack/core/client';
|
|
10
9
|
//@ts-ignore We replace {{REL_PATH}} with the relative path to the project root at scaffold time.
|
|
11
10
|
// import type { SessionLayout } from '{{REL_PATH}}config';
|
|
@@ -17,16 +16,12 @@ export const template = 'plain';
|
|
|
17
16
|
// return { success: true };
|
|
18
17
|
// };
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export default function Page({ params, searchParams }: PageProps) {
|
|
19
|
+
//? Route and query values arrive as props when the page needs them:
|
|
20
|
+
//? ({ params, searchParams }: { params: Record<string, string | undefined>; searchParams: Record<string, string> })
|
|
21
|
+
export default function Page() {
|
|
26
22
|
return (
|
|
27
23
|
<div className='flex items-center justify-center w-full h-full text-title'>
|
|
28
|
-
{/* TODO: replace with your page content */}
|
|
29
|
-
<span>page scaffold — replace this</span>
|
|
24
|
+
{/* TODO: replace with your page content; text goes through the project translator */}
|
|
30
25
|
</div>
|
|
31
26
|
);
|
|
32
27
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/* eslint-disable */
|
|
3
3
|
|
|
4
4
|
//@ts-ignore We replace {{REL_PATH}} with the relative path to the project root at scaffold time.
|
|
5
|
-
import { Functions, SyncClientResponse, SyncClientStreamEmitter } from '{{REL_PATH}}src/_sockets/apiTypes.generated';
|
|
5
|
+
import { Functions, SyncClientResponse, MaybePromise, SyncClientStreamEmitter } from '{{REL_PATH}}src/_sockets/apiTypes.generated';
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
export interface SyncParams {
|
|
@@ -20,7 +20,8 @@ export interface SyncParams {
|
|
|
20
20
|
stream: SyncClientStreamEmitter;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// Take what you need from the parameters, e.g. ({ clientInput, serverOutput, token, functions }: SyncParams)
|
|
24
|
+
export const main = (): MaybePromise<SyncClientResponse> => {
|
|
24
25
|
// THIS FILE RUNS ON THE SERVER AND IT EXECUTES FOR EVERY CLIENT THAT IS IN THE GIVEN ROOM
|
|
25
26
|
// stream payload types are generated from your stream(...) calls in this file
|
|
26
27
|
// Use functions.session.getSession(token) when you need session data for this target client.
|
|
@@ -17,7 +17,8 @@ export interface SyncParams {
|
|
|
17
17
|
stream: SyncClientStreamEmitter;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// Take what you need from the parameters, e.g. ({ clientInput, serverOutput, token, functions }: SyncParams)
|
|
21
|
+
export const main = (): MaybePromise<SyncClientResponse> => {
|
|
21
22
|
// PAIRED SYNC: Types are shared with the _server_v{number}.ts file
|
|
22
23
|
// clientInput type comes from _server_v{number}.ts SyncParams
|
|
23
24
|
// serverOutput type is inferred from _server_v{number}.ts return value
|
|
@@ -17,7 +17,8 @@ export interface SyncParams {
|
|
|
17
17
|
stream: SyncClientStreamEmitter;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// Take what you need from the parameters, e.g. ({ clientInput, token, functions }: SyncParams)
|
|
21
|
+
export const main = (): MaybePromise<SyncClientResponse> => {
|
|
21
22
|
// CLIENT-ONLY SYNC: No server processing, runs for each client in the room
|
|
22
23
|
// stream payload types are generated from your stream(...) calls in this file
|
|
23
24
|
// Use functions.session.getSession(token) when you need session data for this target client.
|
|
@@ -38,7 +38,8 @@ export interface SyncParams {
|
|
|
38
38
|
flushPressure: (options?: { thresholdBytes?: number }) => Promise<void>;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
// Take what you need from the parameters, e.g. ({ clientInput, user, functions }: SyncParams)
|
|
42
|
+
export const main = (): MaybePromise<SyncServerResponse> => {
|
|
42
43
|
// THIS FILE RUNS JUST ONCE ON THE SERVER
|
|
43
44
|
|
|
44
45
|
// Stream payload types are generated from your stream(...) calls.
|
package/docs/hot-reload.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Hot reload design
|
|
2
2
|
|
|
3
|
-
Development route loading serves requests from in-memory registries
|
|
3
|
+
Development route loading serves requests from in-memory registries. Regenerating the editor-facing type artifacts, and reading a reloaded route's input type, both build the TypeScript Program synchronously, so the dev server answers no request while that runs; deferring the work only lets the current event finish first. The pause is accepted for development rather than moving generation into a separate process, and its duration is logged so a stalled request has a visible cause. File-event bursts are coalesced, and a generation already in progress gets a follow-up pass instead of competing builds. Deferring heavy work lets the server listen before the TypeScript Program is built. Consult the watcher source for event classification and timing rather than maintaining a second event-flow implementation here.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
A route file's own change reloads only that route in place. A change to a file a route imports is different: re-importing the route does not re-evaluate its imports, because the ESM loader caches them by URL and the cache-busting query reaches the route module only. Under the supervisor the dev server therefore asks for a restart; without it, the affected routes are re-imported so their input types refresh, and a warning says the handlers keep the old code until a restart. Files under a route marker that are not route surface (private `_`-prefixed helpers, test files) are dependencies, not misnamed routes. Generated outputs must not trigger their own watcher loops. Empty route files, or ones holding only the injector's own guidance line, can receive starter templates; any other content is the developer's, even when it arrives by rename or checkout, and is never overwritten. Paired sync files should remain type-consistent. Preserve the distinction between runtime reload and generated developer artifacts: a generation failure can leave IDE hints stale without justifying a production-path dependency on devkit.
|
|
6
6
|
|
|
7
7
|
Production does not start these watchers. If the live registry appears stale, inspect watcher errors and route naming before relying on regenerated files as proof the running handler was replaced.
|
package/docs/supervisor.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
|
|
3
3
|
A development supervisor restarts the child server when changes cannot safely be handled by live route reload alone. Keeping a small parent process separate from the running server allows a clean replacement without assuming that old module state is reusable. The watcher must coalesce rapid edits to avoid repeatedly starting partially-written code. See the supervisor source for exact state transitions, watched paths and timing.
|
|
4
4
|
|
|
5
|
-
Shutdown should release the child and watchers rather than leaving duplicate servers attached to the same port. This is development-only machinery; production orchestration belongs to the deployment platform. For route-only changes, prefer in-process hot reload to avoid unnecessary full-server restarts; see `hot-reload.md`.
|
|
5
|
+
The child can also ask for a restart over the IPC channel when a file a route imports changes, because in-process reload cannot refresh a route's imports (see `hot-reload.md`). At most one child runs at a time: a pending crash restart and a watcher restart must not each start one. Shutdown should release the child and watchers rather than leaving duplicate servers attached to the same port. This is development-only machinery; production orchestration belongs to the deployment platform. For route-only changes, prefer in-process hot reload to avoid unnecessary full-server restarts; see `hot-reload.md`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luckystack/devkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"test": "vitest run"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@luckystack/core": "^0.
|
|
59
|
+
"@luckystack/core": "^0.15.0",
|
|
60
60
|
"chokidar": "^5.0.0",
|
|
61
61
|
"dotenv": "^17.0.0",
|
|
62
62
|
"typescript-7-api": "npm:typescript@7.1.0-dev.20261013.1"
|