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
7 changes: 7 additions & 0 deletions packages/adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,13 @@ only — and may export a function, so what a project declares can be computed.
parse, a key whose shape is wrong, or a schedule that cannot be run fails the build with the file
named: the alternative is a deployment whose jobs quietly never fire.

Build tools can import `findProjectConfigFile` and `readProjectConfigFile` from
`@stayingupwind/adapter/project-config`. Both use this same filename order and accept additional
host filenames before `vercel.json`. The finder returns the selected filename without executing
it. The reader returns `{ file, value }`, preserving settings for the caller to validate; an absent
file returns `{ file: undefined, value: {} }`. TypeScript evaluation runs project code, so callers
must provide the same isolation and time limits they use for a build.

## A large application, split across Functions

A Function has a size limit, and every byte of its code is compiled when it starts. An application
Expand Down
6 changes: 4 additions & 2 deletions packages/adapter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@
],
"type": "module",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./project-config": "./src/project-config.ts"
},
"publishConfig": {
"exports": {
".": "./dist/index.js"
".": "./dist/index.js",
"./project-config": "./dist/project-config.js"
}
},
"scripts": {
Expand Down
17 changes: 12 additions & 5 deletions packages/adapter/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,27 @@ const EXTERNAL = [
async function main(): Promise<void> {
await rm(OUT_DIR, { recursive: true, force: true });
const build = await rolldown({
input: path.join(ROOT, 'src/index.ts'),
input: {
index: path.join(ROOT, 'src/index.ts'),
'project-config': path.join(ROOT, 'src/project-config.ts'),
},
platform: 'node',
external: [...EXTERNAL, /^node:/u],
});
const { output } = await build.write({
dir: OUT_DIR,
format: 'esm',
entryFileNames: 'index.js',
entryFileNames: '[name].js',
chunkFileNames: '[name]-[hash].js',
});
await build.close();
const [entry] = output;
const bytes = Buffer.byteLength(entry.code);
console.log(`dist/index.js: ${String(bytes)} bytes, ${String(output.length)} chunk(s)`);
for (const entry of output) {
if (entry.type !== 'chunk' || !entry.isEntry) continue;
const bytes = Buffer.byteLength(entry.code);
console.log(
`dist/${entry.fileName}: ${String(bytes)} bytes, ${String(output.length)} chunk(s)`,
);
}
// These templates contain the private host/cache packages. An installed adapter copies the
// finished JavaScript; a customer never has to install the host's workspace-only packages.
const nativeDir = path.join(OUT_DIR, 'native');
Expand Down
2 changes: 1 addition & 1 deletion packages/adapter/src/node-original-load-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ function rowCurrent(own: Stored, id: string, source?: OriginalNodeModeSource): R
}
function explicitCommonJs(value: Uint8Array): boolean {
try {
const text = new TextDecoder('utf-8', { fatal: true }).decode(value);
const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(value);
const metadata: unknown = JSON.parse(text);
if (
typeof metadata !== 'object' ||
Expand Down
32 changes: 24 additions & 8 deletions packages/adapter/src/project-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,18 +293,38 @@ async function importConfigModule(file: string, absolute: string): Promise<unkno
}

/** Where the platform's configuration for this build is, if the project wrote one. */
async function findConfigFile(
export async function findProjectConfigFile(
projectDir: string,
names: readonly string[],
hostConfigFiles: readonly string[] = [],
): Promise<string | undefined> {
for (const name of names) {
for (const name of configFileOrder(hostConfigFiles)) {
if (await exists(path.join(projectDir, name))) {
return name;
}
}
return undefined;
}

/** The selected file's unvalidated value, for tools that read additional project settings. */
export interface ProjectConfigFile {
readonly file: string | undefined;
readonly value: unknown;
}

/** Read the same single file as the adapter, including Node-evaluated TypeScript exports. */
export async function readProjectConfigFile(
projectDir: string,
hostConfigFiles: readonly string[] = [],
): Promise<ProjectConfigFile> {
const file = await findProjectConfigFile(projectDir, hostConfigFiles);
if (file === undefined) return { file: undefined, value: {} };
const absolute = path.join(projectDir, file);
const value = file.endsWith('.ts')
? await importConfigModule(file, absolute)
: parseJsonFile(file, await readFile(absolute, 'utf8'), file.endsWith('.jsonc'));
return { file, value };
}

/**
* Read the project's configuration, or the empty one when it wrote none.
*
Expand All @@ -316,14 +336,10 @@ export async function readProjectConfig(
projectDir: string,
hostConfigFiles: readonly string[] = [],
): Promise<ProjectConfig> {
const file = await findConfigFile(projectDir, configFileOrder(hostConfigFiles));
const { file, value } = await readProjectConfigFile(projectDir, hostConfigFiles);
if (file === undefined) {
return EMPTY_PROJECT_CONFIG;
}
const absolute = path.join(projectDir, file);
const value = file.endsWith('.ts')
? await importConfigModule(file, absolute)
: parseJsonFile(file, await readFile(absolute, 'utf8'), file.endsWith('.jsonc'));
if (file === VERCEL_CONFIG) {
const parsed = projectConfigSchema.safeParse(value);
if (!parsed.success) {
Expand Down
44 changes: 43 additions & 1 deletion packages/adapter/test/project-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,56 @@ import { promisify } from 'node:util';

import { afterEach, describe, expect, it } from 'vitest';

import { CONFIG_FILES, configFileOrder, readProjectConfig } from '../src/project-config.ts';
import {
CONFIG_FILES,
configFileOrder,
findProjectConfigFile,
readProjectConfig,
readProjectConfigFile,
} from '../src/project-config.ts';

function projectWith(file: string, value: unknown): string {
const dir = mkdtempSync(path.join(tmpdir(), 'upwind-project-config-'));
writeFileSync(path.join(dir, file), JSON.stringify(value));
return dir;
}

describe('the project configuration file reader', () => {
it('preserves settings the adapter does not interpret', async () => {
const value = { crons: 'validated by the caller', services: { api: { root: 'api' } } };
const dir = projectWith('upwind.json', value);

await expect(readProjectConfigFile(dir)).resolves.toStrictEqual({ file: 'upwind.json', value });
});

it('selects a TypeScript file without executing it', async () => {
const dir = projectWith('upwind.json', {});
writeFileSync(path.join(dir, 'upwind.config.ts'), "throw new Error('executed');");

await expect(findProjectConfigFile(dir)).resolves.toBe('upwind.config.ts');
await expect(readProjectConfigFile(dir)).rejects.toThrow('executed');
});

it('reads host filenames and asynchronous exports through the same loader', async () => {
const dir = projectWith('vercel.json', { ignored: true });
writeFileSync(
path.join(dir, 'custom.config.ts'),
'export default async () => ({ services: { api: { root: "api" } } });',
);

await expect(readProjectConfigFile(dir, ['custom.config.ts'])).resolves.toStrictEqual({
file: 'custom.config.ts',
value: { services: { api: { root: 'api' } } },
});
});

it('returns an empty value when no configuration exists', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'upwind-empty-config-'));

await expect(readProjectConfigFile(dir)).resolves.toStrictEqual({ file: undefined, value: {} });
});
});

describe("a project's deploy settings", () => {
it('are read from its own file, whole', async () => {
const deploy = {
Expand Down
Loading