Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fix-complete-skip-skills-prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"wrangler": patch
---

Fix `wrangler complete` printing the AI skills prompt into shell completion output

Previously, running `eval "$(wrangler complete zsh)"` (or any other shell) would fail with errors like `zsh: command not found: --install-skills` because the interactive AI agent skills installation prompt was included in the completion script output.

The skills prompt is now skipped when running `wrangler complete`, so the generated completion script is clean and can be sourced correctly.
7 changes: 7 additions & 0 deletions .changeset/fix-workflow-schedules-cron-mapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"wrangler": patch
---

Fix Workflows `schedules` deploy payload to match the control plane API

When deploying a Workflow with a `schedules` binding property, Wrangler sent the cron expressions as a list of strings. The Workflows API expects a list of objects of the form `{ cron: string }`, so the request was rejected. Wrangler now maps each configured cron expression to `{ cron }` (normalizing a single string or an array) when building the request. The user-facing config still accepts a string or an array of strings.
7 changes: 7 additions & 0 deletions .changeset/vite-plugin-websocket-upgrade-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cloudflare/vite-plugin": patch
---

Forward response headers from the Worker on WebSocket upgrade responses

Headers set on a `new Response(null, { status: 101, webSocket, headers })` returned from the Worker are now propagated to the upgrade response sent to the browser during `vite dev`. Previously the headers were dropped, so cookies (`Set-Cookie`) and custom headers (`X-*`) on WebSocket handshake responses were invisible client-side — even though they were delivered correctly by `wrangler dev`.
125 changes: 125 additions & 0 deletions packages/vite-plugin-cloudflare/src/__tests__/websockets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,4 +205,129 @@ describe("handleWebSocket", () => {
});
expect(`${url}`).toBe(`http://127.0.0.1:${port}/`);
});

// https://github.com/cloudflare/workers-sdk/issues/10390
test("does not forward framing headers from the Worker response", async ({
expect,
}) => {
// Defense in depth: even if a Worker returns framing headers, they
// must not leak onto the 101 response (no body, so they're nonsensical).
// Handshake headers (`Sec-WebSocket-*`, `Connection`, `Upgrade`) are
// rejected upstream by miniflare's own validation before reaching the
// forwarding code, so they're not exercised here.
await miniflare.dispose();
miniflare = new Miniflare({
modules: true,
script: `export default {
fetch() {
const [client, server] = Object.values(new WebSocketPair());
server.accept();
const headers = new Headers({
"X-Hello": "testing",
"Transfer-Encoding": "chunked",
"Content-Length": "42",
});
return new Response(null, {
status: 101,
webSocket: client,
headers,
});
}
}`,
});
await listen();

const socket = net.connect(port, "127.0.0.1");
await new Promise<void>((r) => socket.on("connect", r));

const chunks: Buffer[] = [];
socket.on("data", (chunk) => chunks.push(chunk));

socket.write(
"GET / HTTP/1.1\r\n" +
`Host: 127.0.0.1:${port}\r\n` +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" +
"Sec-WebSocket-Version: 13\r\n\r\n"
);

await vi.waitFor(() => {
const raw = Buffer.concat(chunks).toString("utf8");
expect(raw).toContain("HTTP/1.1 101");
expect(raw).toContain("\r\n\r\n");
});

const raw = Buffer.concat(chunks).toString("utf8");
const headerBlock = raw.slice(0, raw.indexOf("\r\n\r\n"));

// Non-excluded header still forwarded.
expect(headerBlock).toContain("x-hello: testing");

// Excluded framing headers must NOT appear on the 101 response.
expect(headerBlock).not.toMatch(/Transfer-Encoding: chunked/i);
expect(headerBlock).not.toMatch(/Content-Length: 42/i);

socket.destroy();
});

