From 7d65d8e1197e24e337d3016b5f67b3c5ab29db34 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Thu, 18 Jun 2026 20:07:40 +0800 Subject: [PATCH] fix(void): ignore type-only exports when detecting route methods and SSR render Both the route HTTP-method scanner (detect-exports) and the SSR entry validator walked named-export specifiers without checking exportKind, so a type-only export was mistaken for a runtime value. `export type { GET }` or `export { type GET }` in a route file made the scanner register a GET route whose handler is undefined at runtime (a 500 instead of a 404/405); `export type { render }` made an SSR entry pass validation with no real render function. Skip exports whose exportKind is "type" (both the statement-level `export type { ... }` and inline `export { type X }` specifiers) in both detectors. Add exportKind to the shared AST specifier/declaration types. --- packages/void/src/router/detect-exports.ts | 8 ++++++++ packages/void/src/shared/ast.ts | 4 ++++ packages/void/src/ssr/entry.ts | 7 +++++++ .../void/test/unit/detect-exports.test.ts | 20 +++++++++++++++++++ packages/void/test/unit/ssr-entry.test.ts | 14 +++++++++++++ 5 files changed, 53 insertions(+) diff --git a/packages/void/src/router/detect-exports.ts b/packages/void/src/router/detect-exports.ts index cd339fd3c..a5853b52e 100644 --- a/packages/void/src/router/detect-exports.ts +++ b/packages/void/src/router/detect-exports.ts @@ -31,6 +31,10 @@ export function detectHttpExports(absPath: string): Array { for (const node of program.body ?? []) { if (node.type === 'ExportNamedDeclaration') { const declarationNode = node as AstExportNamedDeclaration; + // `export type { GET }` exports no runtime value — skip the whole statement. + if (declarationNode.exportKind === 'type') { + continue; + } // export const GET = ...; export function POST() { ... } const decl = declarationNode.declaration; if (decl) { @@ -50,6 +54,10 @@ export function detectHttpExports(absPath: string): Array { // export { GET, POST } (specifiers) if (declarationNode.specifiers) { for (const spec of declarationNode.specifiers) { + // `export { type GET }` re-exports a type, not a runtime handler. + if (spec.exportKind === 'type') { + continue; + } const exported = isIdentifier(spec.exported) ? spec.exported.name : isIdentifier(spec.local) diff --git a/packages/void/src/shared/ast.ts b/packages/void/src/shared/ast.ts index c03863cec..bdbc80ab3 100644 --- a/packages/void/src/shared/ast.ts +++ b/packages/void/src/shared/ast.ts @@ -61,12 +61,16 @@ export type AstFunctionDeclaration = AstNode & { export type AstExportSpecifier = AstNode & { exported?: AstNode; local?: AstNode; + /** "type" for `export { type X }`, "value" (or undefined) otherwise. */ + exportKind?: string; }; export type AstExportNamedDeclaration = AstNode & { type: 'ExportNamedDeclaration'; declaration?: AstNode; specifiers?: Array; + /** "type" for `export type { X }`, "value" (or undefined) otherwise. */ + exportKind?: string; }; export type AstExportDefaultDeclaration = AstNode & { diff --git a/packages/void/src/ssr/entry.ts b/packages/void/src/ssr/entry.ts index 7a9fbd2d8..c31a16d3e 100644 --- a/packages/void/src/ssr/entry.ts +++ b/packages/void/src/ssr/entry.ts @@ -112,6 +112,10 @@ function validateServerRenderExport(entry: ResolvedEntry): void { if (node.type !== 'ExportNamedDeclaration') { return false; } + // `export type { render }` exports no runtime value. + if ((node as AstExportNamedDeclaration).exportKind === 'type') { + return false; + } const declaration = (node as AstExportNamedDeclaration).declaration; if (declaration?.type === 'FunctionDeclaration') { @@ -126,6 +130,9 @@ function validateServerRenderExport(entry: ResolvedEntry): void { const specifiers = (node as AstExportNamedDeclaration).specifiers ?? []; return specifiers.some((specifier: AstExportSpecifier) => { + if (specifier.exportKind === 'type') { + return false; + } const exported = specifier.exported; return isIdentifier(exported) && exported.name === 'render'; }); diff --git a/packages/void/test/unit/detect-exports.test.ts b/packages/void/test/unit/detect-exports.test.ts index 8ad1d706e..e1697e2d1 100644 --- a/packages/void/test/unit/detect-exports.test.ts +++ b/packages/void/test/unit/detect-exports.test.ts @@ -60,6 +60,26 @@ export function transform() {}`, expect(detectHttpExports(path)).toEqual(['GET']); }); + it('ignores a type-only export statement', () => { + // `export type { GET }` erases at runtime, so it must not register a GET route. + const path = writeRoute( + 'hello.ts', + `type GET = () => Response; +export type { GET };`, + ); + expect(detectHttpExports(path)).toEqual([]); + }); + + it('ignores an inline type-only specifier but keeps value specifiers', () => { + const path = writeRoute( + 'hello.ts', + `const POST = () => "ok"; +type GET = () => Response; +export { POST, type GET };`, + ); + expect(detectHttpExports(path)).toEqual(['POST']); + }); + it('returns empty array for no HTTP exports', () => { const path = writeRoute('hello.ts', 'export default () => "ok";'); expect(detectHttpExports(path)).toEqual([]); diff --git a/packages/void/test/unit/ssr-entry.test.ts b/packages/void/test/unit/ssr-entry.test.ts index d7b06caae..7ed01c4b7 100644 --- a/packages/void/test/unit/ssr-entry.test.ts +++ b/packages/void/test/unit/ssr-entry.test.ts @@ -98,6 +98,20 @@ describe('ssr entry helpers', () => { ); }); + it('does not treat a type-only render export as a valid entry', () => { + const root = createTempRoot(); + writeFileSync(join(root, 'src', 'main.client.ts'), "console.log('client entry');"); + writeFileSync( + join(root, 'src', 'main.ssr.ts'), + `type render = () => Response; +export type { render };`, + ); + + expect(() => validateSsrEntry(root)).toThrow( + 'Export `render(c, assetTags)` or `export default defineRender(...)`', + ); + }); + it('throws when server entry exists without client entry', () => { const root = createTempRoot(); writeFileSync(