fix bad socket file location (#2021)

Co-authored-by: JJ Kasper <jj@jjsweb.site>
This commit is contained in:
Luke Sandberg
2026-05-19 12:11:00 -07:00
committed by GitHub
parent 9d2a9261fd
commit 4cde3b962b
6 changed files with 118 additions and 22 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@workflow/next": patch
---
Move `workflow-socket.json` out of `.next/cache/` so it isn't preserved across Vercel/Turborepo builds, and clean up stale copies at builder boot. Resolves `ECONNREFUSED 127.0.0.1:<port>` failures from the webpack loader when a prior build's socket-info file was restored from build cache. The loader now also annotates connection errors with the port, credentials source, and the file being processed.
+8
View File
@@ -1,7 +1,15 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
},
"[typescript]": { "editor.defaultFormatter": "biomejs.biome" },
"[typescriptreact]": { "editor.defaultFormatter": "biomejs.biome" },
"[javascript]": { "editor.defaultFormatter": "biomejs.biome" },
"[javascriptreact]": { "editor.defaultFormatter": "biomejs.biome" },
"[json]": { "editor.defaultFormatter": "biomejs.biome" },
"[jsonc]": { "editor.defaultFormatter": "biomejs.biome" },
"rust-analyzer.linkedProjects": ["packages/swc-plugin-workflow/Cargo.toml"]
}
+9 -8
View File
@@ -22,7 +22,9 @@ import {
resolve,
} from 'node:path';
import {
cleanupStaleSocketInfoFiles,
createSocketServer,
SOCKET_INFO_FILENAME,
type SocketIO,
type SocketServerConfig,
} from './socket-server.js';
@@ -617,13 +619,13 @@ export async function getNextBuilderDeferred() {
join(workflowGeneratedDir, 'manifest.json'),
];
await Promise.all(
staleArtifactPaths.map((stalePath) =>
rm(stalePath, { recursive: true, force: true })
)
);
await Promise.all([
...staleArtifactPaths.map((stalePath) =>
rm(stalePath, { recursive: true, force: true })
),
cleanupStaleSocketInfoFiles(
join(this.config.workingDir, this.getDistDir())
),
this.removeStaleDeferredTempFiles(flowRouteDir),
this.removeStaleDeferredTempFiles(webhookRouteDir),
]);
@@ -754,8 +756,7 @@ export async function getNextBuilderDeferred() {
return join(
this.config.workingDir,
this.getDistDir(),
'cache',
'workflow-socket.json'
SOCKET_INFO_FILENAME
);
}
+11
View File
@@ -3,6 +3,7 @@ import { access, copyFile, mkdir, stat, writeFile } from 'node:fs/promises';
import { extname, join, resolve } from 'node:path';
import type { WorkflowManifest } from '@workflow/builders';
import Watchpack from 'watchpack';
import { cleanupStaleSocketInfoFiles } from './socket-server.js';
let CachedNextBuilderEager: any;
@@ -24,6 +25,16 @@ export async function getNextBuilderEager() {
class NextBuilder extends BaseBuilderClass {
async build() {
// Eager mode never starts a discovery socket server, so any leftover
// workflow-socket.json is from a previous deferred-mode build and
// would make the webpack loader connect to a dead port.
await cleanupStaleSocketInfoFiles(
join(
this.config.workingDir,
(this.config as { distDir?: string }).distDir || '.next'
)
);
const outputDir = await this.findAppDirectory();
const workflowGeneratedDir = join(outputDir, '.well-known/workflow/v1');
+50 -12
View File
@@ -5,6 +5,7 @@ import { dirname, join, relative } from 'node:path';
import { transform } from '@swc/core';
import {
parseMessage,
SOCKET_INFO_FILENAME,
type SocketMessage,
serializeMessage,
} from './socket-server.js';
@@ -46,6 +47,39 @@ type SocketCredentials = {
authToken: string;
};
/**
* Wrap a TCP connect failure with context about where the connection was
* attempted, where the credentials came from, and which source file was
* being processed when it failed. ECONNREFUSED is the common case here, and
* the message points the user at the most likely root cause (stale
* socket-info file).
*/
function annotateConnectionError(
originalError: unknown,
credentials: SocketCredentials
): Error {
const errorCode =
originalError instanceof Error &&
'code' in originalError &&
typeof (originalError as { code?: unknown }).code === 'string'
? ((originalError as { code: string }).code as string)
: undefined;
const errorMessage =
originalError instanceof Error
? originalError.message
: String(originalError);
const lines = [
`Workflow discovery socket connect failed: ${errorCode ?? errorMessage} (127.0.0.1:${credentials.port})`,
];
const annotated = new Error(lines.join('\n'));
if (originalError instanceof Error) {
(annotated as { cause?: unknown }).cause = originalError;
}
return annotated;
}
const ROUTE_STUB_FILE_MARKER = 'WORKFLOW_ROUTE_STUB_FILE';
const ROUTE_STUB_BUILD_WAIT_TIMEOUT_MS = 120_000;
let pendingDeferredRouteStubBuildPromise: Promise<void> | null = null;
@@ -174,19 +208,13 @@ function getSocketInfoFilePath(): string | null {
// Fallback for worker processes that don't inherit dynamic env updates
// from the process that created the socket server.
const distDir = process.env.WORKFLOW_NEXT_DIST_DIR || '.next';
const cwdFallbackPath = join(
process.cwd(),
distDir,
'cache',
'workflow-socket.json'
);
const cwdFallbackPath = join(process.cwd(), distDir, SOCKET_INFO_FILENAME);
const projectRoot = process.env.WORKFLOW_PROJECT_ROOT;
if (projectRoot) {
const projectRootFallbackPath = join(
projectRoot,
distDir,
'cache',
'workflow-socket.json'
SOCKET_INFO_FILENAME
);
if (existsSync(projectRootFallbackPath)) {
return projectRootFallbackPath;
@@ -287,12 +315,17 @@ async function getSocketClient(): Promise<Socket | null> {
};
const onError = (error: Error) => {
cleanup();
reject(error);
reject(annotateConnectionError(error, socketCredentials));
};
const timeout = setTimeout(() => {
cleanup();
socket.destroy();
reject(new Error('Socket connection timeout'));
reject(
annotateConnectionError(
new Error('Socket connection timeout'),
socketCredentials
)
);
}, 1000);
const cleanup = () => {
clearTimeout(timeout);
@@ -379,7 +412,12 @@ async function createSocketConnection(
const timeout = setTimeout(() => {
cleanup();
socket.destroy();
reject(new Error('Socket connection timeout'));
reject(
annotateConnectionError(
new Error('Socket connection timeout'),
socketCredentials
)
);
}, timeoutMs);
const cleanup = () => {
clearTimeout(timeout);
@@ -394,7 +432,7 @@ async function createSocketConnection(
const onError = (error: Error) => {
cleanup();
socket.destroy();
reject(error);
reject(annotateConnectionError(error, socketCredentials));
};
socket.on('connect', onConnect);
+35 -2
View File
@@ -1,5 +1,5 @@
import { randomBytes } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { createServer, type Server, type Socket } from 'node:net';
import { dirname, join } from 'node:path';
@@ -54,8 +54,41 @@ export interface SocketIO {
getAuthToken(): string;
}
/**
* Filename for the socket-info file.
*/
export const SOCKET_INFO_FILENAME = 'workflow-socket.json';
/**
* Previous filesystem location for the socket-info file. This file lives
* inside `.next/cache/`, which Vercel and Turborepo preserve across builds —
* a stale file from a prior build would cause the loader to attempt to
* connect to a dead port (ECONNREFUSED). The current location is a sibling
* of `cache/` so it isn't preserved.
*
* Exported so the builders can unlink the legacy path at boot, cleaning up
* any leftover file written by older versions of the SDK.
*/
export const LEGACY_SOCKET_INFO_RELATIVE_PATH = join(
'cache',
SOCKET_INFO_FILENAME
);
function getDefaultSocketInfoFilePath(): string {
return join(process.cwd(), '.next', 'cache', 'workflow-socket.json');
return join(process.cwd(), '.next', SOCKET_INFO_FILENAME);
}
/**
* Remove any stale socket-info files at boot.
* @param distDir absolute path to the project's `.next` directory.
*/
export async function cleanupStaleSocketInfoFiles(
distDir: string
): Promise<void> {
await Promise.all([
rm(join(distDir, SOCKET_INFO_FILENAME), { force: true }),
rm(join(distDir, LEGACY_SOCKET_INFO_RELATIVE_PATH), { force: true }),
]);
}
/**