@juuno-sdk/cli 4.0.2 → 4.2.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/README.md +67 -0
- package/bin/cli-router.js +91 -0
- package/bin/cli.js +27 -4
- package/dist/cli/src/deploy/index.js +231 -24
- package/dist/cli/src/deploy-backend/context.js +62 -0
- package/dist/cli/src/deploy-backend/index.js +196 -0
- package/dist/cli/src/deploy-backend/outcome.js +85 -0
- package/dist/cli/src/deploy-backend/wrangler-config.js +277 -0
- package/dist/cli/src/utils/api.js +12 -1
- package/dist/cli/src/utils/bin.js +23 -0
- package/dist/cli/tsconfig.build.tsbuildinfo +1 -1
- package/dist/types/src/deploy/index.d.ts +38 -0
- package/dist/types/src/deploy/index.d.ts.map +1 -1
- package/dist/types/src/deploy-backend/context.d.ts +29 -0
- package/dist/types/src/deploy-backend/context.d.ts.map +1 -0
- package/dist/types/src/deploy-backend/index.d.ts +54 -0
- package/dist/types/src/deploy-backend/index.d.ts.map +1 -0
- package/dist/types/src/deploy-backend/outcome.d.ts +63 -0
- package/dist/types/src/deploy-backend/outcome.d.ts.map +1 -0
- package/dist/types/src/deploy-backend/wrangler-config.d.ts +108 -0
- package/dist/types/src/deploy-backend/wrangler-config.d.ts.map +1 -0
- package/dist/types/src/utils/api.d.ts +8 -0
- package/dist/types/src/utils/api.d.ts.map +1 -1
- package/dist/types/src/utils/bin.d.ts +24 -0
- package/dist/types/src/utils/bin.d.ts.map +1 -0
- package/package.json +11 -3
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { basename, join, resolve } from 'node:path';
|
|
3
|
+
import { getApiUrl, getAuthToken } from '../auth/index.js';
|
|
4
|
+
import { spawnSync } from '../utils/bin.js';
|
|
5
|
+
import { fetchContext, withTimeout } from './context.js';
|
|
6
|
+
import { getErrorMessage } from '../utils/error.js';
|
|
7
|
+
import { describeOutcome, report, } from './outcome.js';
|
|
8
|
+
import { buildMetadata, isModuleFile, moduleContentType, parseWranglerConfig, resolveApiUrl, resolveEnvConfig, scriptName, } from './wrangler-config.js';
|
|
9
|
+
/**
|
|
10
|
+
* Far longer than the context read: this uploads the whole bundle and the server
|
|
11
|
+
* then provisions stores and runs the tenant's migrations before answering.
|
|
12
|
+
*/
|
|
13
|
+
const DEPLOY_TIMEOUT_MS = 180_000;
|
|
14
|
+
/**
|
|
15
|
+
* Main script entry point.
|
|
16
|
+
*/
|
|
17
|
+
export async function deployBackend(options) {
|
|
18
|
+
try {
|
|
19
|
+
await run(options);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
// Matches the other commands: one line the developer can act on, not a
|
|
23
|
+
// stack trace. The messages here already name the fix.
|
|
24
|
+
console.error(`❌ ${getErrorMessage(error)}`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the plan, print the metadata, and deploy.
|
|
30
|
+
*/
|
|
31
|
+
async function run(options) {
|
|
32
|
+
const plan = await resolvePlan(options);
|
|
33
|
+
if (options.printMetadata) {
|
|
34
|
+
const { script, env, namespace, metadata } = plan;
|
|
35
|
+
console.log(JSON.stringify({ script, env, namespace, metadata }, null, 2));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const target = plan.namespace ? `${plan.env} (${plan.namespace})` : plan.env;
|
|
39
|
+
console.log(`Deploying '${plan.script}' to ${target} via ${plan.api}`);
|
|
40
|
+
const outcome = await postDeploy(plan, bundle(plan.workerDir, plan.env));
|
|
41
|
+
report(outcome);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Bundle the worker with its own pinned wrangler.
|
|
45
|
+
*
|
|
46
|
+
* Shelled out rather than imported so the version in the worker's
|
|
47
|
+
* devDependencies is the one that runs — a bundler mismatch between the CLI and
|
|
48
|
+
* the worker would be a genuinely confusing failure — and so the CLI takes no
|
|
49
|
+
* wrangler dependency of its own.
|
|
50
|
+
*/
|
|
51
|
+
function bundle(workerDir, env) {
|
|
52
|
+
const outDir = join(workerDir, 'dist');
|
|
53
|
+
// Emptied first: wrangler writes into --outdir without clearing it, and
|
|
54
|
+
// everything left there is uploaded as a module. A renamed entry point or a
|
|
55
|
+
// dropped code-split chunk would otherwise ship alongside the current build
|
|
56
|
+
// forever — dead weight against the Worker size limit, and a bundle whose
|
|
57
|
+
// contents depend on what happened to be built in this directory before.
|
|
58
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
59
|
+
const bundled = spawnSync('npx', ['wrangler', 'deploy', '--env', env, '--dry-run', '--outdir', outDir], { cwd: workerDir, stdio: 'inherit' });
|
|
60
|
+
// spawnSync reports rather than throws, so both failures are checked here:
|
|
61
|
+
// the spawn itself (wrangler missing, PATH wrong) and a non-zero exit.
|
|
62
|
+
if (bundled.error) {
|
|
63
|
+
throw new Error(`Could not run wrangler: ${bundled.error.message}`);
|
|
64
|
+
}
|
|
65
|
+
if (bundled.status !== 0) {
|
|
66
|
+
throw new Error(`wrangler exited with code ${bundled.status ?? 'unknown'}.`);
|
|
67
|
+
}
|
|
68
|
+
if (!existsSync(outDir)) {
|
|
69
|
+
throw new Error(`wrangler produced no output at ${outDir}.`);
|
|
70
|
+
}
|
|
71
|
+
const modules = collectModules(outDir);
|
|
72
|
+
if (!modules.length) {
|
|
73
|
+
throw new Error(`No module files in ${outDir}.`);
|
|
74
|
+
}
|
|
75
|
+
return modules;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Every module in the dry-run output, as parts to upload.
|
|
79
|
+
*
|
|
80
|
+
* The walk is recursive because wrangler writes a code-split chunk or an
|
|
81
|
+
* imported `.wasm` into a subdirectory, and a flat read would leave it out of
|
|
82
|
+
* the upload entirely — either rejected for a missing module, or accepted and
|
|
83
|
+
* then throwing `No such module` on the first request.
|
|
84
|
+
*/
|
|
85
|
+
export function collectModules(outDir) {
|
|
86
|
+
// Separators normalised on the way in: Cloudflare addresses a module by a
|
|
87
|
+
// posix path, and a nested entry on Windows arrives here as `lib\chunk.js`.
|
|
88
|
+
const names = readdirSync(outDir, {
|
|
89
|
+
encoding: 'utf8',
|
|
90
|
+
recursive: true,
|
|
91
|
+
}).map((entry) => entry.replaceAll('\\', '/'));
|
|
92
|
+
return names.filter(isModuleFile).map((name) => ({
|
|
93
|
+
name,
|
|
94
|
+
contents: new Uint8Array(readFileSync(join(outDir, name))),
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Work out what would be deployed, without deploying it.
|
|
99
|
+
*
|
|
100
|
+
* Everything here is a read, so `--print-metadata` can stop straight afterwards
|
|
101
|
+
* and show exactly what a real run would send.
|
|
102
|
+
*/
|
|
103
|
+
async function resolvePlan(options) {
|
|
104
|
+
const workerDir = resolve(options.dir ?? process.cwd());
|
|
105
|
+
const configPath = join(workerDir, 'wrangler.jsonc');
|
|
106
|
+
if (!existsSync(configPath)) {
|
|
107
|
+
throw new Error(`No wrangler.jsonc in ${workerDir}. Run this from a worker directory, or pass --dir.`);
|
|
108
|
+
}
|
|
109
|
+
// Resolved before the token, not after. On the --email/--password path
|
|
110
|
+
// getAuthToken() posts the credentials to this url, so a guard that ran
|
|
111
|
+
// afterwards would refuse a cleartext connection the password had already gone
|
|
112
|
+
// over. Passed back in so the checked value is the one that gets used.
|
|
113
|
+
const api = resolveApiUrl(getApiUrl(options));
|
|
114
|
+
const token = await getAuthToken({ ...options, apiUrl: api });
|
|
115
|
+
// The profile picks the API; the API states its environment. That single
|
|
116
|
+
// answer then selects the wrangler env to bundle, so the bindings that get
|
|
117
|
+
// built and the namespace that receives them can never disagree.
|
|
118
|
+
const { env, namespace } = await fetchContext(api, token);
|
|
119
|
+
const config = parseWranglerConfig(readFileSync(configPath, 'utf8'));
|
|
120
|
+
// Throws naming the envs the file does declare, which is the useful error
|
|
121
|
+
// when a worker has not been set up for the environment you are pointed at.
|
|
122
|
+
const resolved = resolveEnvConfig(config, env);
|
|
123
|
+
return {
|
|
124
|
+
workerDir,
|
|
125
|
+
api,
|
|
126
|
+
token,
|
|
127
|
+
env,
|
|
128
|
+
namespace,
|
|
129
|
+
// The raw config, not the resolved one: naming the script is the one place
|
|
130
|
+
// wrangler suffixes rather than inherits. See scriptName().
|
|
131
|
+
script: scriptName(config, env),
|
|
132
|
+
metadata: buildMetadata(resolved),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The multipart upload Cloudflare's Workers API expects, as the platform
|
|
137
|
+
* forwards it: the script name, the metadata, and one part per module.
|
|
138
|
+
*/
|
|
139
|
+
function buildUploadForm(plan, modules) {
|
|
140
|
+
const form = new FormData();
|
|
141
|
+
form.append('script', plan.script);
|
|
142
|
+
form.append('metadata', JSON.stringify(plan.metadata));
|
|
143
|
+
for (const module of modules) {
|
|
144
|
+
form.append(module.name, new Blob([module.contents], { type: moduleContentType(module.name) }), basename(module.name));
|
|
145
|
+
}
|
|
146
|
+
return form;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Read the body, tolerating one that will not parse.
|
|
150
|
+
*
|
|
151
|
+
* The status is still meaningful, and every status has an answer without a body.
|
|
152
|
+
* An abort is not a parse failure, though — that is the deploy running past its
|
|
153
|
+
* timeout, which has its own answer — so it propagates rather than being flatted
|
|
154
|
+
* into an empty body and reported as "HTTP 200: no error message".
|
|
155
|
+
*/
|
|
156
|
+
export async function readDeployResponse(response) {
|
|
157
|
+
try {
|
|
158
|
+
return (await response.json());
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
return {};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Upload the modules and say what happened.
|
|
169
|
+
*
|
|
170
|
+
* Returns the outcome rather than a status and a body, because nothing upstream
|
|
171
|
+
* wants the transport details — the only question is what happened, and
|
|
172
|
+
* describeOutcome() is what answers it.
|
|
173
|
+
*/
|
|
174
|
+
async function postDeploy(plan, modules) {
|
|
175
|
+
// One timeout around the request and the body read together. The deploy is
|
|
176
|
+
// not finished until the server has answered in full, so an abort partway
|
|
177
|
+
// through the body is the same "may still have landed" situation as an abort
|
|
178
|
+
// before the headers, and gets the same sentence.
|
|
179
|
+
const { status, body } = await withTimeout(async () => {
|
|
180
|
+
const response = await fetch(`${plan.api}/api/v1/developer/app-backends/deploy`, {
|
|
181
|
+
method: 'POST',
|
|
182
|
+
headers: {
|
|
183
|
+
Authorization: `Bearer ${plan.token}`,
|
|
184
|
+
Accept: 'application/json',
|
|
185
|
+
},
|
|
186
|
+
body: buildUploadForm(plan, modules),
|
|
187
|
+
signal: AbortSignal.timeout(DEPLOY_TIMEOUT_MS),
|
|
188
|
+
});
|
|
189
|
+
return {
|
|
190
|
+
status: response.status,
|
|
191
|
+
body: await readDeployResponse(response),
|
|
192
|
+
};
|
|
193
|
+
}, `The deploy did not finish within ${DEPLOY_TIMEOUT_MS / 1000}s. It may still ` +
|
|
194
|
+
"have landed — check the app's Backend tab in Superadmin before retrying.");
|
|
195
|
+
return describeOutcome(status, body, modules.map((module) => module.name), plan.api);
|
|
196
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a deploy response, and saying what happened.
|
|
3
|
+
*
|
|
4
|
+
* Two functions, split so the decision is separable from the printing:
|
|
5
|
+
* `describeOutcome` is pure and decides, `report` performs. Kept apart from the
|
|
6
|
+
* command because the distinction they draw is a safety property rather than
|
|
7
|
+
* presentation — a 404 means the platform refused before anything reached
|
|
8
|
+
* Cloudflare, so nothing is live and the CLI can fail loudly; any other failure
|
|
9
|
+
* may have uploaded the script and then failed a later step, so the server's own
|
|
10
|
+
* message is the only accurate account of what happened and is passed through
|
|
11
|
+
* untouched.
|
|
12
|
+
*
|
|
13
|
+
* Getting that backwards would either hide a live half-deploy behind a tidy
|
|
14
|
+
* error, or tell a developer to go and register something that already exists.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* What the response means, given the modules that were sent.
|
|
18
|
+
*
|
|
19
|
+
* A body of `{ success: false }` on a 200 counts as a failure — the endpoint
|
|
20
|
+
* reports step failures in the body, so the HTTP status alone is not the answer.
|
|
21
|
+
*/
|
|
22
|
+
export function describeOutcome(status, body, moduleNames, api) {
|
|
23
|
+
const failed = status < 200 || status >= 300 || body.success !== true;
|
|
24
|
+
if (!failed) {
|
|
25
|
+
const lines = [
|
|
26
|
+
`\n${body.message ?? 'Deployed.'}`,
|
|
27
|
+
` modules: ${moduleNames.join(', ')}`,
|
|
28
|
+
];
|
|
29
|
+
// Only apps with a database report one, so its absence is not a gap.
|
|
30
|
+
if (body.schema_version != null) {
|
|
31
|
+
lines.push(` schema version: ${body.schema_version}`);
|
|
32
|
+
}
|
|
33
|
+
return { kind: 'success', lines };
|
|
34
|
+
}
|
|
35
|
+
// A 404 alone does not mean "not registered". The deploy endpoint answers one
|
|
36
|
+
// with its own error envelope; a wrong --api, a proxy, or an instance too old
|
|
37
|
+
// to have the route answers one with HTML or nothing, which postDeploy turns
|
|
38
|
+
// into an empty body. Telling that developer to go and register a backend they
|
|
39
|
+
// already registered sends them looking in the wrong place entirely, so the
|
|
40
|
+
// envelope is what distinguishes them.
|
|
41
|
+
if (status === 404) {
|
|
42
|
+
if (typeof body.error === 'string') {
|
|
43
|
+
return {
|
|
44
|
+
kind: 'unregistered',
|
|
45
|
+
message: `${body.error}\n` +
|
|
46
|
+
'Register it in Superadmin, on the Backend tab of the app it serves.',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
kind: 'failed',
|
|
51
|
+
message: `\nDeploy failed (HTTP 404): ${api}/api/v1/developer/app-backends/deploy ` +
|
|
52
|
+
'did not answer as the deploy endpoint.\nCheck the profile’s API url — ' +
|
|
53
|
+
'a 404 without an error body usually means the request never reached it.',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
kind: 'failed',
|
|
58
|
+
message: `\nDeploy failed (HTTP ${status}): ${body.error ?? 'no error message'}`,
|
|
59
|
+
details: body.errors ? JSON.stringify(body.errors, null, 2) : undefined,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Turn an outcome into output and an exit code.
|
|
64
|
+
*
|
|
65
|
+
* Only `unregistered` throws, and only because nothing was uploaded: the caller
|
|
66
|
+
* wraps a throw as a one-line `❌ …`, which is the right shape for "this was
|
|
67
|
+
* refused, here is the fix". A failure that may have half-landed must not be
|
|
68
|
+
* wrapped, so it is written straight to stderr and the exit code is set by hand.
|
|
69
|
+
*/
|
|
70
|
+
export function report(outcome) {
|
|
71
|
+
if (outcome.kind === 'unregistered') {
|
|
72
|
+
throw new Error(outcome.message);
|
|
73
|
+
}
|
|
74
|
+
if (outcome.kind === 'failed') {
|
|
75
|
+
console.error(outcome.message);
|
|
76
|
+
if (outcome.details) {
|
|
77
|
+
console.error(outcome.details);
|
|
78
|
+
}
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
for (const line of outcome.lines) {
|
|
83
|
+
console.log(line);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for a Workers-for-Platforms deploy: building the payload from a
|
|
3
|
+
* worker's wrangler.jsonc, and resolving where to send it.
|
|
4
|
+
*
|
|
5
|
+
* Kept free of I/O so the part that fails silently — the metadata — is directly
|
|
6
|
+
* testable. See wrangler-config.test.ts.
|
|
7
|
+
*/
|
|
8
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
|
9
|
+
/**
|
|
10
|
+
* Normalise the API base, and refuse to put the developer token on a cleartext
|
|
11
|
+
* connection.
|
|
12
|
+
*
|
|
13
|
+
* Loopback stays exempt so a local Laravel over http still works. Anything else
|
|
14
|
+
* must be https, because the next thing the caller does with this value is send
|
|
15
|
+
* a bearer token to it.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveApiUrl(value) {
|
|
18
|
+
let url;
|
|
19
|
+
try {
|
|
20
|
+
url = new URL(value);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new Error(`API url must be absolute, got: ${value}`);
|
|
24
|
+
}
|
|
25
|
+
if (url.protocol !== 'https:' && !LOOPBACK_HOSTS.has(url.hostname)) {
|
|
26
|
+
throw new Error(`API url must use https (or a loopback host for local development), got: ${value}`);
|
|
27
|
+
}
|
|
28
|
+
return value.replace(/\/$/, '');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Binding types wrangler does NOT inherit from the top level into `env.<name>`.
|
|
32
|
+
* Everything else (main, compatibility_date, compatibility_flags, …) is
|
|
33
|
+
* inherited. Getting this wrong in the lenient direction is the dangerous one:
|
|
34
|
+
* it would ship a top-level store binding into an environment that deliberately
|
|
35
|
+
* declared none.
|
|
36
|
+
*/
|
|
37
|
+
const NOT_INHERITED = [
|
|
38
|
+
'vars',
|
|
39
|
+
'kv_namespaces',
|
|
40
|
+
'd1_databases',
|
|
41
|
+
'r2_buckets',
|
|
42
|
+
'queues',
|
|
43
|
+
'routes',
|
|
44
|
+
'route',
|
|
45
|
+
'durable_objects',
|
|
46
|
+
];
|
|
47
|
+
/**
|
|
48
|
+
* Binding kinds buildMetadata cannot express.
|
|
49
|
+
*
|
|
50
|
+
* Cloudflare treats the metadata part as the authoritative binding list, so one
|
|
51
|
+
* dropped here deploys a worker whose binding is simply absent: the deploy
|
|
52
|
+
* reports success and the first request that touches it 500s, with nothing in
|
|
53
|
+
* the output connecting the two. Refused instead, until the platform endpoint
|
|
54
|
+
* provisions them the way it already does KV and D1.
|
|
55
|
+
*/
|
|
56
|
+
const UNSUPPORTED_BINDINGS = [
|
|
57
|
+
'r2_buckets',
|
|
58
|
+
'queues',
|
|
59
|
+
'durable_objects',
|
|
60
|
+
'services',
|
|
61
|
+
];
|
|
62
|
+
/**
|
|
63
|
+
* An env block by name, own properties only.
|
|
64
|
+
*
|
|
65
|
+
* `constructor` and `toString` are inherited from Object.prototype, so a plain
|
|
66
|
+
* lookup finds a function there and sails through a truthiness check — resolving
|
|
67
|
+
* `--env constructor` to a nonsense config instead of erroring.
|
|
68
|
+
*/
|
|
69
|
+
function ownEnv(config, env) {
|
|
70
|
+
return Object.prototype.hasOwnProperty.call(config.env ?? {}, env)
|
|
71
|
+
? config.env?.[env]
|
|
72
|
+
: undefined;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Strip comments and trailing commas from JSONC so JSON.parse accepts it.
|
|
76
|
+
*
|
|
77
|
+
* String-aware: a `//` or block-comment opener inside a quoted value is left
|
|
78
|
+
* alone, as is an escaped quote. Written by hand rather than pulling in a parser
|
|
79
|
+
* because this is the only JSONC we read and the input is our own.
|
|
80
|
+
*
|
|
81
|
+
* The complexity is suppressed rather than refactored. This is a character
|
|
82
|
+
* scanner: four mutually-exclusive states over one cursor, sharing `out`, `i`
|
|
83
|
+
* and three flags. Extracting the branches means threading all of that through
|
|
84
|
+
* helpers or hoisting it into an object, which trades a shape every reader
|
|
85
|
+
* recognises for one nobody does — and a state machine you have to reassemble
|
|
86
|
+
* across functions is exactly where an off-by-one hides. The 31 cases in
|
|
87
|
+
* `__tests__/wrangler-config.test.ts` are what keeps it honest instead.
|
|
88
|
+
*/
|
|
89
|
+
// fallow-ignore-next-line complexity
|
|
90
|
+
export function stripJsonc(source) {
|
|
91
|
+
let out = '';
|
|
92
|
+
let inString = false;
|
|
93
|
+
let inLine = false;
|
|
94
|
+
let inBlock = false;
|
|
95
|
+
for (let i = 0; i < source.length; i++) {
|
|
96
|
+
const c = source[i];
|
|
97
|
+
const next = source[i + 1];
|
|
98
|
+
if (inLine) {
|
|
99
|
+
if (c === '\n') {
|
|
100
|
+
inLine = false;
|
|
101
|
+
out += c;
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (inBlock) {
|
|
106
|
+
if (c === '*' && next === '/') {
|
|
107
|
+
inBlock = false;
|
|
108
|
+
i++;
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (inString) {
|
|
113
|
+
out += c;
|
|
114
|
+
if (c === '\\') {
|
|
115
|
+
// Copy the escaped character verbatim so \" doesn't end the string.
|
|
116
|
+
out += next ?? '';
|
|
117
|
+
i++;
|
|
118
|
+
}
|
|
119
|
+
else if (c === '"') {
|
|
120
|
+
inString = false;
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (c === '"') {
|
|
125
|
+
inString = true;
|
|
126
|
+
out += c;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (c === '/' && next === '/') {
|
|
130
|
+
inLine = true;
|
|
131
|
+
i++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (c === '/' && next === '*') {
|
|
135
|
+
inBlock = true;
|
|
136
|
+
i++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
// Trailing comma before a closing brace/bracket. Done here rather than with
|
|
140
|
+
// a regex over the finished string so a quoted value containing ",}" or
|
|
141
|
+
// ",]" is left alone — at this point we know we are outside a string.
|
|
142
|
+
if (c === '}' || c === ']') {
|
|
143
|
+
out = out.replace(/,\s*$/, '');
|
|
144
|
+
}
|
|
145
|
+
out += c;
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
export function parseWranglerConfig(source) {
|
|
150
|
+
return JSON.parse(stripJsonc(source));
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Flatten a wrangler config for one environment.
|
|
154
|
+
*
|
|
155
|
+
* Inherited keys come from the top level unless the environment overrides them;
|
|
156
|
+
* the binding keys in NOT_INHERITED come from the environment alone, matching
|
|
157
|
+
* wrangler's own rule.
|
|
158
|
+
*/
|
|
159
|
+
export function resolveEnvConfig(config, env) {
|
|
160
|
+
const envConfig = ownEnv(config, env);
|
|
161
|
+
if (!envConfig) {
|
|
162
|
+
throw new Error(`wrangler.jsonc declares no env "${env}" (found: ${Object.keys(config.env ?? {}).join(', ') || 'none'}).`);
|
|
163
|
+
}
|
|
164
|
+
const resolved = { ...config, ...envConfig };
|
|
165
|
+
delete resolved.env;
|
|
166
|
+
for (const key of NOT_INHERITED) {
|
|
167
|
+
if (envConfig[key] === undefined) {
|
|
168
|
+
delete resolved[key];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return resolved;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The entry module's name as it appears in the dry-run output. wrangler compiles
|
|
175
|
+
* `./src/index.ts` to `dist/index.js`, so the extension is normalised.
|
|
176
|
+
*/
|
|
177
|
+
export function entryModuleName(main) {
|
|
178
|
+
// Both separators: a wrangler.jsonc authored on Windows can say
|
|
179
|
+
// "src\\index.ts", and the parts bundle() uploads are named by readdirSync —
|
|
180
|
+
// bare basenames. Leaving a backslash in would make main_module name a part
|
|
181
|
+
// that was never sent, and Cloudflare rejects the upload for a missing entry.
|
|
182
|
+
return main.replace(/^.*[/\\]/, '').replace(/\.(ts|tsx|mts|cts)$/, '.js');
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Build the `metadata` part for the platform deploy endpoint.
|
|
186
|
+
*
|
|
187
|
+
* Store bindings are emitted **by name only**. The platform provisions the
|
|
188
|
+
* store and fills the id — sending one would make it adopt an existing store
|
|
189
|
+
* instead, and nothing would report that it had. Note the two are not
|
|
190
|
+
* symmetric: KV's id field is `namespace_id`, D1's is `id`, and neither is set
|
|
191
|
+
* here.
|
|
192
|
+
*/
|
|
193
|
+
export function buildMetadata(resolved) {
|
|
194
|
+
if (!resolved.main) {
|
|
195
|
+
throw new Error('wrangler.jsonc has no "main" entry point.');
|
|
196
|
+
}
|
|
197
|
+
if (!resolved.compatibility_date) {
|
|
198
|
+
throw new Error('wrangler.jsonc has no "compatibility_date".');
|
|
199
|
+
}
|
|
200
|
+
const unsupported = UNSUPPORTED_BINDINGS.filter((key) => resolved[key] !== undefined);
|
|
201
|
+
if (unsupported.length) {
|
|
202
|
+
throw new Error(`wrangler.jsonc declares bindings this deploy path cannot send: ${unsupported.join(', ')}.`);
|
|
203
|
+
}
|
|
204
|
+
const bindings = [];
|
|
205
|
+
for (const kv of resolved.kv_namespaces ?? []) {
|
|
206
|
+
bindings.push({ type: 'kv_namespace', name: kv.binding });
|
|
207
|
+
}
|
|
208
|
+
for (const d1 of resolved.d1_databases ?? []) {
|
|
209
|
+
bindings.push({ type: 'd1', name: d1.binding });
|
|
210
|
+
}
|
|
211
|
+
for (const [name, value] of Object.entries(resolved.vars ?? {})) {
|
|
212
|
+
// Every var ships as plain text, so an object or array would arrive as
|
|
213
|
+
// "[object Object]" or a comma-joined list — a binding that looks present in
|
|
214
|
+
// the metadata and is not the value the worker reads. `typeof null` is
|
|
215
|
+
// 'object' too, and "null" is no better. Refused rather than corrupted.
|
|
216
|
+
if (typeof value === 'object') {
|
|
217
|
+
throw new Error(`var "${name}" must be a string, number or boolean — this deploy path sends vars as plain text.`);
|
|
218
|
+
}
|
|
219
|
+
bindings.push({ type: 'plain_text', name, text: String(value) });
|
|
220
|
+
}
|
|
221
|
+
const metadata = {
|
|
222
|
+
main_module: entryModuleName(resolved.main),
|
|
223
|
+
compatibility_date: resolved.compatibility_date,
|
|
224
|
+
};
|
|
225
|
+
if (resolved.compatibility_flags?.length) {
|
|
226
|
+
metadata.compatibility_flags = resolved.compatibility_flags;
|
|
227
|
+
}
|
|
228
|
+
if (bindings.length) {
|
|
229
|
+
metadata.bindings = bindings;
|
|
230
|
+
}
|
|
231
|
+
// Forwarded as written; the platform validates it and turns observability on
|
|
232
|
+
// when the key is absent, so this only matters for sampling settings or an
|
|
233
|
+
// explicit opt-out.
|
|
234
|
+
if (resolved.observability !== undefined) {
|
|
235
|
+
metadata.observability = resolved.observability;
|
|
236
|
+
}
|
|
237
|
+
return metadata;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The tenant script name wrangler will have built for this environment.
|
|
241
|
+
*
|
|
242
|
+
* Read off the raw config rather than the resolved one because the rule is not
|
|
243
|
+
* inheritance: an env that declares its own `name` uses it verbatim, and one
|
|
244
|
+
* that does not gets `<top-level-name>-<env>`. The merged config cannot tell the
|
|
245
|
+
* two apart, so reading it would address the upload to `google-reviews` for a
|
|
246
|
+
* bundle wrangler built as `google-reviews-prod` — refused as unregistered, or
|
|
247
|
+
* worse, landing on whichever tenant already holds that name.
|
|
248
|
+
*/
|
|
249
|
+
export function scriptName(config, env) {
|
|
250
|
+
const envName = ownEnv(config, env)?.name;
|
|
251
|
+
if (envName) {
|
|
252
|
+
return envName;
|
|
253
|
+
}
|
|
254
|
+
if (!config.name) {
|
|
255
|
+
throw new Error('wrangler.jsonc has no "name".');
|
|
256
|
+
}
|
|
257
|
+
return `${config.name}-${env}`;
|
|
258
|
+
}
|
|
259
|
+
/** Files in the dry-run output that belong in the upload. */
|
|
260
|
+
export function isModuleFile(filename) {
|
|
261
|
+
// Anchored, which is what excludes sourcemaps: `index.js.map` ends in `.map`,
|
|
262
|
+
// so it never matches in the first place.
|
|
263
|
+
return /\.(js|mjs|wasm)$/.test(filename);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* The Content-Type of a module part, which is how Cloudflare decides what kind
|
|
267
|
+
* of module it is — not a formality. `application/javascript+module` makes it an
|
|
268
|
+
* ES module and `application/wasm` a CompiledWasm one, so labelling a `.wasm`
|
|
269
|
+
* as JavaScript would have Cloudflare try to parse the binary as source and
|
|
270
|
+
* reject the upload. isModuleFile() already accepts `.wasm`, so the two have to
|
|
271
|
+
* agree about what that means.
|
|
272
|
+
*/
|
|
273
|
+
export function moduleContentType(filename) {
|
|
274
|
+
return filename.endsWith('.wasm')
|
|
275
|
+
? 'application/wasm'
|
|
276
|
+
: 'application/javascript+module';
|
|
277
|
+
}
|
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import { getErrorMessage } from './error.js';
|
|
2
|
+
/**
|
|
3
|
+
* A non-2xx answer from the developer API. It keeps the status, so a caller
|
|
4
|
+
* can treat one status, such as a 404, as an answer and not as a failure.
|
|
5
|
+
*/
|
|
6
|
+
export class HttpError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
constructor(status, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.status = status;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
2
13
|
/**
|
|
3
14
|
* Fetch JSON from a developer-API endpoint with bearer-token auth, throwing
|
|
4
15
|
* a useful error on non-2xx responses (status, statusText, body). Used by
|
|
@@ -14,7 +25,7 @@ export async function fetchAuthorisedJson(url, token) {
|
|
|
14
25
|
});
|
|
15
26
|
if (!response.ok) {
|
|
16
27
|
const body = await response.text();
|
|
17
|
-
throw new
|
|
28
|
+
throw new HttpError(response.status, `${response.status} ${response.statusText}\n${body}`);
|
|
18
29
|
}
|
|
19
30
|
return (await response.json());
|
|
20
31
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import crossSpawn from 'cross-spawn';
|
|
2
|
+
/**
|
|
3
|
+
* Spawning a package-manager binary, portably.
|
|
4
|
+
*
|
|
5
|
+
* `npx`, `pnpm` and `yarn` are shell scripts on POSIX and `.cmd` shims on
|
|
6
|
+
* Windows, and neither Node nor libuv will run a `.cmd` from `spawn` without a
|
|
7
|
+
* shell — `CreateProcess` cannot execute one, and since the fix for
|
|
8
|
+
* CVE-2024-27980 Node refuses rather than silently routing through cmd.exe.
|
|
9
|
+
* Naming the shim (`npx.cmd`) fixes PATH resolution and not the spawn.
|
|
10
|
+
*
|
|
11
|
+
* Doing it correctly means invoking `cmd.exe /d /s /c` with the command and
|
|
12
|
+
* every argument escaped for cmd's parser, plus a second round of escaping for
|
|
13
|
+
* shims under `node_modules/.bin`, and `windowsVerbatimArguments` so Node does
|
|
14
|
+
* not re-quote what was just escaped. `shell: true` is the wrong shortcut: it
|
|
15
|
+
* hands the whole line to cmd.exe to re-parse, so a worker directory containing
|
|
16
|
+
* `&` or `^` becomes a command.
|
|
17
|
+
*
|
|
18
|
+
* cross-spawn already does all of it, is the ecosystem's answer to this exact
|
|
19
|
+
* problem, and was already in the tree transitively. Preferred over a local
|
|
20
|
+
* reimplementation, which is where the escaping bugs live.
|
|
21
|
+
*/
|
|
22
|
+
export const spawn = crossSpawn;
|
|
23
|
+
export const spawnSync = crossSpawn.sync;
|