@astrale-os/adapter-cloudflare 0.5.0-beta.71 → 0.5.0-beta.73
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/CHANGELOG.md +23 -0
- package/README.md +4 -0
- package/dist/adapter.d.ts +2 -4
- package/dist/adapter.js +5 -106
- package/dist/build/artifact.js +7 -5
- package/dist/build/{frontend-v1.d.ts → frontend/files.d.ts} +0 -9
- package/dist/build/frontend/files.js +60 -0
- package/dist/build/index.d.ts +2 -2
- package/dist/build/index.js +2 -2
- package/dist/build/worker/execution.prepare.js +1 -1
- package/dist/deploy/deploy.d.ts +2 -1
- package/dist/deploy/deploy.js +1 -1
- package/dist/deploy/output.d.ts +2 -0
- package/dist/deploy/output.js +10 -0
- package/dist/development/develop.d.ts +15 -0
- package/dist/development/develop.js +267 -0
- package/dist/development/frontend.d.ts +11 -0
- package/dist/{build/frontend-v1.js → development/frontend.js} +31 -55
- package/dist/development/host.d.ts +2 -0
- package/dist/development/host.js +46 -0
- package/dist/development/port.d.ts +1 -0
- package/dist/development/port.js +12 -0
- package/dist/development/readiness.d.ts +14 -0
- package/dist/development/readiness.js +71 -0
- package/dist/development/resolved-fetch.d.ts +18 -0
- package/dist/development/resolved-fetch.js +78 -0
- package/dist/development/worker-output.d.ts +2 -0
- package/dist/development/worker-output.js +25 -0
- package/dist/{build/wrangler/development.d.ts → development/worker.d.ts} +5 -5
- package/dist/{build/wrangler/development.js → development/worker.js} +24 -13
- package/dist/identity/source.js +37 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/params.d.ts +3 -3
- package/dist/{build/wrangler → wrangler}/bundle.js +0 -2
- package/dist/{build/wrangler → wrangler}/deploy.js +1 -1
- package/dist/wrangler/index.d.ts +5 -0
- package/dist/wrangler/index.js +5 -0
- package/package.json +2 -2
- package/template/astrale.config.ts +6 -6
- package/dist/build/parse-output.d.ts +0 -26
- package/dist/build/parse-output.js +0 -57
- package/dist/build/wrangler-cli.d.ts +0 -6
- package/dist/build/wrangler-cli.js +0 -6
- /package/dist/{build/wrangler → wrangler}/bundle.d.ts +0 -0
- /package/dist/{build/wrangler → wrangler}/deploy.d.ts +0 -0
- /package/dist/{build/wrangler → wrangler}/process.d.ts +0 -0
- /package/dist/{build/wrangler → wrangler}/process.js +0 -0
- /package/dist/{build/wrangler → wrangler}/secrets.d.ts +0 -0
- /package/dist/{build/wrangler → wrangler}/secrets.js +0 -0
- /package/dist/{build/wrangler → wrangler}/status.d.ts +0 -0
- /package/dist/{build/wrangler → wrangler}/status.js +0 -0
- /package/dist/{build/wrangler → wrangler}/type-generation.d.ts +0 -0
- /package/dist/{build/wrangler → wrangler}/type-generation.js +0 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { addressing, assemble } from '@astrale-os/sdk/deployment/release';
|
|
2
|
+
import { mkdir, rename, rm, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { generateWranglerConfig } from '../build/codegen/wrangler.js';
|
|
5
|
+
import { resolveRouter, workerName } from '../build/configuration.js';
|
|
6
|
+
import { loadWorkerArtifact } from '../build/worker/index.js';
|
|
7
|
+
import { workerModules } from '../build/worker/modules.js';
|
|
8
|
+
import { signingIdentitySecrets } from '../identity/secret.js';
|
|
9
|
+
import { signingIdentity } from '../identity/source.js';
|
|
10
|
+
import { startFrontend } from './frontend.js';
|
|
11
|
+
import { admitHost } from './host.js';
|
|
12
|
+
import { allocatePort } from './port.js';
|
|
13
|
+
import { verifyDevelopmentRelease } from './readiness.js';
|
|
14
|
+
import { startWorker } from './worker.js';
|
|
15
|
+
export async function developCloudflare(parameters, context, dependencies = {
|
|
16
|
+
signingIdentity,
|
|
17
|
+
startFrontend,
|
|
18
|
+
startWorker,
|
|
19
|
+
allocatePort,
|
|
20
|
+
verify: verifyDevelopmentRelease,
|
|
21
|
+
}) {
|
|
22
|
+
const hostSource = context.overrides.publicUrl ?? parameters.host;
|
|
23
|
+
const host = hostSource === undefined ? undefined : admitHost(hostSource);
|
|
24
|
+
const configuredPort = context.overrides.localPort ?? parameters.port;
|
|
25
|
+
if (host !== undefined && configuredPort === undefined) {
|
|
26
|
+
throw new TypeError('Cloudflare development with a public host requires a strict local port.');
|
|
27
|
+
}
|
|
28
|
+
const port = configuredPort ?? (await dependencies.allocatePort());
|
|
29
|
+
const directory = join(context.projectDir, '.astrale', 'development', context.environment, 'cloudflare');
|
|
30
|
+
await mkdir(directory, { recursive: true });
|
|
31
|
+
const configPath = join(directory, 'wrangler.development.jsonc');
|
|
32
|
+
const identity = await dependencies.signingIdentity({
|
|
33
|
+
projectDir: context.projectDir,
|
|
34
|
+
source: parameters.signingIdentity,
|
|
35
|
+
origin: context.artifact.build.schema.compiled.root.origin,
|
|
36
|
+
mode: 'local',
|
|
37
|
+
});
|
|
38
|
+
let current = Object.freeze({ artifact: context.artifact, secrets: context.secrets });
|
|
39
|
+
let artifactPaths = new Set();
|
|
40
|
+
let resources;
|
|
41
|
+
let stopping = false;
|
|
42
|
+
let resolveFinished;
|
|
43
|
+
let rejectFinished;
|
|
44
|
+
const finished = new Promise((resolve, reject) => {
|
|
45
|
+
resolveFinished = resolve;
|
|
46
|
+
rejectFinished = reject;
|
|
47
|
+
});
|
|
48
|
+
const realize = async (input) => {
|
|
49
|
+
artifactPaths = await applyArtifact(directory, artifactPaths, input.artifact);
|
|
50
|
+
if (input.artifact.build.frontend?.source.kind === 'vite') {
|
|
51
|
+
await mkdir(join(directory, 'frontend'), { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
const frontend = await dependencies.startFrontend({
|
|
54
|
+
frontend: input.artifact.build.frontend,
|
|
55
|
+
projectDir: context.projectDir,
|
|
56
|
+
});
|
|
57
|
+
try {
|
|
58
|
+
await writeFile(configPath, configuration(parameters, input.artifact, frontend?.url, host, Object.keys(signingIdentitySecrets(input.secrets, identity)).sort()));
|
|
59
|
+
const worker = await dependencies.startWorker({
|
|
60
|
+
projectDir: context.projectDir,
|
|
61
|
+
configPath,
|
|
62
|
+
port,
|
|
63
|
+
remote: parameters.remote ?? false,
|
|
64
|
+
secrets: signingIdentitySecrets(input.secrets, identity),
|
|
65
|
+
autoPickPort: false,
|
|
66
|
+
pinLocalOrigin: host === undefined && parameters.remote !== true,
|
|
67
|
+
...(host === undefined ? {} : { ip: '0.0.0.0' }),
|
|
68
|
+
});
|
|
69
|
+
const value = Object.freeze({ ...(frontend === undefined ? {} : { frontend }), worker });
|
|
70
|
+
supervise(value);
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
catch (cause) {
|
|
74
|
+
await frontend?.stop().catch(() => undefined);
|
|
75
|
+
throw cause;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const supervise = (selected) => {
|
|
79
|
+
for (const child of [selected.worker.finished, selected.frontend?.finished]) {
|
|
80
|
+
child?.catch((cause) => {
|
|
81
|
+
if (!stopping && resources === selected)
|
|
82
|
+
rejectFinished(cause);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const placementFor = (selected) => Object.freeze({ releaseUrl: host ?? selected.worker.url, localUrl: selected.worker.url });
|
|
87
|
+
const verify = async (input, selected) => {
|
|
88
|
+
const placement = placementFor(selected);
|
|
89
|
+
const release = assemble(input.artifact.build, addressing(placement.releaseUrl));
|
|
90
|
+
await dependencies.verify({ release, localUrl: placement.localUrl, signal: context.signal });
|
|
91
|
+
await context.onPlacement(placement);
|
|
92
|
+
};
|
|
93
|
+
try {
|
|
94
|
+
resources = await realize(current);
|
|
95
|
+
await verify(current, resources);
|
|
96
|
+
}
|
|
97
|
+
catch (cause) {
|
|
98
|
+
stopping = true;
|
|
99
|
+
if (resources !== undefined)
|
|
100
|
+
await stopResources(resources).catch(() => undefined);
|
|
101
|
+
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
|
|
102
|
+
throw cause;
|
|
103
|
+
}
|
|
104
|
+
let updateTail = Promise.resolve();
|
|
105
|
+
const session = Object.freeze({
|
|
106
|
+
current: () => placementFor(resources),
|
|
107
|
+
update(next) {
|
|
108
|
+
if (stopping)
|
|
109
|
+
return Promise.reject(new Error('Cloudflare development session is stopping.'));
|
|
110
|
+
const operation = updateTail.then(async () => {
|
|
111
|
+
if (stopping)
|
|
112
|
+
throw new Error('Cloudflare development session is stopping.');
|
|
113
|
+
sameDomain(current.artifact, next.artifact);
|
|
114
|
+
const previous = current;
|
|
115
|
+
const previousResources = resources;
|
|
116
|
+
const restart = frontendSignature(previous.artifact) !== frontendSignature(next.artifact) ||
|
|
117
|
+
stable(previous.secrets) !== stable(next.secrets);
|
|
118
|
+
if (!restart) {
|
|
119
|
+
try {
|
|
120
|
+
artifactPaths = await applyArtifact(directory, artifactPaths, next.artifact);
|
|
121
|
+
await writeFile(configPath, configuration(parameters, next.artifact, resources.frontend?.url, host, Object.keys(signingIdentitySecrets(next.secrets, identity)).sort()));
|
|
122
|
+
await verify(next, resources);
|
|
123
|
+
current = Object.freeze({ artifact: next.artifact, secrets: next.secrets });
|
|
124
|
+
}
|
|
125
|
+
catch (cause) {
|
|
126
|
+
artifactPaths = await applyArtifact(directory, artifactPaths, previous.artifact);
|
|
127
|
+
await writeFile(configPath, configuration(parameters, previous.artifact, resources.frontend?.url, host, Object.keys(signingIdentitySecrets(previous.secrets, identity)).sort()));
|
|
128
|
+
throw cause;
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
await stopResources(previousResources);
|
|
134
|
+
}
|
|
135
|
+
catch (cause) {
|
|
136
|
+
rejectFinished(cause);
|
|
137
|
+
throw cause;
|
|
138
|
+
}
|
|
139
|
+
let replacement;
|
|
140
|
+
try {
|
|
141
|
+
replacement = await realize(next);
|
|
142
|
+
await verify(next, replacement);
|
|
143
|
+
resources = replacement;
|
|
144
|
+
current = Object.freeze({ artifact: next.artifact, secrets: next.secrets });
|
|
145
|
+
}
|
|
146
|
+
catch (cause) {
|
|
147
|
+
if (replacement !== undefined)
|
|
148
|
+
await stopResources(replacement).catch(() => undefined);
|
|
149
|
+
try {
|
|
150
|
+
resources = await realize(previous);
|
|
151
|
+
await verify(previous, resources);
|
|
152
|
+
}
|
|
153
|
+
catch (rollback) {
|
|
154
|
+
rejectFinished(new AggregateError([cause, rollback], 'Cloudflare development update and rollback failed.'));
|
|
155
|
+
}
|
|
156
|
+
throw cause;
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
updateTail = operation.catch(() => undefined);
|
|
160
|
+
return operation;
|
|
161
|
+
},
|
|
162
|
+
finished,
|
|
163
|
+
async stop() {
|
|
164
|
+
if (stopping)
|
|
165
|
+
return finished.catch(() => undefined);
|
|
166
|
+
stopping = true;
|
|
167
|
+
await updateTail;
|
|
168
|
+
try {
|
|
169
|
+
try {
|
|
170
|
+
await stopResources(resources);
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
await rm(directory, { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
resolveFinished();
|
|
176
|
+
}
|
|
177
|
+
catch (cause) {
|
|
178
|
+
rejectFinished(cause);
|
|
179
|
+
throw cause;
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
return session;
|
|
184
|
+
}
|
|
185
|
+
function configuration(parameters, artifact, frontendUrl, host, secretBindings) {
|
|
186
|
+
const worker = loadWorkerArtifact(artifact);
|
|
187
|
+
const router = parameters.remote === true || parameters.router !== undefined
|
|
188
|
+
? resolveRouter(parameters.router)
|
|
189
|
+
: undefined;
|
|
190
|
+
return generateWranglerConfig({
|
|
191
|
+
workerName: workerName(parameters, artifact.build.schema.compiled.root.origin),
|
|
192
|
+
main: `./${worker.release.entrypoint}`,
|
|
193
|
+
modules: workerModules(worker.release),
|
|
194
|
+
vars: {
|
|
195
|
+
...parameters.vars,
|
|
196
|
+
...(host === undefined ? {} : { WORKER_URL: host }),
|
|
197
|
+
...(frontendUrl === undefined ? {} : { FRONTEND_DEV_URL: frontendUrl }),
|
|
198
|
+
},
|
|
199
|
+
secretBindings,
|
|
200
|
+
...(artifact.build.frontend?.source.kind === 'vite'
|
|
201
|
+
? { frontend: { directory: './frontend', routes: frontendRoutes(artifact), dev: true } }
|
|
202
|
+
: {}),
|
|
203
|
+
...(router === undefined
|
|
204
|
+
? {}
|
|
205
|
+
: { router: { binding: router.binding, service: router.service } }),
|
|
206
|
+
...(parameters.wrangler === undefined ? {} : { wrangler: parameters.wrangler }),
|
|
207
|
+
selfBinding: true,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
async function applyArtifact(root, previous, artifact) {
|
|
211
|
+
const next = new Set(artifact.files.map((file) => file.path));
|
|
212
|
+
for (const file of artifact.files) {
|
|
213
|
+
const path = join(root, file.path);
|
|
214
|
+
const staging = `${path}.next`;
|
|
215
|
+
await mkdir(dirname(path), { recursive: true });
|
|
216
|
+
await writeFile(staging, file.bytes());
|
|
217
|
+
await rename(staging, path);
|
|
218
|
+
}
|
|
219
|
+
for (const path of previous) {
|
|
220
|
+
if (!next.has(path))
|
|
221
|
+
await unlink(join(root, path)).catch(() => undefined);
|
|
222
|
+
}
|
|
223
|
+
return next;
|
|
224
|
+
}
|
|
225
|
+
async function stopResources(resources) {
|
|
226
|
+
const results = await Promise.allSettled([
|
|
227
|
+
resources.worker.stop(),
|
|
228
|
+
resources.frontend?.stop() ?? Promise.resolve(),
|
|
229
|
+
]);
|
|
230
|
+
const failures = results.filter((result) => result.status === 'rejected');
|
|
231
|
+
if (failures.length === 1)
|
|
232
|
+
throw failures[0].reason;
|
|
233
|
+
if (failures.length > 1) {
|
|
234
|
+
throw new AggregateError(failures.map((failure) => failure.reason), 'Cloudflare development cleanup failed.');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function frontendRoutes(artifact) {
|
|
238
|
+
const frontend = artifact.build.frontend;
|
|
239
|
+
if (frontend === undefined || frontend.source.kind !== 'vite')
|
|
240
|
+
return [];
|
|
241
|
+
return Object.freeze(frontend.routes
|
|
242
|
+
.flatMap((route) => frontend.source.kind === 'vite' && frontend.source.spa && route.path !== '/'
|
|
243
|
+
? [route.path, `${route.path}/*`]
|
|
244
|
+
: route.path === '/'
|
|
245
|
+
? ['/*']
|
|
246
|
+
: [route.path])
|
|
247
|
+
.sort());
|
|
248
|
+
}
|
|
249
|
+
function frontendSignature(artifact) {
|
|
250
|
+
return stable(artifact.build.frontend);
|
|
251
|
+
}
|
|
252
|
+
function sameDomain(previous, next) {
|
|
253
|
+
if (previous.build.schema.compiled.root.origin !== next.build.schema.compiled.root.origin) {
|
|
254
|
+
throw new TypeError('Cloudflare development update changed the Domain origin.');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function stable(input) {
|
|
258
|
+
if (input === null || typeof input !== 'object')
|
|
259
|
+
return JSON.stringify(input);
|
|
260
|
+
if (Array.isArray(input))
|
|
261
|
+
return `[${input.map(stable).join(',')}]`;
|
|
262
|
+
const value = input;
|
|
263
|
+
return `{${Object.keys(value)
|
|
264
|
+
.sort()
|
|
265
|
+
.map((key) => `${JSON.stringify(key)}:${stable(value[key])}`)
|
|
266
|
+
.join(',')}}`;
|
|
267
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Build } from '@astrale-os/sdk/deployment/build';
|
|
2
|
+
export interface FrontendHandle {
|
|
3
|
+
readonly url: string;
|
|
4
|
+
readonly finished: Promise<void>;
|
|
5
|
+
stop(): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export declare function startFrontend(input: {
|
|
8
|
+
readonly frontend: Build['frontend'];
|
|
9
|
+
readonly projectDir: string;
|
|
10
|
+
readonly onLog?: (line: string) => void;
|
|
11
|
+
}): Promise<FrontendHandle | undefined>;
|
|
@@ -1,26 +1,7 @@
|
|
|
1
|
-
import { frontend as frontendBuild } from '@astrale-os/sdk/deployment/build';
|
|
2
1
|
import { spawn } from 'node:child_process';
|
|
3
2
|
import { existsSync } from 'node:fs';
|
|
4
|
-
import { readFile, readdir, rm } from 'node:fs/promises';
|
|
5
3
|
import { createServer } from 'node:net';
|
|
6
4
|
import { join } from 'node:path';
|
|
7
|
-
export async function frontendFiles(input) {
|
|
8
|
-
const frontend = input.frontend;
|
|
9
|
-
if (frontend === undefined)
|
|
10
|
-
return [];
|
|
11
|
-
if (frontend.source.kind === 'external')
|
|
12
|
-
return frontendBuild.files(frontend, []);
|
|
13
|
-
const output = join(input.projectDir, '.astrale', 'frontend-build');
|
|
14
|
-
await rm(output, { recursive: true, force: true });
|
|
15
|
-
await run(viteCommand(input.projectDir), [
|
|
16
|
-
'build',
|
|
17
|
-
'--outDir',
|
|
18
|
-
output,
|
|
19
|
-
'--emptyOutDir',
|
|
20
|
-
...(frontend.source.config === undefined ? [] : ['--config', frontend.source.config]),
|
|
21
|
-
], input.projectDir, input.onLog);
|
|
22
|
-
return frontendBuild.files(frontend, await readFiles(output));
|
|
23
|
-
}
|
|
24
5
|
export async function startFrontend(input) {
|
|
25
6
|
const frontend = input.frontend;
|
|
26
7
|
if (frontend === undefined || frontend.source.kind !== 'vite')
|
|
@@ -52,23 +33,32 @@ export async function startFrontend(input) {
|
|
|
52
33
|
await ready(url, child, output);
|
|
53
34
|
}
|
|
54
35
|
catch (cause) {
|
|
55
|
-
await
|
|
36
|
+
await stopChild(child);
|
|
56
37
|
throw cause;
|
|
57
38
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return
|
|
39
|
+
let stopping = false;
|
|
40
|
+
const finished = child.exitCode === null
|
|
41
|
+
? new Promise((resolve, reject) => {
|
|
42
|
+
child.once('exit', (code, signal) => {
|
|
43
|
+
if (stopping)
|
|
44
|
+
resolve();
|
|
45
|
+
else {
|
|
46
|
+
reject(new Error(`Vite stopped unexpectedly (code ${code ?? 'null'}, signal ${signal ?? 'none'}).\n${output.join('\n')}`));
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
child.once('error', reject);
|
|
50
|
+
})
|
|
51
|
+
: Promise.reject(new Error(`Vite stopped unexpectedly (code ${child.exitCode}).`));
|
|
52
|
+
return Object.freeze({
|
|
53
|
+
url,
|
|
54
|
+
finished,
|
|
55
|
+
async stop() {
|
|
56
|
+
if (stopping)
|
|
57
|
+
return finished.catch(() => undefined);
|
|
58
|
+
stopping = true;
|
|
59
|
+
await stopChild(child);
|
|
60
|
+
},
|
|
61
|
+
});
|
|
72
62
|
}
|
|
73
63
|
function viteCommand(projectDir) {
|
|
74
64
|
const command = join(projectDir, 'node_modules', '.bin', 'vite');
|
|
@@ -76,25 +66,6 @@ function viteCommand(projectDir) {
|
|
|
76
66
|
throw new TypeError('Vite frontend requires a project-local vite binary.');
|
|
77
67
|
return command;
|
|
78
68
|
}
|
|
79
|
-
function run(command, args, cwd, onLog) {
|
|
80
|
-
return new Promise((resolve, reject) => {
|
|
81
|
-
const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
82
|
-
let output = '';
|
|
83
|
-
const collect = (buffer) => {
|
|
84
|
-
const value = buffer.toString();
|
|
85
|
-
output += value;
|
|
86
|
-
for (const line of value.split('\n'))
|
|
87
|
-
if (line.trim() !== '')
|
|
88
|
-
onLog?.(line);
|
|
89
|
-
};
|
|
90
|
-
child.stdout?.on('data', collect);
|
|
91
|
-
child.stderr?.on('data', collect);
|
|
92
|
-
child.on('error', reject);
|
|
93
|
-
child.on('exit', (code) => code === 0
|
|
94
|
-
? resolve()
|
|
95
|
-
: reject(new Error(`Vite failed (code ${code ?? 1}):\n${output.slice(-1500)}`)));
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
69
|
function ephemeralPort() {
|
|
99
70
|
return new Promise((resolve, reject) => {
|
|
100
71
|
const server = createServer();
|
|
@@ -123,11 +94,16 @@ async function ready(url, child, output) {
|
|
|
123
94
|
}
|
|
124
95
|
throw new Error(`Vite did not become ready at ${url}.`);
|
|
125
96
|
}
|
|
126
|
-
function
|
|
127
|
-
if (child.exitCode !== null
|
|
97
|
+
function stopChild(child) {
|
|
98
|
+
if (child.exitCode !== null)
|
|
128
99
|
return Promise.resolve();
|
|
129
100
|
return new Promise((resolve) => {
|
|
130
101
|
child.once('exit', () => resolve());
|
|
131
102
|
child.kill('SIGTERM');
|
|
103
|
+
setTimeout(() => {
|
|
104
|
+
if (child.exitCode === null)
|
|
105
|
+
child.kill('SIGKILL');
|
|
106
|
+
resolve();
|
|
107
|
+
}, 4_000).unref?.();
|
|
132
108
|
});
|
|
133
109
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Admit one externally owned public origin without inferring lifecycle ownership. */
|
|
2
|
+
export function admitHost(input) {
|
|
3
|
+
let url;
|
|
4
|
+
try {
|
|
5
|
+
url = new URL(input);
|
|
6
|
+
}
|
|
7
|
+
catch (cause) {
|
|
8
|
+
throw new TypeError('Cloudflare development host must be one public HTTP(S) origin.', { cause });
|
|
9
|
+
}
|
|
10
|
+
if ((url.protocol !== 'https:' && url.protocol !== 'http:') ||
|
|
11
|
+
url.username !== '' ||
|
|
12
|
+
url.password !== '' ||
|
|
13
|
+
url.pathname !== '/' ||
|
|
14
|
+
url.search !== '' ||
|
|
15
|
+
url.hash !== '' ||
|
|
16
|
+
privateHostname(url.hostname)) {
|
|
17
|
+
throw new TypeError('Cloudflare development host must be one public HTTP(S) origin.');
|
|
18
|
+
}
|
|
19
|
+
return url.origin;
|
|
20
|
+
}
|
|
21
|
+
function privateHostname(input) {
|
|
22
|
+
const host = input
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/^\[|\]$/gu, '')
|
|
25
|
+
.replace(/\.$/u, '');
|
|
26
|
+
if (host === 'localhost' || host.endsWith('.localhost'))
|
|
27
|
+
return true;
|
|
28
|
+
if (isIP(host) === 4) {
|
|
29
|
+
const parts = host.split('.').map(Number);
|
|
30
|
+
return (parts[0] === 0 ||
|
|
31
|
+
parts[0] === 10 ||
|
|
32
|
+
parts[0] === 127 ||
|
|
33
|
+
(parts[0] === 169 && parts[1] === 254) ||
|
|
34
|
+
(parts[0] === 172 && (parts[1] ?? 0) >= 16 && (parts[1] ?? 0) <= 31) ||
|
|
35
|
+
(parts[0] === 192 && parts[1] === 168));
|
|
36
|
+
}
|
|
37
|
+
if (isIP(host) !== 6)
|
|
38
|
+
return false;
|
|
39
|
+
return (host === '::' ||
|
|
40
|
+
host === '::1' ||
|
|
41
|
+
host.startsWith('fc') ||
|
|
42
|
+
host.startsWith('fd') ||
|
|
43
|
+
/^fe[89ab]/u.test(host) ||
|
|
44
|
+
/^::ffff:(?:0|10|127|169\.254|172\.(?:1[6-9]|2\d|3[01])|192\.168)\./u.test(host));
|
|
45
|
+
}
|
|
46
|
+
import { isIP } from 'node:net';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function allocatePort(host?: string): Promise<number>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createServer } from 'node:net';
|
|
2
|
+
export function allocatePort(host = '127.0.0.1') {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
const server = createServer();
|
|
5
|
+
server.once('error', reject);
|
|
6
|
+
server.listen(0, host, () => {
|
|
7
|
+
const address = server.address();
|
|
8
|
+
const port = typeof address === 'object' && address !== null ? address.port : 0;
|
|
9
|
+
server.close((cause) => (cause === undefined ? resolve(port) : reject(cause)));
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Release } from '@astrale-os/sdk/deployment/release';
|
|
2
|
+
import { verifyReadiness } from '@astrale-os/sdk/deployment/verify';
|
|
3
|
+
import { resolvedHttpsFetch } from './resolved-fetch.js';
|
|
4
|
+
interface ReadinessDependencies {
|
|
5
|
+
readonly verify: typeof verifyReadiness;
|
|
6
|
+
readonly resolvedFetch: typeof resolvedHttpsFetch;
|
|
7
|
+
}
|
|
8
|
+
/** Verify the exact Release over both public identity and its loopback transport. */
|
|
9
|
+
export declare function verifyDevelopmentRelease(input: {
|
|
10
|
+
readonly release: Release;
|
|
11
|
+
readonly localUrl: string;
|
|
12
|
+
readonly signal: AbortSignal;
|
|
13
|
+
}, dependencies?: ReadinessDependencies): Promise<void>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { verifyReadiness } from '@astrale-os/sdk/deployment/verify';
|
|
2
|
+
import { resolvedHttpsFetch } from './resolved-fetch.js';
|
|
3
|
+
const defaults = Object.freeze({
|
|
4
|
+
verify: verifyReadiness,
|
|
5
|
+
resolvedFetch: resolvedHttpsFetch,
|
|
6
|
+
});
|
|
7
|
+
/** Verify the exact Release over both public identity and its loopback transport. */
|
|
8
|
+
export async function verifyDevelopmentRelease(input, dependencies = defaults) {
|
|
9
|
+
const sameOrigin = new URL(input.release.addressing.issuer).origin === new URL(input.localUrl).origin;
|
|
10
|
+
if (!sameOrigin) {
|
|
11
|
+
await retry(() => dependencies.verify(input.release, {
|
|
12
|
+
signal: attemptSignal(input.signal),
|
|
13
|
+
fetch: (url, init) => {
|
|
14
|
+
const requested = new URL(url);
|
|
15
|
+
const local = new URL(input.localUrl);
|
|
16
|
+
local.pathname = requested.pathname;
|
|
17
|
+
local.search = requested.search;
|
|
18
|
+
return fetch(local.href, init);
|
|
19
|
+
},
|
|
20
|
+
}), input.signal);
|
|
21
|
+
}
|
|
22
|
+
const publicHost = new URL(input.release.addressing.issuer).hostname;
|
|
23
|
+
const publicFetch = publicHost.endsWith('.trycloudflare.com')
|
|
24
|
+
? dependencies.resolvedFetch(input.release.addressing.issuer)
|
|
25
|
+
: undefined;
|
|
26
|
+
await retry(() => dependencies.verify(input.release, {
|
|
27
|
+
signal: attemptSignal(input.signal),
|
|
28
|
+
...(publicFetch === undefined ? {} : { fetch: publicFetch }),
|
|
29
|
+
}), input.signal);
|
|
30
|
+
}
|
|
31
|
+
async function retry(operation, signal) {
|
|
32
|
+
const deadline = Date.now() + 90_000;
|
|
33
|
+
let last;
|
|
34
|
+
while (Date.now() < deadline) {
|
|
35
|
+
throwIfAborted(signal);
|
|
36
|
+
try {
|
|
37
|
+
await operation();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
catch (cause) {
|
|
41
|
+
throwIfAborted(signal);
|
|
42
|
+
last = cause;
|
|
43
|
+
await pause(signal);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
throw last instanceof Error
|
|
47
|
+
? last
|
|
48
|
+
: new Error('Cloudflare development Release did not become ready.');
|
|
49
|
+
}
|
|
50
|
+
function attemptSignal(signal) {
|
|
51
|
+
return AbortSignal.any([signal, AbortSignal.timeout(5_000)]);
|
|
52
|
+
}
|
|
53
|
+
function pause(signal) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const timer = setTimeout(done, 250);
|
|
56
|
+
const abort = () => done(signal.reason instanceof Error ? signal.reason : new Error('Development interrupted.'));
|
|
57
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
58
|
+
function done(cause) {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
signal.removeEventListener('abort', abort);
|
|
61
|
+
if (cause === undefined)
|
|
62
|
+
resolve();
|
|
63
|
+
else
|
|
64
|
+
reject(cause);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function throwIfAborted(signal) {
|
|
69
|
+
if (signal.aborted)
|
|
70
|
+
throw signal.reason instanceof Error ? signal.reason : new Error('Development interrupted.');
|
|
71
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { verifyReadiness } from '@astrale-os/sdk/deployment/verify';
|
|
2
|
+
import { resolve4 } from 'node:dns/promises';
|
|
3
|
+
type ReadinessOptions = NonNullable<Parameters<typeof verifyReadiness>[1]>;
|
|
4
|
+
type ReadinessFetch = NonNullable<ReadinessOptions['fetch']>;
|
|
5
|
+
type RequestAddress = typeof requestAddress;
|
|
6
|
+
export interface ResolvedFetchDependencies {
|
|
7
|
+
readonly resolve: typeof resolve4;
|
|
8
|
+
readonly request: RequestAddress;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Fetch one HTTPS origin through authoritative A records instead of the host
|
|
12
|
+
* process' getaddrinfo cache. Quick Tunnel hostnames can be negatively cached
|
|
13
|
+
* when their URL is observed milliseconds before DNS propagation; retrying
|
|
14
|
+
* global fetch in that process then remains stuck even after the record exists.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolvedHttpsFetch(origin: string, dependencies?: ResolvedFetchDependencies): ReadinessFetch;
|
|
17
|
+
declare function requestAddress(url: URL, address: string, init: Parameters<ReadinessFetch>[1]): Promise<Response>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { resolve4 } from 'node:dns/promises';
|
|
2
|
+
import { request as httpsRequest } from 'node:https';
|
|
3
|
+
import { Readable } from 'node:stream';
|
|
4
|
+
const defaults = Object.freeze({
|
|
5
|
+
resolve: resolve4,
|
|
6
|
+
request: requestAddress,
|
|
7
|
+
});
|
|
8
|
+
/**
|
|
9
|
+
* Fetch one HTTPS origin through authoritative A records instead of the host
|
|
10
|
+
* process' getaddrinfo cache. Quick Tunnel hostnames can be negatively cached
|
|
11
|
+
* when their URL is observed milliseconds before DNS propagation; retrying
|
|
12
|
+
* global fetch in that process then remains stuck even after the record exists.
|
|
13
|
+
*/
|
|
14
|
+
export function resolvedHttpsFetch(origin, dependencies = defaults) {
|
|
15
|
+
const expected = new URL(origin);
|
|
16
|
+
if (expected.protocol !== 'https:') {
|
|
17
|
+
throw new TypeError('Resolved development fetch requires an HTTPS origin.');
|
|
18
|
+
}
|
|
19
|
+
let addresses;
|
|
20
|
+
let cursor = 0;
|
|
21
|
+
return async (input, init) => {
|
|
22
|
+
const url = new URL(input);
|
|
23
|
+
if (url.origin !== expected.origin)
|
|
24
|
+
return fetch(input, init);
|
|
25
|
+
const resolved = addresses ?? (await dependencies.resolve(expected.hostname));
|
|
26
|
+
if (resolved.length === 0)
|
|
27
|
+
throw new Error(`No IPv4 address resolved for ${expected.hostname}.`);
|
|
28
|
+
addresses = resolved;
|
|
29
|
+
let last;
|
|
30
|
+
for (let attempt = 0; attempt < resolved.length; attempt += 1) {
|
|
31
|
+
const address = resolved[(cursor + attempt) % resolved.length];
|
|
32
|
+
try {
|
|
33
|
+
const response = await dependencies.request(url, address, init);
|
|
34
|
+
cursor = (cursor + attempt + 1) % resolved.length;
|
|
35
|
+
return response;
|
|
36
|
+
}
|
|
37
|
+
catch (cause) {
|
|
38
|
+
if (init?.signal?.aborted)
|
|
39
|
+
throw cause;
|
|
40
|
+
last = cause;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
throw last;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function requestAddress(url, address, init) {
|
|
47
|
+
const headers = new Headers();
|
|
48
|
+
headers.set('host', url.host);
|
|
49
|
+
headers.set('accept-encoding', 'identity');
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const request = httpsRequest({
|
|
52
|
+
protocol: 'https:',
|
|
53
|
+
hostname: address,
|
|
54
|
+
port: url.port === '' ? 443 : Number(url.port),
|
|
55
|
+
path: `${url.pathname}${url.search}`,
|
|
56
|
+
method: 'GET',
|
|
57
|
+
headers: Object.fromEntries(headers.entries()),
|
|
58
|
+
servername: url.hostname,
|
|
59
|
+
signal: init?.signal ?? undefined,
|
|
60
|
+
}, (incoming) => {
|
|
61
|
+
const responseHeaders = new Headers();
|
|
62
|
+
for (const [name, value] of Object.entries(incoming.headers)) {
|
|
63
|
+
if (Array.isArray(value))
|
|
64
|
+
for (const item of value)
|
|
65
|
+
responseHeaders.append(name, item);
|
|
66
|
+
else if (value !== undefined)
|
|
67
|
+
responseHeaders.set(name, value);
|
|
68
|
+
}
|
|
69
|
+
resolve(new Response(Readable.toWeb(incoming), {
|
|
70
|
+
status: incoming.statusCode ?? 500,
|
|
71
|
+
statusText: incoming.statusMessage,
|
|
72
|
+
headers: responseHeaders,
|
|
73
|
+
}));
|
|
74
|
+
});
|
|
75
|
+
request.once('error', reject);
|
|
76
|
+
request.end();
|
|
77
|
+
});
|
|
78
|
+
}
|