Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/sixty-plants-shout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/world-local': patch
---

Improve the local queue error message when a Next.js proxy intercepts workflow routes.
34 changes: 33 additions & 1 deletion docs/components/ui/accordion.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { useEffect, useRef } from 'react';
import { ChevronDownIcon } from 'lucide-react';
import { Accordion as AccordionPrimitive } from 'radix-ui';
import type * as React from 'react';
Expand All @@ -9,7 +10,38 @@ import { cn } from '@/lib/utils';
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
const ref = useRef<HTMLDivElement | null>(null);

useEffect(() => {
const syncHashTarget = () => {
const hash = window.location.hash.slice(1);
if (!hash || !ref.current) return;

const target = document.getElementById(decodeURIComponent(hash));
if (!target || !ref.current.contains(target)) return;

const item = target.closest<HTMLElement>('[data-slot="accordion-item"]');
const trigger = item?.querySelector<HTMLElement>(
'[data-slot="accordion-trigger"]'
);

if (item?.dataset.state !== 'open') {
trigger?.click();
}

requestAnimationFrame(() => {
target.scrollIntoView({ block: 'start' });
});
};

syncHashTarget();
window.addEventListener('hashchange', syncHashTarget);
return () => {
window.removeEventListener('hashchange', syncHashTarget);
};
}, []);

return <AccordionPrimitive.Root data-slot="accordion" ref={ref} {...props} />;
}

function AccordionItem({
Expand Down
8 changes: 5 additions & 3 deletions docs/content/docs/v4/getting-started/next.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,19 @@ To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json
</Accordion>

<Accordion type="single" collapsible>
<AccordionItem value="typescript-intellisense" className="[&_h3]:my-0">
<AccordionItem value="configure-proxy-handler" className="[&_h3]:my-0">
<AccordionTrigger className="text-sm">
### Configure Proxy Handler (if applicable)
<h3 id="configure-proxy-handler">Configure Proxy Handler (if applicable)</h3>
</AccordionTrigger>
<AccordionContent className="[&_p]:my-2">

If your Next.js app has a [proxy handler](https://nextjs.org/docs/app/api-reference/file-conventions/proxy)
(formerly known as "middleware"), you'll need to update the matcher pattern to exclude Workflow's
internal paths to prevent the proxy handler from running on them.

Add `.well-known/workflow/*` to your middleware's exclusion list:
If you see `[local world] Queue operation failed` with `Cannot perform ArrayBuffer.prototype.slice on a detached ArrayBuffer`, your proxy matcher is still intercepting Workflow's internal `POST /.well-known/workflow/v1/flow` request. This is especially easy to miss in Next.js 16, where `proxy.ts` replaced `middleware.ts`.

Add `.well-known/workflow/*` to your matcher exclusion list:

```typescript title="proxy.ts" lineNumbers
import { NextResponse } from "next/server";
Expand Down
8 changes: 5 additions & 3 deletions docs/content/docs/v5/getting-started/next.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,19 @@ To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json
</Accordion>

<Accordion type="single" collapsible>
<AccordionItem value="typescript-intellisense" className="[&_h3]:my-0">
<AccordionItem value="configure-proxy-handler" className="[&_h3]:my-0">
<AccordionTrigger className="text-sm">
### Configure Proxy Handler (if applicable)
<h3 id="configure-proxy-handler">Configure Proxy Handler (if applicable)</h3>
</AccordionTrigger>
<AccordionContent className="[&_p]:my-2">

If your Next.js app has a [proxy handler](https://nextjs.org/docs/app/api-reference/file-conventions/proxy)
(formerly known as "middleware"), you'll need to update the matcher pattern to exclude Workflow's
internal paths to prevent the proxy handler from running on them.

Add `.well-known/workflow/*` to your middleware's exclusion list:
If you see `[local world] Queue operation failed` with `Cannot perform ArrayBuffer.prototype.slice on a detached ArrayBuffer`, your proxy matcher is still intercepting Workflow's internal `POST /.well-known/workflow/v1/flow` request. This is especially easy to miss in Next.js 16, where `proxy.ts` replaced `middleware.ts`.

Add `.well-known/workflow/*` to your matcher exclusion list:

```typescript title="proxy.ts" lineNumbers
import { NextResponse } from "next/server";
Expand Down
27 changes: 27 additions & 0 deletions packages/world-local/src/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ describe('queue timeout re-enqueue', () => {

afterEach(async () => {
await localQueue.close();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

it('createQueueHandler returns 200 with timeoutSeconds in the body', async () => {
Expand Down Expand Up @@ -166,4 +168,29 @@ describe('queue timeout re-enqueue', () => {
// setTimeout should NOT have been called for timeoutSeconds: 0
expect(mockSetTimeout).not.toHaveBeenCalled();
});

it('logs actionable guidance for detached ArrayBuffer proxy failures', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const fetchError = new TypeError('fetch failed');
(fetchError as TypeError & { cause?: unknown }).cause = new TypeError(
'Cannot perform ArrayBuffer.prototype.slice on a detached ArrayBuffer'
);
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(fetchError));

await localQueue.queue('__wkf_step_test' as any, stepPayload);

await vi.waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining(
'[local world] Queue operation failed: detected "Cannot perform ArrayBuffer.prototype.slice on a detached ArrayBuffer"'
),
expect.objectContaining({
queueName: '__wkf_step_test',
runId: 'run_01ABC',
stepId: 'step_01ABC',
originalError: fetchError,
})
);
});
});
});
42 changes: 41 additions & 1 deletion packages/world-local/src/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,30 @@ export type LocalQueue = Queue & {
): void;
};

const DETACHED_ARRAYBUFFER_ERROR =
'Cannot perform ArrayBuffer.prototype.slice on a detached ArrayBuffer';
const PROXY_HANDLER_DOCS_URL =
'https://workflow-sdk.dev/docs/getting-started/next#configure-proxy-handler';

function isDetachedArrayBufferQueueError(error: unknown): boolean {
let current = error;
const visited = new Set<unknown>();

while (current && typeof current === 'object' && !visited.has(current)) {
visited.add(current);
if (
'message' in current &&
typeof current.message === 'string' &&
current.message.includes(DETACHED_ARRAYBUFFER_ERROR)
) {
return true;
}
current = 'cause' in current ? current.cause : undefined;
}

return false;
}

function getQueueRoute(queueName: ValidQueueName): {
pathname: 'flow' | 'step';
prefix: '__wkf_step_' | '__wkf_workflow_';
Expand Down Expand Up @@ -240,7 +264,23 @@ export function createQueue(config: Partial<Config>): LocalQueue {
const isAbortError =
err?.name === 'AbortError' || err?.name === 'ResponseAborted';
if (!isAbortError) {
console.error('[local world] Queue operation failed:', err);
if (isDetachedArrayBufferQueueError(err)) {
console.error(
`[local world] Queue operation failed: detected "${DETACHED_ARRAYBUFFER_ERROR}". ` +
"This usually means a Next.js proxy/middleware consumed Workflow's internal " +
'request before the executor could read it. Exclude `/.well-known/workflow/*` ' +
`from your matcher. See ${PROXY_HANDLER_DOCS_URL}`,
{
queueName,
messageId,
...(runId && { runId }),
...(stepId && { stepId }),
originalError: err,
}
);
} else {
console.error('[local world] Queue operation failed:', err);
}
}
})
.finally(() => {
Expand Down
Loading