diff --git a/src/modules/server/classify.test.ts b/src/modules/server/classify.test.ts new file mode 100644 index 0000000000..482e8aa210 --- /dev/null +++ b/src/modules/server/classify.test.ts @@ -0,0 +1,158 @@ +import "#veryfront/schemas/_test-setup.ts"; +/** + * classify.ts unit tests + * + * Table-driven tests over every URL pattern that classifyModuleRequest + * recognises, plus rejection of non-module URLs. + * + * @module modules/server/classify.test + */ + +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { classifyModuleRequest } from "./classify.ts"; + +function url(pathname: string, host = "localhost:3000"): URL { + return new URL(`http://${host}${pathname}`); +} + +describe("classifyModuleRequest", () => { + describe("not-module", () => { + for ( + const pathname of [ + "/", + "/api/data", + "/pages/index", + "/_vf_mod", + "/_veryfront/mod", + "/vf_modules/page.js", + ] + ) { + it(`returns not-module for ${pathname}`, () => { + const result = classifyModuleRequest(url(pathname)); + assertEquals(result.kind, "not-module"); + }); + } + }); + + describe("snippet", () => { + it("classifies /_vf_modules/_snippets/.js as snippet", () => { + const result = classifyModuleRequest( + url("/_vf_modules/_snippets/abc123def456.js"), + ); + assertEquals(result.kind, "snippet"); + if (result.kind === "snippet") { + assertEquals(result.hash, "abc123def456"); + } + }); + + it("classifies full hex hash in snippet URL", () => { + const hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"; + const result = classifyModuleRequest(url(`/_vf_modules/_snippets/${hash}.js`)); + assertEquals(result.kind, "snippet"); + if (result.kind === "snippet") { + assertEquals(result.hash, hash); + } + }); + + it("does NOT classify _snippets path without .js extension as snippet", () => { + // Falls through to dev-module since DEV_MODULE_PREFIX matches + const result = classifyModuleRequest(url("/_vf_modules/_snippets/abc123.ts")); + assertEquals(result.kind, "dev-module"); + }); + }); + + describe("cross-project-versioned", () => { + it("classifies /_vf_modules/_cross/@/@/", () => { + const result = classifyModuleRequest( + url("/_vf_modules/_cross/my-project@1.2.3/@/components/Button.js"), + ); + assertEquals(result.kind, "cross-project-versioned"); + if (result.kind === "cross-project-versioned") { + assertEquals(result.slug, "my-project"); + assertEquals(result.version, "1.2.3"); + assertEquals(result.path, "components/Button.js"); + } + }); + + it("handles semver range version like ^1.0.0", () => { + const result = classifyModuleRequest( + url("/_vf_modules/_cross/demo@^1.0.0/@/lib/utils.js"), + ); + assertEquals(result.kind, "cross-project-versioned"); + if (result.kind === "cross-project-versioned") { + assertEquals(result.version, "^1.0.0"); + } + }); + + it("handles x-range version like 1.x", () => { + const result = classifyModuleRequest( + url("/_vf_modules/_cross/demo@1.x/@/lib/utils.js"), + ); + assertEquals(result.kind, "cross-project-versioned"); + if (result.kind === "cross-project-versioned") { + assertEquals(result.version, "1.x"); + } + }); + }); + + describe("cross-project-latest", () => { + it("classifies /_vf_modules/_cross//@/", () => { + const result = classifyModuleRequest( + url("/_vf_modules/_cross/my-project/@/components/Button.js"), + ); + assertEquals(result.kind, "cross-project-latest"); + if (result.kind === "cross-project-latest") { + assertEquals(result.slug, "my-project"); + assertEquals(result.path, "components/Button.js"); + } + }); + + it("preserves nested path", () => { + const result = classifyModuleRequest( + url("/_vf_modules/_cross/acme-corp/@/a/b/c/deep.js"), + ); + assertEquals(result.kind, "cross-project-latest"); + if (result.kind === "cross-project-latest") { + assertEquals(result.path, "a/b/c/deep.js"); + } + }); + }); + + describe("dev-module", () => { + for ( + const pathname of [ + "/_vf_modules/components/Button.js", + "/_vf_modules/_veryfront/utils/index.js", + "/_veryfront/modules/lib/utils.ts", + "/_vf_modules/_dnt.shims.js", + "/_vf_modules/page.tsx", + ] + ) { + it(`classifies ${pathname} as dev-module`, () => { + const result = classifyModuleRequest(url(pathname)); + assertEquals(result.kind, "dev-module"); + }); + } + + it("classifies /_vf_modules/ with query params as dev-module", () => { + const result = classifyModuleRequest(url("/_vf_modules/file.tsx?t=123&ssr=true")); + assertEquals(result.kind, "dev-module"); + }); + }); + + describe("precedence", () => { + it("snippet prefix takes priority over dev-module", () => { + const result = classifyModuleRequest(url("/_vf_modules/_snippets/deadbeef.js")); + assertEquals(result.kind, "snippet"); + }); + + it("versioned cross-project takes priority over latest cross-project", () => { + // If a slug contains @ it should match versioned, not latest + const result = classifyModuleRequest( + url("/_vf_modules/_cross/proj@2.0.0/@/index.js"), + ); + assertEquals(result.kind, "cross-project-versioned"); + }); + }); +}); diff --git a/src/modules/server/classify.ts b/src/modules/server/classify.ts new file mode 100644 index 0000000000..ed5fc2c426 --- /dev/null +++ b/src/modules/server/classify.ts @@ -0,0 +1,101 @@ +/** + * Module Request Classification + * + * Classifies incoming module request URLs into a discriminated union, moving + * the four URL-pattern regexes out of `serveModule` and providing a single + * pure function that callers can switch on. + * + * @module modules/server/classify + */ + +/** Prefix for dev-module URLs; exported for path stripping in module-server. */ +export const DEV_MODULE_PREFIX = /^\/(?:_vf_modules|_veryfront\/modules)\//; +const SNIPPET_MODULE_PREFIX = /^\/_vf_modules\/_snippets\/([a-f0-9]+)\.js/; +// Cross-project import patterns: /_vf_modules/_cross/[@]/@/ +const CROSS_PROJECT_VERSIONED_PREFIX = + /^\/_vf_modules\/_cross\/([a-z0-9-]+)@([\d^~x][\d.x^~-]*)\/\@\/(.+)$/; +const CROSS_PROJECT_LATEST_PREFIX = /^\/_vf_modules\/_cross\/([a-z0-9-]+)\/\@\/(.+)$/; + +/** URL does not start with any module prefix — not a module request. */ +export interface NotModuleKind { + kind: "not-module"; +} + +/** A compiled snippet module identified by its content hash. */ +export interface SnippetKind { + kind: "snippet"; + /** Hex hash of the snippet source. */ + hash: string; +} + +/** A cross-project import pinned to a specific semver / range version. */ +export interface CrossProjectVersionedKind { + kind: "cross-project-versioned"; + slug: string; + version: string; + path: string; +} + +/** A cross-project import resolved to the latest published version. */ +export interface CrossProjectLatestKind { + kind: "cross-project-latest"; + slug: string; + path: string; +} + +/** A regular project dev-module (including framework modules). */ +export interface DevModuleKind { + kind: "dev-module"; +} + +/** + * Discriminated union of all recognised module URL shapes. + * + * Switch on `kind` to dispatch to the appropriate handler. + */ +export type ModuleRequestKind = + | NotModuleKind + | SnippetKind + | CrossProjectVersionedKind + | CrossProjectLatestKind + | DevModuleKind; + +/** + * Classify a module request URL into one of the known module kinds. + * + * This is a pure function — it performs no I/O and has no side-effects. + * + * @param url - The parsed request URL. + * @returns A `ModuleRequestKind` discriminated union. + */ +export function classifyModuleRequest(url: URL): ModuleRequestKind { + if (!DEV_MODULE_PREFIX.test(url.pathname)) { + return { kind: "not-module" }; + } + + const snippetMatch = url.pathname.match(SNIPPET_MODULE_PREFIX); + if (snippetMatch) { + return { kind: "snippet", hash: snippetMatch[1] ?? "" }; + } + + const versionedMatch = url.pathname.match(CROSS_PROJECT_VERSIONED_PREFIX); + if (versionedMatch) { + return { + kind: "cross-project-versioned", + slug: versionedMatch[1] ?? "", + version: versionedMatch[2] ?? "", + path: versionedMatch[3] ?? "", + }; + } + + const latestMatch = url.pathname.match(CROSS_PROJECT_LATEST_PREFIX); + if (latestMatch) { + return { + kind: "cross-project-latest", + slug: latestMatch[1] ?? "", + path: latestMatch[2] ?? "", + }; + } + + return { kind: "dev-module" }; +} diff --git a/src/modules/server/module-batch-handler.ts b/src/modules/server/module-batch-handler.ts index 0d8bffec3b..7a8381b46f 100644 --- a/src/modules/server/module-batch-handler.ts +++ b/src/modules/server/module-batch-handler.ts @@ -25,15 +25,14 @@ import { } from "#veryfront/utils"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { createSecureFs } from "#veryfront/security"; -import { transformToESM } from "#veryfront/transforms/esm-transform.ts"; import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; import { join } from "#veryfront/compat/path/index.ts"; import { - applySSRImportRewritesAsync, resolveSSRImportTargetModulePath, type SSRImportRewriteTarget, stripSSRModuleJsExtension, } from "./ssr-import-rewriter.ts"; +import { transformModuleToServable } from "./module-transform.ts"; import { buildModuleTransformCacheKey } from "#veryfront/cache/keys.ts"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; import { getFrameworkSourceLookupDirs } from "#veryfront/platform/compat/framework-source-resolver.ts"; @@ -397,26 +396,33 @@ async function transformModule( reactVersion?: string; }, ): Promise { - let code = await transformToESM(source, sourceFile, projectDir, adapter, { - projectId: options.projectId ?? projectDir, - dev: options.dev, - ssr: options.ssr, - reactVersion: options.reactVersion, + return transformModuleToServable({ + source, + sourceFile, + projectDir, + adapter, + transformOpts: { + projectId: options.projectId ?? projectDir, + dev: options.dev, + ssr: options.ssr, + reactVersion: options.reactVersion, + }, + isSSR: options.ssr, + ssrRewriteOptions: options.ssr + ? { + projectSlug: options.projectSlug, + branch: options.branch, + resolveCacheBuster: createBatchSSRTargetCacheBusterResolver({ + projectDir, + secureFs, + currentModulePath: modulePath, + }), + } + : undefined, + // No releaseRewriteOptions: the batch handler does not rewrite release + // dependency imports on the non-SSR path (intentional difference vs + // the module-server paths; noted in module-transform.ts JSDoc). }); - - if (options.ssr) { - code = await applySSRImportRewritesAsync(code, { - projectSlug: options.projectSlug, - branch: options.branch, - resolveCacheBuster: createBatchSSRTargetCacheBusterResolver({ - projectDir, - secureFs, - currentModulePath: modulePath, - }), - }); - } - - return code; } async function readBatchTargetSource( diff --git a/src/modules/server/module-server.ts b/src/modules/server/module-server.ts index c75ef5d76c..91b0c2b6c3 100644 --- a/src/modules/server/module-server.ts +++ b/src/modules/server/module-server.ts @@ -3,7 +3,7 @@ import { join } from "#veryfront/compat/path/index.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; -import { type TransformOptions, transformToESM } from "#veryfront/transforms/esm-transform.ts"; +import type { TransformOptions } from "#veryfront/transforms/esm-transform.ts"; import { serverLogger, VERSION } from "#veryfront/utils"; import { HTTP_NOT_FOUND, HTTP_OK, HTTP_SERVER_ERROR } from "#veryfront/utils"; import { getContentTypeForPath } from "#veryfront/server/handlers/utils/content-types.ts"; @@ -20,7 +20,6 @@ import { injectContext, withSpan } from "#veryfront/observability/tracing/otlp-s import { injectNodePositions } from "#veryfront/transforms/plugins/babel-node-positions.ts"; import { parseProjectDomain } from "#veryfront/server/utils/domain-parser.ts"; import { - applySSRImportRewritesAsync, resolveSSRImportTargetModulePath, type SSRImportRewriteTarget, stripSSRModuleJsExtension, @@ -37,7 +36,6 @@ import { sha256Short } from "#veryfront/cache/hash.ts"; import { getReleaseDependencyRewriteManifestState, hasReleaseDependencyImportSpecifiers, - rewriteReleaseDependencyImportsForModule, } from "#veryfront/release-assets/module-consumption.ts"; import type { ReleaseAssetManifest } from "#veryfront/release-assets/manifest-schema.ts"; import { @@ -56,6 +54,8 @@ import { rememberReleaseModuleResponse, } from "./module-response-cache.ts"; import { ensureFilenameDefaultExport } from "#veryfront/modules/loader-shared/filename-default-export.ts"; +import { classifyModuleRequest, DEV_MODULE_PREFIX } from "./classify.ts"; +import { transformModuleToServable } from "./module-transform.ts"; const logger = serverLogger.component("module-server"); const PROJECT_FALLBACK_EMBEDDED_POLYFILLS = new Set(["deno"]); @@ -129,13 +129,6 @@ export default {}; "deno": `export default ${JSON.stringify({ version: VERSION })};\n`, }; -const DEV_MODULE_PREFIX = /^\/(?:_vf_modules|_veryfront\/modules)\//; -const SNIPPET_MODULE_PREFIX = /^\/_vf_modules\/_snippets\/([a-f0-9]+)\.js/; -// Cross-project import patterns: /_vf_modules/_cross/[@]/@/ -const CROSS_PROJECT_VERSIONED_PREFIX = - /^\/_vf_modules\/_cross\/([a-z0-9-]+)@([\d^~x][\d.x^~-]*)\/\@\/(.+)$/; -const CROSS_PROJECT_LATEST_PREFIX = /^\/_vf_modules\/_cross\/([a-z0-9-]+)\/\@\/(.+)$/; - function appendReleaseModuleVersion(url: string, releaseId: string): string { if ( url.includes(`${RELEASE_MODULE_VERSION_PARAM}=`) || @@ -274,16 +267,17 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise userAgent: debugUserAgent.slice(0, 50), }); - if (!DEV_MODULE_PREFIX.test(url.pathname)) { + const kind = classifyModuleRequest(url); + + if (kind.kind === "not-module") { return createModuleResponse(method, "Module not found", HTTP_NOT_FOUND, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-cache", }); } - const snippetMatch = url.pathname.match(SNIPPET_MODULE_PREFIX); - if (snippetMatch) { - const hash = snippetMatch[1]; + if (kind.kind === "snippet") { + const { hash } = kind; if (!hash) { return createModuleResponse(method, "Missing snippet hash", HTTP_NOT_FOUND, { "Content-Type": "text/plain; charset=utf-8", @@ -316,18 +310,14 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise }); try { - let transformedCode = await profileModuleTransform(() => - transformToESM( - snippetCode, - `_snippets/${hash}.tsx`, - projectDir, - adapter, - { projectId: effectiveProjectId, dev, ssr: isSSR, reactVersion }, - ) - ); - - if (isSSR) { - transformedCode = await applySSRImportRewritesAsync(transformedCode, { + const transformedCode = await transformModuleToServable({ + source: snippetCode, + sourceFile: `_snippets/${hash}.tsx`, + projectDir, + adapter, + transformOpts: { projectId: effectiveProjectId, dev, ssr: isSSR, reactVersion }, + isSSR, + ssrRewriteOptions: { projectSlug: snippetProjectSlug, branch: snippetBranch, resolveCacheBuster: createSSRTargetCacheBusterResolver({ @@ -340,13 +330,13 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise releaseId: options.releaseId, reactVersion, }), - }); - } else { - transformedCode = await rewriteReleaseDependencyImportsForModule(transformedCode, { + }, + releaseRewriteOptions: { releaseId: options.releaseId, readDependencySource: (path) => platformFs.readTextFile(path), - }); - } + }, + profile: true, + }); logger.debug("Snippet transformed", { hash, @@ -373,13 +363,10 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise } } - const versionedMatch = url.pathname.match(CROSS_PROJECT_VERSIONED_PREFIX); - const latestMatch = url.pathname.match(CROSS_PROJECT_LATEST_PREFIX); - - if (versionedMatch || latestMatch) { - const crossProjectSlug = versionedMatch?.[1] ?? latestMatch?.[1]; - const crossVersion = versionedMatch?.[2] ?? "latest"; - const crossPath = versionedMatch?.[3] ?? latestMatch?.[2]; + if (kind.kind === "cross-project-versioned" || kind.kind === "cross-project-latest") { + const crossProjectSlug = kind.slug; + const crossVersion = kind.kind === "cross-project-versioned" ? kind.version : "latest"; + const crossPath = kind.path; if (!crossProjectSlug || !crossPath) { return createModuleResponse(method, "Invalid cross-project import path", HTTP_NOT_FOUND, { @@ -414,18 +401,20 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise const isSSR = isSSRModuleRequest(req, url); - let code = await profileModuleTransform(() => - transformToESM(source, crossPath, projectDir, adapter, { + const code = await transformModuleToServable({ + source, + sourceFile: crossPath, + projectDir, + adapter, + transformOpts: { projectId: effectiveProjectId, dev, ssr: isSSR, moduleServerUrl: `http://${url.host}`, reactVersion, - }) - ); - - if (isSSR) { - code = await applySSRImportRewritesAsync(code, { + }, + isSSR, + ssrRewriteOptions: { crossProjectRef: projectRef, resolveCacheBuster: createSSRTargetCacheBusterResolver({ secureFs, @@ -436,13 +425,13 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise releaseId: options.releaseId, reactVersion, }), - }); - } else { - code = await rewriteReleaseDependencyImportsForModule(code, { + }, + releaseRewriteOptions: { releaseId: options.releaseId, readDependencySource: (path) => platformFs.readTextFile(path), - }); - } + }, + profile: true, + }); return createModuleResponse(method, code, HTTP_OK, { "Content-Type": "application/javascript; charset=utf-8", @@ -457,6 +446,8 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise } } + // dev-module path (kind.kind === "dev-module") + let modulePath = url.pathname.replace(DEV_MODULE_PREFIX, ""); modulePath = modulePath.replace(/^\/+/, ""); if (modulePath.startsWith("_vf_modules/")) { @@ -603,13 +594,22 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise reactVersion, }; - code = await profileModuleTransform(() => - transformToESM(source, sourceFile, projectDir, adapter, transformOpts) - ); - code = ensureFilenameDefaultExport(modulePath, code); - - if (isSSR) { - code = await applySSRImportRewritesAsync(code, { + // The dev-module path has two post-steps that stay outside + // transformModuleToServable to keep its API small: + // - HMR timestamp injection: runs after the full shared sequence + // (originally between the SSR rewrite and the non-SSR release + // rewrite; reordering is safe because they touch disjoint + // specifiers) + // - addReleaseVersionToFallbackImports: runs after the release rewrite + code = await transformModuleToServable({ + source, + sourceFile, + projectDir, + adapter, + transformOpts, + isSSR, + postTransform: (c) => ensureFilenameDefaultExport(modulePath, c), + ssrRewriteOptions: { projectSlug, branch, resolveCacheBuster: createSSRTargetCacheBusterResolver({ @@ -622,8 +622,15 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise releaseId: options.releaseId, reactVersion, }), - }); - } + }, + releaseRewriteOptions: { + releaseId: options.releaseId, + manifest: releaseDependencyRewriteEnabled ? releaseDependencyManifest : undefined, + manifestReadOptions: { refreshCachedNull: true }, + readDependencySource: (path) => platformFs.readTextFile(path), + }, + profile: true, + }); const hmrTimestamp = url.searchParams.get("t"); if (hmrTimestamp) { @@ -635,12 +642,6 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise } if (!isSSR) { - code = await rewriteReleaseDependencyImportsForModule(code, { - releaseId: options.releaseId, - manifest: releaseDependencyRewriteEnabled ? releaseDependencyManifest : undefined, - manifestReadOptions: { refreshCachedNull: true }, - readDependencySource: (path) => platformFs.readTextFile(path), - }); code = await addReleaseVersionToFallbackImports(code, modulePath, options.releaseId); } } @@ -1050,7 +1051,7 @@ async function findSourceFile( */ export function isModuleRequest(req: Request): boolean { const url = new URL(req.url); - return DEV_MODULE_PREFIX.test(url.pathname); + return classifyModuleRequest(url).kind !== "not-module"; } function getModuleHeaders( @@ -1120,15 +1121,6 @@ function createDevModuleErrorBody(modulePath: string, errorMessage: string): str return `// Transform Error\nthrow new Error(${JSON.stringify(errorMessage)});`; } -async function profileModuleTransform(fn: () => Promise): Promise { - const startedAt = performance.now(); - try { - return await profilePhase("module.transform", fn); - } finally { - metrics.recordModuleTransform(performance.now() - startedAt); - } -} - function classifyModuleServeStatus(status: number): ModuleServeStatus { if (status >= 200 && status < 300) return "ok"; if (status === HTTP_NOT_FOUND) return "not_found"; diff --git a/src/modules/server/module-transform.test.ts b/src/modules/server/module-transform.test.ts new file mode 100644 index 0000000000..f7db6f6638 --- /dev/null +++ b/src/modules/server/module-transform.test.ts @@ -0,0 +1,151 @@ +import "#veryfront/schemas/_test-setup.ts"; +/** + * module-transform.ts unit tests + * + * Tests the SSR-vs-release-rewrite decision in `transformModuleToServable` + * and the optional `postTransform` hook. Uses a mock adapter so no filesystem + * access is required; the esbuild transform pipeline is exercised for real, + * with the esbuild service stopped in afterAll so the sanitizers stay clean. + * + * @module modules/server/module-transform.test + */ + +import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { afterAll, describe, it } from "#veryfront/testing/bdd.ts"; +import { createMockAdapter } from "#veryfront/platform/adapters/mock.ts"; +import { stop as stopEsbuild } from "veryfront/extensions/bundler"; +import { transformModuleToServable } from "./module-transform.ts"; + +/** Minimal TypeScript source with a relative import for SSR-rewrite tests. */ +const SOURCE_WITH_IMPORT = `import { child } from "./child.js"; +export const value = child; +`; + +/** Trivial TypeScript source with no imports. */ +const SOURCE_NO_IMPORTS = `export const greeting = "hello"; +`; + +describe( + "transformModuleToServable", + () => { + const adapter = createMockAdapter(); + const projectDir = "/test-project"; + + afterAll(async () => { + await stopEsbuild(); + }); + + describe("SSR-vs-release decision", () => { + it("applies SSR import rewrites when isSSR=true", async () => { + const code = await transformModuleToServable({ + source: SOURCE_WITH_IMPORT, + sourceFile: "/test-project/page.ts", + projectDir, + adapter, + transformOpts: { projectId: "test", dev: true, ssr: true }, + isSSR: true, + ssrRewriteOptions: { projectSlug: "test", branch: null }, + }); + + // applySSRImportRewritesAsync appends ?ssr=true&project= to relative imports + assertStringIncludes(code, "ssr=true"); + assertStringIncludes(code, "project=test"); + }); + + it("does not apply SSR rewrites when isSSR=false", async () => { + const code = await transformModuleToServable({ + source: SOURCE_WITH_IMPORT, + sourceFile: "/test-project/page.ts", + projectDir, + adapter, + transformOpts: { projectId: "test", dev: true, ssr: false }, + isSSR: false, + // ssrRewriteOptions intentionally omitted — should not be called + }); + + assertEquals(code.includes("ssr=true"), false); + }); + + it("skips release dependency rewrite when releaseRewriteOptions is omitted", async () => { + // Source has no http imports so the release rewrite would be a no-op anyway, + // but omitting releaseRewriteOptions means the function returns early on the + // non-SSR path without calling rewriteReleaseDependencyImportsForModule. + const code = await transformModuleToServable({ + source: SOURCE_NO_IMPORTS, + sourceFile: "/test-project/greet.ts", + projectDir, + adapter, + transformOpts: { projectId: "test", dev: true, ssr: false }, + isSSR: false, + // No releaseRewriteOptions — batch-handler behaviour + }); + + assertStringIncludes(code, "greeting"); + }); + + it("enters the non-SSR release branch without throwing when releaseRewriteOptions is provided", async () => { + // No real manifest → rewriteReleaseDependencyImportsForModule returns code + // unchanged (releaseId required; without it the function bails early). + // This test verifies the non-SSR branch is entered without throwing. + const code = await transformModuleToServable({ + source: SOURCE_NO_IMPORTS, + sourceFile: "/test-project/greet.ts", + projectDir, + adapter, + transformOpts: { projectId: "test", dev: false, ssr: false }, + isSSR: false, + releaseRewriteOptions: { + releaseId: null, // null → rewriteReleaseDependencyImportsForModule returns early + readDependencySource: (_path) => Promise.resolve(""), + }, + }); + + assertStringIncludes(code, "greeting"); + }); + }); + + describe("postTransform hook", () => { + it("calls postTransform between transformToESM and SSR rewrites", async () => { + const marker = "/* postTransform-was-called */"; + let postTransformInput = ""; + + const code = await transformModuleToServable({ + source: SOURCE_NO_IMPORTS, + sourceFile: "/test-project/greet.ts", + projectDir, + adapter, + transformOpts: { projectId: "test", dev: true, ssr: false }, + isSSR: false, + postTransform: (c) => { + postTransformInput = c; + return c + "\n" + marker; + }, + }); + + // The hook received output from transformToESM + assertStringIncludes(postTransformInput, "greeting"); + // The hook's output is part of the final code + assertStringIncludes(code, marker); + }); + + it("postTransform result is passed into SSR rewrites", async () => { + const injected = "/* injected */"; + + const code = await transformModuleToServable({ + source: SOURCE_WITH_IMPORT, + sourceFile: "/test-project/page.ts", + projectDir, + adapter, + transformOpts: { projectId: "test", dev: true, ssr: true }, + isSSR: true, + postTransform: (c) => c + "\n" + injected, + ssrRewriteOptions: { projectSlug: "test", branch: null }, + }); + + // Both the injected marker and SSR rewrites are present + assertStringIncludes(code, injected); + assertStringIncludes(code, "ssr=true"); + }); + }); + }, +); diff --git a/src/modules/server/module-transform.ts b/src/modules/server/module-transform.ts new file mode 100644 index 0000000000..58d19b1e28 --- /dev/null +++ b/src/modules/server/module-transform.ts @@ -0,0 +1,123 @@ +/** + * Module Transform — shared ESM transform + SSR / release-rewrite sequence. + * + * Unifies the three near-identical copies in module-server.ts and the fourth + * in module-batch-handler.ts: `transformToESM → (SSR) applySSRImportRewritesAsync + * or (non-SSR) rewriteReleaseDependencyImportsForModule`. + * + * Genuine differences that could not be cleanly unified: + * - `ensureFilenameDefaultExport` in the dev-module path runs between the ESM + * transform and the SSR rewrite; callers use `postTransform` for this. + * - HMR timestamp injection and `addReleaseVersionToFallbackImports` in the + * dev-module path run after the release rewrite; callers apply them manually + * on the returned code. + * - The batch handler (copy 4) never rewrites release dependencies on the + * non-SSR path; callers simply omit `releaseRewriteOptions`. + * + * @module modules/server/module-transform + */ + +import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; +import { INVALID_ARGUMENT } from "#veryfront/errors"; +import { type TransformOptions, transformToESM } from "#veryfront/transforms/esm-transform.ts"; +import { metrics, profilePhase } from "#veryfront/observability"; +import { applySSRImportRewritesAsync, type SSRRewriteOptions } from "./ssr-import-rewriter.ts"; +import { + rewriteReleaseDependencyImportsForModule, + type RewriteReleaseDependencyImportsOptions, +} from "#veryfront/release-assets/module-consumption.ts"; + +/** Options for `transformModuleToServable`. */ +export interface TransformModuleToServableOptions { + /** Raw source code to transform. */ + source: string; + /** Source file path used for source-map generation and type detection. */ + sourceFile: string; + /** Project root directory passed to the transform pipeline. */ + projectDir: string; + /** Platform runtime adapter. */ + adapter: RuntimeAdapter; + /** Options forwarded directly to `transformToESM`. */ + transformOpts: TransformOptions; + /** Whether this is an SSR (server-side rendering) request. */ + isSSR: boolean; + /** + * Optional hook called after `transformToESM` and before SSR / release + * rewrites. Use for steps like `ensureFilenameDefaultExport` that must + * occur between the two stages. + */ + postTransform?: (code: string) => string | Promise; + /** + * SSR import-rewrite options. Required when `isSSR=true` (the transform + * throws otherwise, so an SSR module can never be served un-rewritten). + * Pass `projectSlug`/`branch` or `crossProjectRef` plus a `resolveCacheBuster`. + */ + ssrRewriteOptions?: SSRRewriteOptions; + /** + * Release-dependency import-rewrite options. Applied when `isSSR=false`. + * Omit (or pass `undefined`) to skip release dependency rewriting — used by + * the batch handler which has no non-SSR release rewrite step. + */ + releaseRewriteOptions?: RewriteReleaseDependencyImportsOptions; + /** + * When `true`, wraps `transformToESM` in observability profiling + * (`module.transform` phase + metrics). Set by module-server.ts; the batch + * handler leaves this `false`. + */ + profile?: boolean; +} + +/** + * Run the shared module-serving transform sequence: + * + * 1. `transformToESM` (optionally profiled) + * 2. Optional `postTransform` hook (e.g. `ensureFilenameDefaultExport`) + * 3a. If SSR: `applySSRImportRewritesAsync` + * 3b. If not SSR: `rewriteReleaseDependencyImportsForModule` (when options provided) + * + * @returns Transformed JavaScript source code ready to serve. + */ +export async function transformModuleToServable( + options: TransformModuleToServableOptions, +): Promise { + const { + source, + sourceFile, + projectDir, + adapter, + transformOpts, + isSSR, + profile = false, + } = options; + + const doTransform = () => transformToESM(source, sourceFile, projectDir, adapter, transformOpts); + let code = profile ? await profiledTransform(doTransform) : await doTransform(); + + if (options.postTransform) { + code = await options.postTransform(code); + } + + if (isSSR) { + if (!options.ssrRewriteOptions) { + throw INVALID_ARGUMENT.create({ + detail: "transformModuleToServable requires ssrRewriteOptions when isSSR is true", + context: { sourceFile }, + }); + } + code = await applySSRImportRewritesAsync(code, options.ssrRewriteOptions); + } else if (options.releaseRewriteOptions) { + code = await rewriteReleaseDependencyImportsForModule(code, options.releaseRewriteOptions); + } + + return code; +} + +/** Wrap a transform function with `module.transform` profiling and metrics. */ +async function profiledTransform(fn: () => Promise): Promise { + const startedAt = performance.now(); + try { + return await profilePhase("module.transform", fn); + } finally { + metrics.recordModuleTransform(performance.now() - startedAt); + } +} diff --git a/src/modules/server/ssr-import-rewriter.ts b/src/modules/server/ssr-import-rewriter.ts index a8983997eb..050a6ca7b9 100644 --- a/src/modules/server/ssr-import-rewriter.ts +++ b/src/modules/server/ssr-import-rewriter.ts @@ -44,7 +44,7 @@ export function resolveSSRImportTargetModulePath( return normalizeSSRModulePath(resolved); } -interface SSRRewriteOptions { +export interface SSRRewriteOptions { /** Project slug for multi-project routing */ projectSlug?: string | null; /** Branch name for branch-aware routing */