Skip to content

Commit 05e0c79

Browse files
V48 (impl-only): Harden Vercel Sandbox package load without createRequire
Production synthesis failed with undefined.resolve when createRequire was unusable under Next on Vercel. Discover @vercel/sandbox via node_modules/pnpm filesystem, prefer webpackIgnore package import, then file:// ESM entry. Verified host tests, uapi tsc, api build, pipeline-hosts typecheck/jest, and lint.
1 parent 6d2a8ad commit 05e0c79

2 files changed

Lines changed: 183 additions & 49 deletions

File tree

packages/generic-hosts/VercelSandbox/src/__tests__/vercel-sandbox-host.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fs from 'node:fs';
12
import path from 'node:path';
23
import { pathToFileURL } from 'node:url';
34

@@ -6,6 +7,7 @@ import {
67
assertVercelSandboxAuthAvailable,
78
normalizeCreateOptions,
89
resolveVercelSandboxEsmEntryHref,
10+
resolveVercelSandboxPackageRoot,
911
VercelSandboxPipelineHost,
1012
} from '../vercel-sandbox-host';
1113
import type {
@@ -408,6 +410,16 @@ describe('VercelSandboxPipelineHost', () => {
408410
expect(href.includes('index.cjs')).toBe(false);
409411
});
410412

413+
it('discovers @vercel/sandbox from node_modules without createRequire.resolve', () => {
414+
// Live install path used on Vercel /var/task and local monorepo.
415+
const root = resolveVercelSandboxPackageRoot();
416+
expect(root.includes(`${path.sep}@vercel${path.sep}sandbox`)).toBe(true);
417+
expect(fs.existsSync(path.join(root, 'dist', 'index.js'))).toBe(true);
418+
const href = resolveVercelSandboxEsmEntryHref();
419+
expect(href.startsWith('file:')).toBe(true);
420+
expect(href.endsWith('/dist/index.js') || href.endsWith('\\dist\\index.js')).toBe(true);
421+
});
422+
411423
it('accepts access-token auth when OIDC is absent', () => {
412424
const previous = {
413425
VERCEL_TOKEN: process.env.VERCEL_TOKEN,

packages/generic-hosts/VercelSandbox/src/vercel-sandbox-host.ts

Lines changed: 171 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -493,79 +493,201 @@ export function assertVercelSandboxAuthAvailable(
493493
}
494494

495495
/**
496-
* Resolve the pure-ESM entry of `@vercel/sandbox` as a file:// URL.
496+
* Locate the installed `@vercel/sandbox` package root.
497497
*
498-
* Package root resolution under Next/CJS serverless can load `dist/index.cjs`
499-
* → `command.cjs`, which does `require('@workflow/serde')`. That package is
500-
* pure ESM, so Node throws ERR_REQUIRE_ESM on Vercel `/var/task`. The ESM
501-
* graph (`dist/index.js`) imports serde correctly.
498+
* Avoids `createRequire(...).resolve` as the only strategy: Next/webpack can
499+
* leave `createRequire` unusable on Vercel (`undefined.resolve` → immediate
500+
* "Cannot read properties of undefined (reading 'resolve')"). Prefer
501+
* filesystem discovery under cwd `node_modules` (and pnpm layout), then a
502+
* guarded createRequire fallback for local/Jest.
502503
*/
504+
export function resolveVercelSandboxPackageRoot(
505+
resolveId?: (id: string) => string,
506+
): string {
507+
if (resolveId) {
508+
try {
509+
return path.dirname(resolveId('@vercel/sandbox/package.json'));
510+
} catch {
511+
let dir = path.dirname(resolveId('@vercel/sandbox'));
512+
for (let i = 0; i < 8; i++) {
513+
const candidate = path.join(dir, 'package.json');
514+
if (readPackageName(candidate) === '@vercel/sandbox') return dir;
515+
const parent = path.dirname(dir);
516+
if (parent === dir) break;
517+
dir = parent;
518+
}
519+
}
520+
}
521+
522+
const cwd = process.cwd();
523+
const directCandidates = [
524+
path.join(cwd, 'node_modules', '@vercel', 'sandbox'),
525+
path.join(cwd, '..', 'node_modules', '@vercel', 'sandbox'),
526+
// Next standalone / monorepo traces sometimes nest under .next
527+
path.join(cwd, '.next', 'standalone', 'node_modules', '@vercel', 'sandbox'),
528+
path.join(cwd, '.next', 'server', 'node_modules', '@vercel', 'sandbox'),
529+
];
530+
for (const dir of directCandidates) {
531+
if (readPackageName(path.join(dir, 'package.json')) === '@vercel/sandbox') {
532+
return dir;
533+
}
534+
}
535+
536+
// pnpm virtual store: node_modules/.pnpm/@vercel+sandbox@*/node_modules/@vercel/sandbox
537+
for (const base of [cwd, path.join(cwd, '..')]) {
538+
const pnpmRoot = path.join(base, 'node_modules', '.pnpm');
539+
if (!fs.existsSync(pnpmRoot)) continue;
540+
let entries: string[] = [];
541+
try {
542+
entries = fs.readdirSync(pnpmRoot);
543+
} catch {
544+
continue;
545+
}
546+
const matches = entries
547+
.filter((name) => name.startsWith('@vercel+sandbox@'))
548+
.sort()
549+
.reverse();
550+
for (const name of matches) {
551+
const dir = path.join(pnpmRoot, name, 'node_modules', '@vercel', 'sandbox');
552+
if (readPackageName(path.join(dir, 'package.json')) === '@vercel/sandbox') {
553+
return dir;
554+
}
555+
}
556+
}
557+
558+
const fromRequire = tryCreateRequireResolve('@vercel/sandbox/package.json');
559+
if (fromRequire) return path.dirname(fromRequire);
560+
561+
const fromRuntime = tryCreateRequireResolve('@vercel/sandbox');
562+
if (fromRuntime) {
563+
let dir = path.dirname(fromRuntime);
564+
for (let i = 0; i < 8; i++) {
565+
if (readPackageName(path.join(dir, 'package.json')) === '@vercel/sandbox') {
566+
return dir;
567+
}
568+
const parent = path.dirname(dir);
569+
if (parent === dir) break;
570+
dir = parent;
571+
}
572+
}
573+
574+
throw new Error(
575+
'Could not resolve @vercel/sandbox package root for ESM load ' +
576+
`(cwd=${cwd}). Ensure @vercel/sandbox is installed for the server runtime.`,
577+
);
578+
}
579+
503580
export function resolveVercelSandboxEsmEntryHref(
504-
resolveId: (id: string) => string = defaultResolvePackageId,
581+
resolveId?: (id: string) => string,
505582
): string {
506583
const root = resolveVercelSandboxPackageRoot(resolveId);
507584
const esmIndex = path.join(root, 'dist', 'index.js');
585+
// Only enforce on-disk presence for live discovery (not unit-test injectors).
586+
if (!resolveId && !fs.existsSync(esmIndex)) {
587+
throw new Error(
588+
`@vercel/sandbox ESM entry missing at ${esmIndex} (package root ${root}).`,
589+
);
590+
}
508591
return pathToFileURL(esmIndex).href;
509592
}
510593

511-
export function resolveVercelSandboxPackageRoot(
512-
resolveId: (id: string) => string = defaultResolvePackageId,
513-
): string {
594+
function readPackageName(packageJsonPath: string): string | null {
514595
try {
515-
return path.dirname(resolveId('@vercel/sandbox/package.json'));
596+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { name?: string };
597+
return typeof pkg.name === 'string' ? pkg.name : null;
516598
} catch {
517-
// Incomplete exports / hoisting: resolve a runtime file and walk up until
518-
// package.json name is @vercel/sandbox.
519-
let dir = path.dirname(resolveId('@vercel/sandbox'));
520-
for (let i = 0; i < 8; i++) {
521-
const candidate = path.join(dir, 'package.json');
599+
return null;
600+
}
601+
}
602+
603+
function tryCreateRequireResolve(id: string): string | null {
604+
try {
605+
const createReq = createRequire as unknown as
606+
| ((filename: string) => NodeRequire)
607+
| undefined;
608+
if (typeof createReq !== 'function') return null;
609+
610+
const anchors = [
611+
path.join(process.cwd(), 'package.json'),
612+
path.join(process.cwd(), 'node_modules', '@vercel', 'sandbox', 'package.json'),
613+
// Prefer a real file when present so createRequire is valid under Next.
614+
path.join(process.cwd(), 'node_modules', '@vercel', 'sandbox', 'dist', 'index.js'),
615+
];
616+
for (const anchor of anchors) {
522617
try {
523-
const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8')) as { name?: string };
524-
if (pkg.name === '@vercel/sandbox') return dir;
618+
const req = createReq(anchor);
619+
if (!req || typeof req.resolve !== 'function') continue;
620+
return req.resolve(id);
525621
} catch {
526-
// keep walking
622+
// try next anchor
527623
}
528-
const parent = path.dirname(dir);
529-
if (parent === dir) break;
530-
dir = parent;
531624
}
532-
throw new Error('Could not resolve @vercel/sandbox package root for ESM load.');
625+
} catch {
626+
// createRequire unavailable in this runtime
533627
}
628+
return null;
534629
}
535630

536-
function defaultResolvePackageId(id: string): string {
537-
// createRequire needs a filepath anchor; cwd package.json works under Next,
538-
// Jest, and monorepo package tests without import.meta.
539-
const req = createRequire(path.join(process.cwd(), 'package.json'));
540-
return req.resolve(id);
631+
function extractSandboxFactory(loaded: {
632+
Sandbox?: SandboxFactory;
633+
default?: unknown;
634+
}): SandboxFactory | null {
635+
if (loaded.Sandbox?.create) return loaded.Sandbox;
636+
if (loaded.default && typeof loaded.default === 'object') {
637+
const defaultExport = loaded.default as {
638+
Sandbox?: SandboxFactory;
639+
create?: SandboxFactory['create'];
640+
};
641+
if (defaultExport.Sandbox?.create) return defaultExport.Sandbox;
642+
if (typeof defaultExport.create === 'function') {
643+
return defaultExport as SandboxFactory;
644+
}
645+
}
646+
return null;
541647
}
542648

649+
/**
650+
* Load Sandbox.create without hitting the dual-package CJS hazard.
651+
*
652+
* 1) webpackIgnore import of package name (Node uses the ESM "import" export)
653+
* 2) file:// import of dist/index.js after filesystem root discovery
654+
*
655+
* Plain `import('@vercel/sandbox')` without webpackIgnore can be rewritten to
656+
* CJS require → command.cjs → require(@workflow/serde) → ERR_REQUIRE_ESM.
657+
*/
543658
export async function loadVercelSandboxFactory(): Promise<SandboxFactory> {
544-
const esmHref = resolveVercelSandboxEsmEntryHref();
545-
// webpackIgnore: do not rewrite to a CJS interop chunk; load Node-native ESM.
546-
const loaded = (await import(
547-
/* webpackIgnore: true */
548-
esmHref
549-
)) as {
550-
Sandbox?: SandboxFactory;
551-
default?: unknown;
552-
};
659+
const errors: string[] = [];
553660

554-
let Sandbox = loaded.Sandbox;
555-
if (!Sandbox?.create && loaded.default && typeof loaded.default === 'object') {
556-
const defaultExport = loaded.default as { Sandbox?: SandboxFactory; create?: SandboxFactory['create'] };
557-
if (defaultExport.Sandbox?.create) {
558-
Sandbox = defaultExport.Sandbox;
559-
} else if (typeof defaultExport.create === 'function') {
560-
Sandbox = defaultExport as SandboxFactory;
561-
}
661+
try {
662+
const loaded = (await import(
663+
/* webpackIgnore: true */
664+
'@vercel/sandbox'
665+
)) as { Sandbox?: SandboxFactory; default?: unknown };
666+
const factory = extractSandboxFactory(loaded);
667+
if (factory) return factory;
668+
errors.push('package import missing Sandbox.create');
669+
} catch (error) {
670+
const message = error instanceof Error ? error.message : String(error);
671+
errors.push(`package import: ${message.slice(0, 240)}`);
562672
}
563-
if (!Sandbox?.create) {
564-
throw new Error(
565-
`@vercel/sandbox did not expose Sandbox.create() (ESM entry ${esmHref}).`,
566-
);
673+
674+
try {
675+
const esmHref = resolveVercelSandboxEsmEntryHref();
676+
const loaded = (await import(
677+
/* webpackIgnore: true */
678+
esmHref
679+
)) as { Sandbox?: SandboxFactory; default?: unknown };
680+
const factory = extractSandboxFactory(loaded);
681+
if (factory) return factory;
682+
errors.push(`file import missing Sandbox.create (${esmHref})`);
683+
} catch (error) {
684+
const message = error instanceof Error ? error.message : String(error);
685+
errors.push(`file import: ${message.slice(0, 240)}`);
567686
}
568-
return Sandbox;
687+
688+
throw new Error(
689+
`@vercel/sandbox could not be loaded for host dispatch. ${errors.join(' | ')}`,
690+
);
569691
}
570692

571693
async function readCommandOutput(

0 commit comments

Comments
 (0)