diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 179ee2e..1e577a6 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -21,6 +21,8 @@ jobs: node-version-file: '.nvmrc' - name: Install Dependencies run: pnpm install --frozen-lockfile + - name: Generate Prisma + run: pnpm prisma:generate - name: Lint run: pnpm run lint - name: Test @@ -30,4 +32,3 @@ jobs: with: directory: ./coverage/ token: ${{ secrets.CODECOV_TOKEN }} - diff --git a/src/meta/__tests__/globals-banner.test.ts b/src/meta/__tests__/globals-banner.test.ts new file mode 100644 index 0000000..37d1f31 --- /dev/null +++ b/src/meta/__tests__/globals-banner.test.ts @@ -0,0 +1,68 @@ +import { execFile } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { promisify } from 'node:util'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { GLOBALS_BANNER } from '../build.js'; + +const execFileAsync = promisify(execFile); + +/** + * The banner that `@prisma/client` >= 6.19.0 prepends to its ESM runtime, which esbuild inlines into + * the application bundle verbatim. The assignment to `globalThis` is the part that matters. + */ +const DEPENDENCY_BANNER = [ + 'import * as __banner_node_path from "node:path";', + 'import * as __banner_node_url from "node:url";', + 'const __filename = __banner_node_url.fileURLToPath(import.meta.url);', + "globalThis['__dirname'] = __banner_node_path.dirname(__filename);" +].join('\n'); + +describe('GLOBALS_BANNER', () => { + let outdir: string; + + beforeAll(async () => { + outdir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'globals-banner-')); + }); + + afterAll(async () => { + await fs.promises.rm(outdir, { force: true, recursive: true }); + }); + + it('should define the commonjs globals', () => { + expect(GLOBALS_BANNER).toContain('__dirname'); + expect(GLOBALS_BANNER).toContain('__filename'); + expect(GLOBALS_BANNER).toContain('require'); + }); + + it('should not leave a bundled dependency unable to assign to globalThis', { timeout: 30000 }, async () => { + const esbuild = await import('esbuild'); + const sourceDir = path.join(outdir, 'src'); + await fs.promises.mkdir(sourceDir, { recursive: true }); + await fs.promises.writeFile( + path.join(sourceDir, 'dependency.js'), + `${DEPENDENCY_BANNER}\nexport const value = 'ok';\n` + ); + await fs.promises.writeFile( + path.join(sourceDir, 'entry.js'), + "import { value } from './dependency.js';\nconsole.log(value);\n" + ); + + const outfile = path.join(outdir, 'server.js'); + await esbuild.build({ + banner: { js: GLOBALS_BANNER }, + bundle: true, + entryPoints: [path.join(sourceDir, 'entry.js')], + format: 'esm', + outfile, + platform: 'node' + }); + + // a non-writable property makes the inlined assignment throw, so the bundle dies before this resolves + const { stdout } = await execFileAsync(process.execPath, [outfile]); + expect(stdout.trim()).toBe('ok'); + }); +}); diff --git a/src/meta/build.ts b/src/meta/build.ts index 1774ce4..c67cfb6 100644 --- a/src/meta/build.ts +++ b/src/meta/build.ts @@ -14,6 +14,19 @@ import { swcPlugin } from './plugins/swc.js'; import type { UserConfigOptions } from '../user-config.js'; +/** + * The banner prepended to the production bundle, which backfills the CommonJS globals that some + * dependencies expect to exist at runtime. + * + * These are defined `writable: true` deliberately. Some dependencies ship an esbuild-style banner of + * their own that assigns to `globalThis['__dirname']` (`@prisma/client` >= 6.19.0 does). Once inlined + * into this bundle, that assignment runs in module scope, where a non-writable property makes it throw + * `TypeError: Cannot assign to read only property` instead of failing silently, killing the process + * before the app bootstraps. + */ +export const GLOBALS_BANNER = + "Object.defineProperties(globalThis, { __dirname: { value: import.meta.dirname, writable: true }, __filename: { value: import.meta.filename, writable: true }, require: { value: (await import('module')).createRequire(import.meta.url), writable: true } });"; + export function buildProd({ configFile, verbose @@ -63,7 +76,7 @@ export function buildProd({ await esbuild.build({ banner: { - js: "Object.defineProperties(globalThis, { __dirname: { value: import.meta.dirname, writable: false }, __filename: { value: import.meta.filename, writable: false }, require: { value: (await import('module')).createRequire(import.meta.url), writable: false } });" + js: GLOBALS_BANNER }, bundle: true, define,