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
7 changes: 7 additions & 0 deletions .changeset/fifty-teeth-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@workflow/sveltekit": patch
"@workflow/builders": patch
"@workflow/nitro": patch
---

Fix Nitro and SvelteKit build race conditions and make writing debug file atomic
54 changes: 37 additions & 17 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, join, relative, resolve } from 'node:path';
import { promisify } from 'node:util';
import chalk from 'chalk';
Expand Down Expand Up @@ -176,31 +177,46 @@ export abstract class BaseBuilder {

/**
* Writes debug information to a JSON file for troubleshooting build issues.
* Executes whenever called, regardless of environment variables.
* Uses atomic write (temp file + rename) to prevent race conditions when
* multiple builds run concurrently.
*/
private async writeDebugFile(
outfile: string,
debugData: object,
merge?: boolean
): Promise<void> {
const targetPath = `${outfile}.debug.json`;
let existing = {};

try {
let existing = {};
if (merge) {
existing = JSON.parse(
await readFile(`${outfile}.debug.json`, 'utf8').catch(() => '{}')
);
try {
const content = await readFile(targetPath, 'utf8');
existing = JSON.parse(content);
} catch (e) {
// File doesn't exist yet or is corrupted - start fresh.
// Don't log error for ENOENT (file not found) as that's expected on first run.
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn('Error reading debug file, starting fresh:', e);
}
}
}
await writeFile(
`${outfile}.debug.json`,
JSON.stringify(
{
...existing,
...debugData,
},
null,
2
)

const mergedData = JSON.stringify(
{
...existing,
...debugData,
},
null,
2
);

// Write atomically: write to temp file, then rename.
// rename() is atomic on POSIX systems and provides best-effort atomicity on Windows.
// Prevents race conditions where concurrent builds read partially-written files.
const tempPath = `${targetPath}.${randomUUID()}.tmp`;
await writeFile(tempPath, mergedData);
await rename(tempPath, targetPath);
} catch (error: unknown) {
console.warn('Failed to write debug file:', error);
}
Expand Down Expand Up @@ -561,7 +577,11 @@ export const POST = workflowEntrypoint(workflowCode);`;
const outputDir = dirname(outfile);
await mkdir(outputDir, { recursive: true });

await writeFile(outfile, workflowFunctionCode);
// Atomic write: write to temp file then rename to prevent
// file watchers from reading partial file during write
const tempPath = `${outfile}.${randomUUID()}.tmp`;
await writeFile(tempPath, workflowFunctionCode);
await rename(tempPath, outfile);
return;
}

Expand Down
26 changes: 26 additions & 0 deletions packages/builders/src/build-queue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Creates a build queue that serializes async tasks to prevent race conditions.
*
* When rapid file changes trigger multiple builds, this ensures they run
* sequentially rather than concurrently, avoiding issues like:
* - Partial file reads during writes
* - Corrupted merge operations
* - Duplicate/conflicting builds
*
* @example
* const enqueue = createBuildQueue();
*
* // These will run sequentially, not concurrently
* enqueue(() => builder.build());
* enqueue(() => builder.build());
*/
export function createBuildQueue() {
let queue = Promise.resolve();

return (task: () => Promise<void>): Promise<void> => {
queue = queue.then(task).catch((err) => {
console.error(err);
});
return queue;
};
}
6 changes: 2 additions & 4 deletions packages/builders/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ export type {
VercelBuildOutputConfig,
WorkflowConfig,
} from './types.js';
export {
isValidBuildTarget,
validBuildTargets,
} from './types.js';
export { isValidBuildTarget, validBuildTargets } from './types.js';
export { VercelBuildOutputAPIBuilder } from './vercel-build-output-api.js';
export { createBuildQueue } from './build-queue.js';
32 changes: 9 additions & 23 deletions packages/nitro/src/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import type { Plugin as VitePlugin } from 'vite';
import type { ModuleOptions } from './index.js';
import nitroModule from './index.js';
import { workflowTransformPlugin } from '@workflow/rollup';
import { createBuildQueue } from '@workflow/builders';

export function workflow(options?: ModuleOptions): Plugin[] {
let builder: LocalBuilder | undefined;
let builder: LocalBuilder;
Copy link
Contributor

@vercel vercel bot Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The builder variable is declared without initialization and may be undefined when the hotUpdate hook tries to call builder.build(), causing a runtime error: "Cannot read properties of undefined (reading 'build')".

View Details
📝 Patch Details
diff --git a/packages/nitro/src/vite.ts b/packages/nitro/src/vite.ts
index 608e5fc..0437280 100644
--- a/packages/nitro/src/vite.ts
+++ b/packages/nitro/src/vite.ts
@@ -8,7 +8,7 @@ import nitroModule from './index.js';
 import { workflowRollupPlugin } from './rollup.js';
 
 export function workflow(options?: ModuleOptions): Plugin[] {
-  let builder: LocalBuilder;
+  let builder: LocalBuilder | undefined;
 
   // Build queue to serialize builds and prevent race conditions
   // when rapid file changes trigger concurrent hotUpdate calls.
@@ -87,7 +87,9 @@ export function workflow(options?: ModuleOptions): Plugin[] {
           content = await read();
         } catch {
           // File might have been deleted - trigger rebuild to update generated routes
-          await enqueue(() => builder.build());
+          if (builder) {
+            await enqueue(() => builder.build());
+          }
           return;
         }
 
@@ -102,7 +104,9 @@ export function workflow(options?: ModuleOptions): Plugin[] {
         }
 
         console.log('Workflow file changed, rebuilding...');
-        await enqueue(() => builder.build());
+        if (builder) {
+          await enqueue(() => builder.build());
+        }
         // Let Vite handle the normal HMR for the changed file
         return;
       },

Analysis

Uninitialized builder variable called unconditionally in hotUpdate hook

What fails: The builder variable in packages/nitro/src/vite.ts is declared without initialization (line 11: let builder: LocalBuilder), conditionally initialized only when nitro.options.dev === true (line 37), and then called unconditionally in the hotUpdate hook at lines 90 and 105 without null checks. This causes TypeError: Cannot read properties of undefined (reading 'build') if builder is never initialized.

How to reproduce: Configure a Vite + Nitro setup where nitro.options.dev is false during dev server execution, then modify a TypeScript/JavaScript file containing workflow directives. The hotUpdate hook will attempt to call builder.build() on an undefined variable.

Result: Runtime error - "Workflow build failed: TypeError: Cannot read properties of undefined (reading 'build')". The error is caught and logged by the enqueue function's catch handler, but the file change is not properly processed.

Expected: The hotUpdate hook should safely handle the case when builder is undefined, either by checking if builder exists before calling methods on it, or by ensuring the type annotation accurately reflects that it may be undefined.

Note: This issue was introduced in commit 30eb016 which refactored the code to remove previous defensive null checks (if (builder) { await enqueue(...) }). While in typical usage nitro.options.dev is true during vite dev, defensive programming practices suggest checking for potentially-undefined values before use, especially when the type annotation (LocalBuilder | undefined) previously reflected this possibility.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't think this is true because if we're in dev then nitro.options.dev is true and hence LocalBuilder is defined.

This is only true if somehow hotUpdate is triggered and we're not in a dev environment which isn't possible.

const enqueue = createBuildQueue();

return [
workflowTransformPlugin() as VitePlugin,
Expand All @@ -28,7 +30,7 @@ export function workflow(options?: ModuleOptions): Plugin[] {
},
},
// 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.
// 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 () => {
Expand All @@ -44,7 +46,7 @@ export function workflow(options?: ModuleOptions): Plugin[] {
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.
// 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
Expand All @@ -61,7 +63,7 @@ export function workflow(options?: ModuleOptions): Plugin[] {
},
// TODO: Move this to @workflow/vite or something since this is vite specific
async hotUpdate(options: HotUpdateOptions) {
const { file, server, read } = options;
const { file, read } = options;

// Check if this is a TS/JS file that might contain workflow directives
const jsTsRegex = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
Expand All @@ -75,15 +77,8 @@ export function workflow(options?: ModuleOptions): Plugin[] {
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: '*',
});
console.log('Workflow file changed, rebuilding...');
await enqueue(() => builder.build());
return;
}

Expand All @@ -97,17 +92,8 @@ export function workflow(options?: ModuleOptions): Plugin[] {
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: '*',
});

await enqueue(() => builder.build());
// Let Vite handle the normal HMR for the changed file
return;
},
Expand Down
34 changes: 7 additions & 27 deletions packages/sveltekit/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
import type { HotUpdateOptions, Plugin } from 'vite';
import { SvelteKitBuilder } from './builder.js';
import { workflowTransformPlugin } from '@workflow/rollup';
import { createBuildQueue } from '@workflow/builders';

export function workflowPlugin(): Plugin[] {
let builder: SvelteKitBuilder;
const enqueue = createBuildQueue();

return [
workflowTransformPlugin(),
{
name: 'workflow:sveltekit',

configResolved() {
builder = new SvelteKitBuilder();
},

// TODO: Move this to @workflow/vite or something since this is vite specific
async hotUpdate(options: HotUpdateOptions) {
const { file, server, read } = options;
const { file, read } = options;

// Check if this is a TS/JS file that might contain workflow directives
const jsTsRegex = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
Expand All @@ -30,14 +31,8 @@ export function workflowPlugin(): Plugin[] {
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);
}
console.log('Workflow file changed, rebuilding...');
await enqueue(() => builder.build());
return;
}

Expand All @@ -51,23 +46,8 @@ export function workflowPlugin(): Plugin[] {
return;
}

// Rebuild everything - simpler and more reliable than tracking individual files
console.log('Workflow file changed, regenerating routes...');
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({
type: 'full-reload',
path: '*',
});

console.log('Workflow file changed, rebuilding...');
await enqueue(() => builder.build());
// Let Vite handle the normal HMR for the changed file
return;
},
Expand Down
Loading