Skip to content
Merged
15 changes: 12 additions & 3 deletions lib/ns-pattern.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ export const JS_CLASS_DECLARATION_RESERVED_WORDS = new Set([
"yield",
]);

/** @param {unknown} value */
/**
* @param {unknown} value
* @returns {value is string}
*/
export function isValidJsClassDeclarationName(value) {
return isValidJsIdentifier(value) && !JS_CLASS_DECLARATION_RESERVED_WORDS.has(value);
}
Expand Down Expand Up @@ -115,12 +118,18 @@ export const WDL_RESERVED_ENTRYPOINT_RE = /^__Wdl[A-Za-z0-9_]*__$/;

const NS_RE = new RegExp(`^${NS_PATTERN}$`);

/** @param {unknown} ns */
/**
* @param {unknown} ns
* @returns {ns is string}
*/
export function isReservedNs(ns) {
return typeof ns === "string" && RESERVED_NS_SECTION_RE.test(ns);
}

/** @param {unknown} ns */
/**
* @param {unknown} ns
* @returns {ns is string}
*/
export function isAdminAcceptableNs(ns) {
if (typeof ns !== "string") return false;
if (RESERVED_TENANT_NS.has(ns)) return false;
Expand Down
19 changes: 11 additions & 8 deletions lib/wrangler-pack.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,10 @@ import {
} from "./ns-pattern.js";
import { collectAssets, resolveAssetsDir } from "./wrangler/assets.js";
import {
assertNotRuntimeReservedBinding,
assertValidBindingName,
parseD1DatabasesFromCfg,
parseDurableObjectsFromCfg,
parseExportsFromCfg,
parseKvNamespacesFromCfg,
parsePlatformBindingsFromCfg,
parseQueues,
parseR2BucketsFromCfg,
Expand Down Expand Up @@ -52,6 +51,7 @@ export {
parseD1DatabasesFromCfg,
parseDurableObjectsFromCfg,
parseExportsFromCfg,
parseKvNamespacesFromCfg,
parsePlatformBindingsFromCfg,
parseQueues,
parseR2BucketsFromCfg,
Expand Down Expand Up @@ -89,7 +89,7 @@ const WRANGLER_OUTPUT_MAX_BUFFER = 10 * 1024 * 1024;
* @property {string[]} [routes]
* @property {Array<{ cron: string, timezone: string }>} [crons]
* @property {import("./wrangler/bindings.js").QueueConsumer[]} [queueConsumers]
* @property {Array<{ name: string, binding: string, className: unknown }>} [workflows]
* @property {Array<{ name: string, binding: string, className: string }>} [workflows]
* @property {import("./wrangler/bindings.js").ExportEntry[]} [exports]
* @property {Array<{ binding: string, platform: string }>} [platformBindings]
* @property {Record<string, unknown>} [assets]
Expand Down Expand Up @@ -171,11 +171,8 @@ export async function packWranglerProject({
claimedBindings.add(name);
};

const kvList = /** @type {Array<{binding?: string, id?: string}>} */ (cfg.kv_namespaces || []);
const kvList = wrapCli(() => parseKvNamespacesFromCfg(cfg, configRel));
for (const kv of kvList) {
if (!kv.binding || !kv.id) throw new CliError("[[kv_namespaces]] entry needs both 'binding' and 'id'");
wrapCli(() => assertValidBindingName(configRel, "[[kv_namespaces]]", kv.binding));
assertNotRuntimeReservedBinding(configRel, "[[kv_namespaces]]", kv.binding);
claimBinding(kv.binding);
bindings[kv.binding] = { type: "kv", id: kv.id };
}
Expand All @@ -195,7 +192,7 @@ export async function packWranglerProject({
const svcList = wrapCli(() => parseServicesFromCfg(cfg, configRel));
for (const svc of svcList) {
claimBinding(svc.binding);
/** @type {{ type: string, service: unknown, entrypoint?: unknown, ns?: unknown }} */
/** @type {{ type: string, service: string, entrypoint?: string, ns?: string }} */
const entry = { type: "service", service: svc.service };
if (svc.entrypoint && svc.entrypoint !== "default") entry.entrypoint = svc.entrypoint;
if (svc.ns) entry.ns = svc.ns;
Expand Down Expand Up @@ -236,6 +233,12 @@ export async function packWranglerProject({
}

const vars = normalizeVars(cfg.vars);
// A present-but-non-table [assets] is a config error, not "no assets": reject
// it before bundling instead of letting asRecord() null it out and silently
// skip assets.
if (cfg.assets != null && asRecord(cfg.assets) == null) {
throw new CliError(`${configRel}: [assets] must be a table`);
}
const outDir = path.join(absProject, ".deploy-dist");
rmSync(outDir, { recursive: true, force: true });

Expand Down
75 changes: 62 additions & 13 deletions lib/wrangler/bindings.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,48 @@ export function parseQueues(queues, configRel = "wrangler config") {
return { producers, consumers };
}

/**
* @param {WranglerConfig} cfg
* @param {string} [configRel]
* @returns {Array<{ binding: string, id: string }>}
*/
export function parseKvNamespacesFromCfg(cfg, configRel = "wrangler config") {
if (cfg.kv_namespaces == null) return [];
if (!Array.isArray(cfg.kv_namespaces)) {
throw new Error(`${configRel}: [[kv_namespaces]] must be an array of tables`);
}
// preview_id and remote are Wrangler `wrangler dev` (local-dev) concepts WDL
// doesn't use; allow them (ignored — only binding/id are forwarded) but reject
// other unknown keys, matching the d1/r2 parsers.
const allowedKeys = new Set(["binding", "id", "preview_id", "remote"]);
/** @type {Array<{ binding: string, id: string }>} */
const out = [];
for (const rawEntry of cfg.kv_namespaces) {
const entry = asRecord(rawEntry);
if (!entry) {
throw new Error(`${configRel}: [[kv_namespaces]] entry must be a table`);
}
const unknownKeys = Object.keys(entry).filter((key) => !allowedKeys.has(key));
if (unknownKeys.length > 0) {
throw new Error(
`${configRel}: [[kv_namespaces]] contains unknown field(s): ${unknownKeys.join(", ")}`
);
}
if (typeof entry.binding !== "string" || !entry.binding.trim()) {
throw new Error(`${configRel}: [[kv_namespaces]] entry needs a non-empty string 'binding'`);
}
assertNotRuntimeReservedBinding(configRel, "[[kv_namespaces]]", entry.binding);
assertValidBindingName(configRel, "[[kv_namespaces]]", entry.binding);
Comment thread
cnluzhang marked this conversation as resolved.
if (typeof entry.id !== "string" || !entry.id.trim()) {
throw new Error(`${configRel}: [[kv_namespaces]] ${entry.binding}: 'id' must be a non-empty string`);
}
// Store trimmed values like the d1/r2 parsers, so a stray "abc " id isn't
// forwarded with whitespace.
out.push({ binding: entry.binding.trim(), id: entry.id.trim() });
}
return out;
}

/**
* @param {WranglerConfig} cfg
* @param {string} [configRel]
Expand Down Expand Up @@ -305,14 +347,13 @@ export function parseR2BucketsFromCfg(cfg, configRel = "wrangler config") {

/**
* `binding` and `service` are validated as non-empty strings; `entrypoint` and
* `ns`, when present, are checked as a JS identifier / admin-acceptable namespace
* (both strings at that point) but stay `unknown` here since the validators are
* not TS type predicates.
* `ns`, when present, are validated as a JS identifier / admin-acceptable
* namespace (both narrowed to string by their type-predicate validators).
* @typedef {object} ServiceBinding
* @property {string} binding
* @property {string} service
* @property {unknown} [entrypoint]
* @property {unknown} [ns]
* @property {string} [entrypoint]
* @property {string} [ns]
*/

/**
Expand Down Expand Up @@ -348,6 +389,10 @@ export function parseServicesFromCfg(cfg, configRel = "wrangler config") {
}
assertNotRuntimeReservedBinding(configRel, "[[services]]", entry.binding);
assertValidBindingName(configRel, "[[services]]", entry.binding);
// Validate and capture in the same block so the predicate narrowing flows
// into the typed entrypoint/ns fields below.
/** @type {string | undefined} */
let entrypoint;
if (entry.entrypoint != null) {
if (!isValidJsIdentifier(entry.entrypoint)) {
throw new Error(
Expand All @@ -359,21 +404,25 @@ export function parseServicesFromCfg(cfg, configRel = "wrangler config") {
`${configRel}: [[services]] ${entry.binding}: entrypoint ${JSON.stringify(entry.entrypoint)} is reserved for runtime-injected entrypoints`
);
}
entrypoint = entry.entrypoint;
}
/** @type {string | undefined} */
let ns;
if (entry.ns != null) {
if (!isAdminAcceptableNs(entry.ns)) {
throw new Error(
`${configRel}: [[services]] ${entry.binding}: ns must match ${NS_PATTERN} or an operator-reserved namespace, got ${JSON.stringify(entry.ns)}`
);
}
ns = entry.ns;
}
/** @type {ServiceBinding} */
const normalized = {
binding: entry.binding,
service: entry.service,
};
if (entry.entrypoint != null) normalized.entrypoint = entry.entrypoint;
if (entry.ns != null) normalized.ns = entry.ns;
if (entrypoint != null) normalized.entrypoint = entrypoint;
if (ns != null) normalized.ns = ns;
out.push(normalized);
}
return out;
Expand All @@ -382,7 +431,7 @@ export function parseServicesFromCfg(cfg, configRel = "wrangler config") {
/**
* @param {WranglerConfig} cfg
* @param {string} [configRel]
* @returns {Array<{ binding: string, className: unknown }>}
* @returns {Array<{ binding: string, className: string }>}
*/
Comment thread
cnluzhang marked this conversation as resolved.
export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") {
if (cfg.durable_objects == null) return [];
Expand All @@ -395,7 +444,7 @@ export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") {
if (!Array.isArray(bindingList)) {
throw new Error(`${configRel}: [[durable_objects.bindings]] must be an array of tables`);
}
/** @type {Set<unknown>} */
/** @type {Set<string>} */
const newClasses = new Set();
const migrations = cfg.migrations == null ? [] : cfg.migrations;
if (!Array.isArray(migrations)) {
Expand Down Expand Up @@ -433,7 +482,7 @@ export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") {
}
}

/** @type {Array<{ binding: string, className: unknown }>} */
/** @type {Array<{ binding: string, className: string }>} */
const out = [];
for (const rawEntry of bindingList) {
const entry = asRecord(rawEntry);
Expand Down Expand Up @@ -471,14 +520,14 @@ export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") {
/**
* @param {WranglerConfig} cfg
* @param {string} [configRel]
* @returns {Array<{ name: string, binding: string, className: unknown }>}
* @returns {Array<{ name: string, binding: string, className: string }>}
*/
export function parseWorkflowsFromCfg(cfg, configRel = "wrangler config") {
if (cfg.workflows == null) return [];
if (!Array.isArray(cfg.workflows)) {
throw new Error(`${configRel}: [[workflows]] must be an array of tables`);
}
/** @type {Array<{ name: string, binding: string, className: unknown }>} */
/** @type {Array<{ name: string, binding: string, className: string }>} */
const out = [];
/** @type {Set<string>} */
const seenNames = new Set();
Expand Down Expand Up @@ -538,7 +587,7 @@ export function parseWorkflowsFromCfg(cfg, configRel = "wrangler config") {

/**
* @typedef {object} ExportEntry
* @property {unknown} entrypoint Either "default" or a validated JS class name.
* @property {string} entrypoint Either "default" or a validated JS class name.
* @property {string[]} allowedCallers
* @property {string} [as]
* @property {string[]} [requiredCallerSecrets]
Expand Down
65 changes: 65 additions & 0 deletions tests/unit/cli-deploy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
parseDurableObjectsFromCfg,
parseExportsFromCfg,
parseJsonc,
parseKvNamespacesFromCfg,
parsePlatformBindingsFromCfg,
parseQueues,
parseR2BucketsFromCfg,
Expand Down Expand Up @@ -472,6 +473,42 @@ test("collectRoutes: accepts strings and { pattern } tables, rejects non-arrays"
);
});

test("parseKvNamespacesFromCfg: validates shape and non-empty string binding/id", () => {
assert.deepEqual(parseKvNamespacesFromCfg({}), []);
assert.deepEqual(parseKvNamespacesFromCfg({ kv_namespaces: [] }), []);
assert.deepEqual(
parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "abc" }] }),
[{ binding: "KV", id: "abc" }]
);
assert.deepEqual(
parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "abc " }] }),
[{ binding: "KV", id: "abc" }]
);
assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: {} }), /must be an array/);
assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [null] }), /entry must be a table/);
assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ id: "x" }] }), /needs a non-empty string 'binding'/);
assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "", id: "x" }] }), /needs a non-empty string 'binding'/);
assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: ["KV"], id: "x" }] }), /needs a non-empty string 'binding'/);
assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV" }] }), /'id' must be a non-empty string/);
assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: 123 }] }), /'id' must be a non-empty string/);
// binding name grammar still enforced
assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "bad-kv", id: "x" }] }), /binding must match/);
// unknown keys (typos) are rejected, like the d1/r2 parsers
assert.throws(
() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "x", bindng: "typo" }] }),
/unknown field\(s\): bindng/
);
// Wrangler's local-dev keys (preview_id, remote) are allowed but ignored
assert.deepEqual(
parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "x", preview_id: "p" }] }),
[{ binding: "KV", id: "x" }]
);
assert.deepEqual(
parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "x", remote: true }] }),
[{ binding: "KV", id: "x" }]
);
});

test("parseServicesFromCfg: parses wrangler [[services]] entries", () => {
assert.deepEqual(parseServicesFromCfg({}), []);
assert.deepEqual(parseServicesFromCfg({ services: [] }), []);
Expand Down Expand Up @@ -1750,6 +1787,34 @@ test("runDeployCommand preserves prototype-shaped binding keys for control valid
}
});

test("runDeployCommand rejects a non-table [assets] before bundling", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-assets-"));
try {
mkdirSync(path.join(dir, "src"), { recursive: true });
writeFileSync(path.join(dir, "src", "index.js"), "export default {}");
writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({
name: "api",
main: "src/index.js",
assets: "public",
}));

let execCalled = false;
await assert.rejects(
() => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], {
env: { ADMIN_TOKEN: "tok" },
execFile: () => {
execCalled = true;
throw new Error("execFile should not be called");
},
}),
{ message: "wrangler.json: [assets] must be a table" }
);
assert.equal(execCalled, false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("runDeployCommand rejects non-object vars before bundling", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-vars-"));
try {
Expand Down