@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,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const generationIdPattern = /^[a-f0-9]{64}$/;
|
|
9
|
+
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
10
|
+
|
|
11
|
+
function emit(event, details = {}) {
|
|
12
|
+
process.stdout.write(`${JSON.stringify({ event, ...details })}\n`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolveCurrentGeneration(projectRoot) {
|
|
16
|
+
const cacheStore = path.join(projectRoot, '.miaoda-cache');
|
|
17
|
+
const generationsRoot = path.join(cacheStore, 'generations');
|
|
18
|
+
const currentPointer = path.join(cacheStore, 'current');
|
|
19
|
+
const cacheStat = fs.lstatSync(cacheStore);
|
|
20
|
+
const generationsStat = fs.lstatSync(generationsRoot);
|
|
21
|
+
const pointerStat = fs.lstatSync(currentPointer);
|
|
22
|
+
if (
|
|
23
|
+
!cacheStat.isDirectory() ||
|
|
24
|
+
cacheStat.isSymbolicLink() ||
|
|
25
|
+
!generationsStat.isDirectory() ||
|
|
26
|
+
generationsStat.isSymbolicLink() ||
|
|
27
|
+
!pointerStat.isSymbolicLink()
|
|
28
|
+
) {
|
|
29
|
+
throw new Error('MIAODA_CACHE_GENERATION_STORE_REJECTED');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const pointer = fs.readlinkSync(currentPointer);
|
|
33
|
+
const match = /^generations\/([a-f0-9]{64})$/.exec(pointer);
|
|
34
|
+
if (!match) throw new Error('MIAODA_CACHE_GENERATION_POINTER_REJECTED');
|
|
35
|
+
const generationId = match[1];
|
|
36
|
+
const realGenerationsRoot = fs.realpathSync(generationsRoot);
|
|
37
|
+
const currentRoot = fs.realpathSync(currentPointer);
|
|
38
|
+
if (currentRoot !== path.join(realGenerationsRoot, generationId)) {
|
|
39
|
+
throw new Error('MIAODA_CACHE_GENERATION_POINTER_ESCAPED');
|
|
40
|
+
}
|
|
41
|
+
const generation = JSON.parse(
|
|
42
|
+
fs.readFileSync(path.join(currentRoot, 'generation.json'), 'utf8')
|
|
43
|
+
);
|
|
44
|
+
if (generation.generationId !== generationId) {
|
|
45
|
+
throw new Error('MIAODA_CACHE_GENERATION_POINTER_ID_MISMATCH');
|
|
46
|
+
}
|
|
47
|
+
return { cacheStore, generationsRoot, realGenerationsRoot, generationId };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function pruneSupersededCacheGenerations(projectRootInput) {
|
|
51
|
+
const projectRoot = fs.realpathSync(path.resolve(projectRootInput));
|
|
52
|
+
const {
|
|
53
|
+
generationsRoot,
|
|
54
|
+
realGenerationsRoot,
|
|
55
|
+
generationId: currentGenerationId,
|
|
56
|
+
} = resolveCurrentGeneration(projectRoot);
|
|
57
|
+
const removedGenerationIds = [];
|
|
58
|
+
for (const entry of fs.readdirSync(generationsRoot, {
|
|
59
|
+
withFileTypes: true,
|
|
60
|
+
})) {
|
|
61
|
+
if (
|
|
62
|
+
!generationIdPattern.test(entry.name) ||
|
|
63
|
+
entry.name === currentGenerationId
|
|
64
|
+
) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const target = path.join(generationsRoot, entry.name);
|
|
68
|
+
const stat = fs.lstatSync(target);
|
|
69
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
70
|
+
throw new Error(`MIAODA_CACHE_GENERATION_ENTRY_REJECTED: ${entry.name}`);
|
|
71
|
+
}
|
|
72
|
+
if (
|
|
73
|
+
fs.realpathSync(target) !== path.join(realGenerationsRoot, entry.name)
|
|
74
|
+
) {
|
|
75
|
+
throw new Error(`MIAODA_CACHE_GENERATION_ENTRY_ESCAPED: ${entry.name}`);
|
|
76
|
+
}
|
|
77
|
+
fs.rmSync(target, { recursive: true, force: false });
|
|
78
|
+
removedGenerationIds.push(entry.name);
|
|
79
|
+
}
|
|
80
|
+
return { currentGenerationId, removedGenerationIds };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function run(command, args, options) {
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const child = spawn(command, args, options);
|
|
86
|
+
child.once('error', reject);
|
|
87
|
+
child.once('exit', (code, signal) => resolve({ code, signal }));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function pruneWithDirectoryLock(projectRoot, waitMs) {
|
|
92
|
+
const lockDirectory = path.join(
|
|
93
|
+
projectRoot,
|
|
94
|
+
'.miaoda-cache',
|
|
95
|
+
'.generation.lock.d'
|
|
96
|
+
);
|
|
97
|
+
const deadline = Date.now() + waitMs;
|
|
98
|
+
while (true) {
|
|
99
|
+
try {
|
|
100
|
+
fs.mkdirSync(lockDirectory);
|
|
101
|
+
break;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
104
|
+
if (Date.now() >= deadline) {
|
|
105
|
+
emit('miaoda_cache_generation_prune_skipped', {
|
|
106
|
+
reason: 'lock-timeout',
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
await delay(100);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
emit('miaoda_cache_generations_pruned', {
|
|
115
|
+
...pruneSupersededCacheGenerations(projectRoot),
|
|
116
|
+
lock: 'directory',
|
|
117
|
+
});
|
|
118
|
+
} finally {
|
|
119
|
+
fs.rmdirSync(lockDirectory);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function main() {
|
|
124
|
+
const projectRoot = fs.realpathSync(
|
|
125
|
+
path.resolve(process.env.MIAODA_WORKSPACE_ROOT || process.cwd())
|
|
126
|
+
);
|
|
127
|
+
const waitArg = process.argv.find(arg => arg.startsWith('--wait-ms='));
|
|
128
|
+
const requestedWaitMs = Number(waitArg?.slice('--wait-ms='.length) || 60_000);
|
|
129
|
+
const waitMs = Number.isFinite(requestedWaitMs)
|
|
130
|
+
? Math.max(0, requestedWaitMs)
|
|
131
|
+
: 60_000;
|
|
132
|
+
if (process.argv.includes('--lock-held')) {
|
|
133
|
+
emit('miaoda_cache_generations_pruned', {
|
|
134
|
+
...pruneSupersededCacheGenerations(projectRoot),
|
|
135
|
+
lock: 'flock',
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const script = fileURLToPath(import.meta.url);
|
|
141
|
+
try {
|
|
142
|
+
const result = await run(
|
|
143
|
+
'flock',
|
|
144
|
+
[
|
|
145
|
+
'-w',
|
|
146
|
+
String(Math.ceil(waitMs / 1000)),
|
|
147
|
+
path.join(projectRoot, '.miaoda-cache', '.generation.lock'),
|
|
148
|
+
process.execPath,
|
|
149
|
+
script,
|
|
150
|
+
'--lock-held',
|
|
151
|
+
],
|
|
152
|
+
{ cwd: projectRoot, env: process.env, stdio: 'inherit' }
|
|
153
|
+
);
|
|
154
|
+
if (result.code === 0) return;
|
|
155
|
+
emit('miaoda_cache_generation_prune_skipped', {
|
|
156
|
+
reason: result.signal ? `flock-signal-${result.signal}` : 'lock-timeout',
|
|
157
|
+
});
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
160
|
+
await pruneWithDirectoryLock(projectRoot, waitMs);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (path.resolve(process.argv[1] || '') === fileURLToPath(import.meta.url)) {
|
|
165
|
+
main().catch(error => {
|
|
166
|
+
process.stderr.write(
|
|
167
|
+
`${JSON.stringify({
|
|
168
|
+
event: 'miaoda_cache_generation_prune_failed',
|
|
169
|
+
error: error instanceof Error ? error.message : String(error),
|
|
170
|
+
})}\n`
|
|
171
|
+
);
|
|
172
|
+
process.exitCode = 1;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import {
|
|
10
|
+
validateCacheGeneration,
|
|
11
|
+
validateCacheGenerationReceipt,
|
|
12
|
+
writeCacheGenerationValidationReceipt,
|
|
13
|
+
} from './cache-generation-preflight.mjs';
|
|
14
|
+
|
|
15
|
+
const projectRoot = path.resolve(
|
|
16
|
+
process.env.MIAODA_WORKSPACE_ROOT || process.cwd()
|
|
17
|
+
);
|
|
18
|
+
const scriptsRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const cacheRoot = fs.realpathSync(
|
|
20
|
+
path.resolve(
|
|
21
|
+
process.env.MIAODA_CACHE_ROOT ||
|
|
22
|
+
path.join(projectRoot, '.miaoda-cache', 'current')
|
|
23
|
+
)
|
|
24
|
+
);
|
|
25
|
+
const runtimeManifestFile = path.resolve(
|
|
26
|
+
process.env.MIAODA_PLATFORM_RUNTIME_MANIFEST ||
|
|
27
|
+
'/opt/miaoda/preview-runtime/runtime-manifest.json'
|
|
28
|
+
);
|
|
29
|
+
const dependencyReadyFile = path.resolve(
|
|
30
|
+
process.env.MIAODA_DEPENDENCY_READY_FILE ||
|
|
31
|
+
path.join(projectRoot, '.miaoda-runtime', 'dependencies-ready.json')
|
|
32
|
+
);
|
|
33
|
+
const publicServerPort = Number(process.env.SERVER_PORT || 3000);
|
|
34
|
+
const publicClientPort = Number(process.env.CLIENT_DEV_PORT || 8080);
|
|
35
|
+
const publicServerHost = process.env.SERVER_HOST || '0.0.0.0';
|
|
36
|
+
const publicClientHost = process.env.CLIENT_DEV_HOST || '0.0.0.0';
|
|
37
|
+
const transitionReadyTimeoutMs = Number(
|
|
38
|
+
process.env.MIAODA_CACHE_TRANSITION_READY_TIMEOUT_MS || 60_000
|
|
39
|
+
);
|
|
40
|
+
const dependencyHandoffExitCode = 75;
|
|
41
|
+
const validateOnly = process.argv.includes('--validate-only');
|
|
42
|
+
const requestedArtifactScope = (() => {
|
|
43
|
+
const index = process.argv.indexOf('--artifact-scope');
|
|
44
|
+
const value = index >= 0 ? process.argv[index + 1] : 'all';
|
|
45
|
+
if (!['all', 'vite', 'server'].includes(value)) {
|
|
46
|
+
throw new Error('MIAODA_CACHE_ARTIFACT_SCOPE_INVALID');
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
})();
|
|
50
|
+
const validateWorkspaceInputs = !process.argv.includes(
|
|
51
|
+
'--skip-workspace-inputs'
|
|
52
|
+
);
|
|
53
|
+
const validateConfigEnvironment = !process.argv.includes(
|
|
54
|
+
'--skip-config-environment'
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
function log(stream, message) {
|
|
58
|
+
for (const line of String(message).split(/\r?\n/)) {
|
|
59
|
+
if (!line) continue;
|
|
60
|
+
if (/^MIAODA_LOG\t(?:client|server)\t/.test(line)) {
|
|
61
|
+
process.stdout.write(`${line}\n`);
|
|
62
|
+
} else {
|
|
63
|
+
process.stdout.write(`MIAODA_LOG\t${stream}\t${line}\n`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function emit(event, details = {}) {
|
|
69
|
+
process.stdout.write(`${JSON.stringify({ event, ...details })}\n`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validateGeneration() {
|
|
73
|
+
let validated;
|
|
74
|
+
const receiptFile = process.env.MIAODA_CACHE_VALIDATION_RECEIPT_FILE;
|
|
75
|
+
if (receiptFile) {
|
|
76
|
+
try {
|
|
77
|
+
validated = validateCacheGenerationReceipt({
|
|
78
|
+
projectRoot,
|
|
79
|
+
cacheRoot,
|
|
80
|
+
runtimeManifestFile,
|
|
81
|
+
receiptFile,
|
|
82
|
+
artifactScope: requestedArtifactScope,
|
|
83
|
+
});
|
|
84
|
+
} catch (error) {
|
|
85
|
+
emit('miaoda_cache_validation_receipt_miss', {
|
|
86
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
validated ??= validateCacheGeneration({
|
|
91
|
+
projectRoot,
|
|
92
|
+
cacheRoot,
|
|
93
|
+
runtimeManifestFile,
|
|
94
|
+
artifactScope: requestedArtifactScope,
|
|
95
|
+
validateWorkspaceInputs,
|
|
96
|
+
validateConfigEnvironment,
|
|
97
|
+
});
|
|
98
|
+
if (requestedArtifactScope === 'all') {
|
|
99
|
+
if (validated.generation.applicationKind === 'fullstack') {
|
|
100
|
+
if (
|
|
101
|
+
validated.generation.schemaVersion !== 4 ||
|
|
102
|
+
validated.generation.serverRuntimeMode !==
|
|
103
|
+
'workspace-source-dependencies'
|
|
104
|
+
) {
|
|
105
|
+
throw new Error('MIAODA_CACHE_COORDINATOR_GENERATION_UNSUPPORTED');
|
|
106
|
+
}
|
|
107
|
+
} else if (validated.generation.applicationKind !== 'frontend-only') {
|
|
108
|
+
throw new Error('MIAODA_CACHE_COORDINATOR_GENERATION_UNSUPPORTED');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return validated;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let validated;
|
|
115
|
+
try {
|
|
116
|
+
validated = validateGeneration();
|
|
117
|
+
const requiredFiles = [path.join(scriptsRoot, 'vite-cache-runtime.mjs')];
|
|
118
|
+
if (validated.generation.applicationKind === 'fullstack') {
|
|
119
|
+
requiredFiles.push(
|
|
120
|
+
path.join(scriptsRoot, 'server-transition-runtime.mjs'),
|
|
121
|
+
path.join(scriptsRoot, 'server-cache-module-resolver.cjs')
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
for (const requiredFile of requiredFiles) {
|
|
125
|
+
if (!fs.existsSync(requiredFile)) {
|
|
126
|
+
throw new Error(`MIAODA_CACHE_BOOTSTRAP_INPUT_MISSING: ${requiredFile}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (validateOnly) {
|
|
130
|
+
const receiptFile = process.env.MIAODA_CACHE_VALIDATION_RECEIPT_FILE;
|
|
131
|
+
if (receiptFile && validated.validation?.validatedBy === 'full-sha') {
|
|
132
|
+
writeCacheGenerationValidationReceipt({ validated, receiptFile });
|
|
133
|
+
}
|
|
134
|
+
process.stdout.write(
|
|
135
|
+
`${JSON.stringify({
|
|
136
|
+
valid: true,
|
|
137
|
+
generationId: validated.generation.generationId,
|
|
138
|
+
schemaVersion: validated.generation.schemaVersion,
|
|
139
|
+
applicationKind: validated.generation.applicationKind,
|
|
140
|
+
artifactScope: requestedArtifactScope,
|
|
141
|
+
receiptWritten: Boolean(
|
|
142
|
+
receiptFile && validated.validation?.validatedBy === 'full-sha'
|
|
143
|
+
),
|
|
144
|
+
})}\n`
|
|
145
|
+
);
|
|
146
|
+
process.exit(0);
|
|
147
|
+
}
|
|
148
|
+
} catch (error) {
|
|
149
|
+
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
|
|
150
|
+
process.exit(2);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function assertPort(port, name) {
|
|
154
|
+
if (!Number.isSafeInteger(port) || port <= 0 || port > 65_535) {
|
|
155
|
+
throw new Error(`${name}_INVALID`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
assertPort(publicServerPort, 'MIAODA_COORDINATOR_SERVER_PORT');
|
|
159
|
+
assertPort(publicClientPort, 'MIAODA_COORDINATOR_CLIENT_PORT');
|
|
160
|
+
|
|
161
|
+
if (validated.generation.applicationKind === 'frontend-only') {
|
|
162
|
+
const child = spawn(
|
|
163
|
+
process.execPath,
|
|
164
|
+
[path.join(scriptsRoot, 'vite-cache-runtime.mjs')],
|
|
165
|
+
{
|
|
166
|
+
cwd: projectRoot,
|
|
167
|
+
env: {
|
|
168
|
+
...process.env,
|
|
169
|
+
MIAODA_CACHE_ROOT: cacheRoot,
|
|
170
|
+
},
|
|
171
|
+
detached: true,
|
|
172
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
child.stdout?.on('data', chunk => log('client', chunk));
|
|
176
|
+
child.stderr?.on('data', chunk => log('client', chunk));
|
|
177
|
+
let frontendClosing = false;
|
|
178
|
+
const stopFrontend = signal => {
|
|
179
|
+
if (frontendClosing) return;
|
|
180
|
+
frontendClosing = true;
|
|
181
|
+
killProcessGroup(child, signal);
|
|
182
|
+
};
|
|
183
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
184
|
+
process.once(signal, () => stopFrontend(signal));
|
|
185
|
+
}
|
|
186
|
+
const exit = await new Promise(resolve => {
|
|
187
|
+
child.once('error', () => resolve({ code: 1, signal: null }));
|
|
188
|
+
child.once('exit', (code, signal) => resolve({ code, signal }));
|
|
189
|
+
});
|
|
190
|
+
emit('miaoda_cache_frontend_runtime_exit', exit);
|
|
191
|
+
process.exit(exit.code ?? (frontendClosing ? 0 : 1));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function reservePort() {
|
|
195
|
+
return new Promise((resolve, reject) => {
|
|
196
|
+
const server = net.createServer();
|
|
197
|
+
server.once('error', reject);
|
|
198
|
+
server.listen(0, '127.0.0.1', () => {
|
|
199
|
+
const address = server.address();
|
|
200
|
+
const port = address && typeof address !== 'string' ? address.port : 0;
|
|
201
|
+
server.close(error => (error ? reject(error) : resolve(port)));
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function killProcessGroup(child, signal = 'SIGTERM') {
|
|
207
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
208
|
+
try {
|
|
209
|
+
process.kill(-child.pid, signal);
|
|
210
|
+
} catch {
|
|
211
|
+
try {
|
|
212
|
+
child.kill(signal);
|
|
213
|
+
} catch {}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const children = new Set();
|
|
218
|
+
let closing = false;
|
|
219
|
+
|
|
220
|
+
function spawnManaged({ name, command, args, env }) {
|
|
221
|
+
const child = spawn(command, args, {
|
|
222
|
+
cwd: projectRoot,
|
|
223
|
+
env: { ...process.env, ...env },
|
|
224
|
+
detached: true,
|
|
225
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
226
|
+
});
|
|
227
|
+
child.runtimeName = name;
|
|
228
|
+
children.add(child);
|
|
229
|
+
child.stdout?.on('data', chunk => log(name, chunk));
|
|
230
|
+
child.stderr?.on('data', chunk => log(name, chunk));
|
|
231
|
+
child.once('exit', (code, signal) => {
|
|
232
|
+
children.delete(child);
|
|
233
|
+
emit('miaoda_cache_runtime_child_exit', {
|
|
234
|
+
name,
|
|
235
|
+
pid: child.pid,
|
|
236
|
+
code,
|
|
237
|
+
signal,
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
return child;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function waitForHttp({
|
|
244
|
+
port,
|
|
245
|
+
requestPath,
|
|
246
|
+
expectedStatus = 200,
|
|
247
|
+
bodyIncludes,
|
|
248
|
+
child,
|
|
249
|
+
}) {
|
|
250
|
+
const deadline = Date.now() + transitionReadyTimeoutMs;
|
|
251
|
+
let lastError;
|
|
252
|
+
while (Date.now() < deadline) {
|
|
253
|
+
if (child && (child.exitCode !== null || child.signalCode !== null)) {
|
|
254
|
+
throw new Error(`${child.runtimeName} exited before readiness`);
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
const response = await fetch(
|
|
258
|
+
`http://127.0.0.1:${port}${requestPath || '/'}`
|
|
259
|
+
);
|
|
260
|
+
const body = await response.text();
|
|
261
|
+
if (
|
|
262
|
+
response.status === expectedStatus &&
|
|
263
|
+
(!bodyIncludes || body.includes(bodyIncludes))
|
|
264
|
+
) {
|
|
265
|
+
return { status: response.status, body };
|
|
266
|
+
}
|
|
267
|
+
lastError = new Error(
|
|
268
|
+
`${requestPath} returned ${response.status}, expected ${expectedStatus}`
|
|
269
|
+
);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
lastError = error;
|
|
272
|
+
}
|
|
273
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
274
|
+
}
|
|
275
|
+
throw new Error(
|
|
276
|
+
`MIAODA_RUNTIME_READY_TIMEOUT: ${
|
|
277
|
+
lastError instanceof Error ? lastError.message : String(lastError)
|
|
278
|
+
}`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const transitionPorts = {
|
|
283
|
+
server: await reservePort(),
|
|
284
|
+
client: await reservePort(),
|
|
285
|
+
};
|
|
286
|
+
const transitionPair = {
|
|
287
|
+
epoch: `transition-${validated.generation.generationId.slice(0, 12)}`,
|
|
288
|
+
kind: 'transition',
|
|
289
|
+
server: { port: transitionPorts.server },
|
|
290
|
+
client: { port: transitionPorts.client },
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const transitionServer = spawnManaged({
|
|
294
|
+
name: 'server',
|
|
295
|
+
command: process.execPath,
|
|
296
|
+
args: [path.join(scriptsRoot, 'server-transition-runtime.mjs')],
|
|
297
|
+
env: {
|
|
298
|
+
SERVER_HOST: '127.0.0.1',
|
|
299
|
+
SERVER_PORT: String(transitionPorts.server),
|
|
300
|
+
MIAODA_CACHE_ROOT: cacheRoot,
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
const transitionClient = spawnManaged({
|
|
304
|
+
name: 'client',
|
|
305
|
+
command: process.execPath,
|
|
306
|
+
args: [path.join(scriptsRoot, 'vite-cache-runtime.mjs')],
|
|
307
|
+
env: {
|
|
308
|
+
CLIENT_DEV_HOST: '127.0.0.1',
|
|
309
|
+
CLIENT_DEV_PORT: String(transitionPorts.client),
|
|
310
|
+
MIAODA_CACHE_ROOT: cacheRoot,
|
|
311
|
+
MIAODA_RUNTIME_MANAGED_BY_COORDINATOR: 'true',
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
transitionPair.server.child = transitionServer;
|
|
315
|
+
transitionPair.client.child = transitionClient;
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
await Promise.all([
|
|
319
|
+
waitForHttp({
|
|
320
|
+
port: transitionPorts.server,
|
|
321
|
+
// server-transition-runtime owns the business-path probe and exposes a
|
|
322
|
+
// stable, side-effect-free readiness contract to the coordinator.
|
|
323
|
+
requestPath: '/dev/cache-runtime',
|
|
324
|
+
child: transitionServer,
|
|
325
|
+
}),
|
|
326
|
+
waitForHttp({
|
|
327
|
+
port: transitionPorts.client,
|
|
328
|
+
requestPath: '/dev/cache-runtime',
|
|
329
|
+
child: transitionClient,
|
|
330
|
+
}),
|
|
331
|
+
]);
|
|
332
|
+
} catch (error) {
|
|
333
|
+
for (const child of [transitionServer, transitionClient]) {
|
|
334
|
+
killProcessGroup(child);
|
|
335
|
+
setTimeout(() => killProcessGroup(child, 'SIGKILL'), 1_000).unref();
|
|
336
|
+
}
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const activePair = transitionPair;
|
|
341
|
+
const clientWebSockets = new Set();
|
|
342
|
+
|
|
343
|
+
function proxyHttp(kind, request, response) {
|
|
344
|
+
const pair = activePair;
|
|
345
|
+
const backend = pair[kind];
|
|
346
|
+
const headers = { ...request.headers };
|
|
347
|
+
headers['x-miaoda-release-epoch'] = pair.epoch;
|
|
348
|
+
const upstream = http.request(
|
|
349
|
+
{
|
|
350
|
+
host: '127.0.0.1',
|
|
351
|
+
port: backend.port,
|
|
352
|
+
method: request.method,
|
|
353
|
+
path: request.url,
|
|
354
|
+
headers,
|
|
355
|
+
},
|
|
356
|
+
upstreamResponse => {
|
|
357
|
+
const responseHeaders = { ...upstreamResponse.headers };
|
|
358
|
+
responseHeaders['x-miaoda-release-epoch'] = pair.epoch;
|
|
359
|
+
response.writeHead(upstreamResponse.statusCode || 502, responseHeaders);
|
|
360
|
+
upstreamResponse.pipe(response);
|
|
361
|
+
}
|
|
362
|
+
);
|
|
363
|
+
upstream.once('error', error => {
|
|
364
|
+
if (!response.headersSent) response.statusCode = 502;
|
|
365
|
+
response.end(error.message);
|
|
366
|
+
});
|
|
367
|
+
request.pipe(upstream);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function proxyUpgrade(kind, request, socket, head) {
|
|
371
|
+
const pair = activePair;
|
|
372
|
+
const backend = pair[kind];
|
|
373
|
+
const upstream = net.createConnection(
|
|
374
|
+
{ host: '127.0.0.1', port: backend.port },
|
|
375
|
+
() => {
|
|
376
|
+
let headers = `${request.method} ${request.url} HTTP/${request.httpVersion}\r\n`;
|
|
377
|
+
let hasEpoch = false;
|
|
378
|
+
for (let index = 0; index < request.rawHeaders.length; index += 2) {
|
|
379
|
+
const name = request.rawHeaders[index];
|
|
380
|
+
if (name.toLowerCase() === 'x-miaoda-release-epoch') hasEpoch = true;
|
|
381
|
+
headers += `${name}: ${request.rawHeaders[index + 1]}\r\n`;
|
|
382
|
+
}
|
|
383
|
+
if (!hasEpoch) headers += `x-miaoda-release-epoch: ${pair.epoch}\r\n`;
|
|
384
|
+
upstream.write(`${headers}\r\n`);
|
|
385
|
+
if (head.length > 0) upstream.write(head);
|
|
386
|
+
socket.pipe(upstream).pipe(socket);
|
|
387
|
+
}
|
|
388
|
+
);
|
|
389
|
+
upstream.once('error', () => socket.destroy());
|
|
390
|
+
if (kind === 'client') {
|
|
391
|
+
const record = { socket, upstream, epoch: pair.epoch };
|
|
392
|
+
clientWebSockets.add(record);
|
|
393
|
+
const release = () => clientWebSockets.delete(record);
|
|
394
|
+
socket.once('close', release);
|
|
395
|
+
upstream.once('close', release);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function createPublicProxy(kind, port, host) {
|
|
400
|
+
const server = http.createServer((request, response) =>
|
|
401
|
+
proxyHttp(kind, request, response)
|
|
402
|
+
);
|
|
403
|
+
server.on('upgrade', (request, socket, head) =>
|
|
404
|
+
proxyUpgrade(kind, request, socket, head)
|
|
405
|
+
);
|
|
406
|
+
return new Promise((resolve, reject) => {
|
|
407
|
+
server.once('error', reject);
|
|
408
|
+
server.listen(port, host, () => resolve(server));
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const [serverProxy, clientProxy] = await Promise.all([
|
|
413
|
+
createPublicProxy('server', publicServerPort, publicServerHost),
|
|
414
|
+
createPublicProxy('client', publicClientPort, publicClientHost),
|
|
415
|
+
]);
|
|
416
|
+
emit('miaoda_cache_transition_pair_ready', {
|
|
417
|
+
generationId: validated.generation.generationId,
|
|
418
|
+
epoch: transitionPair.epoch,
|
|
419
|
+
serverPort: publicServerPort,
|
|
420
|
+
clientPort: publicClientPort,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
for (const child of [transitionServer, transitionClient]) {
|
|
424
|
+
child.once('exit', () => {
|
|
425
|
+
if (closing) return;
|
|
426
|
+
process.exitCode = 1;
|
|
427
|
+
emit('miaoda_cache_transition_pair_lost', {
|
|
428
|
+
failed: child.runtimeName,
|
|
429
|
+
epoch: transitionPair.epoch,
|
|
430
|
+
});
|
|
431
|
+
void close('transition-pair-lost');
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function dependencyRestoreReady() {
|
|
436
|
+
try {
|
|
437
|
+
const marker = JSON.parse(fs.readFileSync(dependencyReadyFile, 'utf8'));
|
|
438
|
+
if (marker.generationId !== validated.generation.generationId) return false;
|
|
439
|
+
const nodeModules = fs.realpathSync(path.join(projectRoot, 'node_modules'));
|
|
440
|
+
return fs.statSync(nodeModules).isDirectory();
|
|
441
|
+
} catch {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let handoffStarted = false;
|
|
447
|
+
async function handoffToOriginalRuntime() {
|
|
448
|
+
if (closing || handoffStarted || !dependencyRestoreReady()) return;
|
|
449
|
+
handoffStarted = true;
|
|
450
|
+
emit('miaoda_cache_dependency_handoff_started', {
|
|
451
|
+
generationId: validated.generation.generationId,
|
|
452
|
+
exitCode: dependencyHandoffExitCode,
|
|
453
|
+
});
|
|
454
|
+
await close('dependencies-ready');
|
|
455
|
+
process.exit(dependencyHandoffExitCode);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const dependencyPoller = setInterval(() => {
|
|
459
|
+
if (dependencyRestoreReady()) void handoffToOriginalRuntime();
|
|
460
|
+
}, 250);
|
|
461
|
+
dependencyPoller.unref();
|
|
462
|
+
if (dependencyRestoreReady()) void handoffToOriginalRuntime();
|
|
463
|
+
|
|
464
|
+
async function close(signal) {
|
|
465
|
+
if (closing) return;
|
|
466
|
+
closing = true;
|
|
467
|
+
clearInterval(dependencyPoller);
|
|
468
|
+
for (const connection of clientWebSockets) {
|
|
469
|
+
connection.socket.destroy();
|
|
470
|
+
connection.upstream.destroy();
|
|
471
|
+
}
|
|
472
|
+
serverProxy.closeAllConnections?.();
|
|
473
|
+
clientProxy.closeAllConnections?.();
|
|
474
|
+
await Promise.all([
|
|
475
|
+
new Promise(resolve => serverProxy.close(resolve)),
|
|
476
|
+
new Promise(resolve => clientProxy.close(resolve)),
|
|
477
|
+
]);
|
|
478
|
+
for (const child of children) killProcessGroup(child);
|
|
479
|
+
setTimeout(() => {
|
|
480
|
+
for (const child of children) killProcessGroup(child, 'SIGKILL');
|
|
481
|
+
}, 2_000).unref();
|
|
482
|
+
emit('miaoda_cache_runtime_coordinator_stopped', { signal });
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
486
|
+
process.once(signal, () => void close(signal));
|
|
487
|
+
}
|