diff --git a/packages/kernel-utils/CHANGELOG.md b/packages/kernel-utils/CHANGELOG.md index 56c5e767b0..88f885e05e 100644 --- a/packages/kernel-utils/CHANGELOG.md +++ b/packages/kernel-utils/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add an optional `required` field to `MethodSchema` (mirroring `required` on object `JsonSchema`) naming which arguments are required, and a `{ required }` option on `methodArgsToStruct` that validates unlisted arguments as optional, so a method's argument schema can faithfully represent the optional trailing arguments its guard already allows ([#958](https://github.com/MetaMask/ocap-kernel/pull/958)) - Add `getLibp2pRelayHome()` to the `./nodejs` exports, returning the libp2p relay's bookkeeping directory (default `~/.libp2p-relay`, overridable via `$LIBP2P_RELAY_HOME`) — kept separate from `$OCAP_HOME` so one relay can serve daemons with different OCAP_HOMEs ([#952](https://github.com/MetaMask/ocap-kernel/pull/952)) - `startRelay()` accepts an optional `publicIp` that is fed to libp2p's `appendAnnounce`, so a relay running on a NAT-backed host can announce its publicly-reachable IPv4 alongside its bound NIC addresses ([#952](https://github.com/MetaMask/ocap-kernel/pull/952)) +- Add `replaceNodeEnvPlugin` to the `./vite-plugins` exports, a Rolldown transform plugin that inlines `process.env.NODE_ENV` as a string literal (configurable via `{ value }`, defaulting to `'production'`) in any module referencing it — used by `bundleVat` in place of a build-config `define`, so vats that pull in libraries like immer resolve the reference at bundle time ([#967](https://github.com/MetaMask/ocap-kernel/pull/967)) ## [0.5.0] diff --git a/packages/kernel-utils/src/vite-plugins/bundle-vat.ts b/packages/kernel-utils/src/vite-plugins/bundle-vat.ts index 49e2c5c3fc..6ed2ff6de1 100644 --- a/packages/kernel-utils/src/vite-plugins/bundle-vat.ts +++ b/packages/kernel-utils/src/vite-plugins/bundle-vat.ts @@ -7,6 +7,7 @@ import { build } from 'vite'; import type { VatBundle } from '../vat-bundle.ts'; import { exportMetadataPlugin } from './export-metadata-plugin.ts'; +import { replaceNodeEnvPlugin } from './replace-node-env-plugin.ts'; import { stripCommentsPlugin } from './strip-comments-plugin.ts'; export type { VatBundle } from '../vat-bundle.ts'; @@ -88,12 +89,6 @@ export async function bundleVat(sourcePath: string): Promise { result = await build({ configFile: false, logLevel: 'silent', - // TODO: Remove this define block and add a process shim to VatSupervisor - // workerEndowments instead. This injects into ALL bundles but is only needed - // for libraries like immer that check process.env.NODE_ENV. - define: { - 'process.env.NODE_ENV': JSON.stringify('production'), - }, build: { write: false, minify: false, @@ -109,6 +104,7 @@ export async function bundleVat(sourcePath: string): Promise { }, plugins: [ removeDynamicImportsPlugin(), + replaceNodeEnvPlugin(), stripCommentsPlugin(), metadataPlugin, ], diff --git a/packages/kernel-utils/src/vite-plugins/index.ts b/packages/kernel-utils/src/vite-plugins/index.ts index e4e15fc068..ce52fb000f 100644 --- a/packages/kernel-utils/src/vite-plugins/index.ts +++ b/packages/kernel-utils/src/vite-plugins/index.ts @@ -4,6 +4,8 @@ import { bundleVat } from './bundle-vat.ts'; export { bundleVat, removeDynamicImportsPlugin } from './bundle-vat.ts'; export type { VatBundle } from './bundle-vat.ts'; +export { replaceNodeEnvPlugin } from './replace-node-env-plugin.ts'; +export type { ReplaceNodeEnvPluginOptions } from './replace-node-env-plugin.ts'; type VatEntry = { /** Absolute path to the vat source file */ diff --git a/packages/kernel-utils/src/vite-plugins/replace-node-env-plugin.test.ts b/packages/kernel-utils/src/vite-plugins/replace-node-env-plugin.test.ts new file mode 100644 index 0000000000..b10a581bcf --- /dev/null +++ b/packages/kernel-utils/src/vite-plugins/replace-node-env-plugin.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; + +import { replaceNodeEnvPlugin } from './replace-node-env-plugin.ts'; + +type TransformFn = ( + code: string, + id: string, +) => { code: string; map: null } | null; + +describe('replaceNodeEnvPlugin', () => { + const plugin = replaceNodeEnvPlugin(); + const transform = plugin.transform as TransformFn; + + it.each([ + ['bare reference', `process.env.NODE_ENV`, `"production"`], + [ + 'reference in a conditional', + `if (process.env.NODE_ENV !== 'production') { warn(); }`, + `if ("production" !== 'production') { warn(); }`, + ], + [ + 'multiple references in one file', + `const a = process.env.NODE_ENV;\nconst b = process.env.NODE_ENV;`, + `const a = "production";\nconst b = "production";`, + ], + [ + 'reference surrounded by other code', + `const x = 1;\nexport const mode = process.env.NODE_ENV;\nconst y = 2;`, + `const x = 1;\nexport const mode = "production";\nconst y = 2;`, + ], + ])( + 'replaces %s with the "production" string literal', + (_name, code, expected) => { + const result = transform(code, 'test.ts'); + expect(result).toStrictEqual({ code: expected, map: null }); + }, + ); + + it('injects a quoted string literal, not the bare word', () => { + const result = transform(`const mode = process.env.NODE_ENV;`, 'test.ts'); + expect(result?.code).toContain(`"production"`); + expect(result?.code).not.toContain(`process.env.NODE_ENV`); + expect(result?.code).not.toContain(`= production;`); + }); + + it.each([ + ['no process reference', 'const x = 1;'], + ['unrelated process.env key', `const port = process.env.PORT;`], + ['empty code', ''], + ])('returns null for %s', (_name, code) => { + expect(transform(code, 'test.ts')).toBeNull(); + }); + + it('returns null when the substring matches but the word boundary does not', () => { + const code = `const x = process.env.NODE_ENVIRONMENT;`; + expect(transform(code, 'test.ts')).toBeNull(); + }); + + it('inlines a configured value instead of the default', () => { + const devTransform = replaceNodeEnvPlugin({ value: 'development' }) + .transform as TransformFn; + const result = devTransform( + `const mode = process.env.NODE_ENV;`, + 'test.ts', + ); + expect(result).toStrictEqual({ + code: `const mode = "development";`, + map: null, + }); + }); +}); diff --git a/packages/kernel-utils/src/vite-plugins/replace-node-env-plugin.ts b/packages/kernel-utils/src/vite-plugins/replace-node-env-plugin.ts new file mode 100644 index 0000000000..5245c1caff --- /dev/null +++ b/packages/kernel-utils/src/vite-plugins/replace-node-env-plugin.ts @@ -0,0 +1,51 @@ +import type { Plugin as RolldownPlugin } from 'rolldown'; + +export type ReplaceNodeEnvPluginOptions = { + /** + * The value to inline for `process.env.NODE_ENV`. Defaults to `'production'`. + */ + value?: string; +}; + +/** + * A Rolldown plugin that inlines `process.env.NODE_ENV` as a string literal in + * any module that references it. + * + * This replaces the former build-config `define` (issue #812). Libraries such + * as immer branch on `process.env.NODE_ENV`, and vats have no `process` global + * at runtime, so the reference must be resolved at bundle time. The plugin + * auto-detects the need: modules without the reference are left untouched. + * + * This is a textual replacement (the same class of approach as the sibling + * {@link removeDynamicImportsPlugin}) and handles the dotted + * `process.env.NODE_ENV` form, consistent with the `define` it replaces. + * + * @param options - Plugin options. + * @param options.value - The value to inline for `process.env.NODE_ENV`. + * Defaults to `'production'`. + * @returns A Rolldown plugin. + */ +export function replaceNodeEnvPlugin({ + value = 'production', +}: ReplaceNodeEnvPluginOptions = {}): RolldownPlugin { + const replacement = JSON.stringify(value); + return { + name: 'ocap-kernel:replace-node-env', + transform(code) { + if (!code.includes('process.env.NODE_ENV')) { + return null; + } + + const transformed = code.replace( + /\bprocess\.env\.NODE_ENV\b/gu, + replacement, + ); + + if (transformed === code) { + return null; + } + + return { code: transformed, map: null }; + }, + }; +}