Skip to content

Commit c145bf5

Browse files
authored
[codex] Fix detached ArrayBuffer proxy DX (#1985)
* fix(world-local): explain detached ArrayBuffer proxy failures * fix(docs): make proxy handler anchor navigable * fix(docs): open accordions for hash links
1 parent 22b5a12 commit c145bf5

6 files changed

Lines changed: 116 additions & 8 deletions

File tree

‎.changeset/sixty-plants-shout.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@workflow/world-local': patch
3+
---
4+
5+
Improve the local queue error message when a Next.js proxy intercepts workflow routes.

‎docs/components/ui/accordion.tsx‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client';
22

3+
import { useEffect, useRef } from 'react';
34
import { ChevronDownIcon } from 'lucide-react';
45
import { Accordion as AccordionPrimitive } from 'radix-ui';
56
import type * as React from 'react';
@@ -9,7 +10,38 @@ import { cn } from '@/lib/utils';
910
function Accordion({
1011
...props
1112
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
12-
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
13+
const ref = useRef<HTMLDivElement | null>(null);
14+
15+
useEffect(() => {
16+
const syncHashTarget = () => {
17+
const hash = window.location.hash.slice(1);
18+
if (!hash || !ref.current) return;
19+
20+
const target = document.getElementById(decodeURIComponent(hash));
21+
if (!target || !ref.current.contains(target)) return;
22+
23+
const item = target.closest<HTMLElement>('[data-slot="accordion-item"]');
24+
const trigger = item?.querySelector<HTMLElement>(
25+
'[data-slot="accordion-trigger"]'
26+
);
27+
28+
if (item?.dataset.state !== 'open') {
29+
trigger?.click();
30+
}
31+
32+
requestAnimationFrame(() => {
33+
target.scrollIntoView({ block: 'start' });
34+
});
35+
};
36+
37+
syncHashTarget();
38+
window.addEventListener('hashchange', syncHashTarget);
39+
return () => {
40+
window.removeEventListener('hashchange', syncHashTarget);
41+
};
42+
}, []);
43+
44+
return <AccordionPrimitive.Root data-slot="accordion" ref={ref} {...props} />;
1345
}
1446

1547
function AccordionItem({

‎docs/content/docs/v4/getting-started/next.mdx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,17 +75,19 @@ To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json
7575
</Accordion>
7676

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

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

88-
Add `.well-known/workflow/*` to your middleware's exclusion list:
88+
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`.
89+
90+
Add `.well-known/workflow/*` to your matcher exclusion list:
8991

9092
```typescript title="proxy.ts" lineNumbers
9193
import { NextResponse } from "next/server";

‎docs/content/docs/v5/getting-started/next.mdx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,17 +75,19 @@ To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json
7575
</Accordion>
7676

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

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

88-
Add `.well-known/workflow/*` to your middleware's exclusion list:
88+
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`.
89+
90+
Add `.well-known/workflow/*` to your matcher exclusion list:
8991

9092
```typescript title="proxy.ts" lineNumbers
9193
import { NextResponse } from "next/server";

‎packages/world-local/src/queue.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ describe('queue timeout re-enqueue', () => {
4848

4949
afterEach(async () => {
5050
await localQueue.close();
51+
vi.restoreAllMocks();
52+
vi.unstubAllGlobals();
5153
});
5254

5355
it('createQueueHandler returns 200 with timeoutSeconds in the body', async () => {
@@ -166,4 +168,29 @@ describe('queue timeout re-enqueue', () => {
166168
// setTimeout should NOT have been called for timeoutSeconds: 0
167169
expect(mockSetTimeout).not.toHaveBeenCalled();
168170
});
171+
172+
it('logs actionable guidance for detached ArrayBuffer proxy failures', async () => {
173+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
174+
const fetchError = new TypeError('fetch failed');
175+
(fetchError as TypeError & { cause?: unknown }).cause = new TypeError(
176+
'Cannot perform ArrayBuffer.prototype.slice on a detached ArrayBuffer'
177+
);
178+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(fetchError));
179+
180+
await localQueue.queue('__wkf_step_test' as any, stepPayload);
181+
182+
await vi.waitFor(() => {
183+
expect(consoleError).toHaveBeenCalledWith(
184+
expect.stringContaining(
185+
'[local world] Queue operation failed: detected "Cannot perform ArrayBuffer.prototype.slice on a detached ArrayBuffer"'
186+
),
187+
expect.objectContaining({
188+
queueName: '__wkf_step_test',
189+
runId: 'run_01ABC',
190+
stepId: 'step_01ABC',
191+
originalError: fetchError,
192+
})
193+
);
194+
});
195+
});
169196
});

‎packages/world-local/src/queue.ts‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,30 @@ export type LocalQueue = Queue & {
6666
): void;
6767
};
6868

69+
const DETACHED_ARRAYBUFFER_ERROR =
70+
'Cannot perform ArrayBuffer.prototype.slice on a detached ArrayBuffer';
71+
const PROXY_HANDLER_DOCS_URL =
72+
'https://workflow-sdk.dev/docs/getting-started/next#configure-proxy-handler';
73+
74+
function isDetachedArrayBufferQueueError(error: unknown): boolean {
75+
let current = error;
76+
const visited = new Set<unknown>();
77+
78+
while (current && typeof current === 'object' && !visited.has(current)) {
79+
visited.add(current);
80+
if (
81+
'message' in current &&
82+
typeof current.message === 'string' &&
83+
current.message.includes(DETACHED_ARRAYBUFFER_ERROR)
84+
) {
85+
return true;
86+
}
87+
current = 'cause' in current ? current.cause : undefined;
88+
}
89+
90+
return false;
91+
}
92+
6993
function getQueueRoute(queueName: ValidQueueName): {
7094
pathname: 'flow' | 'step';
7195
prefix: '__wkf_step_' | '__wkf_workflow_';
@@ -240,7 +264,23 @@ export function createQueue(config: Partial<Config>): LocalQueue {
240264
const isAbortError =
241265
err?.name === 'AbortError' || err?.name === 'ResponseAborted';
242266
if (!isAbortError) {
243-
console.error('[local world] Queue operation failed:', err);
267+
if (isDetachedArrayBufferQueueError(err)) {
268+
console.error(
269+
`[local world] Queue operation failed: detected "${DETACHED_ARRAYBUFFER_ERROR}". ` +
270+
"This usually means a Next.js proxy/middleware consumed Workflow's internal " +
271+
'request before the executor could read it. Exclude `/.well-known/workflow/*` ' +
272+
`from your matcher. See ${PROXY_HANDLER_DOCS_URL}`,
273+
{
274+
queueName,
275+
messageId,
276+
...(runId && { runId }),
277+
...(stepId && { stepId }),
278+
originalError: err,
279+
}
280+
);
281+
} else {
282+
console.error('[local world] Queue operation failed:', err);
283+
}
244284
}
245285
})
246286
.finally(() => {

0 commit comments

Comments
 (0)