Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 115 additions & 1 deletion packages/void/src/plugin-framework.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import MagicString from 'magic-string';
import { dirname } from 'pathslash';
import { defaultServerConditions, type Plugin } from 'vite';
import { defaultServerConditions, normalizePath, type Plugin } from 'vite';
import { type ApplicationAnalysis } from './application-analysis.ts';
import {
ensureWranglerCompat,
Expand Down Expand Up @@ -34,16 +36,125 @@ import {
createBareVoidRedirectPlugin,
createCloudflareBuiltinsExternalPlugin,
} from './plugin-worker-environment.ts';
import { isClientEnvKey } from './shared/env.ts';
import { injectSvelteKitBuildHooks, injectSvelteKitHooks } from './sveltekit-hooks-transform.ts';

const handlerFile = fileURLToPath(import.meta.resolve('#self/handler'));
const envModuleFile = fileURLToPath(import.meta.resolve('#self/_env'));
const envPublicFile = fileURLToPath(import.meta.resolve('#self/env'));
// Packages Void depends on are installed for code in this directory.
const voidDir = dirname(fileURLToPath(import.meta.url));

const ASTRO_SERVER_ENTRY = '@astrojs/cloudflare/entrypoints/server';
const ASTRO_SCOPED_SERVER_ENTRY = '\0void:astro-server-entry';

/**
* A define value as Vite writes it into code (its handleDefineValue): a string is code, anything
* else is JSON.
*/
function defineValue(value: unknown): string {
if (value === undefined) {
return 'undefined';
}
return typeof value === 'string' ? value : JSON.stringify(value);
}

/** The real path, so a symlinked id (`preserveSymlinks`) or a Windows short name still matches. */
function realPath(file: string): string {
try {
return normalizePath(realpathSync.native(file));
} catch {
return normalizePath(file);
}
}

const IMPORT_META_ENV = 'import.meta.env';

function isNameChar(char: string | undefined): boolean {
return (
char !== undefined &&
((char >= 'a' && char <= 'z') ||
(char >= 'A' && char <= 'Z') ||
(char >= '0' && char <= '9') ||
char === '_' ||
char === '$')
);
}

/**
* Where `import.meta.env` stands on its own: not inside a longer name, and not a property like
* `a.import.meta.env`.
*/
export function findImportMetaEnv(code: string): Array<number> {
const starts: Array<number> = [];
let index = code.indexOf(IMPORT_META_ENV);
while (index !== -1) {
const before = code[index - 1];
if (
!isNameChar(before) &&
before !== '.' &&
!isNameChar(code[index + IMPORT_META_ENV.length])
) {
starts.push(index);
}
index = code.indexOf(IMPORT_META_ENV, index + IMPORT_META_ENV.length);
}
return starts;
}

// In a build, Astro copies each private env value (from `.env` files or the build shell) into
// any server module that reads `import.meta.env` as a whole and has that variable's name anywhere
// in its text, comments included. `void/env` reads the whole object to pick its `VITE_*` values,
// so before Astro sees that module, swap the read for those values, built as Vite's define builds
// them. Astro then finds no `import.meta.env` there and copies nothing.
export function createAstroPublicEnvPlugin(file: string = envPublicFile): Plugin {
const envPublicPath = realPath(file);
let astroBuildEnv: Record<string, unknown> = {};
return {
name: 'void:astro-public-env',
apply: 'build',
enforce: 'pre',
configResolved(resolvedConfig) {
// Keep the shared object, not a copy: Void's env plugin adds schema defaults to it in its
// own configResolved hook, and Vite runs those hooks concurrently.
astroBuildEnv = resolvedConfig.env;
},
transform: {
filter: { id: /env-public\.mjs$/ },
handler(code, id) {
if (this.environment?.name === 'client' || realPath(id) !== envPublicPath) {
return;
}
// Build the object as Vite's define does: the env values, then each define of an
// `import.meta.env.*` key, which wins and keeps its value as code.
const values: Record<string, unknown> = {};
for (const [key, value] of Object.entries(astroBuildEnv)) {
if (isClientEnvKey(key)) {
values[key] = JSON.stringify(value);
}
}
for (const [key, value] of Object.entries(this.environment.config.define ?? {})) {
const name = key.slice('import.meta.env.'.length);
if (key.startsWith('import.meta.env.') && isClientEnvKey(name)) {
values[name] = value;
}
}
const object = `{${Object.keys(values)
.sort()
.map((key) => `${JSON.stringify(key)}: ${defineValue(values[key])}`)
.join(', ')}}`;
const ms = new MagicString(code);
for (const start of findImportMetaEnv(code)) {
ms.overwrite(start, start + IMPORT_META_ENV.length, `(${object})`);
}
return ms.hasChanged()
? { code: ms.toString(), map: ms.generateMap({ hires: true }) }
: undefined;
},
},
};
}

export function createFrameworkPlugins(input: {
readonly localD1: ReturnType<typeof resolveLocalD1Target>;
readonly root: string;
Expand Down Expand Up @@ -310,6 +421,8 @@ export default {
}
: undefined;

const astroPublicEnvPlugin = detected.name === 'astro' ? createAstroPublicEnvPlugin() : undefined;

// SvelteKit builds its server in Vite's Node `ssr` environment, and the adapter's Worker runs
// that output as it is. For PostgreSQL and MySQL, void/db's driver then fails in workerd: pg
// loads pg-cloudflare's Node stub, and CommonJS requires of Node built-ins become
Expand Down Expand Up @@ -389,6 +502,7 @@ export default {
wranglerSyncPlugin,
...(nuxtVite8WebSocketCompatPlugin ? [nuxtVite8WebSocketCompatPlugin] : []),
...(astroServerEntryPlugin ? [astroServerEntryPlugin] : []),
...(astroPublicEnvPlugin ? [astroPublicEnvPlugin] : []),
svelteKitHooksInjectPlugin,
...(svelteKitWorkerBuildPlugin ? [svelteKitWorkerBuildPlugin] : []),
...(svelteKitD1CasingBuildPlugin ? [svelteKitD1CasingBuildPlugin] : []),
Expand Down
124 changes: 123 additions & 1 deletion packages/void/test/integration/astro.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { execFileSync } from 'node:child_process';
import { once } from 'node:events';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import type { AddressInfo } from 'node:net';
import { join } from 'node:path';
import { PGlite } from '@electric-sql/pglite';
Expand Down Expand Up @@ -345,3 +353,117 @@ export default defineScheduled(async () => {});
}
});
});

