diff --git a/.changeset/eager-lands-rhyme.md b/.changeset/eager-lands-rhyme.md new file mode 100644 index 000000000..8bab4c8b6 --- /dev/null +++ b/.changeset/eager-lands-rhyme.md @@ -0,0 +1,5 @@ +--- +"@workflow/nitro": patch +--- + +Add Vite middleware to handle 404s in workflow routes from Nitro and silence undefined unhandled rejections diff --git a/.changeset/five-planets-push.md b/.changeset/five-planets-push.md new file mode 100644 index 000000000..cf4965fa2 --- /dev/null +++ b/.changeset/five-planets-push.md @@ -0,0 +1,5 @@ +--- +"@workflow/sveltekit": patch +--- + +Fix SvelteKit plugin reading deleted files on HMR diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 534ad8790..921a5aed6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -71,6 +71,8 @@ jobs: project-id: "prj_oTgiz3SGX2fpZuM6E0P38Ts8de6d" - name: "sveltekit" project-id: "prj_MqnBLm71ceXGSnm3Fs8i8gBnI23G" + - name: "hono" + project-id: "prj_p0GIEsfl53L7IwVbosPvi9rPSOYW" env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ vars.TURBO_TEAM }} diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index e87bc9348..5076b613e 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -155,7 +155,10 @@ describe('e2e', () => { method: 'POST', body: JSON.stringify({ token: 'invalid' }), }); - expect(res.status).toBe(404); + // NOTE: For Nitro apps (Vite, Hono, etc.) in dev mode, status 404 does some + // unexpected stuff and could return a Vite SPA fallback or can cause a Hono route to hang. + // This is because Nitro passes the 404 requests to the dev server to handle. + expect(res.status).toBeOneOf([404, 422]); body = await res.json(); expect(body).toBeNull(); @@ -579,14 +582,16 @@ describe('e2e', () => { expect(returnValue.cause).toHaveProperty('stack'); expect(typeof returnValue.cause.stack).toBe('string'); - // Known issue: SvelteKit dev mode has incorrect source map mappings for bundled imports. + // Known issue: vite-based frameworks dev mode has incorrect source map mappings for bundled imports. // esbuild with bundle:true inlines helpers.ts but source maps incorrectly map to 99_e2e.ts // This works correctly in production and other frameworks. // TODO: Investigate esbuild source map generation for bundled modules - const isSvelteKitDevMode = - process.env.APP_NAME === 'sveltekit' && isLocalDeployment(); + const isViteBasedFrameworkDevMode = + (process.env.APP_NAME === 'sveltekit' || + process.env.APP_NAME === 'vite') && + isLocalDeployment(); - if (!isSvelteKitDevMode) { + if (!isViteBasedFrameworkDevMode) { // Stack trace should include frames from the helper module (helpers.ts) expect(returnValue.cause.stack).toContain('helpers.ts'); } diff --git a/packages/nitro/src/builders.ts b/packages/nitro/src/builders.ts index e9944be1d..3f8b62dc9 100644 --- a/packages/nitro/src/builders.ts +++ b/packages/nitro/src/builders.ts @@ -63,10 +63,21 @@ export class LocalBuilder extends BaseBuilder { inputFiles, }); + const webhookRouteFile = join(this.#outDir, 'webhook.mjs'); + await this.createWebhookBundle({ - outfile: join(this.#outDir, 'webhook.mjs'), + outfile: webhookRouteFile, bundle: false, }); + + // Post-process the generated file to wrap with SvelteKit request converter + let webhookRouteContent = await readFile(webhookRouteFile, 'utf-8'); + + // NOTE: This is a workaround to avoid crashing in local dev when context isn't set for waitUntil() + webhookRouteContent = `process.on('unhandledRejection', (reason) => { if (reason !== undefined) console.error('Unhandled rejection detected', reason); }); +${webhookRouteContent}`; + + await writeFile(webhookRouteFile, webhookRouteContent); } } diff --git a/packages/nitro/src/vite.ts b/packages/nitro/src/vite.ts index 74b923378..f21025022 100644 --- a/packages/nitro/src/vite.ts +++ b/packages/nitro/src/vite.ts @@ -1,10 +1,13 @@ import type { Nitro } from 'nitro/types'; -import type { Plugin } from 'vite'; +import type { HotUpdateOptions, Plugin } from 'vite'; +import { LocalBuilder } from './builders.js'; import type { ModuleOptions } from './index.js'; import nitroModule from './index.js'; import { workflowRollupPlugin } from './rollup.js'; export function workflow(options?: ModuleOptions): Plugin[] { + let builder: LocalBuilder | undefined; + return [ workflowRollupPlugin(), { @@ -18,9 +21,96 @@ export function workflow(options?: ModuleOptions): Plugin[] { ...options, _vite: true, }; + if (nitro.options.dev) { + builder = new LocalBuilder(nitro); + } return nitroModule.setup(nitro); }, }, + // NOTE: This is a workaround because Nitro passes the 404 requests to the dev server to handle. + // For workflow routes, we override to send an empty body to prevent Hono/Vite's SPA fallback. + configureServer(server) { + // Add middleware to intercept 404s on workflow routes before Vite's SPA fallback + return () => { + server.middlewares.use((req, res, next) => { + // Only handle workflow webhook routes + if (!req.url?.startsWith('/.well-known/workflow/v1/')) { + return next(); + } + + // Wrap writeHead to ensure we send empty body for 404s + const originalWriteHead = res.writeHead; + res.writeHead = function (this: typeof res, ...args: any[]) { + const statusCode = typeof args[0] === 'number' ? args[0] : 200; + + // NOTE: Workaround because Nitro passes 404 requests to the vite to handle. + // Causes `webhook route with invalid token` test to fail. + // For 404s on workflow routes, ensure we're sending the right headers + if (statusCode === 404) { + // Set content-length to 0 to prevent Vite from overriding + res.setHeader('Content-Length', '0'); + } + + // @ts-expect-error - Complex overload signature + return originalWriteHead.apply(this, args); + } as any; + + next(); + }); + }; + }, + // TODO: Move this to @workflow/vite or something since this is vite specific + async hotUpdate(options: HotUpdateOptions) { + const { file, server, read } = options; + + // Check if this is a TS/JS file that might contain workflow directives + const jsTsRegex = /\.(ts|tsx|js|jsx|mjs|cjs)$/; + if (!jsTsRegex.test(file)) { + return; + } + + // Read the file to check for workflow/step directives + let content: string; + try { + content = await read(); + } catch { + // File might have been deleted - trigger rebuild to update generated routes + console.log('Workflow file deleted, rebuilding...'); + if (builder) { + await builder.build(); + } + // NOTE: Might be too aggressive + server.ws.send({ + type: 'full-reload', + path: '*', + }); + return; + } + + const useWorkflowPattern = /^\s*(['"])use workflow\1;?\s*$/m; + const useStepPattern = /^\s*(['"])use step\1;?\s*$/m; + + if ( + !useWorkflowPattern.test(content) && + !useStepPattern.test(content) + ) { + return; + } + + // Trigger full reload - this will cause Nitro's dev:reload hook to fire, + // which will rebuild workflows and update routes + console.log('Workflow file changed, rebuilding...'); + if (builder) { + await builder.build(); + } + server.ws.send({ + type: 'full-reload', + path: '*', + }); + + // Let Vite handle the normal HMR for the changed file + return; + }, }, ]; } diff --git a/packages/sveltekit/src/plugin.ts b/packages/sveltekit/src/plugin.ts index 0859bfb83..b9dc13d19 100644 --- a/packages/sveltekit/src/plugin.ts +++ b/packages/sveltekit/src/plugin.ts @@ -113,7 +113,22 @@ export function workflowPlugin(options?: WorkflowPluginOptions): Plugin { } // Read the file to check for workflow/step directives - const content = await read(); + let content: string; + try { + content = await read(); + } catch { + // File might have been deleted - trigger rebuild to update generated routes + console.log('Workflow file deleted, regenerating routes...'); + try { + await builder.build(); + } catch (buildError) { + // Build might fail if files are being deleted during test cleanup + // Log but don't crash - the next successful change will trigger a rebuild + console.error('Build failed during file deletion:', buildError); + } + return; + } + const useWorkflowPattern = /^\s*(['"])use workflow\1;?\s*$/m; const useStepPattern = /^\s*(['"])use step\1;?\s*$/m; @@ -123,7 +138,14 @@ export function workflowPlugin(options?: WorkflowPluginOptions): Plugin { // Rebuild everything - simpler and more reliable than tracking individual files console.log('Workflow file changed, regenerating routes...'); - await builder.build(); + try { + await builder.build(); + } catch (buildError) { + // Build might fail if files are being modified/deleted during test cleanup + // Log but don't crash - the next successful change will trigger a rebuild + console.error('Build failed during HMR:', buildError); + return; + } // Trigger full reload of workflow routes server.ws.send({ diff --git a/workbench/example/api/trigger.ts b/workbench/example/api/trigger.ts index bd6ae39b0..aa7e79f03 100644 --- a/workbench/example/api/trigger.ts +++ b/workbench/example/api/trigger.ts @@ -1,10 +1,10 @@ import { getRun, start } from 'workflow/api'; -import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; -import workflowManifest from '../manifest.js'; import { WorkflowRunFailedError, WorkflowRunNotCompletedError, } from 'workflow/internal/errors'; +import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; +import workflowManifest from '../manifest.js'; export async function POST(req: Request) { const url = new URL(req.url); diff --git a/workbench/example/workflows/7_full.ts b/workbench/example/workflows/7_full.ts index 4c0e89467..173c7196e 100644 --- a/workbench/example/workflows/7_full.ts +++ b/workbench/example/workflows/7_full.ts @@ -1,4 +1,4 @@ -import { sleep, createWebhook } from 'workflow'; +import { createWebhook, sleep } from 'workflow'; export async function handleUserSignup(email: string) { 'use workflow'; diff --git a/workbench/hono/server.ts b/workbench/hono/server.ts index 61b22f56c..d4509cc27 100644 --- a/workbench/hono/server.ts +++ b/workbench/hono/server.ts @@ -1,11 +1,11 @@ import { Hono } from 'hono'; import { getHookByToken, getRun, resumeHook, start } from 'workflow/api'; -import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; -import { allWorkflows } from './_workflows.js'; import { WorkflowRunFailedError, WorkflowRunNotCompletedError, } from 'workflow/internal/errors'; +import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; +import { allWorkflows } from './_workflows.js'; const app = new Hono(); @@ -163,8 +163,9 @@ app.post('/api/hook', async ({ req }) => { } catch (error) { console.log('error during getHookByToken', error); // TODO: `WorkflowAPIError` is not exported, so for now - // we'll return 404 assuming it's the "invalid" token test case - return Response.json(null, { status: 404 }); + // we'll return 422 assuming it's the "invalid" token test case + // NOTE: Need to return 422 because Nitro passes 404 requests to the dev server to handle. + return Response.json(null, { status: 422 }); } await resumeHook(hook.token, { diff --git a/workbench/nextjs-turbopack/app/api/trigger/route.ts b/workbench/nextjs-turbopack/app/api/trigger/route.ts index d6d9a30cc..f9b8d5ef4 100644 --- a/workbench/nextjs-turbopack/app/api/trigger/route.ts +++ b/workbench/nextjs-turbopack/app/api/trigger/route.ts @@ -1,10 +1,10 @@ import { getRun, start } from 'workflow/api'; -import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; -import { allWorkflows } from '@/_workflows'; import { WorkflowRunFailedError, WorkflowRunNotCompletedError, } from 'workflow/internal/errors'; +import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; +import { allWorkflows } from '@/_workflows'; export async function POST(req: Request) { const url = new URL(req.url); diff --git a/workbench/nextjs-webpack/app/api/duplicate-case/route.ts b/workbench/nextjs-webpack/app/api/duplicate-case/route.ts new file mode 100644 index 000000000..b30a7e1f5 --- /dev/null +++ b/workbench/nextjs-webpack/app/api/duplicate-case/route.ts @@ -0,0 +1,11 @@ +// NOTE: This route isn't needed/ever used, we're just +// using it because webpack relies on esbuild's tree shaking + +import { start } from 'workflow/api'; +import { addTenWorkflow } from '@/workflows/98_duplicate_case'; + +export async function GET(_: Request) { + const run = await start(addTenWorkflow, [10]); + const result = await run.returnValue; + return Response.json({ result }); +} diff --git a/workbench/nextjs-webpack/app/api/trigger/route.ts b/workbench/nextjs-webpack/app/api/trigger/route.ts index c0b8c94ec..d1dafb427 100644 --- a/workbench/nextjs-webpack/app/api/trigger/route.ts +++ b/workbench/nextjs-webpack/app/api/trigger/route.ts @@ -1,10 +1,10 @@ import { getRun, start } from 'workflow/api'; -import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; -import { allWorkflows } from '@/_workflows'; import { WorkflowRunFailedError, WorkflowRunNotCompletedError, } from 'workflow/internal/errors'; +import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; +import { allWorkflows } from '@/_workflows'; export async function POST(req: Request) { const url = new URL(req.url); diff --git a/workbench/sveltekit/src/routes/api/trigger/+server.ts b/workbench/sveltekit/src/routes/api/trigger/+server.ts index ab50a6b7f..6492f436d 100644 --- a/workbench/sveltekit/src/routes/api/trigger/+server.ts +++ b/workbench/sveltekit/src/routes/api/trigger/+server.ts @@ -1,11 +1,11 @@ -import { type RequestHandler } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; import { getRun, start } from 'workflow/api'; -import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; -import { allWorkflows } from '$lib/_workflows.js'; import { WorkflowRunFailedError, WorkflowRunNotCompletedError, } from 'workflow/internal/errors'; +import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; +import { allWorkflows } from '$lib/_workflows.js'; export const POST: RequestHandler = async ({ request }) => { const url = new URL(request.url); diff --git a/workbench/vite/routes/api/hook.post.ts b/workbench/vite/routes/api/hook.post.ts index 6578a4af1..ecbdc636c 100644 --- a/workbench/vite/routes/api/hook.post.ts +++ b/workbench/vite/routes/api/hook.post.ts @@ -10,8 +10,9 @@ export default async ({ req }: { req: Request }) => { } catch (error) { console.log('error during getHookByToken', error); // TODO: `WorkflowAPIError` is not exported, so for now - // we'll return 404 assuming it's the "invalid" token test case - return Response.json(null, { status: 404 }); + // we'll return 422 assuming it's the "invalid" token test case + // NOTE: Need to return 422 because Nitro passes 404 requests to the dev server to handle. + return Response.json(null, { status: 422 }); } await resumeHook(hook.token, { diff --git a/workbench/vite/vite.config.ts b/workbench/vite/vite.config.ts index a8b609d6c..78aa31437 100644 --- a/workbench/vite/vite.config.ts +++ b/workbench/vite/vite.config.ts @@ -1,5 +1,5 @@ -import { defineConfig } from 'vite'; import { nitro } from 'nitro/vite'; +import { defineConfig } from 'vite'; import { workflow } from 'workflow/vite'; export default defineConfig({