diff --git a/.changeset/kind-lark-slim.md b/.changeset/kind-lark-slim.md new file mode 100644 index 000000000000..fd5f29cad9fa --- /dev/null +++ b/.changeset/kind-lark-slim.md @@ -0,0 +1,5 @@ +--- +'@astrojs/node': patch +--- + +Fixes an infinite loop in `resolveClientDir()` when the server entry point is bundled with esbuild or similar tools. The function now throws a descriptive error instead of hanging indefinitely when the expected server directory segment is not found in the file path. diff --git a/packages/integrations/node/src/shared.ts b/packages/integrations/node/src/shared.ts index ddb59459ab05..d52c6879ced4 100644 --- a/packages/integrations/node/src/shared.ts +++ b/packages/integrations/node/src/shared.ts @@ -11,6 +11,10 @@ export const STATIC_HEADERS_FILE = '_headers.json'; * * At build time, we know the relative path between server and client directories. * At runtime, we need to find the actual location based on where the server entry is running. + * + * ## Error + * + * It throws an error if it can't find the directory while walking the parent directories. */ export function resolveClientDir(options: Options) { // options.client and options.server are file:// URLs set at build time @@ -26,7 +30,20 @@ export function resolveClientDir(options: Options) { // We need to find the actual runtime location, not the build-time paths const serverFolder = path.basename(options.server); let serverEntryFolderURL = path.dirname(import.meta.url); + let previous = ''; while (!serverEntryFolderURL.endsWith(serverFolder)) { + // Guard against infinite loop + if (serverEntryFolderURL === previous) { + throw new Error( + `[@astrojs/node] Could not find the server directory "${serverFolder}" ` + + `by walking up from "${import.meta.url}". This can happen when the server ` + + `entry point is bundled into a single file (e.g. with esbuild) so that ` + + `import.meta.url no longer contains the original "${serverFolder}" path segment. ` + + `When bundling the server entry, make sure the output path contains a ` + + `"${serverFolder}" directory segment, or avoid bundling the server entry entirely.`, + ); + } + previous = serverEntryFolderURL; serverEntryFolderURL = path.dirname(serverEntryFolderURL); } diff --git a/packages/integrations/node/test/units/resolve-client-dir.test.js b/packages/integrations/node/test/units/resolve-client-dir.test.js new file mode 100644 index 000000000000..fb2fd710d965 --- /dev/null +++ b/packages/integrations/node/test/units/resolve-client-dir.test.js @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { pathToFileURL } from 'node:url'; +import { resolveClientDir } from '../../dist/shared.js'; + +describe('resolveClientDir', () => { + it('throws a descriptive error when the server folder is not found in the path', () => { + // Use pathToFileURL to build platform-valid file:// URLs. On Windows, + // file URLs require a drive letter (e.g. file:///C:/…); bare + // file:///project/… would cause fileURLToPath() to throw before the + // loop guard is reached. + const root = new URL('project/dist/', pathToFileURL('/')); + const client = new URL('client/', root).href; + const server = new URL('server/', root).href; + + // When import.meta.url (of shared.js) does not contain a "server" segment, + // the while loop should terminate and throw instead of looping forever. + // This simulates what happens when the entry point is bundled with esbuild + // into a path that lacks the expected "server" directory segment. + assert.throws( + () => + resolveClientDir({ + client, + server, + mode: 'middleware', + host: false, + port: 4321, + staticHeaders: false, + bodySizeLimit: 0, + }), + { + message: /Could not find the server directory "server".*bundled into a single file/, + }, + ); + }); +});