@lark-apaas/fullstack-cli 1.1.59 → 1.1.61-alpha.20260818172555
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/dist/client-dependency-graph.d.ts +37 -0
- package/dist/client-dependency-graph.js +466 -0
- package/dist/index.js +3482 -143
- package/package.json +4 -1
- package/templates/scripts/cache-generation-preflight.mjs +1346 -0
- package/templates/scripts/cache-generation-prune.mjs +174 -0
- package/templates/scripts/cache-runtime-coordinator.mjs +487 -0
- package/templates/scripts/dev.js +433 -59
- package/templates/scripts/dev.sh +2 -0
- package/templates/scripts/lint.js +2 -25
- package/templates/scripts/patch-vite-dependency-graph-hash.mjs +141 -0
- package/templates/scripts/server-cache-module-resolver.cjs +63 -0
- package/templates/scripts/server-cache-runtime.mjs +681 -0
- package/templates/scripts/server-startup-runtime.mjs +496 -0
- package/templates/scripts/server-transition-runtime.mjs +655 -0
- package/templates/scripts/vite-cache-runtime.mjs +3100 -0
- package/templates/scripts/workspace-client-runtime.mjs +176 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import {
|
|
12
|
+
validateCacheGeneration,
|
|
13
|
+
validateCacheGenerationReceipt,
|
|
14
|
+
} from './cache-generation-preflight.mjs';
|
|
15
|
+
|
|
16
|
+
const projectRoot = path.resolve(
|
|
17
|
+
process.env.MIAODA_WORKSPACE_ROOT || process.cwd()
|
|
18
|
+
);
|
|
19
|
+
const requestedCacheRoot = path.resolve(
|
|
20
|
+
process.env.MIAODA_CACHE_ROOT ||
|
|
21
|
+
path.join(projectRoot, '.miaoda-cache', 'current')
|
|
22
|
+
);
|
|
23
|
+
const cacheRoot = fs.realpathSync(requestedCacheRoot);
|
|
24
|
+
const runtimeManifestFile = path.resolve(
|
|
25
|
+
process.env.MIAODA_PLATFORM_RUNTIME_MANIFEST ||
|
|
26
|
+
'/opt/miaoda/preview-runtime/runtime-manifest.json'
|
|
27
|
+
);
|
|
28
|
+
const validateOnly = process.argv.includes('--validate-only');
|
|
29
|
+
const publicHost = process.env.SERVER_HOST || '0.0.0.0';
|
|
30
|
+
const publicPort = Number(process.env.SERVER_PORT || 3000);
|
|
31
|
+
const cacheNodeModules = path.join(cacheRoot, 'server', 'node_modules');
|
|
32
|
+
const cacheBundleFile = path.join(
|
|
33
|
+
cacheRoot,
|
|
34
|
+
'server',
|
|
35
|
+
'bundle',
|
|
36
|
+
'server.bundle.cjs'
|
|
37
|
+
);
|
|
38
|
+
const cacheBundleMetadataFile = `${cacheBundleFile}.meta.json`;
|
|
39
|
+
const resolverFile = path.join(
|
|
40
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
41
|
+
'server-cache-module-resolver.cjs'
|
|
42
|
+
);
|
|
43
|
+
const readyMarker =
|
|
44
|
+
process.env.MIAODA_NEST_TRANSITION_READY_FILE ||
|
|
45
|
+
'/tmp/event/MIAODA_NEST_TRANSITION_SERVING_READY';
|
|
46
|
+
const pollIntervalMs = Number(
|
|
47
|
+
process.env.MIAODA_NEST_SOURCE_POLL_INTERVAL_MS || 250
|
|
48
|
+
);
|
|
49
|
+
const restartDebounceMs = Number(
|
|
50
|
+
process.env.MIAODA_NEST_SOURCE_RESTART_DEBOUNCE_MS || 120
|
|
51
|
+
);
|
|
52
|
+
const startupTimeoutMs = Number(
|
|
53
|
+
process.env.MIAODA_NEST_SOURCE_READY_TIMEOUT_MS || 60_000
|
|
54
|
+
);
|
|
55
|
+
const drainTimeoutMs = Number(
|
|
56
|
+
process.env.MIAODA_NEST_TRANSITION_DRAIN_TIMEOUT_MS || 5_000
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
function output(name, message) {
|
|
60
|
+
for (const line of String(message).split(/\r?\n/)) {
|
|
61
|
+
if (line) process.stdout.write(`MIAODA_LOG\t${name}\t${line}\n`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function event(name, details = {}) {
|
|
66
|
+
process.stdout.write(
|
|
67
|
+
`${JSON.stringify({ event: name, runtime: 'nest-transition', ...details })}\n`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validateGeneration() {
|
|
72
|
+
let validated;
|
|
73
|
+
const receiptFile = process.env.MIAODA_CACHE_VALIDATION_RECEIPT_FILE;
|
|
74
|
+
if (receiptFile) {
|
|
75
|
+
try {
|
|
76
|
+
validated = validateCacheGenerationReceipt({
|
|
77
|
+
projectRoot,
|
|
78
|
+
cacheRoot,
|
|
79
|
+
runtimeManifestFile,
|
|
80
|
+
receiptFile,
|
|
81
|
+
artifactScope: 'server',
|
|
82
|
+
});
|
|
83
|
+
} catch {
|
|
84
|
+
// Receipt is an optimization only; a miss falls back to full validation.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
validated ??= validateCacheGeneration({
|
|
88
|
+
projectRoot,
|
|
89
|
+
cacheRoot,
|
|
90
|
+
runtimeManifestFile,
|
|
91
|
+
artifactScope: 'server',
|
|
92
|
+
});
|
|
93
|
+
if (
|
|
94
|
+
validated.generation.schemaVersion !== 4 ||
|
|
95
|
+
validated.generation.serverRuntimeMode !==
|
|
96
|
+
'workspace-source-dependencies' ||
|
|
97
|
+
!validated.serverDependencies
|
|
98
|
+
) {
|
|
99
|
+
throw new Error('MIAODA_SERVER_TRANSITION_GENERATION_UNSUPPORTED');
|
|
100
|
+
}
|
|
101
|
+
return validated;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let validated;
|
|
105
|
+
try {
|
|
106
|
+
validated = validateGeneration();
|
|
107
|
+
if (!fs.existsSync(resolverFile)) {
|
|
108
|
+
throw new Error(`MIAODA_CACHE_BOOTSTRAP_INPUT_MISSING: ${resolverFile}`);
|
|
109
|
+
}
|
|
110
|
+
if (validateOnly) {
|
|
111
|
+
process.stdout.write(
|
|
112
|
+
`${JSON.stringify({
|
|
113
|
+
valid: true,
|
|
114
|
+
generationId: validated.generation.generationId,
|
|
115
|
+
sourceEntry: validated.serverDependencies.sourceEntry,
|
|
116
|
+
})}\n`
|
|
117
|
+
);
|
|
118
|
+
process.exit(0);
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
|
|
122
|
+
process.exit(2);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (
|
|
126
|
+
!Number.isSafeInteger(publicPort) ||
|
|
127
|
+
publicPort <= 0 ||
|
|
128
|
+
publicPort > 65_535
|
|
129
|
+
) {
|
|
130
|
+
throw new Error('MIAODA_NEST_TRANSITION_PORT_INVALID');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const packageJsonFile = path.join(projectRoot, 'package.json');
|
|
134
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonFile, 'utf8'));
|
|
135
|
+
const configuredProbe =
|
|
136
|
+
packageJson.miaodaCache?.availabilityProbe?.readiness ?? {};
|
|
137
|
+
const probe = {
|
|
138
|
+
...configuredProbe,
|
|
139
|
+
// The platform mounts the application server below CLIENT_BASE_PATH. Use
|
|
140
|
+
// that runtime fact before template defaults so old and new workspaces share
|
|
141
|
+
// the same readiness contract.
|
|
142
|
+
path:
|
|
143
|
+
process.env.MIAODA_SERVER_READY_PATH ||
|
|
144
|
+
process.env.CLIENT_BASE_PATH ||
|
|
145
|
+
configuredProbe.path ||
|
|
146
|
+
'/',
|
|
147
|
+
expectedStatus: Number(configuredProbe.expectedStatus ?? 200),
|
|
148
|
+
};
|
|
149
|
+
const sourceEntry = path.resolve(
|
|
150
|
+
projectRoot,
|
|
151
|
+
validated.serverDependencies.sourceEntry
|
|
152
|
+
);
|
|
153
|
+
if (!fs.existsSync(sourceEntry)) {
|
|
154
|
+
throw new Error(`MIAODA_NEST_SOURCE_ENTRY_MISSING: ${sourceEntry}`);
|
|
155
|
+
}
|
|
156
|
+
const cacheRequire = createRequire(
|
|
157
|
+
path.join(cacheRoot, 'server', 'cache-runtime-entry.cjs')
|
|
158
|
+
);
|
|
159
|
+
const bootstrapFiles = validated.serverDependencies.bootstrapModules.map(
|
|
160
|
+
specifier => {
|
|
161
|
+
try {
|
|
162
|
+
return cacheRequire.resolve(specifier);
|
|
163
|
+
} catch {
|
|
164
|
+
throw new Error(`MIAODA_NEST_BOOTSTRAP_MODULE_MISSING: ${specifier}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
);
|
|
168
|
+
const projectRootToken = '${MIAODA_WORKSPACE_ROOT}';
|
|
169
|
+
const materializeProjectPaths = value => {
|
|
170
|
+
if (typeof value === 'string') {
|
|
171
|
+
if (value === projectRootToken) return projectRoot;
|
|
172
|
+
if (value.startsWith(`${projectRootToken}/`)) {
|
|
173
|
+
const resolved = path.resolve(
|
|
174
|
+
projectRoot,
|
|
175
|
+
value.slice(projectRootToken.length + 1)
|
|
176
|
+
);
|
|
177
|
+
if (!resolved.startsWith(`${projectRoot}${path.sep}`)) {
|
|
178
|
+
throw new Error('MIAODA_NEST_TRANSITION_TSCONFIG_PATH_REJECTED');
|
|
179
|
+
}
|
|
180
|
+
return resolved;
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
if (Array.isArray(value)) return value.map(materializeProjectPaths);
|
|
185
|
+
if (value && typeof value === 'object') {
|
|
186
|
+
return Object.fromEntries(
|
|
187
|
+
Object.entries(value).map(([key, entry]) => [
|
|
188
|
+
key,
|
|
189
|
+
materializeProjectPaths(entry),
|
|
190
|
+
])
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return value;
|
|
194
|
+
};
|
|
195
|
+
const transitionTsconfigDirectory = path.join(
|
|
196
|
+
projectRoot,
|
|
197
|
+
'.miaoda-runtime',
|
|
198
|
+
'nest-transition'
|
|
199
|
+
);
|
|
200
|
+
fs.mkdirSync(transitionTsconfigDirectory, { recursive: true });
|
|
201
|
+
const transitionTsconfigFile = path.join(
|
|
202
|
+
transitionTsconfigDirectory,
|
|
203
|
+
`tsconfig.${validated.generation.generationId}.json`
|
|
204
|
+
);
|
|
205
|
+
const transitionTsconfigTemporary = `${transitionTsconfigFile}.${process.pid}.tmp`;
|
|
206
|
+
fs.writeFileSync(
|
|
207
|
+
transitionTsconfigTemporary,
|
|
208
|
+
`${JSON.stringify(
|
|
209
|
+
{
|
|
210
|
+
compilerOptions: {
|
|
211
|
+
...materializeProjectPaths(
|
|
212
|
+
validated.serverDependencies.transitionCompilerOptions
|
|
213
|
+
),
|
|
214
|
+
// The transition runtime only transpiles files that Node actually loads.
|
|
215
|
+
// Type-check/build outputs remain owned by the unchanged full service.
|
|
216
|
+
incremental: false,
|
|
217
|
+
composite: false,
|
|
218
|
+
declaration: false,
|
|
219
|
+
declarationMap: false,
|
|
220
|
+
},
|
|
221
|
+
'ts-node': { transpileOnly: true, files: false },
|
|
222
|
+
},
|
|
223
|
+
null,
|
|
224
|
+
2
|
|
225
|
+
)}\n`,
|
|
226
|
+
{ mode: 0o600 }
|
|
227
|
+
);
|
|
228
|
+
fs.renameSync(transitionTsconfigTemporary, transitionTsconfigFile);
|
|
229
|
+
|
|
230
|
+
function reservePort() {
|
|
231
|
+
return new Promise((resolve, reject) => {
|
|
232
|
+
const server = net.createServer();
|
|
233
|
+
server.once('error', reject);
|
|
234
|
+
server.listen(0, '127.0.0.1', () => {
|
|
235
|
+
const address = server.address();
|
|
236
|
+
const port = address && typeof address !== 'string' ? address.port : 0;
|
|
237
|
+
server.close(error => (error ? reject(error) : resolve(port)));
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function waitForProbe(port, child) {
|
|
243
|
+
const deadline = Date.now() + startupTimeoutMs;
|
|
244
|
+
let lastError;
|
|
245
|
+
while (Date.now() < deadline) {
|
|
246
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`MIAODA_NEST_TRANSITION_EXITED: ${child.exitCode ?? child.signalCode}`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
const response = await fetch(
|
|
253
|
+
`http://127.0.0.1:${port}${probe.path || '/'}`
|
|
254
|
+
);
|
|
255
|
+
const body = await response.text();
|
|
256
|
+
if (
|
|
257
|
+
response.status === Number(probe.expectedStatus ?? 200) &&
|
|
258
|
+
(!probe.bodyIncludes || body.includes(probe.bodyIncludes))
|
|
259
|
+
) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
lastError = new Error(
|
|
263
|
+
`probe returned ${response.status}, expected ${probe.expectedStatus ?? 200}`
|
|
264
|
+
);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
lastError = error;
|
|
267
|
+
}
|
|
268
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
269
|
+
}
|
|
270
|
+
throw new Error(
|
|
271
|
+
`MIAODA_NEST_TRANSITION_READY_TIMEOUT: ${
|
|
272
|
+
lastError instanceof Error ? lastError.message : String(lastError)
|
|
273
|
+
}`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function terminate(child, signal = 'SIGTERM') {
|
|
278
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
279
|
+
try {
|
|
280
|
+
child.kill(signal);
|
|
281
|
+
} catch {}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let activeBackend;
|
|
285
|
+
let closing = false;
|
|
286
|
+
let starting = false;
|
|
287
|
+
let preferInitialBundle = false;
|
|
288
|
+
let restartPending = false;
|
|
289
|
+
let restartTimer;
|
|
290
|
+
let watchTimer;
|
|
291
|
+
const children = new Set();
|
|
292
|
+
|
|
293
|
+
function spawnSourceWorker(port) {
|
|
294
|
+
const args = [resolverFile, ...bootstrapFiles].flatMap(file => ['-r', file]);
|
|
295
|
+
args.push(sourceEntry);
|
|
296
|
+
const childEnvironment = {
|
|
297
|
+
...process.env,
|
|
298
|
+
NODE_ENV: 'development',
|
|
299
|
+
SERVER_HOST: '127.0.0.1',
|
|
300
|
+
SERVER_PORT: String(port),
|
|
301
|
+
NODE_PATH: cacheNodeModules,
|
|
302
|
+
MIAODA_SERVER_CACHE_NODE_MODULES: cacheNodeModules,
|
|
303
|
+
MIAODA_CACHE_BOOTSTRAP_ENABLED: 'false',
|
|
304
|
+
TS_NODE_TRANSPILE_ONLY: 'true',
|
|
305
|
+
TS_NODE_PREFER_TS_EXTS: 'true',
|
|
306
|
+
TS_NODE_PROJECT: transitionTsconfigFile,
|
|
307
|
+
};
|
|
308
|
+
// This is a user-facing development service, not the isolated generation
|
|
309
|
+
// probe. Inherit the same real database/auth environment as the unchanged
|
|
310
|
+
// full service. In particular, never carry the OpenAPI probe's
|
|
311
|
+
// DEPRECATED_SKIP_INIT_DB_CONNECTION flag into the transition runtime: that
|
|
312
|
+
// flag injects a placeholder database object and makes business routes fail.
|
|
313
|
+
delete childEnvironment.MIAODA_SERVER_CACHE_PROBE;
|
|
314
|
+
delete childEnvironment.DEPRECATED_SKIP_INIT_DB_CONNECTION;
|
|
315
|
+
const child = spawn(process.execPath, args, {
|
|
316
|
+
cwd: projectRoot,
|
|
317
|
+
env: childEnvironment,
|
|
318
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
319
|
+
});
|
|
320
|
+
children.add(child);
|
|
321
|
+
child.stdout?.on('data', chunk => output('server', chunk));
|
|
322
|
+
child.stderr?.on('data', chunk => output('server', chunk));
|
|
323
|
+
child.once('exit', (code, signal) => {
|
|
324
|
+
children.delete(child);
|
|
325
|
+
event('miaoda_nest_transition_worker_exit', {
|
|
326
|
+
pid: child.pid,
|
|
327
|
+
code,
|
|
328
|
+
signal,
|
|
329
|
+
active: activeBackend?.child === child,
|
|
330
|
+
});
|
|
331
|
+
if (!closing && activeBackend?.child === child) scheduleRestart(0);
|
|
332
|
+
});
|
|
333
|
+
return child;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function workspaceSourceSha256() {
|
|
337
|
+
const hash = crypto.createHash('sha256');
|
|
338
|
+
for (const file of workspaceFiles()) {
|
|
339
|
+
const relative = path.relative(projectRoot, file).split(path.sep).join('/');
|
|
340
|
+
if (
|
|
341
|
+
!['server/', 'shared/', 'src/'].some(root => relative.startsWith(root))
|
|
342
|
+
) {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
hash.update(relative);
|
|
346
|
+
hash.update('\0');
|
|
347
|
+
hash.update(fs.readFileSync(file));
|
|
348
|
+
hash.update('\0');
|
|
349
|
+
}
|
|
350
|
+
return hash.digest('hex');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function initialBundleIsConsumable() {
|
|
354
|
+
try {
|
|
355
|
+
const metadata = JSON.parse(
|
|
356
|
+
fs.readFileSync(cacheBundleMetadataFile, 'utf8')
|
|
357
|
+
);
|
|
358
|
+
return (
|
|
359
|
+
metadata.schemaVersion === 2 &&
|
|
360
|
+
metadata.consumable === true &&
|
|
361
|
+
metadata.workspaceSourceSha256 === workspaceSourceSha256() &&
|
|
362
|
+
metadata.bundleSha256 ===
|
|
363
|
+
crypto
|
|
364
|
+
.createHash('sha256')
|
|
365
|
+
.update(fs.readFileSync(cacheBundleFile))
|
|
366
|
+
.digest('hex')
|
|
367
|
+
);
|
|
368
|
+
} catch {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function spawnBundleWorker(port) {
|
|
374
|
+
const childEnvironment = {
|
|
375
|
+
...process.env,
|
|
376
|
+
NODE_ENV: 'development',
|
|
377
|
+
SERVER_HOST: '127.0.0.1',
|
|
378
|
+
SERVER_PORT: String(port),
|
|
379
|
+
NODE_PATH: '',
|
|
380
|
+
MIAODA_WORKSPACE_ROOT: projectRoot,
|
|
381
|
+
MIAODA_CACHE_BOOTSTRAP_ENABLED: 'false',
|
|
382
|
+
};
|
|
383
|
+
delete childEnvironment.MIAODA_SERVER_CACHE_PROBE;
|
|
384
|
+
delete childEnvironment.DEPRECATED_SKIP_INIT_DB_CONNECTION;
|
|
385
|
+
const child = spawn(process.execPath, [cacheBundleFile], {
|
|
386
|
+
// Keep process.cwd() identical to the normal NestJS service. Application
|
|
387
|
+
// code and framework modules resolve .env, server/capabilities,
|
|
388
|
+
// dist/client and other workspace files from this root. Assets derived
|
|
389
|
+
// from __dirname, __filename and import.meta.url are still sealed beside
|
|
390
|
+
// the bundle and do not depend on cwd.
|
|
391
|
+
cwd: projectRoot,
|
|
392
|
+
env: childEnvironment,
|
|
393
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
394
|
+
});
|
|
395
|
+
children.add(child);
|
|
396
|
+
child.stdout?.on('data', chunk => output('server', chunk));
|
|
397
|
+
child.stderr?.on('data', chunk => output('server', chunk));
|
|
398
|
+
child.once('exit', (code, signal) => {
|
|
399
|
+
children.delete(child);
|
|
400
|
+
event('miaoda_nest_transition_worker_exit', {
|
|
401
|
+
pid: child.pid,
|
|
402
|
+
code,
|
|
403
|
+
signal,
|
|
404
|
+
workerMode: 'bundle',
|
|
405
|
+
active: activeBackend?.child === child,
|
|
406
|
+
});
|
|
407
|
+
if (!closing && activeBackend?.child === child) {
|
|
408
|
+
preferInitialBundle = false;
|
|
409
|
+
scheduleRestart(0);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
return child;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function drainBackend(backend) {
|
|
416
|
+
const deadline = Date.now() + drainTimeoutMs;
|
|
417
|
+
while (backend.activeRequests > 0 && Date.now() < deadline) {
|
|
418
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
419
|
+
}
|
|
420
|
+
terminate(backend.child);
|
|
421
|
+
setTimeout(() => terminate(backend.child, 'SIGKILL'), 1_000).unref();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function startCandidate() {
|
|
425
|
+
if (closing || starting) {
|
|
426
|
+
restartPending = true;
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
starting = true;
|
|
430
|
+
const startedAt = Date.now();
|
|
431
|
+
let child;
|
|
432
|
+
let workerMode;
|
|
433
|
+
try {
|
|
434
|
+
const port = await reservePort();
|
|
435
|
+
workerMode = preferInitialBundle ? 'bundle' : 'workspace-source';
|
|
436
|
+
child = preferInitialBundle
|
|
437
|
+
? spawnBundleWorker(port)
|
|
438
|
+
: spawnSourceWorker(port);
|
|
439
|
+
await waitForProbe(port, child);
|
|
440
|
+
const previous = activeBackend;
|
|
441
|
+
activeBackend = {
|
|
442
|
+
kind: 'transition',
|
|
443
|
+
workerMode,
|
|
444
|
+
port,
|
|
445
|
+
child,
|
|
446
|
+
activeRequests: 0,
|
|
447
|
+
sourceFingerprint: workspaceFingerprint(),
|
|
448
|
+
};
|
|
449
|
+
event('miaoda_nest_transition_ready', {
|
|
450
|
+
generationId: validated.generation.generationId,
|
|
451
|
+
pid: child.pid,
|
|
452
|
+
port: publicPort,
|
|
453
|
+
upstreamPort: port,
|
|
454
|
+
startupMs: Date.now() - startedAt,
|
|
455
|
+
workerMode,
|
|
456
|
+
replacedPid: previous?.child.pid,
|
|
457
|
+
});
|
|
458
|
+
publishReady();
|
|
459
|
+
if (previous) void drainBackend(previous);
|
|
460
|
+
} catch (error) {
|
|
461
|
+
terminate(child);
|
|
462
|
+
if (workerMode === 'bundle') {
|
|
463
|
+
// A bundle is attempted only once. If it cannot become ready, retrying
|
|
464
|
+
// the same immutable bytes cannot heal it; fall back to live Workspace
|
|
465
|
+
// source plus the sealed dependency tree.
|
|
466
|
+
preferInitialBundle = false;
|
|
467
|
+
}
|
|
468
|
+
output(
|
|
469
|
+
'server',
|
|
470
|
+
`Transition source candidate failed; keeping current service: ${
|
|
471
|
+
error instanceof Error ? error.message : String(error)
|
|
472
|
+
}`
|
|
473
|
+
);
|
|
474
|
+
if (!activeBackend && !closing) scheduleRestart(500);
|
|
475
|
+
} finally {
|
|
476
|
+
starting = false;
|
|
477
|
+
if (restartPending) {
|
|
478
|
+
restartPending = false;
|
|
479
|
+
scheduleRestart(restartDebounceMs);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function scheduleRestart(delay = restartDebounceMs) {
|
|
485
|
+
if (closing) return;
|
|
486
|
+
if (restartTimer) clearTimeout(restartTimer);
|
|
487
|
+
restartTimer = setTimeout(() => {
|
|
488
|
+
restartTimer = undefined;
|
|
489
|
+
void startCandidate();
|
|
490
|
+
}, delay);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function workspaceFiles() {
|
|
494
|
+
const files = [];
|
|
495
|
+
const visit = directory => {
|
|
496
|
+
if (!fs.existsSync(directory)) return;
|
|
497
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
498
|
+
const candidate = path.join(directory, entry.name);
|
|
499
|
+
if (entry.isSymbolicLink()) continue;
|
|
500
|
+
if (entry.isDirectory()) visit(candidate);
|
|
501
|
+
else if (entry.isFile()) files.push(candidate);
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
for (const root of ['server', 'shared', 'src']) {
|
|
505
|
+
visit(path.join(projectRoot, root));
|
|
506
|
+
}
|
|
507
|
+
for (const relative of [
|
|
508
|
+
'package.json',
|
|
509
|
+
'nest-cli.json',
|
|
510
|
+
'tsconfig.json',
|
|
511
|
+
'tsconfig.node.json',
|
|
512
|
+
'.env',
|
|
513
|
+
'.env.development',
|
|
514
|
+
'.env.development.local',
|
|
515
|
+
]) {
|
|
516
|
+
const file = path.join(projectRoot, relative);
|
|
517
|
+
if (fs.existsSync(file)) files.push(file);
|
|
518
|
+
}
|
|
519
|
+
return files.sort();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function workspaceFingerprint() {
|
|
523
|
+
return workspaceFiles()
|
|
524
|
+
.map(file => {
|
|
525
|
+
const stat = fs.statSync(file);
|
|
526
|
+
return `${path.relative(projectRoot, file)}:${stat.size}:${stat.mtimeMs}`;
|
|
527
|
+
})
|
|
528
|
+
.join('|');
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
preferInitialBundle =
|
|
532
|
+
process.env.MIAODA_NEST_DISABLE_INITIAL_BUNDLE !== 'true' &&
|
|
533
|
+
initialBundleIsConsumable();
|
|
534
|
+
|
|
535
|
+
function publishReady() {
|
|
536
|
+
fs.mkdirSync(path.dirname(readyMarker), { recursive: true });
|
|
537
|
+
const temporary = `${readyMarker}.${process.pid}.tmp`;
|
|
538
|
+
fs.writeFileSync(
|
|
539
|
+
temporary,
|
|
540
|
+
`${JSON.stringify({
|
|
541
|
+
schemaVersion: 1,
|
|
542
|
+
generationId: validated.generation.generationId,
|
|
543
|
+
pid: process.pid,
|
|
544
|
+
port: publicPort,
|
|
545
|
+
})}\n`,
|
|
546
|
+
{ mode: 0o600 }
|
|
547
|
+
);
|
|
548
|
+
fs.renameSync(temporary, readyMarker);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const proxy = http.createServer((request, response) => {
|
|
552
|
+
if (request.url === '/dev/cache-runtime') {
|
|
553
|
+
response.statusCode = activeBackend ? 200 : 503;
|
|
554
|
+
response.setHeader('content-type', 'application/json');
|
|
555
|
+
response.end(
|
|
556
|
+
JSON.stringify({
|
|
557
|
+
mode: 'transition',
|
|
558
|
+
runtime: 'nest',
|
|
559
|
+
generationId: validated.generation.generationId,
|
|
560
|
+
pid: activeBackend?.child.pid,
|
|
561
|
+
ready: Boolean(activeBackend),
|
|
562
|
+
})
|
|
563
|
+
);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const backend = activeBackend;
|
|
567
|
+
if (!backend) {
|
|
568
|
+
response.statusCode = 503;
|
|
569
|
+
response.end('MIAODA_NEST_TRANSITION_NOT_READY');
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
backend.activeRequests++;
|
|
573
|
+
const upstream = http.request(
|
|
574
|
+
{
|
|
575
|
+
host: '127.0.0.1',
|
|
576
|
+
port: backend.port,
|
|
577
|
+
method: request.method,
|
|
578
|
+
path: request.url,
|
|
579
|
+
headers: request.headers,
|
|
580
|
+
},
|
|
581
|
+
upstreamResponse => {
|
|
582
|
+
response.writeHead(
|
|
583
|
+
upstreamResponse.statusCode || 502,
|
|
584
|
+
upstreamResponse.headers
|
|
585
|
+
);
|
|
586
|
+
upstreamResponse.pipe(response);
|
|
587
|
+
}
|
|
588
|
+
);
|
|
589
|
+
const release = () => {
|
|
590
|
+
if (backend.activeRequests > 0) backend.activeRequests--;
|
|
591
|
+
};
|
|
592
|
+
response.once('close', release);
|
|
593
|
+
upstream.once('error', error => {
|
|
594
|
+
if (!response.headersSent) response.statusCode = 502;
|
|
595
|
+
response.end(error.message);
|
|
596
|
+
});
|
|
597
|
+
request.pipe(upstream);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
proxy.on('upgrade', (request, socket, head) => {
|
|
601
|
+
const backend = activeBackend;
|
|
602
|
+
if (!backend) return socket.destroy();
|
|
603
|
+
const upstream = net.createConnection(
|
|
604
|
+
{ host: '127.0.0.1', port: backend.port },
|
|
605
|
+
() => {
|
|
606
|
+
let headers = `${request.method} ${request.url} HTTP/${request.httpVersion}\r\n`;
|
|
607
|
+
for (let index = 0; index < request.rawHeaders.length; index += 2) {
|
|
608
|
+
headers += `${request.rawHeaders[index]}: ${request.rawHeaders[index + 1]}\r\n`;
|
|
609
|
+
}
|
|
610
|
+
upstream.write(`${headers}\r\n`);
|
|
611
|
+
if (head.length > 0) upstream.write(head);
|
|
612
|
+
socket.pipe(upstream).pipe(socket);
|
|
613
|
+
}
|
|
614
|
+
);
|
|
615
|
+
upstream.once('error', () => socket.destroy());
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
async function close(signal) {
|
|
619
|
+
if (closing) return;
|
|
620
|
+
closing = true;
|
|
621
|
+
if (watchTimer) clearInterval(watchTimer);
|
|
622
|
+
if (restartTimer) clearTimeout(restartTimer);
|
|
623
|
+
fs.rmSync(readyMarker, { force: true });
|
|
624
|
+
await new Promise(resolve => proxy.close(resolve));
|
|
625
|
+
for (const child of children) terminate(child);
|
|
626
|
+
setTimeout(() => {
|
|
627
|
+
for (const child of children) terminate(child, 'SIGKILL');
|
|
628
|
+
}, 1_000).unref();
|
|
629
|
+
event('miaoda_nest_transition_stopped', { signal });
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
633
|
+
process.once(signal, () => void close(signal));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
fs.rmSync(readyMarker, { force: true });
|
|
637
|
+
await new Promise((resolve, reject) => {
|
|
638
|
+
proxy.once('error', reject);
|
|
639
|
+
proxy.listen(publicPort, publicHost, resolve);
|
|
640
|
+
});
|
|
641
|
+
await startCandidate();
|
|
642
|
+
let fingerprint = workspaceFingerprint();
|
|
643
|
+
watchTimer = setInterval(() => {
|
|
644
|
+
try {
|
|
645
|
+
const current = workspaceFingerprint();
|
|
646
|
+
if (current !== fingerprint) {
|
|
647
|
+
fingerprint = current;
|
|
648
|
+
preferInitialBundle = false;
|
|
649
|
+
scheduleRestart();
|
|
650
|
+
}
|
|
651
|
+
} catch (error) {
|
|
652
|
+
output('server', error instanceof Error ? error.message : String(error));
|
|
653
|
+
}
|
|
654
|
+
}, pollIntervalMs);
|
|
655
|
+
watchTimer.unref();
|