From 5490eaabee1759e3a0bcedc58a32e840e2152ab4 Mon Sep 17 00:00:00 2001 From: "soleil.colza" Date: Thu, 29 Oct 2026 23:55:44 +0900 Subject: [PATCH 1/3] Expose project configuration file loading for build tools --- packages/adapter/README.md | 7 ++++ packages/adapter/package.json | 6 ++- packages/adapter/scripts/build.ts | 17 ++++++--- packages/adapter/src/project-config.ts | 32 ++++++++++++---- packages/adapter/test/project-config.test.ts | 40 +++++++++++++++++++- 5 files changed, 86 insertions(+), 16 deletions(-) diff --git a/packages/adapter/README.md b/packages/adapter/README.md index 52316998c..13f080486 100644 --- a/packages/adapter/README.md +++ b/packages/adapter/README.md @@ -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 diff --git a/packages/adapter/package.json b/packages/adapter/package.json index 377947b6c..9ef7895cd 100644 --- a/packages/adapter/package.json +++ b/packages/adapter/package.json @@ -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": { diff --git a/packages/adapter/scripts/build.ts b/packages/adapter/scripts/build.ts index 85fa2073c..77359305e 100644 --- a/packages/adapter/scripts/build.ts +++ b/packages/adapter/scripts/build.ts @@ -41,20 +41,27 @@ const EXTERNAL = [ async function main(): Promise { 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'); diff --git a/packages/adapter/src/project-config.ts b/packages/adapter/src/project-config.ts index 3c9848ade..c2d1b599b 100644 --- a/packages/adapter/src/project-config.ts +++ b/packages/adapter/src/project-config.ts @@ -293,11 +293,11 @@ async function importConfigModule(file: string, absolute: string): Promise { - for (const name of names) { + for (const name of configFileOrder(hostConfigFiles)) { if (await exists(path.join(projectDir, name))) { return name; } @@ -305,6 +305,26 @@ async function findConfigFile( 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 { + 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. * @@ -316,14 +336,10 @@ export async function readProjectConfig( projectDir: string, hostConfigFiles: readonly string[] = [], ): Promise { - 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) { diff --git a/packages/adapter/test/project-config.test.ts b/packages/adapter/test/project-config.test.ts index 53d73cd4d..4f3c7b329 100644 --- a/packages/adapter/test/project-config.test.ts +++ b/packages/adapter/test/project-config.test.ts @@ -8,7 +8,13 @@ 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-')); @@ -16,6 +22,38 @@ function projectWith(file: string, value: unknown): string { 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 = { From f5b36206bac5a8510392e9ec3ee5a5f0ee31a677 Mon Sep 17 00:00:00 2001 From: "soleil.colza" Date: Thu, 29 Oct 2026 23:57:45 +0900 Subject: [PATCH 2/3] Set the decoder BOM default explicitly for portable types --- packages/adapter/src/node-original-load-mode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adapter/src/node-original-load-mode.ts b/packages/adapter/src/node-original-load-mode.ts index 6f3608cb9..6e22980ca 100644 --- a/packages/adapter/src/node-original-load-mode.ts +++ b/packages/adapter/src/node-original-load-mode.ts @@ -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' || From ce6a57567ac07d44e7bc76f16d928c4ff7eeccdd Mon Sep 17 00:00:00 2001 From: "soleil.colza" Date: Fri, 30 Oct 2026 00:05:18 +0900 Subject: [PATCH 3/3] Separate configuration test assertions for lint --- packages/adapter/test/project-config.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/adapter/test/project-config.test.ts b/packages/adapter/test/project-config.test.ts index 4f3c7b329..ceccf9b20 100644 --- a/packages/adapter/test/project-config.test.ts +++ b/packages/adapter/test/project-config.test.ts @@ -26,12 +26,14 @@ 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'); }); @@ -42,6 +44,7 @@ describe('the project configuration file reader', () => { 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' } } }, @@ -50,6 +53,7 @@ describe('the project configuration file reader', () => { 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: {} }); }); });