// https://github.com/cloudflare/workers-sdk/issues/10390
test("forwards response headers from the Worker on the 101 upgrade response", async ({
expect,
}) => {
// Override the default Miniflare instance with a Worker that returns
// custom headers (including two Set-Cookie entries) on the upgrade
// response.
await miniflare.dispose();
miniflare = new Miniflare({
modules: true,
script: `export default {
fetch() {
const [client, server] = Object.values(new WebSocketPair());
server.accept();
const headers = new Headers({ "X-Hello": "testing" });
headers.append("Set-Cookie", "session=abc; Path=/");
headers.append("Set-Cookie", "theme=dark; Path=/; HttpOnly");
return new Response(null, {
status: 101,
webSocket: client,
headers,
});
}
}`,
});
await listen();

const socket = net.connect(port, "127.0.0.1");
await new Promise<void>((r) => socket.on("connect", r));

const chunks: Buffer[] = [];
socket.on("data", (chunk) => chunks.push(chunk));

socket.write(
"GET / HTTP/1.1\r\n" +
`Host: 127.0.0.1:${port}\r\n` +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" +
"Sec-WebSocket-Version: 13\r\n\r\n"
);

await vi.waitFor(() => {
const raw = Buffer.concat(chunks).toString("utf8");
expect(raw).toContain("HTTP/1.1 101");
expect(raw).toContain("\r\n\r\n");
});

const raw = Buffer.concat(chunks).toString("utf8");
const headerBlock = raw.slice(0, raw.indexOf("\r\n\r\n"));

// Fetch `Headers` normalize names to lowercase; `Set-Cookie` keeps its
// canonical casing because it is appended manually via `getSetCookie()`.
expect(headerBlock).toContain("x-hello: testing");
expect(headerBlock).toContain("Set-Cookie: session=abc; Path=/");
expect(headerBlock).toContain("Set-Cookie: theme=dark; Path=/; HttpOnly");

socket.destroy();
});
});
70 changes: 70 additions & 0 deletions packages/vite-plugin-cloudflare/src/websockets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,27 @@ export function handleWebSocket(
) {
const nodeWebSocket = new WebSocketServer({ noServer: true });

// Stash Worker 101-response headers keyed by the upgrade request so a single
// persistent `headers` listener can apply them when `ws` emits the upgrade
// response. Matches the pattern in `packages/miniflare/src/index.ts`.
//
// Using `once()` per upgrade is unsafe: if `ws.handleUpgrade` aborts before
// emitting `headers` (e.g. malformed `Sec-WebSocket-Key`/`Sec-WebSocket-
// Version`), the listener stays attached and fires on the next successful
// upgrade with stale headers leaked from the previous Worker response. The
// WeakMap entry is GC'd if the request never completes.
const workerResponseHeaders = new WeakMap<IncomingMessage, Headers>();
nodeWebSocket.on(
"headers",
(responseHeaders: string[], request: IncomingMessage) => {
const extra = workerResponseHeaders.get(request);
workerResponseHeaders.delete(request);
if (extra) {
appendWorkerResponseHeaders(responseHeaders, extra);
}
}
);

httpServer.on(
"upgrade",
async (request: IncomingMessage, socket: Duplex, head: Buffer) => {
Expand Down Expand Up @@ -60,6 +81,14 @@ export function handleWebSocket(
return;
}

// Forward response headers (e.g. Set-Cookie, custom auth headers) from
// the Worker's 101 response onto the upgrade response sent to the
// client. Without this, headers set on a `new Response(null, { status:
// 101, webSocket, headers })` are silently dropped during `vite dev`,
// even though they are delivered correctly by `wrangler dev`.
// See cloudflare/workers-sdk#10390.
workerResponseHeaders.set(request, response.headers);

nodeWebSocket.handleUpgrade(
request,
socket,
Expand All @@ -73,6 +102,47 @@ export function handleWebSocket(
);
}

/**
* Headers that must not be forwarded on the 101 upgrade response — they are
* either part of the WebSocket handshake managed by `ws` or irrelevant on a
* response with no body.
*/
const EXCLUDED_RESPONSE_HEADERS = new Set([
"connection",
"content-length",
"sec-websocket-accept",
"sec-websocket-extensions",
"sec-websocket-protocol",
"transfer-encoding",
"upgrade",
]);

function appendWorkerResponseHeaders(
responseHeaders: string[],
workerHeaders: Headers
) {
// `Set-Cookie` can appear multiple times in a single response.
// `Headers.forEach` collapses them into a single comma-joined value, which
// breaks cookies that themselves contain commas (e.g. Expires).
// `getSetCookie` returns them as a string array.
if (typeof workerHeaders.getSetCookie === "function") {
for (const cookie of workerHeaders.getSetCookie()) {
responseHeaders.push(`Set-Cookie: ${cookie}`);
}
}

workerHeaders.forEach((value, name) => {
const lower = name.toLowerCase();
if (lower === "set-cookie") {
return;
}
if (EXCLUDED_RESPONSE_HEADERS.has(lower)) {
return;
}
responseHeaders.push(`${name}: ${value}`);
});
}

/**
* Matches the origin of a Sandbox SDK preview URL.
* See: https://developers.cloudflare.com/sandbox/concepts/preview-urls/
Expand Down
8 changes: 4 additions & 4 deletions packages/wrangler/src/__tests__/deploy/workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ describe("deploy", () => {
expect(body).toEqual({
script_name: "test-name",
class_name: "MyWorkflow",
schedules: "0 * * * *",
schedules: [{ cron: "0 * * * *" }],
});
return HttpResponse.json(
createFetchResult({ id: "mock-new-workflow-id" })
Expand Down Expand Up @@ -375,7 +375,7 @@ describe("deploy", () => {
expect(body).toEqual({
script_name: "test-name",
class_name: "MyWorkflow",
schedules: ["0 * * * *", "0 9 * * 1"],
schedules: [{ cron: "0 * * * *" }, { cron: "0 9 * * 1" }],
});
return HttpResponse.json(
createFetchResult({ id: "mock-new-workflow-id" })
Expand Down Expand Up @@ -434,7 +434,7 @@ describe("deploy", () => {
script_name: "test-name",
class_name: "MyWorkflow",
limits: { steps: 5000 },
schedules: "*/15 * * * *",
schedules: [{ cron: "*/15 * * * *" }],
});
return HttpResponse.json(
createFetchResult({ id: "mock-new-workflow-id" })
Expand Down Expand Up @@ -818,7 +818,7 @@ describe("deploy", () => {
expect(body).toEqual({
script_name: "my-app-staging",
class_name: "MyWorkflow",
schedules: "0 * * * *",
schedules: [{ cron: "0 * * * *" }],
});
return HttpResponse.json(
createFetchResult({ id: "mock-new-workflow-id" })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,23 @@ describe("register-yargs-command skills integration", () => {

expect(maybeInstallCloudflareSkillsGlobally).toHaveBeenCalledWith(true);
});

test("does not call maybeInstallCloudflareSkillsGlobally for `wrangler complete`", async ({
expect,
}) => {
// `wrangler complete zsh` output is captured by `eval "$(wrangler complete zsh)"`.
// The interactive skills prompt must not appear in that output.
await runWrangler("complete zsh");

expect(maybeInstallCloudflareSkillsGlobally).not.toHaveBeenCalled();
});

test("still calls maybeInstallCloudflareSkillsGlobally when --install-skills is explicit on `wrangler complete`", async ({
expect,
}) => {
// Explicit --install-skills should be honored even for `complete`.
await runWrangler("complete zsh --install-skills");

expect(maybeInstallCloudflareSkillsGlobally).toHaveBeenCalledWith(true);
});
});
1 change: 1 addition & 0 deletions packages/wrangler/src/complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export const completionsCommand = createCommand({
behaviour: {
printBanner: false,
provideConfig: false,
skipSkillsPrompt: true,
},
positionalArgs: ["shell"],
args: {
Expand Down
4 changes: 3 additions & 1 deletion packages/wrangler/src/core/register-yargs-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,9 @@ function createHandler(def: InternalCommandDefinition, argv: string[]) {
await printWranglerBanner();
}

await maybeInstallCloudflareSkillsGlobally(args.installSkills);
if (!def.behaviour?.skipSkillsPrompt || args.installSkills) {
await maybeInstallCloudflareSkillsGlobally(args.installSkills);
}

if (!getWranglerHideBanner()) {
if (def.metadata.deprecated) {
Expand Down
8 changes: 8 additions & 0 deletions packages/wrangler/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,14 @@ export type CommandDefinition<
* @default true
*/
sendMetrics?: boolean;

/**
* Skip the AI coding agent skills installation prompt for this command.
* Set to `true` for commands whose stdout is captured by the shell (e.g. `complete`),
* where interactive prompts would corrupt the output.
* @default false
*/
skipSkillsPrompt?: boolean;
};

/**
Expand Down
7 changes: 6 additions & 1 deletion packages/wrangler/src/triggers/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,12 @@ export default async function triggersDeploy(
script_name: scriptName,
class_name: workflow.class_name,
...(workflow.limits && { limits: workflow.limits }),
...(workflow.schedules && { schedules: workflow.schedules }),
...(workflow.schedules && {
schedules: (Array.isArray(workflow.schedules)
? workflow.schedules
: [workflow.schedules]
).map((cron) => ({ cron })),
}),
}),
headers: {
"Content-Type": "application/json",
Expand Down
Loading