Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/streaming-node-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gigadrive/network-config': patch
---

Add explicit response streaming configuration for Node functions and Vercel Build Output functions.
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
128 changes: 114 additions & 14 deletions packages/network-config/src/services/v4-config-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -31,6 +32,29 @@ const withTempFunctionProject = async <T>(run: (projectFolder: string) => Promis
}
};

const requireAssets = (result: NormalizedConfig): NonNullable<NormalizedConfig['assets']> => {
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();
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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 () => {
Expand All @@ -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 = {
Expand All @@ -144,7 +242,7 @@ describe('V4ConfigParser', () => {
)
);

expect(result.entrypoints[0].package).toEqual({
expect(requireEntrypoint(result).package).toEqual({
includeFiles: ['maxmind/**'],
excludeFiles: ['**/*.map'],
});
Expand All @@ -170,7 +268,7 @@ describe('V4ConfigParser', () => {
)
);

expect(result.entrypoints[0].package).toEqual({
expect(requireEntrypoint(result).package).toEqual({
includeFiles: ['maxmind/**'],
excludeFiles: undefined,
});
Expand All @@ -196,7 +294,7 @@ describe('V4ConfigParser', () => {
)
);

expect(result.entrypoints[0].package).toEqual({
expect(requireEntrypoint(result).package).toEqual({
includeFiles: undefined,
excludeFiles: ['**/*.map'],
});
Expand All @@ -221,7 +319,7 @@ describe('V4ConfigParser', () => {
)
);

expect(result.entrypoints[0].package).toBeUndefined();
expect(requireEntrypoint(result).package).toBeUndefined();
});
});

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);
});
});
40 changes: 26 additions & 14 deletions packages/network-config/src/services/v4-config-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ----------------------------------------------------------

/**
Expand Down Expand Up @@ -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';
Comment thread
Zeryther marked this conversation as resolved.
Outdated
};

/** Type guard that validates a string is a known Region. */
Expand All @@ -100,10 +110,13 @@ const resolveRegions = (configRegions?: string[] | null): Region[] => {
/**
* Maps a V4 route definition to a NormalizedConfigRoute.
*/
const mapRoute = (route: NonNullable<ConfigV4['routes']>[number]): NormalizedConfigRoute => ({
const mapRoute = (
route: NonNullable<ConfigV4['routes']>[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,
Expand All @@ -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,
});
Expand All @@ -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,
});
Expand Down Expand Up @@ -262,7 +274,7 @@ export class V4ConfigParser extends Effect.Service<V4ConfigParser>()('V4ConfigPa
entrypoints,
errors: [],
warnings: [],
routes: (config.routes ?? []).map(mapRoute),
routes: (config.routes ?? []).map((route) => mapRoute(route, entrypoints)),
} as NormalizedConfig;
}),
}),
Expand Down
Loading
Loading