describe('playground/astro build', () => {
// Astro copies a private value (from .env or the build shell) into a module that reads Vite's env
// object as a whole and has the variable's name anywhere in its text. Each name occurs in
// `void/env`: STRIPE_KEY in its documentation, the others in its code.
const LOCAL_ENV = {
STRIPE_KEY: 'sk_local_stripe_canary',
registry: 'sk_local_registry_canary',
getClientEnvValues: 'sk_local_function_canary',
};
// Set only in the build shell, as a CI secret would be.
const SHELL_ENV = { validatedCache: 'sk_shell_cache_canary' };
const ORIGIN = 'https://build.example.com';
const DEFINED = 'https://define.example.com';
let playground: IsolatedPlayground;
const astro = () => join(playground.root, 'node_modules/astro/bin/astro.mjs');
const env = () => ({
...process.env,
ASTRO_DISABLE_UPDATE_CHECK: 'true',
ASTRO_PREVIEW_BACKGROUND: 'false',
VOID_INSPECTOR_PORT: 'false',
});

beforeAll(() => {
playground = createIsolatedPlayground(ROOT, PLAYGROUND);
const root = playground.root;
writeFileSync(
join(root, '.env'),
Object.entries(LOCAL_ENV)
.map(([name, value]) => `${name}=${value}\n`)
.join(''),
);
// Astro's own prefix is PUBLIC_, so the build has VITE_ values only when the app adds it.
// Vite also puts each define of an `import.meta.env.*` key in the env object, over its value.
const config = readFileSync(join(root, 'astro.config.mjs'), 'utf8');
expect(config).toContain('vite: {');
writeFileSync(
join(root, 'astro.config.mjs'),
config.replace(
'vite: {',
`vite: {
envPrefix: ['PUBLIC_', 'VITE_'],
define: {
'import.meta.env.VITE_DEFINED': ${JSON.stringify(JSON.stringify(DEFINED))},
'import.meta.env.VITE_OVERRIDDEN': ${JSON.stringify(JSON.stringify(DEFINED))},
},`,
),
);
mkdirSync(join(root, 'src/pages/api'), { recursive: true });
writeFileSync(
join(root, 'src/pages/api/env.ts'),
`import { env } from 'void/env';

export const prerender = false;

export const GET = () =>
Response.json({
stripeKey: env.STRIPE_KEY,
origin: env.VITE_ORIGIN,
defined: env.VITE_DEFINED,
overridden: env.VITE_OVERRIDDEN,
});
`,
);
execFileSync(process.execPath, [astro(), 'build'], {
cwd: root,
stdio: 'pipe',
env: { ...env(), ...SHELL_ENV, VITE_ORIGIN: ORIGIN, VITE_OVERRIDDEN: ORIGIN },
});
});

afterAll(() => {
playground?.cleanup();
});

it('keeps private .env and build-shell values out of the build output', () => {
const dist = join(playground.root, 'dist');
// Local development reads .dev.vars, and a deploy leaves it out of the upload.
const files = readdirSync(dist, { recursive: true, encoding: 'utf8' }).filter(
(file) => !file.endsWith('.dev.vars') && statSync(join(dist, file)).isFile(),
);
expect(files.some((file) => file.endsWith('.mjs'))).toBe(true);
const found = files.flatMap((file) => {
const content = readFileSync(join(dist, file), 'utf8');
return Object.entries({ ...LOCAL_ENV, ...SHELL_ENV })
.filter(([, value]) => content.includes(value))
.map(([name]) => `${name} in ${file}`);
});
expect(found).toEqual([]);
});

it('serves .env values from the Worker env and public values as Vite defines them', async () => {
const server = await startServer(
(port) =>
spawn(
process.execPath,
[astro(), 'preview', '--host', '127.0.0.1', '--port', String(port)],
{ cwd: playground.root, stdio: ['ignore', 'pipe', 'pipe'], env: env() },
),
20_000,
);
try {
const response = await fetch(`${server.base}/api/env`);
expect(await response.json(), server.output().slice(-4000)).toEqual({
stripeKey: LOCAL_ENV.STRIPE_KEY,
origin: ORIGIN,
defined: DEFINED,
overridden: DEFINED,
});
} finally {
await killChild(server.child);
}
});
});
109 changes: 109 additions & 0 deletions packages/void/test/unit/astro-public-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ResolvedConfig } from 'vite';
import { afterEach, describe, expect, it } from 'vite-plus/test';
import { createAstroPublicEnvPlugin, findImportMetaEnv } from '../../src/plugin-framework.ts';

