From d4ac36f6f3a23d16b7f974e692b49d1fa1c8c185 Mon Sep 17 00:00:00 2001 From: Mehdi Baaboura Date: Mon, 18 May 2026 03:27:12 +0200 Subject: [PATCH 1/3] feat(network-config): add node response streaming config --- .changeset/streaming-node-config.md | 5 + .../src/detection/frameworks/nestjs.test.ts | 2 + .../src/services/schema-validator.test.ts | 4 +- .../src/services/v4-config-parser.test.ts | 128 ++++++++++++++++-- .../src/services/v4-config-parser.ts | 40 ++++-- .../vercel-build-output-parser.test.ts | 43 +++++- .../services/vercel-build-output-parser.ts | 5 +- packages/network-config/src/v4/index.ts | 6 +- packages/network-config/src/v4/parse.test.ts | 8 +- packages/network-config/src/v4/schema.json | 4 + packages/network-config/src/v4/schema.ts | 4 + packages/network-config/tsconfig.json | 1 + tsconfig.base.json | 1 + 13 files changed, 215 insertions(+), 36 deletions(-) create mode 100644 .changeset/streaming-node-config.md diff --git a/.changeset/streaming-node-config.md b/.changeset/streaming-node-config.md new file mode 100644 index 00000000..5a621db2 --- /dev/null +++ b/.changeset/streaming-node-config.md @@ -0,0 +1,5 @@ +--- +'@gigadrive/network-config': patch +--- + +Add explicit response streaming configuration for Node functions and Vercel Build Output functions. diff --git a/packages/network-config/src/detection/frameworks/nestjs.test.ts b/packages/network-config/src/detection/frameworks/nestjs.test.ts index 7ea97549..5815f034 100644 --- a/packages/network-config/src/detection/frameworks/nestjs.test.ts +++ b/packages/network-config/src/detection/frameworks/nestjs.test.ts @@ -29,6 +29,8 @@ describe('NestJS framework detection', () => { // NestJS has higher priority than Express. expect(result.framework.slug).toBe('nestjs'); expect(result.config.entrypoints[0].path).toBe('dist/main.js'); + expect(result.config.entrypoints[0].streaming).toBe(true); + expect(result.config.routes[0].handler).toBe('SERVERLESS_FUNCTION_STREAMING'); expect(result.config.entrypoints[0].package).toBeUndefined(); }); diff --git a/packages/network-config/src/services/schema-validator.test.ts b/packages/network-config/src/services/schema-validator.test.ts index cea08793..5bb7789e 100644 --- a/packages/network-config/src/services/schema-validator.test.ts +++ b/packages/network-config/src/services/schema-validator.test.ts @@ -37,8 +37,10 @@ describe('SchemaValidator', () => { ) ); + if (result == null) throw new Error('Expected schema validation error to be caught'); + expect(result).toMatchObject({ _tag: 'caught', filePath: '/config.yaml' }); - expect((result as { errors: string[] }).errors.length).toBeGreaterThan(0); + expect(result.errors.length).toBeGreaterThan(0); }); it('should fail when required field is missing', async () => { diff --git a/packages/network-config/src/services/v4-config-parser.test.ts b/packages/network-config/src/services/v4-config-parser.test.ts index 873f224c..f2244ea7 100644 --- a/packages/network-config/src/services/v4-config-parser.test.ts +++ b/packages/network-config/src/services/v4-config-parser.test.ts @@ -8,6 +8,7 @@ import os from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { parse as parseYaml } from 'yaml'; +import type { NormalizedConfig, NormalizedConfigEntrypoint, NormalizedConfigRoute } from '../normalized-config'; import { AVAILABLE_REGIONS } from '../regions'; import { makeTestFs, TestPathLayer } from '../test-utils'; import type { ConfigV4 } from '../v4'; @@ -31,6 +32,29 @@ const withTempFunctionProject = async (run: (projectFolder: string) => Promis } }; +const requireAssets = (result: NormalizedConfig): NonNullable => { + if (result.assets == null) throw new Error('Expected assets to be defined'); + return result.assets; +}; + +const requireAssetPaths = (result: NormalizedConfig): string[] => { + const paths = requireAssets(result).paths; + if (paths == null) throw new Error('Expected asset paths to be defined'); + return paths; +}; + +const requireEntrypoint = (result: NormalizedConfig, index = 0): NormalizedConfigEntrypoint => { + const entrypoint = result.entrypoints[index]; + if (entrypoint == null) throw new Error(`Expected entrypoint at index ${index}`); + return entrypoint; +}; + +const requireRoute = (result: NormalizedConfig, index = 0): NormalizedConfigRoute => { + const route = result.routes[index]; + if (route == null) throw new Error(`Expected route at index ${index}`); + return route; +}; + describe('V4ConfigParser', () => { it('check if example matches schema', () => { const config = loadExample(); @@ -69,7 +93,7 @@ describe('V4ConfigParser', () => { it('getFunctionSettings', () => { const config = loadExample(); - expect(getFunctionSettings('src/index.ts', config as ConfigV4)).toEqual({ + expect(getFunctionSettings('src/index.ts', config as unknown as ConfigV4)).toEqual({ runtime: 'bun-1', memory: 128, max_duration: 15, @@ -81,7 +105,7 @@ describe('V4ConfigParser', () => { }, }); - expect(getFunctionSettings('src/cron.ts', config as ConfigV4)).toEqual({ + expect(getFunctionSettings('src/cron.ts', config as unknown as ConfigV4)).toEqual({ runtime: 'bun-1', memory: 128, max_duration: 15, @@ -93,8 +117,8 @@ describe('V4ConfigParser', () => { }, }); - expect(getFunctionSettings('app/test.ts', config as ConfigV4)).toBeUndefined(); - expect(getFunctionSettings('test/index.ts', config as ConfigV4)).toBeUndefined(); + expect(getFunctionSettings('app/test.ts', config as unknown as ConfigV4)).toBeUndefined(); + expect(getFunctionSettings('test/index.ts', config as unknown as ConfigV4)).toBeUndefined(); }); it('should preserve function includeFiles and excludeFiles as package rules', async () => { @@ -117,13 +141,87 @@ describe('V4ConfigParser', () => { ) ); - expect(result.entrypoints[0].package).toEqual({ + expect(requireEntrypoint(result).package).toEqual({ includeFiles: ['maxmind/**'], excludeFiles: ['**/*.map'], }); }); }); + it('should default Node functions to response streaming', async () => { + await withTempFunctionProject(async (projectFolder) => { + const config: ConfigV4 = { + version: 4, + functions: { + 'dist/main.js': { + runtime: 'node-22', + }, + }, + routes: [{ source: '/*', destination: 'dist/main.js' }], + }; + + const result = await Effect.runPromise( + V4ConfigParser.parse(config, projectFolder).pipe( + Effect.provide(V4ConfigParser.Default), + Effect.provide(NodeContext.layer) + ) + ); + + expect(requireEntrypoint(result).streaming).toBe(true); + expect(requireRoute(result).handler).toBe('SERVERLESS_FUNCTION_STREAMING'); + }); + }); + + it('should allow explicitly disabling function streaming', async () => { + await withTempFunctionProject(async (projectFolder) => { + const config: ConfigV4 = { + version: 4, + functions: { + 'dist/main.js': { + runtime: 'node-22', + streaming: false, + }, + }, + routes: [{ source: '/*', destination: 'dist/main.js' }], + }; + + const result = await Effect.runPromise( + V4ConfigParser.parse(config, projectFolder).pipe( + Effect.provide(V4ConfigParser.Default), + Effect.provide(NodeContext.layer) + ) + ); + + expect(requireEntrypoint(result).streaming).toBe(false); + expect(requireRoute(result).handler).toBe('SERVERLESS_FUNCTION'); + }); + }); + + it('should preserve explicitly enabled function streaming and route handlers', async () => { + await withTempFunctionProject(async (projectFolder) => { + const config: ConfigV4 = { + version: 4, + functions: { + 'dist/main.js': { + runtime: 'node-22', + streaming: true, + }, + }, + routes: [{ source: '/*', destination: 'dist/main.js' }], + }; + + const result = await Effect.runPromise( + V4ConfigParser.parse(config, projectFolder).pipe( + Effect.provide(V4ConfigParser.Default), + Effect.provide(NodeContext.layer) + ) + ); + + expect(requireEntrypoint(result).streaming).toBe(true); + expect(requireRoute(result).handler).toBe('SERVERLESS_FUNCTION_STREAMING'); + }); + }); + it('should normalize scalar includeFiles and excludeFiles into package rule arrays', async () => { await withTempFunctionProject(async (projectFolder) => { const config: ConfigV4 = { @@ -144,7 +242,7 @@ describe('V4ConfigParser', () => { ) ); - expect(result.entrypoints[0].package).toEqual({ + expect(requireEntrypoint(result).package).toEqual({ includeFiles: ['maxmind/**'], excludeFiles: ['**/*.map'], }); @@ -170,7 +268,7 @@ describe('V4ConfigParser', () => { ) ); - expect(result.entrypoints[0].package).toEqual({ + expect(requireEntrypoint(result).package).toEqual({ includeFiles: ['maxmind/**'], excludeFiles: undefined, }); @@ -196,7 +294,7 @@ describe('V4ConfigParser', () => { ) ); - expect(result.entrypoints[0].package).toEqual({ + expect(requireEntrypoint(result).package).toEqual({ includeFiles: undefined, excludeFiles: ['**/*.map'], }); @@ -221,7 +319,7 @@ describe('V4ConfigParser', () => { ) ); - expect(result.entrypoints[0].package).toBeUndefined(); + expect(requireEntrypoint(result).package).toBeUndefined(); }); }); @@ -286,13 +384,14 @@ describe('V4ConfigParser', () => { ) ); - expect(result.assets.paths).toEqual([ + const assets = requireAssets(result); + expect(requireAssetPaths(result)).toEqual([ 'public/css/style.css', 'public/images/icons/favicon.ico', 'public/images/logo.png', 'public/index.html', ]); - expect(result.assets.prefixToStrip).toBe('public/'); + expect(assets.prefixToStrip).toBe('public/'); }); it('should filter disallowed asset extensions', async () => { @@ -314,7 +413,7 @@ describe('V4ConfigParser', () => { ) ); - expect(result.assets.paths).toEqual(['public/index.html']); + expect(requireAssetPaths(result)).toEqual(['public/index.html']); }); it('should terminate when directory structure contains a symlink loop', async () => { @@ -361,7 +460,8 @@ describe('V4ConfigParser', () => { // The function should terminate (not hang) and collect files up to the depth limit. // With depth limit of 100, we expect 101 readDirectory calls (depth 0 through 100). expect(readDirCalls).toBe(101); - expect(result.assets.paths.length).toBeGreaterThan(0); - expect(result.assets.paths.length).toBeLessThanOrEqual(101); + const assetPaths = requireAssetPaths(result); + expect(assetPaths.length).toBeGreaterThan(0); + expect(assetPaths.length).toBeLessThanOrEqual(101); }); }); diff --git a/packages/network-config/src/services/v4-config-parser.ts b/packages/network-config/src/services/v4-config-parser.ts index 0e73a3fd..4f8e2716 100644 --- a/packages/network-config/src/services/v4-config-parser.ts +++ b/packages/network-config/src/services/v4-config-parser.ts @@ -37,6 +37,9 @@ const toArray = (value: string | string[] | undefined): string[] | undefined => return Array.isArray(value) ? value : [value]; }; +const runtimeStreamsByDefault = (runtime: ConfigV4FunctionSettings['runtime'] | undefined): boolean => + runtime == null || runtime.startsWith('node-'); + // -- Pure helpers (no Effect needed) ---------------------------------------------------------- /** @@ -77,12 +80,19 @@ export const getFunctionSettings = (path: string, config: ConfigV4): ConfigV4Fun /** * Determines the route handler type based on destination and redirect flag. */ -const resolveRouteHandler = (destination: string, redirect?: boolean): NormalizedConfigRouteHandler => { +const resolveRouteHandler = ( + destination: string, + redirect: boolean | undefined, + entrypoints: readonly NormalizedConfigEntrypoint[] = [] +): NormalizedConfigRouteHandler => { const isExternal = destination.toLowerCase().startsWith('http://') || destination.toLowerCase().startsWith('https://'); if (redirect === true) return 'HTTP_REDIRECT'; - return isExternal ? 'HTTP_PROXY' : 'SERVERLESS_FUNCTION'; + if (isExternal) return 'HTTP_PROXY'; + + const entrypoint = entrypoints.find((item) => item.path === destination); + return entrypoint?.streaming === true ? 'SERVERLESS_FUNCTION_STREAMING' : 'SERVERLESS_FUNCTION'; }; /** Type guard that validates a string is a known Region. */ @@ -100,10 +110,13 @@ const resolveRegions = (configRegions?: string[] | null): Region[] => { /** * Maps a V4 route definition to a NormalizedConfigRoute. */ -const mapRoute = (route: NonNullable[number]): NormalizedConfigRoute => ({ +const mapRoute = ( + route: NonNullable[number], + entrypoints: readonly NormalizedConfigEntrypoint[] +): NormalizedConfigRoute => ({ path: route.source, destination: route.destination, - handler: resolveRouteHandler(route.destination, route.redirect), + handler: resolveRouteHandler(route.destination, route.redirect, entrypoints), headers: route.headers ?? {}, methods: route.methods ?? ['ANY'], positiveRequirements: route.has, @@ -124,7 +137,7 @@ const parseEntrypoints = Effect.fn('parseEntrypoints')(function* (config: Config const settings = getFunctionSettings(fnPath, config); if (settings == null) { - yield* new FunctionConfigError({ + return yield* new FunctionConfigError({ message: `Settings invalid for function at path '${fnPath}'`, functionPath: fnPath, }); @@ -145,18 +158,17 @@ const parseEntrypoints = Effect.fn('parseEntrypoints')(function* (config: Config entrypoints.push({ path: file, - runtime: settings!.runtime ?? 'node-20', - memory: settings!.memory ?? 128, - maxDuration: settings!.max_duration ?? 30, + runtime: settings.runtime ?? 'node-20', + memory: settings.memory ?? 128, + maxDuration: settings.max_duration ?? 30, schedule: func.schedule, symlinks: func.symlinks, - streaming: - settings!.runtime == null || settings!.runtime.startsWith('node-') || settings!.runtime.startsWith('bun-'), + streaming: settings.streaming ?? runtimeStreamsByDefault(settings.runtime), package: - settings!.includeFiles != null || settings!.excludeFiles != null + settings.includeFiles != null || settings.excludeFiles != null ? { - includeFiles: toArray(settings!.includeFiles), - excludeFiles: toArray(settings!.excludeFiles), + includeFiles: toArray(settings.includeFiles), + excludeFiles: toArray(settings.excludeFiles), } : undefined, }); @@ -262,7 +274,7 @@ export class V4ConfigParser extends Effect.Service()('V4ConfigPa entrypoints, errors: [], warnings: [], - routes: (config.routes ?? []).map(mapRoute), + routes: (config.routes ?? []).map((route) => mapRoute(route, entrypoints)), } as NormalizedConfig; }), }), diff --git a/packages/network-config/src/services/vercel-build-output-parser.test.ts b/packages/network-config/src/services/vercel-build-output-parser.test.ts index 3a0f0dc8..6fe91ea2 100644 --- a/packages/network-config/src/services/vercel-build-output-parser.test.ts +++ b/packages/network-config/src/services/vercel-build-output-parser.test.ts @@ -27,7 +27,7 @@ const runParser = (files: Record, config: NormalizedConfig, proj Effect.runPromise( Effect.gen(function* () { const parser = yield* VercelBuildOutputParser; - return yield* parser.parse(config, projectFolder); + return yield* parser.parse(structuredClone(config), projectFolder); }).pipe( Effect.provide(VercelBuildOutputParser.Default), Effect.provide(RawConfigReader.Default), @@ -127,4 +127,45 @@ describe('VercelBuildOutputParser', () => { 'index.html': { contentType: 'text/html' }, }); }); + + it('should default Node Vercel Build Output functions to response streaming', async () => { + mockGetFilesForPattern.mockResolvedValueOnce(['.vercel/output/functions/api.func/.vc-config.json']); + + const result = await runParser( + { + '/project/.vercel/output/config.json': JSON.stringify({ version: 3 }), + '/project/.vercel/output/functions/api.func/.vc-config.json': JSON.stringify({ + handler: 'index.js', + runtime: 'nodejs20.x', + filePathMap: { 'index.js': 'index.js' }, + }), + }, + emptyConfig, + '/project' + ); + + expect(result.entrypoints?.[0].streaming).toBe(true); + expect(result.routes?.[0].handler).toBe('SERVERLESS_FUNCTION_STREAMING'); + }); + + it('should allow Vercel response streaming metadata to disable streaming', async () => { + mockGetFilesForPattern.mockResolvedValueOnce(['.vercel/output/functions/api.func/.vc-config.json']); + + const result = await runParser( + { + '/project/.vercel/output/config.json': JSON.stringify({ version: 3 }), + '/project/.vercel/output/functions/api.func/.vc-config.json': JSON.stringify({ + handler: 'index.js', + runtime: 'nodejs20.x', + supportsResponseStreaming: false, + filePathMap: { 'index.js': 'index.js' }, + }), + }, + emptyConfig, + '/project' + ); + + expect(result.entrypoints?.[0].streaming).toBe(false); + expect(result.routes?.[0].handler).toBe('SERVERLESS_FUNCTION'); + }); }); diff --git a/packages/network-config/src/services/vercel-build-output-parser.ts b/packages/network-config/src/services/vercel-build-output-parser.ts index 25fb4adb..4e916704 100644 --- a/packages/network-config/src/services/vercel-build-output-parser.ts +++ b/packages/network-config/src/services/vercel-build-output-parser.ts @@ -113,7 +113,10 @@ export class VercelBuildOutputParser extends Effect.Service = {}; for (const [key, value] of Object.entries(configFilePathMap)) { diff --git a/packages/network-config/src/v4/index.ts b/packages/network-config/src/v4/index.ts index b1293f24..3cee4792 100644 --- a/packages/network-config/src/v4/index.ts +++ b/packages/network-config/src/v4/index.ts @@ -98,7 +98,7 @@ export interface ConfigV4 extends Config { /** * Additional environment variables to set during runtime and build. */ - env: Record; + env?: Record; /** * Optionally, specify additional services like databases to deploy. @@ -170,6 +170,10 @@ export interface ConfigV4FunctionSettings { * The runtime to use for the function. */ runtime?: Runtime; + /** + * Enable function response streaming. When omitted, Node runtimes stream by default. + */ + streaming?: boolean; /** * Use to create symlinks on the final function. This is useful for applications that require use of the file system, since serverless functions are ephemeral and have read-only file systems, except for /tmp. */ diff --git a/packages/network-config/src/v4/parse.test.ts b/packages/network-config/src/v4/parse.test.ts index 96036dd1..1e7fe5ea 100644 --- a/packages/network-config/src/v4/parse.test.ts +++ b/packages/network-config/src/v4/parse.test.ts @@ -91,7 +91,7 @@ describe('parse config v4', function () { const exampleFile = path.join(__dirname, 'example.yaml'); const config = await readFixture(exampleFile); - expect(getFunctionSettings('src/index.ts', config as ConfigV4)).toEqual({ + expect(getFunctionSettings('src/index.ts', config as unknown as ConfigV4)).toEqual({ runtime: 'bun-1', memory: 128, max_duration: 15, @@ -103,7 +103,7 @@ describe('parse config v4', function () { }, }); - expect(getFunctionSettings('src/cron.ts', config as ConfigV4)).toEqual({ + expect(getFunctionSettings('src/cron.ts', config as unknown as ConfigV4)).toEqual({ runtime: 'bun-1', memory: 128, max_duration: 15, @@ -115,8 +115,8 @@ describe('parse config v4', function () { }, }); - expect(getFunctionSettings('app/test.ts', config as ConfigV4)).toBeUndefined(); + expect(getFunctionSettings('app/test.ts', config as unknown as ConfigV4)).toBeUndefined(); - expect(getFunctionSettings('test/index.ts', config as ConfigV4)).toBeUndefined(); + expect(getFunctionSettings('test/index.ts', config as unknown as ConfigV4)).toBeUndefined(); }); }); diff --git a/packages/network-config/src/v4/schema.json b/packages/network-config/src/v4/schema.json index 6f7621a3..d199e0fb 100644 --- a/packages/network-config/src/v4/schema.json +++ b/packages/network-config/src/v4/schema.json @@ -62,6 +62,10 @@ "$ref": "#/$defs/runtimes", "description": "The runtime to use for the function." }, + "streaming": { + "type": "boolean", + "description": "Enable function response streaming. When omitted, Node runtimes stream by default." + }, "schedule": { "type": "string", "description": "An expression to schedule the function to run at specific times.", diff --git a/packages/network-config/src/v4/schema.ts b/packages/network-config/src/v4/schema.ts index e36b0789..a316404a 100644 --- a/packages/network-config/src/v4/schema.ts +++ b/packages/network-config/src/v4/schema.ts @@ -65,6 +65,10 @@ export const schema = { $ref: '#/$defs/runtimes', description: 'The runtime to use for the function.', }, + streaming: { + type: 'boolean', + description: 'Enable function response streaming. When omitted, Node runtimes stream by default.', + }, schedule: { type: 'string', description: 'An expression to schedule the function to run at specific times.', diff --git a/packages/network-config/tsconfig.json b/packages/network-config/tsconfig.json index a1b2c64a..17a7ed83 100644 --- a/packages/network-config/tsconfig.json +++ b/packages/network-config/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", + "rootDir": "../..", "module": "ES2022" }, "include": ["src"] diff --git a/tsconfig.base.json b/tsconfig.base.json index f04ab1a8..24cc4b93 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -4,6 +4,7 @@ "declaration": true, "declarationMap": true, "esModuleInterop": true, + "ignoreDeprecations": "5.0", "lib": ["ES2022", "ESNext.disposable"], "module": "es2022", "moduleResolution": "bundler", From 5902df24be6408db483f4e0bac8540ec73d6b8f2 Mon Sep 17 00:00:00 2001 From: Mehdi Baaboura Date: Mon, 18 May 2026 03:34:25 +0200 Subject: [PATCH 2/3] fix(network-config): address response streaming review --- .changeset/streaming-node-config.md | 2 +- .../src/services/v4-config-parser.test.ts | 49 +++++++++++++++++++ .../src/services/v4-config-parser.ts | 24 +++++++-- packages/network-config/tsconfig.json | 1 - tsconfig.base.json | 1 - 5 files changed, 71 insertions(+), 6 deletions(-) diff --git a/.changeset/streaming-node-config.md b/.changeset/streaming-node-config.md index 5a621db2..dba1b4ee 100644 --- a/.changeset/streaming-node-config.md +++ b/.changeset/streaming-node-config.md @@ -2,4 +2,4 @@ '@gigadrive/network-config': patch --- -Add explicit response streaming configuration for Node functions and Vercel Build Output functions. +Add explicit response streaming configuration for Node functions and Vercel Build Output functions. Node functions now stream by default and can opt out with `streaming: false`; Vercel Build Output functions can opt out with `supportsResponseStreaming: false`. diff --git a/packages/network-config/src/services/v4-config-parser.test.ts b/packages/network-config/src/services/v4-config-parser.test.ts index f2244ea7..17a349d7 100644 --- a/packages/network-config/src/services/v4-config-parser.test.ts +++ b/packages/network-config/src/services/v4-config-parser.test.ts @@ -222,6 +222,55 @@ describe('V4ConfigParser', () => { }); }); + it('should match streaming function routes with absolute destinations', async () => { + await withTempFunctionProject(async (projectFolder) => { + const config: ConfigV4 = { + version: 4, + functions: { + 'dist/main.js': { + runtime: 'node-22', + }, + }, + routes: [{ source: '/*', destination: '/dist/main.js' }], + }; + + const result = await Effect.runPromise( + V4ConfigParser.parse(config, projectFolder).pipe( + Effect.provide(V4ConfigParser.Default), + Effect.provide(NodeContext.layer) + ) + ); + + expect(requireRoute(result).handler).toBe('SERVERLESS_FUNCTION_STREAMING'); + }); + }); + + it('should match streaming function routes with substituted destinations', async () => { + await withTempFunctionProject(async (projectFolder) => { + fs.mkdirSync(path.join(projectFolder, 'pages'), { recursive: true }); + fs.writeFileSync(path.join(projectFolder, 'pages/user.tsx'), 'exports.handler = () => {}'); + + const config: ConfigV4 = { + version: 4, + functions: { + 'pages/*.tsx': { + runtime: 'node-22', + }, + }, + routes: [{ source: '/pages/(.*)', destination: '/pages/$1.tsx?name=$1' }], + }; + + const result = await Effect.runPromise( + V4ConfigParser.parse(config, projectFolder).pipe( + Effect.provide(V4ConfigParser.Default), + Effect.provide(NodeContext.layer) + ) + ); + + expect(requireRoute(result).handler).toBe('SERVERLESS_FUNCTION_STREAMING'); + }); + }); + it('should normalize scalar includeFiles and excludeFiles into package rule arrays', async () => { await withTempFunctionProject(async (projectFolder) => { const config: ConfigV4 = { diff --git a/packages/network-config/src/services/v4-config-parser.ts b/packages/network-config/src/services/v4-config-parser.ts index 4f8e2716..678bbf0b 100644 --- a/packages/network-config/src/services/v4-config-parser.ts +++ b/packages/network-config/src/services/v4-config-parser.ts @@ -40,6 +40,21 @@ const toArray = (value: string | string[] | undefined): string[] | undefined => const runtimeStreamsByDefault = (runtime: ConfigV4FunctionSettings['runtime'] | undefined): boolean => runtime == null || runtime.startsWith('node-'); +const normalizeRouteDestination = (destination: string): string => { + const [withoutHash] = destination.split('#', 1); + const [withoutQuery] = withoutHash.split('?', 1); + return withoutQuery.replace(/^\/+/, ''); +}; + +const destinationMatchesEntrypoint = (destination: string, entrypointPath: string): boolean => { + const normalizedDestination = normalizeRouteDestination(destination); + if (normalizedDestination === entrypointPath) return true; + if (!normalizedDestination.includes('$')) return false; + + const pattern = normalizedDestination.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\$\d+/g, '[^/]+'); + return new RegExp(`^${pattern}$`).test(entrypointPath); +}; + // -- Pure helpers (no Effect needed) ---------------------------------------------------------- /** @@ -91,7 +106,7 @@ const resolveRouteHandler = ( if (redirect === true) return 'HTTP_REDIRECT'; if (isExternal) return 'HTTP_PROXY'; - const entrypoint = entrypoints.find((item) => item.path === destination); + const entrypoint = entrypoints.find((item) => destinationMatchesEntrypoint(destination, item.path)); return entrypoint?.streaming === true ? 'SERVERLESS_FUNCTION_STREAMING' : 'SERVERLESS_FUNCTION'; }; @@ -156,14 +171,17 @@ const parseEntrypoints = Effect.fn('parseEntrypoints')(function* (config: Config if (entrypoints.some((ep) => ep.path === file)) continue; if (Object.keys(config.functions).some((f) => f === file && f !== fnPath)) continue; + const runtime = settings.runtime ?? 'node-20'; + const streaming = settings.streaming ?? runtimeStreamsByDefault(runtime); + entrypoints.push({ path: file, - runtime: settings.runtime ?? 'node-20', + runtime, memory: settings.memory ?? 128, maxDuration: settings.max_duration ?? 30, schedule: func.schedule, symlinks: func.symlinks, - streaming: settings.streaming ?? runtimeStreamsByDefault(settings.runtime), + streaming, package: settings.includeFiles != null || settings.excludeFiles != null ? { diff --git a/packages/network-config/tsconfig.json b/packages/network-config/tsconfig.json index 17a7ed83..a1b2c64a 100644 --- a/packages/network-config/tsconfig.json +++ b/packages/network-config/tsconfig.json @@ -2,7 +2,6 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "../..", "module": "ES2022" }, "include": ["src"] diff --git a/tsconfig.base.json b/tsconfig.base.json index 24cc4b93..f04ab1a8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -4,7 +4,6 @@ "declaration": true, "declarationMap": true, "esModuleInterop": true, - "ignoreDeprecations": "5.0", "lib": ["ES2022", "ESNext.disposable"], "module": "es2022", "moduleResolution": "bundler", From f0fbcc5018456d6d0b84490c0b4bd5d32f9fc0a3 Mon Sep 17 00:00:00 2001 From: Mehdi Baaboura Date: Mon, 18 May 2026 03:42:05 +0200 Subject: [PATCH 3/3] fix(network-config): harden streaming route matching --- .changeset/streaming-node-config.md | 2 +- .../src/services/v4-config-parser.test.ts | 108 ++++++++++++++++++ .../src/services/v4-config-parser.ts | 22 ++-- packages/network-config/src/v4/index.ts | 2 +- packages/network-config/src/v4/schema.json | 2 +- packages/network-config/src/v4/schema.ts | 2 +- 6 files changed, 127 insertions(+), 11 deletions(-) diff --git a/.changeset/streaming-node-config.md b/.changeset/streaming-node-config.md index dba1b4ee..aac44665 100644 --- a/.changeset/streaming-node-config.md +++ b/.changeset/streaming-node-config.md @@ -2,4 +2,4 @@ '@gigadrive/network-config': patch --- -Add explicit response streaming configuration for Node functions and Vercel Build Output functions. Node functions now stream by default and can opt out with `streaming: false`; Vercel Build Output functions can opt out with `supportsResponseStreaming: false`. +Add explicit response streaming configuration for Node functions and Vercel Build Output functions. Node and Bun functions now stream by default and can opt out with `streaming: false`; Vercel Build Output functions can opt out with `supportsResponseStreaming: false`. diff --git a/packages/network-config/src/services/v4-config-parser.test.ts b/packages/network-config/src/services/v4-config-parser.test.ts index 17a349d7..0c242df4 100644 --- a/packages/network-config/src/services/v4-config-parser.test.ts +++ b/packages/network-config/src/services/v4-config-parser.test.ts @@ -172,6 +172,30 @@ describe('V4ConfigParser', () => { }); }); + it('should preserve Bun functions as response streaming by default', async () => { + await withTempFunctionProject(async (projectFolder) => { + const config: ConfigV4 = { + version: 4, + functions: { + 'dist/main.js': { + runtime: 'bun-1', + }, + }, + routes: [{ source: '/*', destination: 'dist/main.js' }], + }; + + const result = await Effect.runPromise( + V4ConfigParser.parse(config, projectFolder).pipe( + Effect.provide(V4ConfigParser.Default), + Effect.provide(NodeContext.layer) + ) + ); + + expect(requireEntrypoint(result).streaming).toBe(true); + expect(requireRoute(result).handler).toBe('SERVERLESS_FUNCTION_STREAMING'); + }); + }); + it('should allow explicitly disabling function streaming', async () => { await withTempFunctionProject(async (projectFolder) => { const config: ConfigV4 = { @@ -271,6 +295,90 @@ describe('V4ConfigParser', () => { }); }); + it('should match streaming function routes with nested substituted destinations', async () => { + await withTempFunctionProject(async (projectFolder) => { + fs.mkdirSync(path.join(projectFolder, 'pages/blog'), { recursive: true }); + fs.writeFileSync(path.join(projectFolder, 'pages/blog/post.tsx'), 'exports.handler = () => {}'); + + const config: ConfigV4 = { + version: 4, + functions: { + 'pages/**/*.tsx': { + runtime: 'node-22', + }, + }, + routes: [{ source: '/pages/(.*)', destination: '/pages/$1.tsx' }], + }; + + const result = await Effect.runPromise( + V4ConfigParser.parse(config, projectFolder).pipe( + Effect.provide(V4ConfigParser.Default), + Effect.provide(NodeContext.layer) + ) + ); + + expect(requireRoute(result).handler).toBe('SERVERLESS_FUNCTION_STREAMING'); + }); + }); + + it('should match streaming function routes with named substituted destinations', async () => { + await withTempFunctionProject(async (projectFolder) => { + fs.mkdirSync(path.join(projectFolder, 'sites/acme'), { recursive: true }); + fs.writeFileSync(path.join(projectFolder, 'sites/acme/index.js'), 'exports.handler = () => {}'); + + const config: ConfigV4 = { + version: 4, + functions: { + 'sites/*/index.js': { + runtime: 'node-22', + }, + }, + routes: [{ source: '/sites/(?.*)', destination: '/sites/$tenant/index.js' }], + }; + + const result = await Effect.runPromise( + V4ConfigParser.parse(config, projectFolder).pipe( + Effect.provide(V4ConfigParser.Default), + Effect.provide(NodeContext.layer) + ) + ); + + expect(requireRoute(result).handler).toBe('SERVERLESS_FUNCTION_STREAMING'); + }); + }); + + it('should use non-streaming handlers for substituted routes with mixed streaming entrypoints', async () => { + await withTempFunctionProject(async (projectFolder) => { + fs.mkdirSync(path.join(projectFolder, 'pages'), { recursive: true }); + fs.writeFileSync(path.join(projectFolder, 'pages/user.tsx'), 'exports.handler = () => {}'); + fs.writeFileSync(path.join(projectFolder, 'pages/admin.tsx'), 'exports.handler = () => {}'); + + const config: ConfigV4 = { + version: 4, + functions: { + 'pages/*.tsx': { + runtime: 'node-22', + }, + 'pages/admin.*': { + streaming: false, + }, + }, + routes: [{ source: '/pages/(.*)', destination: '/pages/$1.tsx' }], + }; + + const result = await Effect.runPromise( + V4ConfigParser.parse(config, projectFolder).pipe( + Effect.provide(V4ConfigParser.Default), + Effect.provide(NodeContext.layer) + ) + ); + + const admin = result.entrypoints.find((entrypoint) => entrypoint.path === 'pages/admin.tsx'); + expect(admin?.streaming).toBe(false); + expect(requireRoute(result).handler).toBe('SERVERLESS_FUNCTION'); + }); + }); + it('should normalize scalar includeFiles and excludeFiles into package rule arrays', async () => { await withTempFunctionProject(async (projectFolder) => { const config: ConfigV4 = { diff --git a/packages/network-config/src/services/v4-config-parser.ts b/packages/network-config/src/services/v4-config-parser.ts index 678bbf0b..9d80cb94 100644 --- a/packages/network-config/src/services/v4-config-parser.ts +++ b/packages/network-config/src/services/v4-config-parser.ts @@ -38,7 +38,7 @@ const toArray = (value: string | string[] | undefined): string[] | undefined => }; const runtimeStreamsByDefault = (runtime: ConfigV4FunctionSettings['runtime'] | undefined): boolean => - runtime == null || runtime.startsWith('node-'); + runtime == null || runtime.startsWith('node-') || runtime.startsWith('bun-'); const normalizeRouteDestination = (destination: string): string => { const [withoutHash] = destination.split('#', 1); @@ -51,7 +51,7 @@ const destinationMatchesEntrypoint = (destination: string, entrypointPath: strin if (normalizedDestination === entrypointPath) return true; if (!normalizedDestination.includes('$')) return false; - const pattern = normalizedDestination.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\$\d+/g, '[^/]+'); + const pattern = normalizedDestination.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\$[\w]+/g, '.+'); return new RegExp(`^${pattern}$`).test(entrypointPath); }; @@ -106,8 +106,10 @@ const resolveRouteHandler = ( if (redirect === true) return 'HTTP_REDIRECT'; if (isExternal) return 'HTTP_PROXY'; - const entrypoint = entrypoints.find((item) => destinationMatchesEntrypoint(destination, item.path)); - return entrypoint?.streaming === true ? 'SERVERLESS_FUNCTION_STREAMING' : 'SERVERLESS_FUNCTION'; + const matchingEntrypoints = entrypoints.filter((item) => destinationMatchesEntrypoint(destination, item.path)); + return matchingEntrypoints.length > 0 && matchingEntrypoints.every((entrypoint) => entrypoint.streaming === true) + ? 'SERVERLESS_FUNCTION_STREAMING' + : 'SERVERLESS_FUNCTION'; }; /** Type guard that validates a string is a known Region. */ @@ -149,9 +151,7 @@ const parseEntrypoints = Effect.fn('parseEntrypoints')(function* (config: Config if (config.functions == null) return entrypoints; for (const [fnPath, func] of Object.entries(config.functions)) { - const settings = getFunctionSettings(fnPath, config); - - if (settings == null) { + if (getFunctionSettings(fnPath, config) == null) { return yield* new FunctionConfigError({ message: `Settings invalid for function at path '${fnPath}'`, functionPath: fnPath, @@ -171,6 +171,14 @@ const parseEntrypoints = Effect.fn('parseEntrypoints')(function* (config: Config if (entrypoints.some((ep) => ep.path === file)) continue; if (Object.keys(config.functions).some((f) => f === file && f !== fnPath)) continue; + const settings = getFunctionSettings(file, config); + if (settings == null) { + return yield* new FunctionConfigError({ + message: `Settings invalid for function at path '${file}'`, + functionPath: file, + }); + } + const runtime = settings.runtime ?? 'node-20'; const streaming = settings.streaming ?? runtimeStreamsByDefault(runtime); diff --git a/packages/network-config/src/v4/index.ts b/packages/network-config/src/v4/index.ts index 3cee4792..08b5f69b 100644 --- a/packages/network-config/src/v4/index.ts +++ b/packages/network-config/src/v4/index.ts @@ -171,7 +171,7 @@ export interface ConfigV4FunctionSettings { */ runtime?: Runtime; /** - * Enable function response streaming. When omitted, Node runtimes stream by default. + * Enable function response streaming. When omitted, Node and Bun runtimes stream by default. */ streaming?: boolean; /** diff --git a/packages/network-config/src/v4/schema.json b/packages/network-config/src/v4/schema.json index d199e0fb..1820dfcb 100644 --- a/packages/network-config/src/v4/schema.json +++ b/packages/network-config/src/v4/schema.json @@ -64,7 +64,7 @@ }, "streaming": { "type": "boolean", - "description": "Enable function response streaming. When omitted, Node runtimes stream by default." + "description": "Enable function response streaming. When omitted, Node and Bun runtimes stream by default." }, "schedule": { "type": "string", diff --git a/packages/network-config/src/v4/schema.ts b/packages/network-config/src/v4/schema.ts index a316404a..98e7ab63 100644 --- a/packages/network-config/src/v4/schema.ts +++ b/packages/network-config/src/v4/schema.ts @@ -67,7 +67,7 @@ export const schema = { }, streaming: { type: 'boolean', - description: 'Enable function response streaming. When omitted, Node runtimes stream by default.', + description: 'Enable function response streaming. When omitted, Node and Bun runtimes stream by default.', }, schedule: { type: 'string',