const CODE = 'const values = import.meta.env;\n';
const tempDirs: Array<string> = [];

afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});

/** A real `env-public.mjs` and a symlinked directory that points to it. */
function createEnvPublicFile() {
const root = mkdtempSync(join(tmpdir(), 'void-astro-public-env-'));
tempDirs.push(root);
const real = join(root, 'real');
mkdirSync(real);
const file = join(real, 'env-public.mjs');
writeFileSync(file, CODE);
const linked = join(root, 'linked');
// A junction needs no extra rights on Windows.
symlinkSync(real, linked, 'junction');
return { file, linkedFile: join(linked, 'env-public.mjs'), root };
}

function transform(
file: string,
input: {
id: string;
env?: Record<string, unknown>;
define?: Record<string, unknown>;
environment?: string;
},
) {
const plugin = createAstroPublicEnvPlugin(file);
const configResolved = plugin.configResolved as (config: ResolvedConfig) => void;
configResolved({ env: input.env ?? {} } as ResolvedConfig);
const hook = plugin.transform as {
handler: (this: unknown, code: string, id: string) => { code: string } | undefined;
};
const result = hook.handler.call(
{ environment: { name: input.environment ?? 'ssr', config: { define: input.define } } },
CODE,
input.id,
);
return result?.code;
}

describe('findImportMetaEnv', () => {
it('finds each read of the whole object', () => {
const code = 'a = import.meta.env; b = (import.meta.env).VITE_X; c = import.meta.env.VITE_Y;';
expect(findImportMetaEnv(code)).toEqual([4, 26, 55]);
});

it('skips longer names and property paths', () => {
for (const code of [
'a.import.meta.env',
'$import.meta.env',
'_import.meta.env',
'ximport.meta.env',
'import.meta.envX',
'import.meta.env$',
'import.meta.env_',
'import.meta.env2',
]) {
expect(findImportMetaEnv(code), code).toEqual([]);
}
});
});

describe('void:astro-public-env', () => {
it('replaces the read with VITE_ values, where a define wins and stays code', () => {
const { file } = createEnvPublicFile();
const code = transform(file, {
id: file,
env: { VITE_A: 'from env', VITE_B: 'kept', STRIPE_KEY: 'sk_secret', registry: 'sk_reg' },
define: {
'import.meta.env.VITE_A': '"defined"',
'import.meta.env.VITE_NUMBER': 42,
'import.meta.env.SECRET': '"no"',
'process.env.VITE_OTHER': '"no"',
},
});
expect(code).toBe(
'const values = ({"VITE_A": "defined", "VITE_B": "kept", "VITE_NUMBER": 42});\n',
);
});

it('matches the module through a symlinked path', () => {
const { file, linkedFile } = createEnvPublicFile();
expect(transform(file, { id: linkedFile, env: { VITE_A: 'a' } })).toBe(
'const values = ({"VITE_A": "a"});\n',
);
});

it('leaves the client build and other env-public.mjs files alone', () => {
const { file, root } = createEnvPublicFile();
expect(transform(file, { id: file, environment: 'client' })).toBeUndefined();
const other = join(root, 'other', 'env-public.mjs');
mkdirSync(join(root, 'other'));
writeFileSync(other, CODE);
expect(transform(file, { id: other })).toBeUndefined();
});
});
Loading