From f1cf8ffa8443d63a87c55e817d65bab1ca6f730c Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Sat, 19 Jul 2025 00:06:06 +0200 Subject: [PATCH 01/21] major: build islands + scan routeDir without importing user code This fixes a bunch of long standing issues in our tracker where a build wouldn't finish because of importing user code. It's common for backend apps to spawn some open ended tasks like subscribing to Deno KV or other things. This addresses another problem of having to set up ENV vars just for the build, even though they are technically not required to build islands. This also gives a roughly ~30% performance boost on cold starts. --- deno.json | 3 +- init/src/init.ts | 17 +- init/src/init_test.ts | 2 +- plugin-tailwindcss/src/mod.ts | 6 +- src/app.ts | 324 +++++--------- src/app_test.tsx | 79 +--- src/build_cache.ts | 147 +++---- src/build_cache_test.ts | 80 ---- src/commands.ts | 309 ++++++++++++++ src/config.ts | 57 +-- src/config_test.ts | 101 +---- src/context.ts | 16 +- src/dev/builder.ts | 376 +++++++++++------ src/dev/builder_test.ts | 166 +++++--- src/dev/check.ts | 73 ++++ src/dev/dev_build_cache.ts | 296 +++++++++---- src/dev/dev_build_cache_test.ts | 20 +- src/dev/file_transformer.ts | 11 +- src/dev/fs_crawl.ts | 102 +++++ src/dev/mod.ts | 6 +- src/finish_setup.tsx | 54 --- src/fs_routes.ts | 277 ++++++++++++ .../mod_test.tsx => fs_routes_test.tsx} | 66 ++- src/internals.ts | 5 + src/middlewares/static_files_test.ts | 25 +- src/mod.ts | 1 - src/plugins/fs_routes/mod.ts | 397 ------------------ src/render.ts | 9 +- src/router.ts | 3 +- src/router_test.ts | 2 +- src/runtime/server/preact_hooks.tsx | 25 +- src/test_utils.ts | 40 +- src/utils.ts | 20 + tests/active_links_test.tsx | 19 +- tests/fixture_precompile/invalid/dev.ts | 3 +- tests/islands_test.tsx | 108 +---- tests/partials_test.tsx | 22 +- tests/test_utils.tsx | 154 ++++--- www/deno.json | 2 +- www/dev.ts | 7 +- www/main.ts | 12 +- www/main_test.ts | 4 +- 42 files changed, 1775 insertions(+), 1671 deletions(-) delete mode 100644 src/build_cache_test.ts create mode 100644 src/commands.ts create mode 100644 src/dev/check.ts create mode 100644 src/dev/fs_crawl.ts delete mode 100644 src/finish_setup.tsx create mode 100644 src/fs_routes.ts rename src/{plugins/fs_routes/mod_test.tsx => fs_routes_test.tsx} (96%) create mode 100644 src/internals.ts delete mode 100644 src/plugins/fs_routes/mod.ts diff --git a/deno.json b/deno.json index aebbb5631a7..fa1fb6e2c7a 100644 --- a/deno.json +++ b/deno.json @@ -14,7 +14,8 @@ ".": "./src/mod.ts", "./runtime": "./src/runtime/shared.ts", "./dev": "./src/dev/mod.ts", - "./compat": "./src/compat.ts" + "./compat": "./src/compat.ts", + "./do-not-use": "./src/internals.ts" }, "tasks": { "test": "deno test -A --parallel", diff --git a/init/src/init.ts b/init/src/init.ts index ffb6f50a69c..c8e532b1ab1 100644 --- a/init/src/init.ts +++ b/init/src/init.ts @@ -349,7 +349,7 @@ ${GRADIENT_CSS}`; // Skip this and be silent if there is a network issue. } - const MAIN_TS = `import { App, fsRoutes, staticFiles } from "fresh"; + const MAIN_TS = `import { App, staticFiles } from "fresh"; import { define, type State } from "./utils.ts"; export const app = new App(); @@ -371,10 +371,8 @@ const exampleLoggerMiddleware = define.middleware((ctx) => { }); app.use(exampleLoggerMiddleware); -await fsRoutes(app, { - loadIsland: (path) => import(\`./islands/\${path}\`), - loadRoute: (path) => import(\`./routes/\${path}\`), -}); +// Include file-system based routes here +app.fsRoutes(); if (import.meta.main) { await app.listen(); @@ -489,14 +487,13 @@ export default function Counter(props: CounterProps) { const DEV_TS = `#!/usr/bin/env -S deno run -A --watch=static/,routes/ ${useTailwind ? `import { tailwind } from "@fresh/plugin-tailwind";\n` : ""} import { Builder } from "fresh/dev"; -import { app } from "./main.ts"; const builder = new Builder(); -${useTailwind ? "tailwind(builder, app);" : ""} +${useTailwind ? "tailwind(builder);" : ""} if (Deno.args.includes("build")) { - await builder.build(app); + await builder.build(); } else { - await builder.listen(app); + await builder.listen(() => import("./main.ts")); }`; await writeFile("dev.ts", DEV_TS); @@ -506,7 +503,7 @@ if (Deno.args.includes("build")) { check: "deno fmt --check . && deno lint . && deno check", dev: "deno run -A --watch=static/,routes/ dev.ts", build: "deno run -A dev.ts build", - start: "deno run -A main.ts", + start: "deno serve -A _fresh/server.js", update: "deno run -A -r jsr:@fresh/update .", }, lint: { diff --git a/init/src/init_test.ts b/init/src/init_test.ts index b6964f06736..e4c9001b48b 100644 --- a/init/src/init_test.ts +++ b/init/src/init_test.ts @@ -240,5 +240,5 @@ Deno.test("init - errors on missing build cache in prod", async () => { const { stderr } = getStdOutput(cp); expect(cp.code).toEqual(1); - expect(stderr).toMatch(/Found 1 islands, but did not/); + expect(stderr).toMatch(/Module not found/); }); diff --git a/plugin-tailwindcss/src/mod.ts b/plugin-tailwindcss/src/mod.ts index 6184f320402..6db172a80b5 100644 --- a/plugin-tailwindcss/src/mod.ts +++ b/plugin-tailwindcss/src/mod.ts @@ -1,5 +1,4 @@ import type { Builder } from "fresh/dev"; -import type { App } from "fresh"; import twPostcss from "@tailwindcss/postcss"; import postcss from "postcss"; import type { TailwindPluginOptions } from "./types.ts"; @@ -7,14 +6,13 @@ import type { TailwindPluginOptions } from "./types.ts"; // Re-export types for public API export type { TailwindPluginOptions } from "./types.ts"; -export function tailwind( +export function tailwind( builder: Builder, - app: App, options: TailwindPluginOptions = {}, ): void { const { exclude, ...tailwindOptions } = options; const instance = postcss(twPostcss({ - optimize: app.config.mode === "production", + optimize: builder.config.mode === "production", ...tailwindOptions, })); diff --git a/src/app.ts b/src/app.ts index dc24a4b05c9..9b5d2e06448 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,35 +1,30 @@ -import { type ComponentType, h } from "preact"; -import { renderToString } from "preact-render-to-string"; import { trace } from "@opentelemetry/api"; import { DENO_DEPLOYMENT_ID } from "./runtime/build_id.ts"; import * as colors from "@std/fmt/colors"; import { type MiddlewareFn, runMiddlewares } from "./middlewares/mod.ts"; -import { Context, type ServerIslandRegistry } from "./context.ts"; -import { - mergePath, - type Method, - type Router, - UrlPatternRouter, -} from "./router.ts"; -import { - type FreshConfig, - normalizeConfig, - type ResolvedFreshConfig, -} from "./config.ts"; -import { type BuildCache, ProdBuildCache } from "./build_cache.ts"; -import { FinishSetup, ForgotBuild } from "./finish_setup.tsx"; +import { Context } from "./context.ts"; +import { mergePath, type Method, UrlPatternRouter } from "./router.ts"; +import type { FreshConfig, ResolvedFreshConfig } from "./config.ts"; +import type { BuildCache } from "./build_cache.ts"; import { HttpError } from "./error.ts"; -import { pathToExportName } from "./utils.ts"; import type { LayoutConfig, Route } from "./types.ts"; +import type { RouteComponent } from "./segments.ts"; import { - getOrCreateSegment, - newSegment, - renderRoute, - type RouteComponent, - segmentToMiddlewares, -} from "./segments.ts"; -import { isHandlerByMethod, type PageResponse } from "./handlers.ts"; + applyComands, + type Command, + CommandType, + DEFAULT_NOT_ALLOWED_METHOD, + DEFAULT_NOT_FOUND, + newAppCmd, + newErrorCmd, + newHandlerCmd, + newLayoutCmd, + newMiddlewareCmd, + newNotFoundCmd, + newRouteCmd, +} from "./commands.ts"; +import { MockBuildCache } from "./test_utils.ts"; // TODO: Completed type clashes in older Deno versions // deno-lint-ignore no-explicit-any @@ -38,16 +33,6 @@ export const DEFAULT_CONN_INFO: any = { remoteAddr: { transport: "tcp", hostname: "localhost", port: 1234 }, }; -const DEFAULT_RENDER = (): Promise> => - // deno-lint-ignore no-explicit-any - Promise.resolve({ data: {} as any }); - -const DEFAULT_NOT_FOUND = (): Promise => { - throw new HttpError(404); -}; -const DEFAULT_NOT_ALLOWED_METHOD = (): Promise => { - throw new HttpError(405); -}; const defaultOptionsHandler = (methods: string[]): () => Promise => { return () => Promise.resolve( @@ -159,35 +144,26 @@ async function listenOnFreePort( throw firstError; } -// deno-lint-ignore no-explicit-any -export let getIslandRegistry: (app: App) => ServerIslandRegistry; -// deno-lint-ignore no-explicit-any -export let getBuildCache: (app: App) => BuildCache | null; -// deno-lint-ignore no-explicit-any -export let setBuildCache: (app: App, cache: BuildCache | null) => void; +export let getBuildCache: (app: App) => BuildCache | null; +export let setBuildCache: ( + app: App, + cache: BuildCache, +) => void; /** * Create an application instance that passes the incoming `Request` * instance through middlewares and routes. */ export class App { - #router: Router> = new UrlPatternRouter< - MiddlewareFn - >(); - #islandRegistry: ServerIslandRegistry = new Map(); - #buildCache: BuildCache | null = null; - #islandNames = new Set(); - #root = newSegment("", null); - #routeDefs: { - method: Method | "ALL"; - pattern: string; - fns: MiddlewareFn[]; - }[] = []; + #getBuildCache: () => BuildCache | null = () => null; + #commands: Command[] = []; static { - getIslandRegistry = (app) => app.#islandRegistry; - getBuildCache = (app) => app.#buildCache; - setBuildCache = (app, cache) => app.#buildCache = cache; + getBuildCache = (app) => app.#getBuildCache(); + setBuildCache = (app, cache) => { + app.config.root = cache.root; + app.#getBuildCache = () => cache; + }; } /** @@ -196,33 +172,11 @@ export class App { config: ResolvedFreshConfig; constructor(config: FreshConfig = {}) { - this.config = normalizeConfig(config); - } - - island( - filePathOrUrl: string | URL, - exportName: string, - // deno-lint-ignore no-explicit-any - fn: ComponentType, - ): this { - const filePath = filePathOrUrl instanceof URL - ? filePathOrUrl.href - : filePathOrUrl; - - // Create unique island name - let name = exportName === "default" - ? pathToExportName(filePath) - : exportName; - if (this.#islandNames.has(name)) { - let i = 0; - while (this.#islandNames.has(`${name}_${i}`)) { - i++; - } - name = `${name}_${i}`; - } - - this.#islandRegistry.set(fn, { fn, exportName, name, file: filePathOrUrl }); - return this; + this.config = { + root: Deno.cwd(), + basePath: config.basePath ?? "", + mode: "production", + }; } /** @@ -234,19 +188,18 @@ export class App { pathOrMiddleware: string | MiddlewareFn, ...middlewares: MiddlewareFn[] ): this { - let path: string; + let pattern: string; let fns: MiddlewareFn[]; if (typeof pathOrMiddleware === "string") { - path = pathOrMiddleware; + pattern = pathOrMiddleware; fns = middlewares!; } else { - path = "*"; + pattern = "*"; middlewares.unshift(pathOrMiddleware); fns = middlewares; } - const segment = getOrCreateSegment(this.#root, path, true); - segment.middlewares.push(...fns); + this.#commands.push(newMiddlewareCmd(pattern, fns, true)); return this; } @@ -255,12 +208,7 @@ export class App { * Set the app's 404 error handler. Can be a {@linkcode Route} or a {@linkcode MiddlewareFn}. */ notFound(routeOrMiddleware: Route | MiddlewareFn): this { - const route = typeof routeOrMiddleware === "function" - ? { handler: routeOrMiddleware } - : routeOrMiddleware; - ensureHandler(route); - this.#root.notFound = (ctx) => renderRoute(ctx, route); - + this.#commands.push(newNotFoundCmd(routeOrMiddleware)); return this; } @@ -268,19 +216,13 @@ export class App { path: string, routeOrMiddleware: Route | MiddlewareFn, ): this { - const segment = getOrCreateSegment(this.#root, path, true); - segment.errorRoute = typeof routeOrMiddleware === "function" - ? { handler: routeOrMiddleware } - : routeOrMiddleware; - - ensureHandler(segment.errorRoute); - + this.#commands.push(newErrorCmd(path, routeOrMiddleware, true)); return this; } - appWrapper(component: RouteComponent) { - const segment = getOrCreateSegment(this.#root, "", false); - segment.app = component; + appWrapper(component: RouteComponent): this { + this.#commands.push(newAppCmd(component)); + return this; } layout( @@ -288,32 +230,12 @@ export class App { component: RouteComponent, config?: LayoutConfig, ): this { - const segment = getOrCreateSegment(this.#root, path, true); - segment.layout = { component, config: config ?? null }; - + this.#commands.push(newLayoutCmd(path, component, config, true)); return this; } route(path: string, route: Route): this { - const segment = getOrCreateSegment(this.#root, path, false); - const middlewares = segmentToMiddlewares(segment); - - ensureHandler(route); - middlewares.push((ctx) => renderRoute(ctx, route)); - - const routePath = mergePath( - this.config.basePath, - route.config?.routeOverride ?? path, - ); - - if (typeof route.handler === "function") { - this.#addRoute("ALL", routePath, middlewares); - } else if (isHandlerByMethod(route.handler!)) { - for (const method of Object.keys(route.handler)) { - this.#addRoute(method as Method, routePath, middlewares); - } - } - + this.#commands.push(newRouteCmd(path, route, false)); return this; } @@ -321,42 +243,42 @@ export class App { * Add middlewares for GET requests at the specified path. */ get(path: string, ...middlewares: MiddlewareFn[]): this { - this.#addMiddleware("GET", path, middlewares); + this.#commands.push(newHandlerCmd("GET", path, middlewares, false)); return this; } /** * Add middlewares for POST requests at the specified path. */ post(path: string, ...middlewares: MiddlewareFn[]): this { - this.#addMiddleware("POST", path, middlewares); + this.#commands.push(newHandlerCmd("POST", path, middlewares, false)); return this; } /** * Add middlewares for PATCH requests at the specified path. */ patch(path: string, ...middlewares: MiddlewareFn[]): this { - this.#addMiddleware("PATCH", path, middlewares); + this.#commands.push(newHandlerCmd("PATCH", path, middlewares, false)); return this; } /** * Add middlewares for PUT requests at the specified path. */ put(path: string, ...middlewares: MiddlewareFn[]): this { - this.#addMiddleware("PUT", path, middlewares); + this.#commands.push(newHandlerCmd("PUT", path, middlewares, false)); return this; } /** * Add middlewares for DELETE requests at the specified path. */ delete(path: string, ...middlewares: MiddlewareFn[]): this { - this.#addMiddleware("DELETE", path, middlewares); + this.#commands.push(newHandlerCmd("DELETE", path, middlewares, false)); return this; } /** * Add middlewares for HEAD requests at the specified path. */ head(path: string, ...middlewares: MiddlewareFn[]): this { - this.#addMiddleware("HEAD", path, middlewares); + this.#commands.push(newHandlerCmd("HEAD", path, middlewares, false)); return this; } @@ -364,30 +286,22 @@ export class App { * Add middlewares for all HTTP verbs at the specified path. */ all(path: string, ...middlewares: MiddlewareFn[]): this { - this.#addMiddleware("ALL", path, middlewares); + this.#commands.push(newHandlerCmd("ALL", path, middlewares, false)); return this; } - #addMiddleware( - method: Method | "ALL", - path: string, - fns: MiddlewareFn[], - ) { - const segment = getOrCreateSegment(this.#root, path, false); - const result = segmentToMiddlewares(segment); - - result.push(...fns); - - const resPath = mergePath(this.config.basePath, path); - this.#addRoute(method, resPath, result); - } - - #addRoute( - method: Method | "ALL", - path: string, - fns: MiddlewareFn[], - ) { - this.#routeDefs.push({ method, pattern: path, fns }); + fsRoutes(pattern = "*"): this { + this.#commands.push({ + type: CommandType.FsRoute, + pattern, + getItems: () => { + const buildCache = this.#getBuildCache(); + if (buildCache === null) return []; + return buildCache.getFsRoutes(); + }, + includeLastSegment: false, + }); + return this; } /** @@ -395,26 +309,25 @@ export class App { * specified path. */ mountApp(path: string, app: App): this { - const segmentPath = mergePath("", path); - const segment = getOrCreateSegment(this.#root, segmentPath, true); - const fns = segmentToMiddlewares(segment); - - segment.middlewares.push(...app.#root.middlewares); - - const routes = app.#routeDefs; - for (let i = 0; i < routes.length; i++) { - const route = routes[i]; + for (let i = 0; i < app.#commands.length; i++) { + const cmd = app.#commands[i]; + + if (cmd.type !== CommandType.App && cmd.type !== CommandType.NotFound) { + const clone = { + ...cmd, + pattern: mergePath(path, cmd.pattern), + includeLastSegment: cmd.pattern === "/" || cmd.includeLastSegment, + }; + this.#commands.push(clone); + continue; + } - const merged = mergePath(path, route.pattern); - const mergedFns = [...fns, ...route.fns]; - this.#addRoute(route.method, merged, mergedFns); + this.#commands.push(cmd); } - app.#islandRegistry.forEach((value, key) => { - this.#islandRegistry.set(key, value); - }); - - app.#root.notFound = this.#root.notFound; + // deno-lint-ignore no-this-alias + const self = this; + app.#getBuildCache = () => self.#getBuildCache(); return this; } @@ -427,36 +340,27 @@ export class App { request: Request, info?: Deno.ServeHandlerInfo, ) => Promise { - if (this.#buildCache === null) { - this.#buildCache = ProdBuildCache.fromSnapshot( - this.config, - this.#islandRegistry.size, - ); - } - - if ( - !this.#buildCache.hasSnapshot && this.config.mode === "production" && - DENO_DEPLOYMENT_ID !== undefined - ) { - return missingBuildHandler; - } - - for (let i = 0; i < this.#routeDefs.length; i++) { - const route = this.#routeDefs[i]; - if (route.method === "ALL") { - this.#router.add("GET", route.pattern, route.fns); - this.#router.add("DELETE", route.pattern, route.fns); - this.#router.add("HEAD", route.pattern, route.fns); - this.#router.add("OPTIONS", route.pattern, route.fns); - this.#router.add("PATCH", route.pattern, route.fns); - this.#router.add("POST", route.pattern, route.fns); - this.#router.add("PUT", route.pattern, route.fns); + let buildCache = this.#getBuildCache(); + if (buildCache === null) { + if ( + this.config.mode === "production" && + DENO_DEPLOYMENT_ID !== undefined + ) { + throw new Error( + `Could not find _fresh directory. Maybe you forgot to run "deno task build"?`, + ); } else { - this.#router.add(route.method, route.pattern, route.fns); + buildCache = new MockBuildCache([]); } } - const rootMiddlewares = segmentToMiddlewares(this.#root); + const router = new UrlPatternRouter>(); + + const { rootMiddlewares } = applyComands( + router, + this.#commands, + this.config.basePath, + ); return async ( req: Request, @@ -467,7 +371,7 @@ export class App { url.pathname = url.pathname.replace(/\/+/g, "/"); const method = req.method.toUpperCase() as Method; - const matched = this.#router.match(method, url); + const matched = router.match(method, url); let { params, pattern, handlers, methodMatch } = matched; const span = trace.getActiveSpan(); @@ -484,7 +388,7 @@ export class App { if (matched.pattern !== null && !methodMatch) { if (method === "OPTIONS") { - const allowed = this.#router.getAllowedMethods(matched.pattern); + const allowed = router.getAllowedMethods(matched.pattern); next = defaultOptionsHandler(allowed); } else { next = DEFAULT_NOT_ALLOWED_METHOD; @@ -501,8 +405,7 @@ export class App { params, this.config, next, - this.#islandRegistry, - this.#buildCache!, + buildCache!, ); try { @@ -540,26 +443,3 @@ export class App { await listenOnFreePort(options, handler); } } - -// deno-lint-ignore require-await -const missingBuildHandler = async (): Promise => { - const headers = new Headers(); - headers.set("Content-Type", "text/html; charset=utf-8"); - - const html = DENO_DEPLOYMENT_ID - ? renderToString(h(FinishSetup, null)) - : renderToString(h(ForgotBuild, null)); - return new Response(html, { headers, status: 500 }); -}; - -function ensureHandler(route: Route) { - if (route.handler === undefined) { - route.handler = route.component !== undefined - ? DEFAULT_RENDER - : DEFAULT_NOT_FOUND; - } else if (isHandlerByMethod(route.handler)) { - if (route.component !== undefined && !route.handler.GET) { - route.handler.GET = DEFAULT_RENDER; - } - } -} diff --git a/src/app_test.tsx b/src/app_test.tsx index de956123f72..f496fef45df 100644 --- a/src/app_test.tsx +++ b/src/app_test.tsx @@ -1,7 +1,6 @@ import { expect } from "@std/expect"; -import { App, getIslandRegistry, setBuildCache } from "./app.ts"; +import { App } from "./app.ts"; import { FakeServer } from "./test_utils.ts"; -import { ProdBuildCache } from "./build_cache.ts"; import { HttpError } from "./error.ts"; Deno.test("App - .use()", async () => { @@ -261,6 +260,27 @@ Deno.test("App - .mountApp() compose apps", async () => { expect(await res.text()).toEqual("A"); }); +Deno.test("App - .mountApp() compose apps with .route()", async () => { + const innerApp = new App<{ text: string }>() + .use((ctx) => { + ctx.state.text = "A"; + return ctx.next(); + }) + .route("/", { handler: (ctx) => new Response(ctx.state.text) }); + + const app = new App<{ text: string }>() + .get("/", () => new Response("ok")) + .mountApp("/foo", innerApp); + + const server = new FakeServer(app.handler()); + + let res = await server.get("/"); + expect(await res.text()).toEqual("ok"); + + res = await server.get("/foo"); + expect(await res.text()).toEqual("A"); +}); + Deno.test("App - .mountApp() self mount, no middleware", async () => { const innerApp = new App<{ text: string }>() .use((ctx) => { @@ -458,30 +478,6 @@ Deno.test("App - catches errors", async () => { expect(thrownErr).toBeInstanceOf(Error); }); -// TODO: Find a better way to test this -Deno.test.ignore("App - finish setup", async () => { - const app = new App<{ text: string }>() - .get("/", (ctx) => { - return ctx.render(
ok
); - }); - - setBuildCache( - app, - await ProdBuildCache.fromSnapshot({ - ...app.config, - build: { - outDir: "foo", - }, - }, getIslandRegistry(app).size), - ); - - const server = new FakeServer(app.handler()); - const res = await server.get("/"); - const text = await res.text(); - expect(text).toContain("Finish setting up"); - expect(res.status).toEqual(500); -}); - Deno.test("App - sets error on context", async () => { const thrown: [unknown, unknown][] = []; const app = new App() @@ -545,37 +541,6 @@ Deno.test("App - throw when middleware returns no response", async () => { expect(text).toContain("Internal server error"); }); -Deno.test("App - adding Island should convert to valid export names", () => { - const app = new App(); - const islands = getIslandRegistry(app); - - const component1 = () => <>OK; - const component2 = () => <>OK; - const component3 = () => <>OK; - app.island("/islands/foo.v2.tsx", "default", component1); - app.island("/islands/_bar-baz-...-$.tsx", "default", component2); - app.island("/islands/1_hello.tsx", "default", component3); - - expect(islands.get(component1)!).toEqual({ - file: "/islands/foo.v2.tsx", - name: "foo_v2", - exportName: "default", - fn: component1, - }); - expect(islands.get(component2)!).toEqual({ - file: "/islands/_bar-baz-...-$.tsx", - name: "_bar_baz_$", - exportName: "default", - fn: component2, - }); - expect(islands.get(component3)!).toEqual({ - file: "/islands/1_hello.tsx", - name: "_hello", - exportName: "default", - fn: component3, - }); -}); - Deno.test("App - overwrite default 404 handler", async () => { const app = new App() .notFound(() => new Response("bar", { status: 404 })) diff --git a/src/build_cache.ts b/src/build_cache.ts index 709ab36fef3..a3595ee9d24 100644 --- a/src/build_cache.ts +++ b/src/build_cache.ts @@ -1,18 +1,22 @@ import * as path from "@std/path"; -import { getSnapshotPath, type ResolvedFreshConfig } from "./config.ts"; -import { DENO_DEPLOYMENT_ID, setBuildId } from "./runtime/build_id.ts"; -import * as colors from "@std/fmt/colors"; +import { setBuildId } from "./runtime/build_id.ts"; +import type { Command } from "./commands.ts"; +import { fsItemsToCommands, type FsRouteFile } from "./fs_routes.ts"; +import type { ServerIslandRegistry } from "./context.ts"; +import type { AnyComponent } from "preact"; +import { UniqueNamer } from "./utils.ts"; export interface FileSnapshot { - generated: boolean; + name: string; + filePath: string; hash: string | null; } -export interface BuildSnapshot { - version: number; - buildId: string; - staticFiles: Record; - islands: Record; +export interface BuildSnapshot { + version: string; + fsRoutes: FsRouteFile[]; + staticFiles: Map; + islands: ServerIslandRegistry; } export interface StaticFile { @@ -22,98 +26,35 @@ export interface StaticFile { close(): void; } -export interface BuildCache { - hasSnapshot: boolean; +// deno-lint-ignore no-explicit-any +export interface BuildCache { + root: string; + islandRegistry: ServerIslandRegistry; + getFsRoutes(): Command[]; readFile(pathname: string): Promise; - getIslandChunkName(islandName: string): string | null; } -export class ProdBuildCache implements BuildCache { - static fromSnapshot(config: ResolvedFreshConfig, islandCount: number) { - const snapshotPath = getSnapshotPath(config); - - const staticFiles = new Map(); - const islandToChunk = new Map(); - - let hasSnapshot = false; - try { - const content = Deno.readTextFileSync(snapshotPath); - const snapshot = JSON.parse(content) as BuildSnapshot; - hasSnapshot = true; - setBuildId(snapshot.buildId); - - const files = Object.keys(snapshot.staticFiles); - for (let i = 0; i < files.length; i++) { - const pathname = files[i]; - const info = snapshot.staticFiles[pathname]; - staticFiles.set(pathname, info); - } - - const islands = Object.keys(snapshot.islands); - for (let i = 0; i < islands.length; i++) { - const pathname = islands[i]; - islandToChunk.set(pathname, snapshot.islands[pathname]); - } - - if (!DENO_DEPLOYMENT_ID) { - // deno-lint-ignore no-console - console.log( - `Found snapshot at ${colors.cyan(snapshotPath)}`, - ); - } - } catch (err) { - if ((err instanceof Deno.errors.NotFound)) { - if (islandCount > 0) { - throw new Error( - `Found ${ - colors.green(`${islandCount} islands`) - }, but did not find build snapshot at:\n${ - colors.red(snapshotPath) - }.\n\nMaybe your forgot to run ${ - colors.cyan("deno task build") - } before starting the production server\nor maybe you wanted to run ${ - colors.cyan("deno task dev") - } to spin up a development server instead?\n`, - ); - } - } else { - throw err; - } - } +export class ProdBuildCache implements BuildCache { + #snapshot: BuildSnapshot; + islandRegistry: ServerIslandRegistry; - return new ProdBuildCache(config, islandToChunk, staticFiles, hasSnapshot); + constructor(public root: string, snapshot: BuildSnapshot) { + setBuildId(snapshot.version); + this.#snapshot = snapshot; + this.islandRegistry = snapshot.islands; } - #islands: Map; - #fileInfo: Map; - #config: ResolvedFreshConfig; - - constructor( - config: ResolvedFreshConfig, - islands: Map, - files: Map, - public hasSnapshot: boolean, - ) { - this.#islands = islands; - this.#fileInfo = files; - this.#config = config; + getFsRoutes(): Command[] { + return fsItemsToCommands(this.#snapshot.fsRoutes); } async readFile(pathname: string): Promise { - const info = this.#fileInfo.get(pathname); - if (info === undefined) return null; + const { staticFiles } = this.#snapshot; - const base = info.generated - ? this.#config.build.outDir - : this.#config.staticDir; - const filePath = info.generated - ? path.join(base, "static", pathname) - : path.join(base, pathname); + const info = staticFiles.get(pathname); + if (info === undefined) return null; - // Check if path resolves outside of intended directory. - if (path.relative(base, filePath).startsWith("..")) { - return null; - } + const filePath = path.join(this.root, info.filePath); const [stat, file] = await Promise.all([ Deno.stat(filePath), @@ -127,8 +68,30 @@ export class ProdBuildCache implements BuildCache { close: () => file.close(), }; } +} + +export class IslandPreparer { + namer = new UniqueNamer(); - getIslandChunkName(islandName: string): string | null { - return this.#islands.get(islandName) ?? null; + prepare( + registry: ServerIslandRegistry, + mod: Record, + chunkName: string, + modName: string, + ) { + for (const [name, value] of Object.entries(mod)) { + if (typeof value !== "function") continue; + + const islandName = name === "default" ? modName : name; + const uniqueName = this.namer.getUniqueName(islandName); + + const fn = value as AnyComponent; + registry.set(fn, { + exportName: name, + file: chunkName, + fn, + name: uniqueName, + }); + } } } diff --git a/src/build_cache_test.ts b/src/build_cache_test.ts deleted file mode 100644 index 24e65b5df0a..00000000000 --- a/src/build_cache_test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { expect } from "@std/expect"; -import * as path from "@std/path"; -import { ProdBuildCache, type StaticFile } from "./build_cache.ts"; -import { withTmpDir } from "./test_utils.ts"; -import type { ResolvedFreshConfig } from "./mod.ts"; - -async function getContent(readResult: Promise) { - const res = await readResult; - if (res === null) return null; - if (res.readable instanceof Uint8Array) throw new Error("not implemented"); - return new Response(res.readable).text(); -} - -Deno.test({ - name: "ProdBuildCache - should error if reading outside of staticDir", - fn: async () => { - await using _tmp = await withTmpDir(); - const tmp = _tmp.dir; - const config: ResolvedFreshConfig = { - root: tmp, - mode: "production", - basePath: "/", - staticDir: path.join(tmp, "static"), - build: { - outDir: path.join(tmp, "dist"), - }, - }; - await Deno.mkdir(path.join(tmp, "static", ".well-known"), { - recursive: true, - }); - await Deno.mkdir(path.join(tmp, "dist", "static"), { - recursive: true, - }); - await Promise.all([ - Deno.writeTextFile( - path.join(tmp, "dist", "secret-styles.css"), - "SECRET!", - ), - Deno.writeTextFile(path.join(tmp, "SECRETS.txt"), "SECRET!"), - Deno.writeTextFile(path.join(tmp, "dist", "static", "styles.css"), "OK"), - Deno.writeTextFile( - path.join(tmp, "static", ".well-known", "foo.txt"), - "OK", - ), - ]); - const buildCache = new ProdBuildCache( - config, - new Map(), - new Map([ - ["../secret-styles.css", { generated: true, hash: "SECRET!" }], - ["../SECRETS.txt", { generated: false, hash: "SECRET!" }], - ["./../secret-styles.css", { generated: true, hash: "SECRET!" }], - ["./../SECRETS.txt", { generated: false, hash: "SECRET!" }], - ["styles.css", { generated: true, hash: "OK" }], - [".well-known/foo.txt", { generated: false, hash: "OK" }], - ["./styles.css", { generated: true, hash: "OK" }], - ["./.well-known/foo.txt", { generated: false, hash: "OK" }], - ]), - true, - ); - - const secret1 = getContent(buildCache.readFile("../styles.css")); - const secret2 = getContent(buildCache.readFile("../SECRETS.txt")); - const secret3 = getContent(buildCache.readFile("./../styles.css")); - const secret4 = getContent(buildCache.readFile("./../SECRETS.txt")); - const public1 = getContent(buildCache.readFile("styles.css")); - const public2 = getContent(buildCache.readFile(".well-known/foo.txt")); - const public3 = getContent(buildCache.readFile("./styles.css")); - const public4 = getContent(buildCache.readFile("./.well-known/foo.txt")); - - await expect(secret1).resolves.toBe(null); - await expect(secret2).resolves.toBe(null); - await expect(secret3).resolves.toBe(null); - await expect(secret4).resolves.toBe(null); - await expect(public1).resolves.toBe("OK"); - await expect(public2).resolves.toBe("OK"); - await expect(public3).resolves.toBe("OK"); - await expect(public4).resolves.toBe("OK"); - }, -}); diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 00000000000..238935f5367 --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,309 @@ +import { HttpError } from "./error.ts"; +import { isHandlerByMethod, type PageResponse } from "./handlers.ts"; +import type { MiddlewareFn } from "./middlewares/mod.ts"; +import { mergePath, type Method, type Router } from "./router.ts"; +import { + getOrCreateSegment, + newSegment, + renderRoute, + type RouteComponent, + type Segment, + segmentToMiddlewares, +} from "./segments.ts"; +import type { LayoutConfig, Route } from "./types.ts"; + +export const DEFAULT_NOT_FOUND = (): Promise => { + throw new HttpError(404); +}; +export const DEFAULT_NOT_ALLOWED_METHOD = (): Promise => { + throw new HttpError(405); +}; + +const DEFAULT_RENDER = (): Promise> => + // deno-lint-ignore no-explicit-any + Promise.resolve({ data: {} as any }); + +function ensureHandler(route: Route) { + if (route.handler === undefined) { + route.handler = route.component !== undefined + ? DEFAULT_RENDER + : DEFAULT_NOT_FOUND; + } else if (isHandlerByMethod(route.handler)) { + if (route.component !== undefined && !route.handler.GET) { + route.handler.GET = DEFAULT_RENDER; + } + } +} + +export const enum CommandType { + Middleware = "middleware", + Layout = "layout", + App = "app", + Route = "route", + Error = "error", + NotFound = "notFound", + Handler = "handler", + FsRoute = "fsRoute", +} + +export interface ErrorCmd { + type: CommandType.Error; + pattern: string; + item: Route; + includeLastSegment: boolean; +} +export function newErrorCmd( + pattern: string, + routeOrMiddleware: Route | MiddlewareFn, + includeLastSegment: boolean, +): ErrorCmd { + const route = typeof routeOrMiddleware === "function" + ? { handler: routeOrMiddleware } + : routeOrMiddleware; + ensureHandler(route); + + return { type: CommandType.Error, pattern, item: route, includeLastSegment }; +} + +export interface AppCommand { + type: CommandType.App; + component: RouteComponent; +} +export function newAppCmd( + component: RouteComponent, +): AppCommand { + return { type: CommandType.App, component }; +} + +export interface LayoutCommand { + type: CommandType.Layout; + pattern: string; + component: RouteComponent; + config?: LayoutConfig; + includeLastSegment: boolean; +} +export function newLayoutCmd( + pattern: string, + component: RouteComponent, + config: LayoutConfig | undefined, + includeLastSegment: boolean, +): LayoutCommand { + return { + type: CommandType.Layout, + pattern, + component, + config, + includeLastSegment, + }; +} + +export interface MiddlewareCmd { + type: CommandType.Middleware; + pattern: string; + fns: MiddlewareFn[]; + includeLastSegment: boolean; +} +export function newMiddlewareCmd( + pattern: string, + fns: MiddlewareFn[], + includeLastSegment: boolean, +): MiddlewareCmd { + return { type: CommandType.Middleware, pattern, fns, includeLastSegment }; +} + +export interface NotFoundCmd { + type: CommandType.NotFound; + fn: MiddlewareFn; +} +export function newNotFoundCmd( + routeOrMiddleware: Route | MiddlewareFn, +): NotFoundCmd { + const route = typeof routeOrMiddleware === "function" + ? { handler: routeOrMiddleware } + : routeOrMiddleware; + ensureHandler(route); + + return { type: CommandType.NotFound, fn: (ctx) => renderRoute(ctx, route) }; +} + +export interface RouteCommand { + type: CommandType.Route; + pattern: string; + route: Route; + includeLastSegment: boolean; +} +export function newRouteCmd( + pattern: string, + route: Route, + includeLastSegment: boolean, +): RouteCommand { + ensureHandler(route); + return { type: CommandType.Route, pattern, route, includeLastSegment }; +} + +export interface HandlerCommand { + type: CommandType.Handler; + pattern: string; + method: Method | "ALL"; + fns: MiddlewareFn[]; + includeLastSegment: boolean; +} +export function newHandlerCmd( + method: Method | "ALL", + pattern: string, + fns: MiddlewareFn[], + includeLastSegment: boolean, +): HandlerCommand { + return { + type: CommandType.Handler, + pattern, + method, + fns, + includeLastSegment, + }; +} + +export interface FsRouteCommand { + type: CommandType.FsRoute; + pattern: string; + getItems: () => Command[]; + includeLastSegment: boolean; +} + +export type Command = + | ErrorCmd + | AppCommand + | LayoutCommand + | NotFoundCmd + | MiddlewareCmd + | RouteCommand + | HandlerCommand + | FsRouteCommand; + +export function applyComands( + router: Router>, + commands: Command[], + basePath: string, +): { rootMiddlewares: MiddlewareFn[] } { + const root = newSegment("", null); + + applyCommandsInner(root, router, commands, basePath); + + return { rootMiddlewares: segmentToMiddlewares(root) }; +} + +function applyCommandsInner( + root: Segment, + router: Router>, + commands: Command[], + basePath: string, +) { + for (let i = 0; i < commands.length; i++) { + const cmd = commands[i]; + + switch (cmd.type) { + case CommandType.Middleware: { + const segment = getOrCreateSegment( + root, + cmd.pattern, + cmd.includeLastSegment, + ); + segment.middlewares.push(...cmd.fns); + break; + } + case CommandType.NotFound: { + root.notFound = cmd.fn; + break; + } + case CommandType.Error: { + const segment = getOrCreateSegment( + root, + cmd.pattern, + cmd.includeLastSegment, + ); + segment.errorRoute = cmd.item; + break; + } + case CommandType.App: { + root.app = cmd.component; + break; + } + case CommandType.Layout: { + const segment = getOrCreateSegment( + root, + cmd.pattern, + cmd.includeLastSegment, + ); + segment.layout = { + component: cmd.component, + config: cmd.config ?? null, + }; + break; + } + case CommandType.Route: { + const { pattern, route } = cmd; + const segment = getOrCreateSegment( + root, + pattern, + cmd.includeLastSegment, + ); + const fns = segmentToMiddlewares(segment); + + fns.push((ctx) => renderRoute(ctx, route)); + + const routePath = mergePath( + basePath, + route.config?.routeOverride ?? pattern, + ); + + if (typeof route.handler === "function") { + router.add("GET", routePath, fns); + router.add("DELETE", routePath, fns); + router.add("HEAD", routePath, fns); + router.add("OPTIONS", routePath, fns); + router.add("PATCH", routePath, fns); + router.add("POST", routePath, fns); + router.add("PUT", routePath, fns); + } else if (isHandlerByMethod(route.handler!)) { + for (const method of Object.keys(route.handler)) { + router.add(method as Method, routePath, fns); + } + } + break; + } + case CommandType.Handler: { + const { pattern, fns, method } = cmd; + const segment = getOrCreateSegment( + root, + pattern, + cmd.includeLastSegment, + ); + const result = segmentToMiddlewares(segment); + + result.push(...fns); + + const resPath = mergePath(basePath, pattern); + if (method === "ALL") { + router.add("GET", resPath, result); + router.add("DELETE", resPath, result); + router.add("HEAD", resPath, result); + router.add("OPTIONS", resPath, result); + router.add("PATCH", resPath, result); + router.add("POST", resPath, result); + router.add("PUT", resPath, result); + } else { + router.add(method, resPath, result); + } + + break; + } + case CommandType.FsRoute: { + const items = cmd.getItems(); + applyCommandsInner(root, router, items, basePath); + break; + } + default: + throw new Error(`Unknown command: ${JSON.stringify(cmd)}`); + } + } +} diff --git a/src/config.ts b/src/config.ts index 5987c6dabed..92c847e1213 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,24 +1,6 @@ import * as path from "@std/path"; export interface FreshConfig { - /** - * The root directory of the Fresh project. - * - * Other paths, such as `build.outDir`, `staticDir`, and `fsRoutes()` - * are resolved relative to this directory. - * @default Deno.cwd() - */ - root?: string; - build?: { - /** - * The directory to write generated files to when `dev.ts build` is run. - * - * This can be an absolute path, a file URL or a relative path. - * Relative paths are resolved against the `root` option. - * @default "_fresh" - */ - outDir?: string; - }; /** * Serve fresh from a base path instead of from the root. * "/foo/bar" -> http://localhost:8000/foo/bar @@ -40,29 +22,20 @@ export interface FreshConfig { */ export interface ResolvedFreshConfig { root: string; - build: { - outDir: string; - }; /** * Serve fresh from a base path instead of from the root. * "/foo/bar" -> http://localhost:8000/foo/bar */ basePath: string; - staticDir: string; /** * The mode Fresh can run in. */ mode: "development" | "production"; } -export function parseRootPath(root: string, cwd: string): string { - return parseDirPath(root, cwd, true); -} - -function parseDirPath( +export function parseDirPath( dirPath: string, root: string, - fileToDir = false, ): string { if (dirPath.startsWith("file://")) { dirPath = path.fromFileUrl(dirPath); @@ -70,37 +43,9 @@ function parseDirPath( dirPath = path.join(root, dirPath); } - if (fileToDir) { - const ext = path.extname(dirPath); - if ( - ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx" || - ext === ".mjs" - ) { - dirPath = path.dirname(dirPath); - } - } - if (Deno.build.os === "windows") { dirPath = dirPath.replaceAll("\\", "/"); } return dirPath; } - -export function normalizeConfig(options: FreshConfig): ResolvedFreshConfig { - const root = parseRootPath(options.root ?? ".", Deno.cwd()); - - return { - root, - build: { - outDir: parseDirPath(options.build?.outDir ?? "_fresh", root), - }, - basePath: options.basePath ?? "", - staticDir: parseDirPath(options.staticDir ?? "static", root), - mode: "production", - }; -} - -export function getSnapshotPath(config: ResolvedFreshConfig): string { - return path.join(config.build.outDir, "snapshot.json"); -} diff --git a/src/config_test.ts b/src/config_test.ts index adae62b5807..0162b5d987c 100644 --- a/src/config_test.ts +++ b/src/config_test.ts @@ -1,108 +1,21 @@ import { expect } from "@std/expect"; -import { normalizeConfig, parseRootPath } from "./config.ts"; -import type { FreshConfig } from "./mod.ts"; +import { parseDirPath } from "./config.ts"; -Deno.test("parseRootPath", () => { +Deno.test("parseDirPath", () => { const cwd = Deno.cwd().replaceAll("\\", "/"); // File paths - expect(parseRootPath("file:///foo/bar", cwd)).toEqual("/foo/bar"); - expect(parseRootPath("file:///foo/bar.ts", cwd)).toEqual("/foo"); + expect(parseDirPath("file:///foo/bar", cwd)).toEqual("/foo/bar"); if (Deno.build.os === "windows") { - expect(parseRootPath("file:///C:/foo/bar", cwd)).toEqual("C:/foo/bar"); - expect(parseRootPath("file:///C:/foo/bar.ts", cwd)).toEqual("C:/foo"); + expect(parseDirPath("file:///C:/foo/bar", cwd)).toEqual("C:/foo/bar"); } // Relative paths - expect(parseRootPath("./foo/bar", cwd)).toEqual(`${cwd}/foo/bar`); - expect(parseRootPath("./foo/bar.ts", cwd)).toEqual(`${cwd}/foo`); + expect(parseDirPath("./foo/bar", cwd)).toEqual(`${cwd}/foo/bar`); // Absolute paths - expect(parseRootPath("/foo/bar", cwd)).toEqual("/foo/bar"); - expect(parseRootPath("/foo/bar.ts", cwd)).toEqual("/foo"); - expect(parseRootPath("/foo/bar.tsx", cwd)).toEqual("/foo"); - expect(parseRootPath("/foo/bar.js", cwd)).toEqual("/foo"); - expect(parseRootPath("/foo/bar.jsx", cwd)).toEqual("/foo"); - expect(parseRootPath("/foo/bar.mjs", cwd)).toEqual("/foo"); + expect(parseDirPath("/foo/bar", cwd)).toEqual("/foo/bar"); if (Deno.build.os === "windows") { - expect(parseRootPath("C:/foo/bar", cwd)).toEqual("C:/foo/bar"); - expect(parseRootPath("C:/foo/bar.ts", cwd)).toEqual("C:/foo"); + expect(parseDirPath("C:/foo/bar", cwd)).toEqual("C:/foo/bar"); } }); - -Deno.test("normalizeConfig - root", () => { - const cwd = Deno.cwd().replaceAll("\\", "/"); - const configRoot = (root?: string) => normalizeConfig({ root }).root; - - expect(configRoot()).toEqual(cwd); - expect(configRoot("/foo/bar")).toEqual("/foo/bar"); - expect(configRoot("/foo/bar.ts")).toEqual("/foo"); - expect(configRoot("file:///foo/bar")).toEqual("/foo/bar"); - expect(configRoot("./foo/bar")).toEqual(`${cwd}/foo/bar`); - expect(configRoot("./foo/bar.ts")).toEqual(`${cwd}/foo`); - - if (Deno.build.os === "windows") { - expect(configRoot("C:/foo/bar.ts")).toEqual("C:/foo"); - expect(configRoot("file:///C:/foo/bar")).toEqual("C:/foo/bar"); - } -}); - -Deno.test("normalizeConfig - build.outDir", () => { - const cwd = Deno.cwd().replaceAll("\\", "/"); - const outDir = (options: FreshConfig) => - normalizeConfig(options).build.outDir; - - // Default outDir - expect(outDir({ root: "./src" })).toEqual(`${cwd}/src/_fresh`); - expect(outDir({ root: "/src" })).toEqual("/src/_fresh"); - expect(outDir({ root: "file:///src" })).toEqual("/src/_fresh"); - - // Relative outDir - expect(outDir({ root: "/src", build: { outDir: "dist" } })).toEqual( - "/src/dist", - ); - expect(outDir({ root: "/src", build: { outDir: "./dist" } })).toEqual( - "/src/dist", - ); - - // Absolute outDir - expect(outDir({ root: "/src", build: { outDir: "/dist" } })).toEqual( - "/dist", - ); - expect(outDir({ root: "/src", build: { outDir: "/dist/fresh" } })).toEqual( - "/dist/fresh", - ); - expect(outDir({ root: "/src", build: { outDir: "file:///dist" } })).toEqual( - "/dist", - ); -}); - -Deno.test("normalizeConfig - staticDir", () => { - const cwd = Deno.cwd().replaceAll("\\", "/"); - const staticDir = (options: FreshConfig) => - normalizeConfig(options).staticDir; - - // Default staticDir - expect(staticDir({ root: "./src" })).toEqual(`${cwd}/src/static`); - expect(staticDir({ root: "/src" })).toEqual("/src/static"); - expect(staticDir({ root: "file:///src" })).toEqual("/src/static"); - - // Relative staticDir - expect(staticDir({ root: "/src", staticDir: "public" })).toEqual( - "/src/public", - ); - expect(staticDir({ root: "/src", staticDir: "./public" })).toEqual( - "/src/public", - ); - - // Absolute staticDir - expect(staticDir({ root: "/src", staticDir: "/public" })).toEqual( - "/public", - ); - expect(staticDir({ root: "/src", staticDir: "/public/assets" })).toEqual( - "/public/assets", - ); - expect(staticDir({ root: "/src", staticDir: "file:///public" })).toEqual( - "/public", - ); -}); diff --git a/src/context.ts b/src/context.ts index eac92cd9f33..098379b2562 100644 --- a/src/context.ts +++ b/src/context.ts @@ -23,7 +23,7 @@ import { } from "./render.ts"; export interface Island { - file: string | URL; + file: string; name: string; exportName: string; fn: ComponentType; @@ -43,7 +43,7 @@ export interface UiTree { */ export type FreshContext = Context; -export let getBuildCache: (ctx: Context) => BuildCache; +export let getBuildCache: (ctx: Context) => BuildCache; export let getInternals: (ctx: Context) => UiTree; /** @@ -108,16 +108,15 @@ export class Context { */ next: () => Promise; - #islandRegistry: ServerIslandRegistry; - #buildCache: BuildCache; + #buildCache: BuildCache; // FIXME: remove after switching to Component!: FunctionComponent; static { // deno-lint-ignore no-explicit-any - getInternals = (ctx) => (ctx as Context).#internal as any; - getBuildCache = (ctx) => (ctx as Context).#buildCache; + getInternals = (ctx: Context) => ctx.#internal as any; + getBuildCache = (ctx: Context) => ctx.#buildCache; } constructor( @@ -128,8 +127,7 @@ export class Context { params: Record, config: ResolvedFreshConfig, next: () => Promise, - islandRegistry: ServerIslandRegistry, - buildCache: BuildCache, + buildCache: BuildCache, ) { this.url = url; this.req = req; @@ -139,7 +137,6 @@ export class Context { this.config = config; this.isPartial = url.searchParams.has(PARTIAL_SEARCH_PARAM); this.next = next; - this.#islandRegistry = islandRegistry; this.#buildCache = buildCache; } @@ -265,7 +262,6 @@ export class Context { span.setAttribute("fresh.span_type", "render"); const state = new RenderState( this, - this.#islandRegistry, this.#buildCache, partialId, ); diff --git a/src/dev/builder.ts b/src/dev/builder.ts index 0f627106e1b..63f47141148 100644 --- a/src/dev/builder.ts +++ b/src/dev/builder.ts @@ -1,15 +1,9 @@ -import { - App, - getBuildCache, - getIslandRegistry, - type ListenOptions, - setBuildCache, -} from "../app.ts"; +import { App, type ListenOptions, setBuildCache } from "../app.ts"; import { fsAdapter } from "../fs.ts"; import * as path from "@std/path"; import * as colors from "@std/fmt/colors"; import { bundleJs } from "./esbuild.ts"; -import * as JSONC from "@std/jsonc"; + import { liveReload } from "./middlewares/live_reload.ts"; import { cssAssetHash, @@ -17,13 +11,21 @@ import { type OnTransformOptions, } from "./file_transformer.ts"; import type { TransformFn } from "./file_transformer.ts"; -import { DiskBuildCache, MemoryBuildCache } from "./dev_build_cache.ts"; -import type { Island } from "../context.ts"; +import { + type DevBuildCache, + DiskBuildCache, + type FsRoute, + MemoryBuildCache, +} from "./dev_build_cache.ts"; import { BUILD_ID } from "../runtime/build_id.ts"; import { updateCheck } from "./update_check.ts"; import { DAY } from "@std/datetime"; import { devErrorOverlay } from "./middlewares/error_overlay/middleware.tsx"; import { automaticWorkspaceFolders } from "./middlewares/automatic_workspace_folders.ts"; +import { parseDirPath } from "../config.ts"; +import { pathToExportName, UniqueNamer } from "../utils.ts"; +import { checkDenoCompilerOptions } from "./check.ts"; +import { crawlRouteDir, walkDir } from "./fs_crawl.ts"; export interface BuildOptions { /** @@ -33,20 +35,97 @@ export interface BuildOptions { * @default {"es2022"} */ target?: string | string[]; + /** + * The root directory of the Fresh project. + * + * Other paths, such as `build.outDir`, `staticDir`, and `fsRoutes()` + * are resolved relative to this directory. + * @default Deno.cwd() + */ + root?: string; + /** + * The directory to write generated files to when `dev.ts build` is run. + * + * This can be an absolute path, a file URL or a relative path. + * Relative paths are resolved against the `root` option. + * @default "_fresh" + */ + outDir?: string; + /** + * The directory to serve static files from. + * + * This can be an absolute path, a file URL or a relative path. + * Relative paths are resolved against the `root` option. + * @default "static" + */ + staticDir?: string; + /** + * The directory which contains islands. + * + * This can be an absolute path, a file URL or a relative path. + * Relative paths are resolved against the `root` option. + * @default "islands" + */ + islandDir?: string; + /** + * The directory which contains routes. + * + * This can be an absolute path, a file URL or a relative path. + * Relative paths are resolved against the `root` option. + * @default "routes" + */ + routeDir?: string; + /** + * File paths which should be ignored when crawling the file system. + */ + ignore?: RegExp[]; } -export class Builder { +/** + * The final resolved Builder configuration. + */ +export type ResolvedBuildConfig = Required & { + mode: "development" | "production"; + buildId: string; +}; + +const TEST_FILE_PATTERN = /[._]test\.(?:[tj]sx?|[mc][tj]s)$/; + +// deno-lint-ignore no-explicit-any +export class Builder { #transformer = new FreshFileTransformer(fsAdapter); #addedInternalTransforms = false; - #options: Required; - #chunksReady = Promise.withResolvers(); + config: ResolvedBuildConfig; + #islandSpecifiers = new Set(); + #fsRoutes: FsRoute; + #ready = Promise.withResolvers(); constructor(options?: BuildOptions) { - this.#options = { + const root = parseDirPath(options?.root ?? ".", Deno.cwd()); + const outDir = parseDirPath(options?.outDir ?? "_fresh", root); + const staticDir = parseDirPath(options?.staticDir ?? "static", root); + const islandDir = parseDirPath(options?.islandDir ?? "islands", root); + const routeDir = parseDirPath(options?.routeDir ?? "routes", root); + + this.#fsRoutes = { dir: routeDir, files: [], id: "default" }; + + this.config = { target: options?.target ?? ["chrome99", "firefox99", "safari15"], + root, + outDir, + staticDir, + islandDir, + routeDir, + ignore: options?.ignore ?? [TEST_FILE_PATTERN], + mode: "production", + buildId: BUILD_ID, }; } + registerIsland(specifier: string): void { + this.#islandSpecifiers.add(specifier); + } + onTransformStaticFile( options: OnTransformOptions, callback: TransformFn, @@ -54,75 +133,118 @@ export class Builder { this.#transformer.onTransform(options, callback); } - async listen(app: App, options: ListenOptions = {}): Promise { + async listen( + importApp: () => Promise<{ app: App } | App>, + options: ListenOptions = {}, + ): Promise { // Run update check in background updateCheck(DAY).catch(() => {}); - const devApp = new App(app.config) + this.config.mode = "development"; + + await this.#crawlFsItems(); + + let app = await importApp(); + if (!(app instanceof App) && "app" in app) { + app = app.app; + } + + const buildCache = new MemoryBuildCache( + this.config, + this.#fsRoutes, + this.#transformer, + ); + + await buildCache.prepare(); + + const devApp = new App(app.config) .use(liveReload()) .use(devErrorOverlay()) - .use(automaticWorkspaceFolders(app.config.root)) - // Wait for island chunks to be ready before attempting to serve them + .use(automaticWorkspaceFolders(this.config.root)) + // Wait for islands to be ready .use(async (ctx) => { - await this.#chunksReady.promise; - return await ctx.next(); + await this.#ready.promise; + return ctx.next(); }) .mountApp("/*", app); + devApp.config.root = this.config.root; devApp.config.mode = "development"; - setBuildCache( - devApp, - new MemoryBuildCache( - devApp.config, - BUILD_ID, - this.#transformer, - this.#options.target, - ), - ); + setBuildCache(devApp, buildCache); await Promise.all([ devApp.listen(options), - this.#build(devApp, true), + this.#build(buildCache, true), ]); return; } - async build(app: App): Promise { - setBuildCache( - app, - new DiskBuildCache( - app.config, - BUILD_ID, - this.#transformer, - this.#options.target, - ), + async build(): Promise { + this.config.mode = "production"; + + await this.#crawlFsItems(); + + const buildCache = new DiskBuildCache( + this.config, + this.#fsRoutes, + this.#transformer, + ); + + return await this.#build(buildCache, false); + } + + async buildForTests(): Promise> { + this.config.mode = "production"; + + await this.#crawlFsItems(); + + const buildCache = new MemoryBuildCache( + this.config, + this.#fsRoutes, + this.#transformer, ); - return await this.#build(app, false); + await this.#build(buildCache, false); + await buildCache.prepare(); + return buildCache; + } + + async #crawlFsItems() { + await Promise.all([ + walkDir( + fsAdapter, + this.config.islandDir, + (entry) => this.registerIsland(entry.path), + this.config.ignore, + ), + crawlRouteDir( + fsAdapter, + this.config.routeDir, + this.config.ignore, + (entry) => this.registerIsland(entry), + this.#fsRoutes.files, + ), + ]); } - async #build(app: App, dev: boolean): Promise { - const { build } = app.config; - const staticOutDir = path.join(build.outDir, "static"); + async #build(buildCache: DevBuildCache, dev: boolean): Promise { + const { target, outDir, root } = this.config; + const staticOutDir = path.join(outDir, "static"); + + const { denoJson, jsxImportSource } = await checkDenoCompilerOptions(root); if (!this.#addedInternalTransforms) { this.#addedInternalTransforms = true; cssAssetHash(this.#transformer); } - const target = this.#options.target; - try { await Deno.remove(staticOutDir); } catch { // Ignore } - const buildCache = getBuildCache(app)! as - | MemoryBuildCache - | DiskBuildCache; - const runtimePath = dev ? "../runtime/client/dev.ts" : "../runtime/client/mod.tsx"; @@ -130,130 +252,108 @@ export class Builder { const entryPoints: Record = { "fresh-runtime": new URL(runtimePath, import.meta.url).href, }; - const seenEntries = new Map(); - const mapIslandToEntry = new Map(); - const islandRegistry = getIslandRegistry(app); - for (const island of islandRegistry.values()) { - const filePath = island.file instanceof URL - ? island.file.href - : island.file; - - const seen = seenEntries.get(filePath); - if (seen !== undefined) { - mapIslandToEntry.set(island, seen.name); - } else { - entryPoints[island.name] = filePath; - seenEntries.set(filePath, island); - mapIslandToEntry.set(island, island.name); - } - } - const denoJson = await findNearestDenoConfigWithCompilerOptions( - app.config.root, - ); + const namer = new UniqueNamer(); + for (const spec of this.#islandSpecifiers) { + const specName = specToName(spec); + const name = namer.getUniqueName(specName); - const jsxImportSource = denoJson.config.compilerOptions?.jsxImportSource; - if (jsxImportSource === undefined) { - throw new Error( - `Option compilerOptions > jsxImportSource not set in: ${denoJson.filePath}`, - ); - } + entryPoints[name] = spec; - // Check precompile option - if (denoJson.config.compilerOptions?.jsx === "precompile") { - const expected = ["a", "img", "source", "body", "html", "head"]; - const skipped = denoJson.config.compilerOptions.jsxPrecompileSkipElements; - if (!skipped || expected.some((name) => !skipped.includes(name))) { - throw new Error( - `Expected option compilerOptions > jsxPrecompileSkipElements to contain ${ - expected.map((name) => `"${name}"`).join(", ") - }`, - ); - } + buildCache.islandModNameToChunk.set(name, { + name, + server: spec, + browser: null, + }); } const output = await bundleJs({ - cwd: app.config.root, + cwd: root, outDir: staticOutDir, dev: dev ?? false, target, buildId: BUILD_ID, entryPoints, jsxImportSource, - denoJsonPath: denoJson.filePath, + denoJsonPath: denoJson, }); const prefix = `/_fresh/js/${BUILD_ID}/`; + for (const name of namer.getNames()) { + const chunkName = output.entryToChunk.get(name); + if (chunkName === undefined) { + throw new Error(`Could not find chunk for island ${name}`); + } + + const pathname = `${prefix}${chunkName}`; + buildCache.islandModNameToChunk.get(name)!.browser = pathname; + } + for (let i = 0; i < output.files.length; i++) { const file = output.files[i]; const pathname = `${prefix}${file.path}`; await buildCache.addProcessedFile(pathname, file.contents, file.hash); } - // Go through same entry islands - for (const [island, entry] of mapIslandToEntry.entries()) { - const chunk = output.entryToChunk.get(entry); - if (chunk === undefined) { - throw new Error( - `Missing chunk for ${island.file}#${island.exportName}`, - ); - } - buildCache.islands.set(island.name, `${prefix}${chunk}`); - } - await buildCache.flush(); - this.#chunksReady.resolve(); - if (!dev) { // deno-lint-ignore no-console console.log( - `Assets written to: ${colors.cyan(build.outDir)}`, + `Assets written to: ${colors.cyan(outDir)}`, ); } + + this.#ready.resolve(); } } -export interface DenoConfig { - workspace?: string[]; - compilerOptions?: { - jsx?: string; - jsxImportSource?: string; - jsxPrecompileSkipElements?: string[]; - }; -} +export function specToName(spec: string): string { + if (/^(https?:|file:)/.test(spec)) { + const url = new URL(spec); + if (url.pathname === "/") { + return pathToExportName(url.hostname); + } -export async function findNearestDenoConfigWithCompilerOptions( - directory: string, -): Promise<{ config: DenoConfig; filePath: string }> { - let dir = directory; - while (true) { - for (const name of ["deno.json", "deno.jsonc"]) { - const filePath = path.join(dir, name); - try { - const file = await Deno.readTextFile(filePath); - let config; - if (name.endsWith(".jsonc")) { - config = JSONC.parse(file); - } else { - config = JSON.parse(file); - } - if (config.compilerOptions) return { config, filePath }; - if (config.workspace) break; - break; - } catch (err) { - if (!(err instanceof Deno.errors.NotFound)) { - throw err; - } + const idx = spec.lastIndexOf("/"); + return spec.slice(idx + 1); + } else if (spec.startsWith("jsr:")) { + const match = spec.match( + /jsr:@([^/]+)\/([^@/]+)(@[\^~]?\d+\.\d+\.\d+([^/]+)?)?(\/.*)?$/, + )!; + if (match[5] === undefined) { + return pathToExportName(`${match[1]}_${match[2]}`); + } + + return pathToExportName(match[5]); + } else if (spec.startsWith("npm:")) { + const match = spec.match( + /npm:(@([^/]+)\/([^@/]+)|[^@/]+)(@[\^~]?\d+\.\d+\.\d+([^/]+)?)?(\/.*)?$/, + )!; + + if (match[6] === undefined) { + if (match[2] === undefined) { + return pathToExportName(match[1]); } + return pathToExportName(`${match[2]}_${match[3]}`); } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; + + return pathToExportName(match[6]); + } + + const match = spec.match(/^(@([^/]+)\/([^@/]+)|[^@/]+)(\/.*)?$/); + if (match !== null) { + if (match[4] === undefined) { + if (match[2] !== undefined) { + return pathToExportName(`${match[2]}_${match[3]}`); + } + + return pathToExportName(match[1]); + } + + return pathToExportName(match[4]); } - throw new Error( - `Could not find a deno.json or deno.jsonc file in the current directory or any parent directory that contains a 'compilerOptions' field.`, - ); + return pathToExportName(spec); } diff --git a/src/dev/builder_test.ts b/src/dev/builder_test.ts index 65e1c9c4595..1744c8c4322 100644 --- a/src/dev/builder_test.ts +++ b/src/dev/builder_test.ts @@ -1,16 +1,21 @@ import { expect } from "@std/expect"; import * as path from "@std/path"; -import { Builder } from "./builder.ts"; +import { Builder, specToName } from "./builder.ts"; import { App } from "../app.ts"; -import { RemoteIsland } from "@marvinh-test/fresh-island"; import { BUILD_ID } from "../runtime/build_id.ts"; import { withTmpDir } from "../test_utils.ts"; Deno.test({ name: "Builder - chain onTransformStaticFile", fn: async () => { + await using _tmp = await withTmpDir(); + const tmp = _tmp.dir; + const logs: string[] = []; - const builder = new Builder(); + const builder = new Builder({ + outDir: path.join(tmp, "dist"), + staticDir: tmp, + }); builder.onTransformStaticFile( { pluginName: "A", filter: /\.css$/ }, () => { @@ -30,16 +35,8 @@ Deno.test({ }, ); - await using _tmp = await withTmpDir(); - const tmp = _tmp.dir; await Deno.writeTextFile(path.join(tmp, "foo.css"), "body { color: red; }"); - const app = new App({ - staticDir: tmp, - build: { - outDir: path.join(tmp, "dist"), - }, - }); - await builder.build(app); + await builder.build(); expect(logs).toEqual(["A", "B", "C"]); }, @@ -50,24 +47,22 @@ Deno.test({ Deno.test({ name: "Builder - handles Windows paths", fn: async () => { - const builder = new Builder(); await using _tmp = await withTmpDir(); const tmp = _tmp.dir; + + const builder = new Builder({ + outDir: path.join(tmp, "dist"), + staticDir: tmp, + }); await Deno.mkdir(path.join(tmp, "images")); await Deno.writeTextFile( path.join(tmp, "images", "batman.svg"), "", ); - const app = new App({ - staticDir: tmp, - build: { - outDir: path.join(tmp, "dist"), - }, - }); - await builder.build(app); + await builder.build(); const snapshotJson = await Deno.readTextFile( - path.join(tmp, "dist", "snapshot.json"), + path.join(tmp, "dist", "static-files.json"), ); expect(snapshotJson).toContain("/images/batman.svg"); }, @@ -78,20 +73,18 @@ Deno.test({ Deno.test({ name: "Builder - hashes CSS urls by default", fn: async () => { - const builder = new Builder(); await using _tmp = await withTmpDir(); const tmp = _tmp.dir; + const builder = new Builder({ + outDir: path.join(tmp, "dist"), + staticDir: tmp, + }); + await Deno.writeTextFile( path.join(tmp, "foo.css"), "body { background: url('/foo.jpg'); }", ); - const app = new App({ - staticDir: tmp, - build: { - outDir: path.join(tmp, "dist"), - }, - }); - await builder.build(app); + await builder.build(); const css = await Deno.readTextFile( path.join(tmp, "dist", "static", "foo.css"), @@ -106,20 +99,17 @@ Deno.test({ Deno.test({ name: "Builder - hashes CSS urls by default", fn: async () => { - const builder = new Builder(); await using _tmp = await withTmpDir(); const tmp = _tmp.dir; + const builder = new Builder({ + outDir: path.join(tmp, "dist"), + staticDir: tmp, + }); await Deno.writeTextFile( path.join(tmp, "foo.css"), `:root { --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 154.5, 137.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); }`, ); - const app = new App({ - staticDir: tmp, - build: { - outDir: path.join(tmp, "dist"), - }, - }); - await builder.build(app); + await builder.build(); const css = await Deno.readTextFile( path.join(tmp, "dist", "static", "foo.css"), @@ -135,20 +125,18 @@ Deno.test({ Deno.test({ name: "Builder - can bundle islands from JSR", fn: async () => { - const builder = new Builder(); await using _tmp = await withTmpDir(); const tmp = _tmp.dir; - const app = new App({ - staticDir: tmp, - build: { - outDir: path.join(tmp, "dist"), - }, - }); - app.island("jsr:@marvinh-test/fresh-island", "RemoteIsland", RemoteIsland); + const outDir = path.join(tmp, "dist"); + const builder = new Builder({ outDir }); - await builder.build(app); + const specifier = "jsr:@marvinh-test/fresh-island"; + builder.registerIsland(specifier); + await builder.build(); + + const name = specToName(specifier); const code = await Deno.readTextFile( path.join( tmp, @@ -157,7 +145,7 @@ Deno.test({ "_fresh", "js", BUILD_ID, - "RemoteIsland.js", + `${name}.js`, ), ); expect(code).toContain('"remote-island"'); @@ -169,8 +157,14 @@ Deno.test({ Deno.test({ name: "Builder - exclude files", fn: async () => { + await using _tmp = await withTmpDir(); + const tmp = _tmp.dir; + const logs: string[] = []; - const builder = new Builder(); + const builder = new Builder({ + outDir: path.join(tmp, "dist"), + staticDir: tmp, + }); // String builder.onTransformStaticFile( @@ -196,20 +190,12 @@ Deno.test({ }, ); - await using _tmp = await withTmpDir(); - const tmp = _tmp.dir; await Deno.writeTextFile(path.join(tmp, "foo.css"), "body { color: red; }"); await Deno.writeTextFile( path.join(tmp, "bar.css"), "body { color: blue; }", ); - const app = new App({ - staticDir: tmp, - build: { - outDir: path.join(tmp, "dist"), - }, - }); - await builder.build(app); + await builder.build(); expect(logs).toEqual(["A: bar.css", "B: bar.css", "C: bar.css"]); }, @@ -220,17 +206,22 @@ Deno.test({ Deno.test({ name: "Builder - workspace folder middleware on listen", fn: async () => { - const builder = new Builder(); - const tmp = await Deno.makeTempDir(); + await using _tmp = await withTmpDir(); + const tmp = _tmp.dir; + + const builder = new Builder({ + outDir: path.join(tmp, "dist"), + staticDir: tmp, + }); const app = new App({ staticDir: tmp, - build: { - outDir: path.join(tmp, "dist"), - }, }); const abort = new AbortController(); const port = 8011; - await builder.listen(app, { port, signal: abort.signal }); + await builder.listen(() => Promise.resolve(app), { + port, + signal: abort.signal, + }); const res = await fetch( `http://localhost:${port}/.well-known/appspecific/com.chrome.devtools.json`, @@ -243,7 +234,7 @@ Deno.test({ expect(res.headers.get("etag")).toEqual(expect.any(String)); expect(json).toEqual({ workspace: { - root: app.config.root, + root: builder.config.root, uuid: expect.any(String), }, }); @@ -251,3 +242,50 @@ Deno.test({ sanitizeOps: false, sanitizeResources: false, }); + +Deno.test("specToName", () => { + // HTTP + expect(specToName("http://example.com")).toEqual("example"); + expect(specToName("http://example.com:8000")).toEqual("example"); + expect(specToName("http://example.com:8000/foo/bar")).toEqual( + "bar", + ); + + // HTTPS + expect(specToName("https://example.com")).toEqual("example"); + expect(specToName("https://example.com:8000")).toEqual("example"); + expect(specToName("https://example.com:8000/foo/bar")).toEqual( + "bar", + ); + + // JSR + expect(specToName("jsr:@foo/bar")).toEqual("foo_bar"); + expect(specToName("jsr:@foo/bar@1.0.0")).toEqual("foo_bar"); + expect(specToName("jsr:@foo/bar@^1.0.0")).toEqual("foo_bar"); + expect(specToName("jsr:@foo/bar@~1.0.0")).toEqual("foo_bar"); + expect(specToName("jsr:@foo/bar@~1.0.0-alpha.32")).toEqual("foo_bar"); + expect(specToName("jsr:@foo/bar@~1.0.0-alpha.32/asdf")).toEqual("asdf"); + expect(specToName("jsr:@foo/bar/asdf")).toEqual("asdf"); + + // npm + expect(specToName("npm:foo")).toEqual("foo"); + expect(specToName("npm:foo/bar")).toEqual("bar"); + expect(specToName("npm:foo@1.0.0")).toEqual("foo"); + expect(specToName("npm:foo@^1.0.0")).toEqual("foo"); + expect(specToName("npm:foo@~1.0.0-alpha.32")).toEqual("foo"); + expect(specToName("npm:@foo/bar")).toEqual("foo_bar"); + expect(specToName("npm:@foo/bar/asdf")).toEqual("asdf"); + expect(specToName("npm:@foo/bar@1.0.0")).toEqual("foo_bar"); + expect(specToName("npm:@foo/bar@^1.0.0")).toEqual("foo_bar"); + expect(specToName("npm:@foo/bar@~1.0.0-alpha.32")).toEqual("foo_bar"); + + // other + expect(specToName("foo")).toEqual("foo"); + expect(specToName("@foo/bar")).toEqual("foo_bar"); + expect(specToName("foo/bar")).toEqual("bar"); + expect(specToName("@foo/bar/asdf")).toEqual("asdf"); + + expect(specToName("islands/foo.v2.tsx")).toEqual("foo_v2"); + expect(specToName("/islands/_bar-baz-...-$.tsx")).toEqual("_bar_baz_$"); + expect(specToName("/islands/1_hello.tsx")).toEqual("_hello"); +}); diff --git a/src/dev/check.ts b/src/dev/check.ts new file mode 100644 index 00000000000..e39c9358404 --- /dev/null +++ b/src/dev/check.ts @@ -0,0 +1,73 @@ +import * as JSONC from "@std/jsonc"; +import * as path from "@std/path"; + +export interface DenoConfig { + workspace?: string[]; + compilerOptions?: { + jsx?: string; + jsxImportSource?: string; + jsxPrecompileSkipElements?: string[]; + }; +} + +export async function checkDenoCompilerOptions(root: string) { + const denoJson = await findNearestDenoConfigWithCompilerOptions( + root, + ); + + const jsxImportSource = denoJson.config.compilerOptions?.jsxImportSource; + if (jsxImportSource === undefined) { + throw new Error( + `Option compilerOptions > jsxImportSource not set in: ${denoJson.filePath}`, + ); + } + + // Check precompile option + if (denoJson.config.compilerOptions?.jsx === "precompile") { + const expected = ["a", "img", "source", "body", "html", "head"]; + const skipped = denoJson.config.compilerOptions.jsxPrecompileSkipElements; + if (!skipped || expected.some((name) => !skipped.includes(name))) { + throw new Error( + `Expected option compilerOptions > jsxPrecompileSkipElements to contain ${ + expected.map((name) => `"${name}"`).join(", ") + }`, + ); + } + } + + return { jsxImportSource, denoJson: denoJson.filePath }; +} + +export async function findNearestDenoConfigWithCompilerOptions( + directory: string, +): Promise<{ config: DenoConfig; filePath: string }> { + let dir = directory; + while (true) { + for (const name of ["deno.json", "deno.jsonc"]) { + const filePath = path.join(dir, name); + try { + const file = await Deno.readTextFile(filePath); + let config; + if (name.endsWith(".jsonc")) { + config = JSONC.parse(file); + } else { + config = JSON.parse(file); + } + if (config.compilerOptions) return { config, filePath }; + if (config.workspace) break; + break; + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) { + throw err; + } + } + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + + throw new Error( + `Could not find a deno.json or deno.jsonc file in the current directory or any parent directory that contains a 'compilerOptions' field.`, + ); +} diff --git a/src/dev/dev_build_cache.ts b/src/dev/dev_build_cache.ts index 358addc1bc3..77be03ee9ee 100644 --- a/src/dev/dev_build_cache.ts +++ b/src/dev/dev_build_cache.ts @@ -1,50 +1,80 @@ -import type { BuildCache, StaticFile } from "../build_cache.ts"; +import { + type BuildCache, + type FileSnapshot, + IslandPreparer, + type StaticFile, +} from "../build_cache.ts"; import * as path from "@std/path"; import { SEPARATOR as WINDOWS_SEPARATOR } from "@std/path/windows/constants"; -import { getSnapshotPath, type ResolvedFreshConfig } from "../config.ts"; -import type { BuildSnapshot } from "../build_cache.ts"; import { encodeHex } from "@std/encoding/hex"; import { crypto } from "@std/crypto"; import { fsAdapter } from "../fs.ts"; -import type { FreshFileTransformer } from "./file_transformer.ts"; +import type { FileTransformer } from "./file_transformer.ts"; import { assertInDir } from "../utils.ts"; +import type { ResolvedBuildConfig } from "./builder.ts"; +import type { AnyComponent } from "preact"; +import { fsItemsToCommands, type FsRouteFile } from "../fs_routes.ts"; +import type { Command } from "../commands.ts"; +import type { ServerIslandRegistry } from "../context.ts"; export interface MemoryFile { hash: string | null; content: Uint8Array; } -export interface DevBuildCache extends BuildCache { - islands: Map; +export interface IslandModChunk { + name: string; + server: string; + browser: string | null; +} + +export type FsRouteFileNoMod = Omit, "mod">; - addUnprocessedFile(pathname: string): void; +export interface FsRoute { + id: string; + dir: string; + files: FsRouteFileNoMod[]; +} +export interface DevBuildCache extends BuildCache { + islandModNameToChunk: Map; + addUnprocessedFile(pathname: string, dir: string): void; addProcessedFile( pathname: string, content: Uint8Array, hash: string | null, ): Promise; - flush(): Promise; + prepare(): Promise; } -export class MemoryBuildCache implements DevBuildCache { - hasSnapshot = true; - islands = new Map(); +export class MemoryBuildCache implements DevBuildCache { #processedFiles = new Map(); #unprocessedFiles = new Map(); - #ready = Promise.withResolvers(); + #config: ResolvedBuildConfig; + #transformer: FileTransformer; + islandModNameToChunk = new Map(); + #fsRoutes: FsRoute; + #commands: Command[] = []; + root: string; + islandRegistry: ServerIslandRegistry = new Map(); constructor( - public config: ResolvedFreshConfig, - public buildId: string, - public transformer: FreshFileTransformer, - public target: string | string[], + config: ResolvedBuildConfig, + fsRoutes: FsRoute, + transformer: FileTransformer, ) { + this.#config = config; + this.#fsRoutes = fsRoutes; + this.#transformer = transformer; + this.root = config.root; + } + + getFsRoutes(): Command[] { + return this.#commands; } async readFile(pathname: string): Promise { - await this.#ready.promise; const processed = this.#processedFiles.get(pathname); if (processed !== undefined) { return { @@ -69,14 +99,14 @@ export class MemoryBuildCache implements DevBuildCache { readable: file.readable, close: () => file.close(), }; - } catch (_err) { + } catch { return null; } } let entry = pathname.startsWith("/") ? pathname.slice(1) : pathname; - entry = path.join(this.config.staticDir, entry); - const relative = path.relative(this.config.staticDir, entry); + entry = path.join(this.#config.staticDir, entry); + const relative = path.relative(this.#config.staticDir, entry); if (relative.startsWith("..")) { throw new Error( `Processed file resolved outside of static dir ${entry}`, @@ -84,16 +114,16 @@ export class MemoryBuildCache implements DevBuildCache { } // Might be a file that we still need to process - const transformed = await this.transformer.process( + const transformed = await this.#transformer.process( entry, "development", - this.target, + this.#config.target, ); if (transformed !== null) { for (let i = 0; i < transformed.length; i++) { const file = transformed[i]; - const relative = path.relative(this.config.staticDir, file.path); + const relative = path.relative(this.#config.staticDir, file.path); if (relative.startsWith(".")) { throw new Error( `Processed file resolved outside of static dir ${file.path}`, @@ -108,10 +138,10 @@ export class MemoryBuildCache implements DevBuildCache { } } else { try { - const filePath = path.join(this.config.staticDir, pathname); - const relative = path.relative(this.config.staticDir, filePath); + const filePath = path.join(this.#config.staticDir, pathname); + const relative = path.relative(this.#config.staticDir, filePath); if (!relative.startsWith(".") && (await Deno.stat(filePath)).isFile) { - this.addUnprocessedFile(pathname); + this.addUnprocessedFile(pathname, this.#config.staticDir); return this.readFile(pathname); } } catch (err) { @@ -124,14 +154,16 @@ export class MemoryBuildCache implements DevBuildCache { return null; } - getIslandChunkName(islandName: string): string | null { - return this.islands.get(islandName) ?? null; + getIslandChunkName(_fn: AnyComponent): string | null { + // FIXME + // return this.#islands.get(fn) ?? null; + return null; } - addUnprocessedFile(pathname: string): void { + addUnprocessedFile(pathname: string, dir: string): void { this.#unprocessedFiles.set( pathname, - path.join(this.config.staticDir, pathname), + path.join(dir, pathname), ); } @@ -144,39 +176,70 @@ export class MemoryBuildCache implements DevBuildCache { this.#processedFiles.set(pathname, { content, hash }); } - // deno-lint-ignore require-await async flush(): Promise { - this.#ready.resolve(); + const preparer = new IslandPreparer(); + + // Load islands + await Promise.all( + Array.from(this.islandModNameToChunk.entries()).map( + async ([name, chunk]) => { + const mod = await import(chunk.server); + + if (chunk.browser === null) { + throw new Error(`Unexpected missing browser chunk`); + } + + preparer.prepare(this.islandRegistry, mod, chunk.browser, name); + }, + ), + ); + } + + async prepare(): Promise { + // Load FS routes + const files = await Promise.all(this.#fsRoutes.files.map(async (file) => { + return { + ...file, + mod: await import(file.filePath), + }; + })); + this.#commands = fsItemsToCommands(files); } } -// await fsAdapter.mkdirp(staticOutDir); -export class DiskBuildCache implements DevBuildCache { - hasSnapshot = true; - islands = new Map(); +export class DiskBuildCache implements DevBuildCache { #processedFiles = new Map(); #unprocessedFiles = new Map(); - #transformer: FreshFileTransformer; - #target: string | string[]; + #transformer: FileTransformer; + #config: ResolvedBuildConfig; + islandModNameToChunk = new Map(); + #fsRoutes: FsRoute; + root: string; + islandRegistry: ServerIslandRegistry = new Map(); constructor( - public config: ResolvedFreshConfig, - public buildId: string, - transformer: FreshFileTransformer, - target: string | string[], + config: ResolvedBuildConfig, + fsRoutes: FsRoute, + transformer: FileTransformer, ) { + this.#fsRoutes = fsRoutes; this.#transformer = transformer; - this.#target = target; + this.#config = config; + this.root = config.root; } - getIslandChunkName(islandName: string): string | null { - return this.islands.get(islandName) ?? null; + getFsRoutes(): Command[] { + return []; } - addUnprocessedFile(pathname: string): void { + getIslandChunkName(_fn: AnyComponent): string | null { + return null; + } + + addUnprocessedFile(pathname: string, dir: string): void { this.#unprocessedFiles.set( pathname.replaceAll(WINDOWS_SEPARATOR, "/"), - path.join(this.config.staticDir, pathname), + path.join(dir, pathname), ); } @@ -188,8 +251,8 @@ export class DiskBuildCache implements DevBuildCache { this.#processedFiles.set(pathname, hash); const outDir = pathname === "/metafile.json" - ? this.config.build.outDir - : path.join(this.config.build.outDir, "static"); + ? this.#config.outDir + : path.join(this.#config.outDir, "static"); const filePath = path.join(outDir, pathname); assertInDir(filePath, outDir); @@ -202,9 +265,12 @@ export class DiskBuildCache implements DevBuildCache { throw new Error("Not implemented in build mode"); } + async prepare(): Promise { + // not needed + } + async flush(): Promise { - const staticDir = this.config.staticDir; - const outDir = this.config.build.outDir; + const { staticDir, outDir, target, root } = this.#config; if (await fsAdapter.isDirectory(staticDir)) { const entries = fsAdapter.walk(staticDir, { @@ -224,7 +290,7 @@ export class DiskBuildCache implements DevBuildCache { const result = await this.#transformer.process( entry.path, "production", - this.#target, + target, ); if (result !== null) { @@ -237,30 +303,21 @@ export class DiskBuildCache implements DevBuildCache { } else { const relative = path.relative(staticDir, entry.path); const pathname = `/${relative}`; - this.addUnprocessedFile(pathname); + this.addUnprocessedFile(pathname, staticDir); } } } - const snapshot: BuildSnapshot = { - version: 1, - buildId: this.buildId, - islands: {}, - staticFiles: {}, - }; - - for (const [name, chunk] of this.islands.entries()) { - snapshot.islands[name] = chunk; - } - + const staticFiles = new Map(); for (const [name, filePath] of this.#unprocessedFiles.entries()) { const file = await Deno.open(filePath); const hash = await hashContent(file.readable); - snapshot.staticFiles[name] = { + staticFiles.set(name, { + name, hash, - generated: false, - }; + filePath: path.relative(root, filePath), + }); } for (const [name, maybeHash] of this.#processedFiles.entries()) { @@ -271,21 +328,114 @@ export class DiskBuildCache implements DevBuildCache { continue; } + const filePath = path.join(outDir, "static", name); if (maybeHash === null) { - const filePath = path.join(this.config.build.outDir, "static", name); const file = await Deno.open(filePath); hash = await hashContent(file.readable); } - snapshot.staticFiles[name] = { + staticFiles.set(name, { + name, hash, - generated: true, - }; + filePath: path.relative(root, filePath), + }); } await Deno.writeTextFile( - getSnapshotPath(this.config), - JSON.stringify(snapshot, null, 2), + path.join(outDir, "static-files.json"), + JSON.stringify(Array.from(staticFiles.values()), null, 2), + ); + + const islandSpecifiers: string[] = []; + for (const spec of this.islandModNameToChunk.keys()) { + islandSpecifiers.push(spec); + } + + const editWarning = + `// WARNING: DO NOT EDIT THIS FILE. It is autogenerated by Fresh.`; + + const islands = Array.from(this.islandModNameToChunk.values()); + + await Deno.writeTextFile( + path.join(outDir, "snapshot.js"), + `${editWarning} + +import { IslandPreparer } from "fresh/do-not-use"; +import staticFileData from "./static-files.json" with { type: "json" }; + +// Import islands +${ + islands.map((item) => `import * as ${item.name} from "${item.server}";`) + .join("\n") + } + +// Import routes +${ + this.#fsRoutes.files + .map((item, i) => { + const spec = path.relative(outDir, item.filePath); + return `import * as fsRoute_${i} from "${spec}"`; + }) + .join("\n") + } + +export const version = ${JSON.stringify(this.#config.buildId)}; + +const prefix = \`/_fresh/js/\${version}\`; + +export const islands = new Map(); +const islandPreparer = new IslandPreparer(); +${ + islands.map((item) => { + // Strip prefix + const prefix = `/_fresh/js/${this.#config.buildId}`; + const chunkName = item.browser + ? item.browser.slice(prefix.length) + : item.browser; + return `islandPreparer.prepare(islands, ${item.name}, \`\${prefix}${chunkName}\`, ${ + JSON.stringify(item.name) + });`; + }).join("\n") + } + +export const staticFiles = new Map(); +for (let i = 0; i < staticFileData.length; i++) { + const data = staticFileData[i]; + staticFiles.set(data.name, data); +} + +export const fsRoutes = [ +${ + this.#fsRoutes.files + .map((item, i) => { + const id = JSON.stringify(item.id); + const pattern = JSON.stringify(item.pattern); + + return ` { id: ${id}, mod: fsRoute_${i}, type: ${ + JSON.stringify(item.type) + }, pattern: ${pattern} },`; + }) + .join("\n") + } +]; +`, + ); + + // TODO: Make main file configurable + const appPath = path.relative(outDir, root); + await Deno.writeTextFile( + path.join(outDir, "server.js"), + `${editWarning} +import { setBuildCache, ProdBuildCache, path } from "fresh/do-not-use"; +import * as snapshot from "./snapshot.js"; +import { app } from "${appPath}/main.ts"; + +const root = path.join(import.meta.dirname, ${JSON.stringify(appPath)}); +setBuildCache(app, new ProdBuildCache(root, snapshot)); + +export default { + fetch: app.handler() +}`, ); } } diff --git a/src/dev/dev_build_cache_test.ts b/src/dev/dev_build_cache_test.ts index 513865ed18a..0ab23a475e0 100644 --- a/src/dev/dev_build_cache_test.ts +++ b/src/dev/dev_build_cache_test.ts @@ -1,30 +1,30 @@ import { expect } from "@std/expect"; -import * as path from "@std/path"; import { MemoryBuildCache } from "./dev_build_cache.ts"; import { FreshFileTransformer } from "./file_transformer.ts"; import { createFakeFs, withTmpDir } from "../test_utils.ts"; -import type { ResolvedFreshConfig } from "../mod.ts"; +import type { ResolvedBuildConfig } from "./builder.ts"; Deno.test({ name: "MemoryBuildCache - should error if reading outside of staticDir", fn: async () => { await using _tmp = await withTmpDir(); const tmp = _tmp.dir; - const config: ResolvedFreshConfig = { + const config: ResolvedBuildConfig = { root: tmp, mode: "development", - basePath: "/", - staticDir: path.join(tmp, "static"), - build: { - outDir: path.join(tmp, "dist"), - }, + buildId: "", + ignore: [], + islandDir: "", + outDir: "", + routeDir: "", + staticDir: "", + target: "latest", }; const fileTransformer = new FreshFileTransformer(createFakeFs({})); const buildCache = new MemoryBuildCache( config, - "testing", + { dir: "", files: [], id: "" }, fileTransformer, - "latest", ); const thrown = buildCache.readFile("../SECRETS.txt"); diff --git a/src/dev/file_transformer.ts b/src/dev/file_transformer.ts index a9e7bf379b6..a1d525aa710 100644 --- a/src/dev/file_transformer.ts +++ b/src/dev/file_transformer.ts @@ -56,7 +56,16 @@ interface TransformReq { inputFiles: string[]; } -export class FreshFileTransformer { +export interface FileTransformer { + onTransform(options: OnTransformOptions, callback: TransformFn): void; + process( + filePath: string, + mode: TransformMode, + target: string | string[], + ): Promise; +} + +export class FreshFileTransformer implements FileTransformer { #transformers: Transformer[] = []; #fs: FsAdapter; diff --git a/src/dev/fs_crawl.ts b/src/dev/fs_crawl.ts new file mode 100644 index 00000000000..64769a96939 --- /dev/null +++ b/src/dev/fs_crawl.ts @@ -0,0 +1,102 @@ +import type { FsAdapter } from "../fs.ts"; +import type { WalkEntry } from "@std/fs/walk"; +import type { FsRouteFileNoMod } from "./dev_build_cache.ts"; +import * as path from "@std/path"; +import { pathToPattern } from "../router.ts"; +import { CommandType } from "../commands.ts"; +import { sortRoutePaths } from "../fs_routes.ts"; + +const GROUP_REG = /[/\\\\]\((_[^/\\\\]+)\)[/\\\\]/; + +export async function crawlRouteDir( + fs: FsAdapter, + routeDir: string, + ignore: RegExp[], + onIslandSpecifier: (spec: string) => void, + files: FsRouteFileNoMod[], +) { + await walkDir( + fs, + routeDir, + (entry) => { + // A `(_islands)` path segment is a local island folder. + // Any route path segment wrapped in `(_...)` is ignored + // during route collection. + const match = entry.path.match(GROUP_REG); + if (match !== null) { + if (match[1] === "_islands") { + onIslandSpecifier(entry.path); + } + return; + } + + const relative = path.relative(routeDir, entry.path); + const url = new URL(relative, "http://localhost/"); + const id = url.pathname.slice(0, url.pathname.lastIndexOf(".")); + + let pattern = "*"; + let routePattern = pattern; + let type = CommandType.Route; + if (id.endsWith("/_middleware")) { + type = CommandType.Middleware; + pattern = pathToPattern( + id.slice(1, -"/_middleware".length), + { keepGroups: true }, + ); + routePattern = pattern; + } else if (id.endsWith("/_layout")) { + type = CommandType.Layout; + pattern = pathToPattern( + id.slice(1, -"/_layout".length), + { keepGroups: true }, + ); + routePattern = pattern; + } else if (id.endsWith("/_app")) { + type = CommandType.App; + } else if (id.endsWith("/_404")) { + type = CommandType.NotFound; + } else if (id.endsWith("/_error") || id.endsWith("/_500")) { + type = CommandType.Error; + pattern = pathToPattern( + id.slice(1, -"/_error".length), + { keepGroups: true }, + ); + routePattern = pattern; + } else { + pattern = pathToPattern(id.slice(1), { keepGroups: true }); + if (id.endsWith("/index")) { + if (!pattern.endsWith("/")) { + pattern += "/"; + } + } + + routePattern = pathToPattern(id.slice(1)); + } + + files.push({ id, filePath: entry.path, type, pattern, routePattern }); + }, + ignore, + ); + + files.sort((a, b) => sortRoutePaths(a.id, b.id)); +} + +export async function walkDir( + fs: FsAdapter, + dir: string, + callback: (entry: WalkEntry) => void, + ignore: RegExp[], +) { + if (!await fs.isDirectory(dir)) return; + + const entries = fs.walk(dir, { + includeDirs: false, + includeFiles: true, + exts: ["tsx", "jsx", "ts", "js"], + skip: ignore, + }); + + for await (const entry of entries) { + callback(entry); + } +} diff --git a/src/dev/mod.ts b/src/dev/mod.ts index 3c0e387a1e2..002941bee7b 100644 --- a/src/dev/mod.ts +++ b/src/dev/mod.ts @@ -1,4 +1,8 @@ -export { Builder } from "./builder.ts"; +export { + Builder, + type BuildOptions, + type ResolvedBuildConfig, +} from "./builder.ts"; export { type OnTransformArgs, type OnTransformOptions, diff --git a/src/finish_setup.tsx b/src/finish_setup.tsx deleted file mode 100644 index b3673d22059..00000000000 --- a/src/finish_setup.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { ComponentChildren } from "preact"; - -export function FinishSetup() { - return ( - -
-
-

Finish setting up Fresh

-
    -
  1. - Go to your project in Deno Deploy and click the{" "} - Settings tab. -
  2. -
  3. - In the Git Integration section, enter deno task build - {" "} - in the Build Command input. -
  4. -
  5. - Save the changes. -
  6. -
-
-
-
- ); -} - -export function ForgotBuild() { - return ( - -
-
-

Missing build directory

-

- Did you forget to run deno task build? -

-
-
-
- ); -} - -function Doc(props: { children?: ComponentChildren }) { - return ( - - - - Finish setting up Fresh - - {props.children} - - ); -} diff --git a/src/fs_routes.ts b/src/fs_routes.ts new file mode 100644 index 00000000000..8cbb1a2150c --- /dev/null +++ b/src/fs_routes.ts @@ -0,0 +1,277 @@ +import type { AnyComponent } from "preact"; +import type { RouteConfig } from "./types.ts"; +import type { HandlerByMethod, RouteHandler } from "./handlers.ts"; +import type { MiddlewareFn } from "./middlewares/mod.ts"; +import type { AsyncAnyComponent } from "./render.ts"; +import { type HandlerFn, isHandlerByMethod } from "./handlers.ts"; +import type { PageProps } from "./render.ts"; +import { + type Command, + CommandType, + newAppCmd, + newErrorCmd, + newLayoutCmd, + newMiddlewareCmd, + newNotFoundCmd, + newRouteCmd, +} from "./commands.ts"; + +export interface FreshFsMod { + config?: RouteConfig; + handler?: RouteHandler | HandlerFn[]; + handlers?: RouteHandler; + default?: + | AnyComponent> + | AsyncAnyComponent>; +} + +export interface FsRouteFile { + id: string; // FIXME remove? + filePath: string; + mod: FreshFsMod; + pattern: string; + type: CommandType; + routePattern: string; +} + +// deno-lint-ignore no-explicit-any +function isFreshFile(mod: any): mod is FreshFsMod { + return mod !== null && typeof mod === "object" && + typeof mod.default === "function" || + typeof mod.config === "object" || typeof mod.handlers === "object" || + typeof mod.handlers === "function" || typeof mod.handler === "object" || + typeof mod.handler === "function"; +} + +export interface FsRoutesOptions { + /** + * Parent directory for the `/routes` and `/islands` folders. + * + * By default, the `root` config option of the provided app is used. + * @default app.config.root + */ + dir?: string; + ignoreFilePattern?: RegExp[]; + loadRoute: (path: string) => Promise; +} + +export function fsItemsToCommands( + items: FsRouteFile[], +): Command[] { + const commands: Command[] = []; + + for (let i = 0; i < items.length; i++) { + const { filePath, type, mod, pattern, routePattern } = items[i]; + + if (!isFreshFile(mod)) { + throw new Error( + `Expected a route, middleware, layout or error template, but couldn't find relevant exports in: ${filePath}`, + ); + } + + const handlers = mod.handlers ?? mod.handler ?? null; + if (typeof handlers === "function" && handlers.length > 1) { + throw new Error( + `Handlers must only have one argument but found more than one. Check the function signature in: ${filePath}`, + ); + } + + switch (type) { + case CommandType.Middleware: { + let middlewares = (handlers ?? mod.default) as unknown as + | MiddlewareFn + | MiddlewareFn[] + | HandlerByMethod ?? + null; + if (middlewares === null) continue; + + if (isHandlerByMethod(middlewares)) { + warnInvalidRoute( + "Middleware does not support object handlers with GET, POST, etc.", + ); + continue; + } + + if (!Array.isArray(middlewares)) { + middlewares = [middlewares]; + } + + commands.push( + newMiddlewareCmd(pattern, middlewares, true), + ); + continue; + } + case CommandType.Layout: { + if (handlers !== null) { + warnInvalidRoute("Layout does not support handlers"); + } + if (!mod.default) continue; + + commands.push(newLayoutCmd(pattern, mod.default, mod.config, true)); + continue; + } + case CommandType.Error: { + commands.push(newErrorCmd( + pattern, + { + component: mod.default ?? undefined, + config: mod.config ?? undefined, + handler: (handlers as any) ?? undefined, + }, + true, + )); + continue; + } + case CommandType.NotFound: { + commands.push(newNotFoundCmd({ + config: mod.config, + component: mod.default, + handler: handlers as any ?? undefined, + })); + continue; + } + case CommandType.App: { + if (mod.default === undefined) continue; + + commands.push(newAppCmd(mod.default)); + continue; + } + case CommandType.Route: { + commands.push(newRouteCmd( + pattern, + { + config: { + ...mod.config, + routeOverride: mod.config?.routeOverride ?? routePattern, + }, + handler: (handlers as any) ?? undefined, + component: mod.default, + }, + false, + )); + continue; + } + case CommandType.Handler: + throw new Error(`Not supported`); + case CommandType.FsRoute: + throw new Error(`Nested FsRoutes are not supported`); + default: + throw new Error(`Unknown command type: ${type}`); + } + } + + return commands; +} + +function warnInvalidRoute(message: string) { + // deno-lint-ignore no-console + console.warn( + `🍋 %c[WARNING] Unsupported route config: ${message}`, + "color:rgb(251, 184, 0)", + ); +} + +const APP_REG = /_app(?!\.[tj]sx?)?$/; + +/** + * Sort route paths where special Fresh files like `_app`, + * `_layout` and `_middleware` are sorted in front. + */ +export function sortRoutePaths(a: string, b: string) { + // The `_app` route should always be the first + if (APP_REG.test(a)) return -1; + else if (APP_REG.test(b)) return 1; + + const aLen = a.length; + const bLen = b.length; + + let segment = false; + let aIdx = 0; + let bIdx = 0; + for (; aIdx < aLen && bIdx < bLen; aIdx++, bIdx++) { + const charA = a.charAt(aIdx); + const charB = b.charAt(bIdx); + + // When comparing a grouped route with a non-grouped one, we + // need to skip over the group name to effectively compare the + // actual route. + if (charA === "(" && charB !== "(") { + if (charB == "[") return -1; + return 1; + } else if (charB === "(" && charA !== "(") { + if (charA == "[") return 1; + return -1; + } + + if (charA === "/" || charB === "/") { + segment = true; + + // If the other path doesn't close the segment + // then we don't need to continue + if (charA !== "/") return 1; + if (charB !== "/") return -1; + + continue; + } + + if (segment) { + segment = false; + + const scoreA = getRoutePathScore(charA, a, aIdx); + const scoreB = getRoutePathScore(charB, b, bIdx); + if (scoreA === scoreB) { + if (charA !== charB) { + // TODO: Do we need localeSort here or is this good enough? + return charA < charB ? 0 : 1; + } + continue; + } + + return scoreA > scoreB ? -1 : 1; + } + + if (charA !== charB) { + // TODO: Do we need localeSort here or is this good enough? + return charA < charB ? 0 : 1; + } + + // If we're at the end of A or B, then we assume that the longer + // path is more specific + if (aIdx === aLen - 1 && bIdx < bLen - 1) { + return 1; + } else if (bIdx === bLen - 1 && aIdx < aLen - 1) { + return -1; + } + } + + return 0; +} + +/** + * Assign a score based on the first two characters of a path segment. + * The goal is to sort `_middleware` and `_layout` in front of everything + * and `[` or `[...` last respectively. + */ +function getRoutePathScore(char: string, s: string, i: number): number { + if (char === "_") { + if (i + 1 < s.length) { + if (s[i + 1] === "e") return 4; + if (s[i + 1] === "m") return 6; + } + return 5; + } else if (char === "[") { + if (i + 1 < s.length && s[i + 1] === ".") { + return 0; + } + return 1; + } + + if ( + i + 4 === s.length - 1 && char === "i" && s[i + 1] === "n" && + s[i + 2] === "d" && s[i + 3] === "e" && s[i + 4] === "x" + ) { + return 3; + } + + return 2; +} diff --git a/src/plugins/fs_routes/mod_test.tsx b/src/fs_routes_test.tsx similarity index 96% rename from src/plugins/fs_routes/mod_test.tsx rename to src/fs_routes_test.tsx index 400848dad42..090026e50bb 100644 --- a/src/plugins/fs_routes/mod_test.tsx +++ b/src/fs_routes_test.tsx @@ -1,42 +1,38 @@ -import { App } from "../../app.ts"; -import { - type FreshFsItem, - fsRoutes, - type FsRoutesOptions, - sortRoutePaths, - type TESTING_ONLY__FsRoutesOptions, -} from "./mod.ts"; -import { delay, FakeServer } from "../../test_utils.ts"; -import { createFakeFs } from "../../test_utils.ts"; +import { App, setBuildCache } from "./app.ts"; +import { type FreshFsMod, sortRoutePaths } from "./fs_routes.ts"; +import { delay, FakeServer, MockBuildCache } from "./test_utils.ts"; +import { createFakeFs } from "./test_utils.ts"; import { expect, fn } from "@std/expect"; import { stub } from "@std/testing/mock"; -import { type HandlerByMethod, type HandlerFn, page } from "../../handlers.ts"; -import type { Method } from "../../router.ts"; -import { parseHtml } from "../../../tests/test_utils.tsx"; -import type { FreshContext } from "../../context.ts"; -import { HttpError } from "../../error.ts"; +import { type HandlerByMethod, type HandlerFn, page } from "./handlers.ts"; +import type { Method } from "./router.ts"; +import { parseHtml } from "../tests/test_utils.tsx"; +import type { Context } from "./context.ts"; +import { HttpError } from "./error.ts"; +import type { FsRouteFileNoMod } from "./dev/dev_build_cache.ts"; +import { crawlRouteDir } from "./dev/fs_crawl.ts"; +import * as path from "@std/path"; async function createServer( - files: Record>, + files: Record>, ): Promise { - const app = new App(); - - await fsRoutes( - app, - { - dir: ".", - loadIsland: async () => {}, - // deno-lint-ignore require-await - loadRoute: async (filePath) => { - const full = `routes/${filePath.replaceAll(/[\\]+/g, "/")}`; - if (full in files) { - return files[full]; - } - throw new Error(`Mock FS: file ${full} not found`); - }, - _fs: createFakeFs(files), - } as FsRoutesOptions & TESTING_ONLY__FsRoutesOptions, - ); + const fs = createFakeFs(files); + + const routeDir = path.join(fs.cwd(), "routes"); + const rawFiles: FsRouteFileNoMod[] = []; + await crawlRouteDir(fs, routeDir, [], () => {}, rawFiles); + + const fsFiles = rawFiles.map((file) => { + // deno-lint-ignore no-explicit-any + return { ...file, mod: files[file.filePath] as any }; + }); + + const app = new App() + .fsRoutes(); + + const buildCache = new MockBuildCache(fsFiles); + setBuildCache(app, buildCache); + return new FakeServer(app.handler()); } @@ -178,7 +174,7 @@ Deno.test("fsRoutes - middleware", async () => { const server = await createServer<{ text: string }>({ "routes/index.ts": { handler: (ctx) => new Response(ctx.state.text) }, "routes/_middleware.ts": { - default: ((ctx: FreshContext<{ text: string }>) => { + default: ((ctx: Context<{ text: string }>) => { ctx.state.text = "ok"; return ctx.next(); // deno-lint-ignore no-explicit-any diff --git a/src/internals.ts b/src/internals.ts new file mode 100644 index 00000000000..f2dcd2fbe4d --- /dev/null +++ b/src/internals.ts @@ -0,0 +1,5 @@ +import * as path from "@std/path"; + +export { setBuildCache } from "./app.ts"; +export { IslandPreparer, ProdBuildCache } from "./build_cache.ts"; +export { path }; diff --git a/src/middlewares/static_files_test.ts b/src/middlewares/static_files_test.ts index bb9c1c1df04..834f8153c19 100644 --- a/src/middlewares/static_files_test.ts +++ b/src/middlewares/static_files_test.ts @@ -4,11 +4,15 @@ import type { BuildCache, StaticFile } from "../build_cache.ts"; import { expect } from "@std/expect"; import { ASSET_CACHE_BUST_KEY } from "../runtime/shared_internal.tsx"; import { BUILD_ID } from "../runtime/build_id.ts"; +import type { AnyComponent } from "preact"; +import type { Command } from "../commands.ts"; +import type { ServerIslandRegistry } from "../context.ts"; class MockBuildCache implements BuildCache { + root = ""; buildId = "MockId"; files = new Map(); - hasSnapshot = true; + islandRegistry: ServerIslandRegistry = new Map(); constructor(files: Record) { const encoder = new TextEncoder(); @@ -25,11 +29,16 @@ class MockBuildCache implements BuildCache { } } + // deno-lint-ignore no-explicit-any + getFsRoutes(): Command[] { + return []; + } + // deno-lint-ignore require-await async readFile(pathname: string): Promise { return this.files.get(pathname) ?? null; } - getIslandChunkName(_islandName: string): string | null { + getIslandChunkName(_islandName: AnyComponent): string | null { return null; } } @@ -135,13 +144,9 @@ Deno.test("static files - disables caching in development", async () => { { buildCache, config: { + root: "", basePath: "", - build: { - outDir: "", - }, mode: "development", - root: ".", - staticDir: "", }, }, ); @@ -163,13 +168,9 @@ Deno.test("static files - enables caching in production", async () => { { buildCache, config: { + root: "", basePath: "", - build: { - outDir: "", - }, mode: "production", - root: ".", - staticDir: "", }, }, ); diff --git a/src/mod.ts b/src/mod.ts index bd9d35b7677..8038435d238 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -1,6 +1,5 @@ export { App, type ListenOptions } from "./app.ts"; export { trailingSlashes } from "./middlewares/trailing_slashes.ts"; -export { fsRoutes, type FsRoutesOptions } from "./plugins/fs_routes/mod.ts"; export { type HandlerByMethod, type HandlerFn, diff --git a/src/plugins/fs_routes/mod.ts b/src/plugins/fs_routes/mod.ts deleted file mode 100644 index 81f82585b00..00000000000 --- a/src/plugins/fs_routes/mod.ts +++ /dev/null @@ -1,397 +0,0 @@ -import type { AnyComponent } from "preact"; -import type { App } from "../../app.ts"; -import type { WalkEntry } from "@std/fs/walk"; -import * as path from "@std/path"; -import type { RouteConfig } from "../../types.ts"; -import type { RouteHandler } from "../../handlers.ts"; -import type { MiddlewareFn } from "../../middlewares/mod.ts"; -import type { AsyncAnyComponent } from "../../render.ts"; -import { pathToPattern } from "../../router.ts"; -import { type HandlerFn, isHandlerByMethod } from "../../handlers.ts"; -import { type FsAdapter, fsAdapter } from "../../fs.ts"; -import { parseRootPath } from "../../config.ts"; -import type { PageProps } from "../../render.ts"; - -const TEST_FILE_PATTERN = /[._]test\.(?:[tj]sx?|[mc][tj]s)$/; -const GROUP_REG = /(^|[/\\\\])\((_[^/\\\\]+)\)[/\\\\]/; - -interface InternalRoute { - path: string; - base: string; - filePath: string; - config: RouteConfig | null; - handlers: - | MiddlewareFn[] - | RouteHandler - | null; - component: AnyComponent> | null; -} - -export interface FreshFsItem { - config?: RouteConfig; - handler?: RouteHandler | HandlerFn[]; - handlers?: RouteHandler; - default?: - | AnyComponent> - | AsyncAnyComponent>; -} - -// deno-lint-ignore no-explicit-any -function isFreshFile(mod: any): mod is FreshFsItem { - return mod !== null && typeof mod === "object" && - typeof mod.default === "function" || - typeof mod.config === "object" || typeof mod.handlers === "object" || - typeof mod.handlers === "function" || typeof mod.handler === "object" || - typeof mod.handler === "function"; -} - -export interface FsRoutesOptions { - /** - * Parent directory for the `/routes` and `/islands` folders. - * - * By default, the `root` config option of the provided app is used. - * @default app.config.root - */ - dir?: string; - ignoreFilePattern?: RegExp[]; - loadRoute: (path: string) => Promise; - loadIsland: (path: string) => Promise; -} - -export interface TESTING_ONLY__FsRoutesOptions { - _fs?: FsAdapter; -} - -export async function fsRoutes( - app: App, - options_: FsRoutesOptions, -) { - const options = options_ as FsRoutesOptions & TESTING_ONLY__FsRoutesOptions; - const ignore = options.ignoreFilePattern ?? [TEST_FILE_PATTERN]; - const fs = options._fs ?? fsAdapter; - - const dir = options.dir - ? parseRootPath(options.dir, fs.cwd()) - : app.config.root; - const islandDir = path.join(dir, "islands"); - const routesDir = path.join(dir, "routes"); - - const islandPaths: string[] = []; - const relRoutePaths: string[] = []; - - // Walk routes folder - await Promise.all([ - walkDir( - islandDir, - (entry) => { - islandPaths.push(entry.path); - }, - ignore, - fs, - ), - walkDir( - routesDir, - (entry) => { - const relative = path.relative(routesDir, entry.path); - - // A `(_islands)` path segment is a local island folder. - // Any route path segment wrapped in `(_...)` is ignored - // during route collection. - const match = relative.match(GROUP_REG); - if (match && match[2][0] === "_") { - if (match[2] === "_islands") { - islandPaths.push(entry.path); - } - return; - } - - const url = new URL(relative, "http://localhost/"); - relRoutePaths.push(url.pathname.slice(1)); - }, - ignore, - fs, - ), - ]); - - await Promise.all(islandPaths.map(async (islandPath) => { - const relative = path.relative(islandDir, islandPath); - // deno-lint-ignore no-explicit-any - const mod = await options.loadIsland(relative) as any; - for (const key of Object.keys(mod)) { - const maybeFn = mod[key]; - if (typeof maybeFn === "function") { - app.island(islandPath, key, maybeFn); - } - } - })); - - const routeModules: InternalRoute[] = await Promise.all( - relRoutePaths.map(async (routePath) => { - const mod = await options.loadRoute(routePath); - if (!isFreshFile(mod)) { - throw new Error( - `Expected a route, middleware, layout or error template, but couldn't find relevant exports in: ${routePath}`, - ); - } - - const handlers = mod.handlers ?? mod.handler ?? null; - if (typeof handlers === "function" && handlers.length > 1) { - throw new Error( - `Handlers must only have one argument but found more than one. Check the function signature in: ${ - path.join(routesDir, routePath) - }`, - ); - } - - const normalizedPath = `/${ - routePath.slice(0, routePath.lastIndexOf(".")) - }`; - const base = normalizedPath.slice(0, normalizedPath.lastIndexOf("/")); - const isMiddleware = normalizedPath.endsWith("/_middleware"); - return { - path: normalizedPath, - filePath: routePath, - base, - handlers: mod.handlers ?? mod.handler ?? - (isMiddleware ? mod.default ?? null : null), - config: mod.config ?? null, - component: isMiddleware ? null : mod.default ?? null, - } as InternalRoute; - }), - ); - - routeModules.sort((a, b) => sortRoutePaths(a.path, b.path)); - const errorPaths = new Set(); - - for (let i = 0; i < routeModules.length; i++) { - const routeMod = routeModules[i]; - const normalized = routeMod.path; - - if (normalized.endsWith("/_app")) { - const component = routeMod.component; - if (component !== null) { - app.appWrapper(component); - } - continue; - } else if (normalized.endsWith("/_middleware")) { - if (routeMod.handlers === null) continue; - - if (isHandlerByMethod(routeMod.handlers)) { - warnInvalidRoute( - "Middleware does not support object handlers with GET, POST, etc.", - ); - continue; - } - const pattern = pathToPattern( - normalized.slice(1, -"/_middleware".length), - { keepGroups: true }, - ); - - const handlers = (Array.isArray(routeMod.handlers) - ? routeMod.handlers - : [routeMod.handlers]) as MiddlewareFn[]; - - app.use(pattern, ...handlers); - - continue; - } else if (normalized.endsWith("/_layout")) { - if (routeMod.handlers !== null) { - warnInvalidRoute("Layout does not support handlers"); - } - - const pattern = pathToPattern(normalized.slice(1, -"/_layout".length), { - keepGroups: true, - }); - const { component, config } = routeMod; - if (component !== null) { - app.layout(pattern, component, config ?? undefined); - } - continue; - } else if (normalized.endsWith("/_error")) { - const pattern = pathToPattern(normalized.slice(1, -"/_error".length), { - keepGroups: true, - }); - errorPaths.add(pattern); - app.onError(pattern, { - config: routeMod.config ?? undefined, - component: routeMod.component ?? undefined, - // deno-lint-ignore no-explicit-any - handler: routeMod.handlers as any ?? undefined, - }); - continue; - } else if (normalized.endsWith("/_404")) { - app.notFound({ - config: routeMod.config ?? undefined, - component: routeMod.component ?? undefined, - // deno-lint-ignore no-explicit-any - handler: routeMod.handlers as any ?? undefined, - }); - continue; - } else if (normalized.endsWith("/_500")) { - const pattern = pathToPattern(normalized.slice(1, -"/_500".length), { - keepGroups: true, - }); - if (errorPaths.has(pattern)) continue; - - app.onError(pattern, { - config: routeMod.config ?? undefined, - component: routeMod.component ?? undefined, - // deno-lint-ignore no-explicit-any - handler: routeMod.handlers as any ?? undefined, - }); - continue; - } - - let pattern = pathToPattern(normalized.slice(1), { keepGroups: true }); - if (normalized.endsWith("/index")) { - if (!pattern.endsWith("/")) { - pattern += "/"; - } - } - - const routePattern = pathToPattern(normalized.slice(1)); - - app.route(pattern, { - config: { - ...routeMod.config ?? undefined, - routeOverride: routeMod.config?.routeOverride ?? routePattern, - }, - component: routeMod.component ?? undefined, - // deno-lint-ignore no-explicit-any - handler: routeMod.handlers as any ?? undefined, - }); - } -} - -function warnInvalidRoute(message: string) { - // deno-lint-ignore no-console - console.warn( - `🍋 %c[WARNING] Unsupported route config: ${message}`, - "color:rgb(251, 184, 0)", - ); -} - -async function walkDir( - dir: string, - callback: (entry: WalkEntry) => void, - ignore: RegExp[], - fs: FsAdapter, -) { - if (!await fs.isDirectory(dir)) return; - - const entries = fs.walk(dir, { - includeDirs: false, - includeFiles: true, - exts: ["tsx", "jsx", "ts", "js"], - skip: ignore, - }); - - for await (const entry of entries) { - callback(entry); - } -} - -const APP_REG = /_app(?!\.[tj]sx?)?$/; - -/** - * Sort route paths where special Fresh files like `_app`, - * `_layout` and `_middleware` are sorted in front. - */ -export function sortRoutePaths(a: string, b: string) { - // The `_app` route should always be the first - if (APP_REG.test(a)) return -1; - else if (APP_REG.test(b)) return 1; - - const aLen = a.length; - const bLen = b.length; - - let segment = false; - let aIdx = 0; - let bIdx = 0; - for (; aIdx < aLen && bIdx < bLen; aIdx++, bIdx++) { - const charA = a.charAt(aIdx); - const charB = b.charAt(bIdx); - - // When comparing a grouped route with a non-grouped one, we - // need to skip over the group name to effectively compare the - // actual route. - if (charA === "(" && charB !== "(") { - if (charB == "[") return -1; - return 1; - } else if (charB === "(" && charA !== "(") { - if (charA == "[") return 1; - return -1; - } - - if (charA === "/" || charB === "/") { - segment = true; - - // If the other path doesn't close the segment - // then we don't need to continue - if (charA !== "/") return 1; - if (charB !== "/") return -1; - - continue; - } - - if (segment) { - segment = false; - - const scoreA = getRoutePathScore(charA, a, aIdx); - const scoreB = getRoutePathScore(charB, b, bIdx); - if (scoreA === scoreB) { - if (charA !== charB) { - // TODO: Do we need localeSort here or is this good enough? - return charA < charB ? 0 : 1; - } - continue; - } - - return scoreA > scoreB ? -1 : 1; - } - - if (charA !== charB) { - // TODO: Do we need localeSort here or is this good enough? - return charA < charB ? 0 : 1; - } - - // If we're at the end of A or B, then we assume that the longer - // path is more specific - if (aIdx === aLen - 1 && bIdx < bLen - 1) { - return 1; - } else if (bIdx === bLen - 1 && aIdx < aLen - 1) { - return -1; - } - } - - return 0; -} - -/** - * Assign a score based on the first two characters of a path segment. - * The goal is to sort `_middleware` and `_layout` in front of everything - * and `[` or `[...` last respectively. - */ -function getRoutePathScore(char: string, s: string, i: number): number { - if (char === "_") { - if (i + 1 < s.length) { - if (s[i + 1] === "e") return 4; - if (s[i + 1] === "m") return 6; - } - return 5; - } else if (char === "[") { - if (i + 1 < s.length && s[i + 1] === ".") { - return 0; - } - return 1; - } - - if ( - i + 4 === s.length - 1 && char === "i" && s[i + 1] === "n" && - s[i + 2] === "d" && s[i + 3] === "e" && s[i + 4] === "x" - ) { - return 3; - } - - return 2; -} diff --git a/src/render.ts b/src/render.ts index aca3bdf0649..7fabcec1156 100644 --- a/src/render.ts +++ b/src/render.ts @@ -90,12 +90,9 @@ export function preactRender( const runtimeUrl = `${basePath}/_fresh/js/${BUILD_ID}/fresh-runtime.js`; let link = `<${encodeURI(runtimeUrl)}>; rel="modulepreload"; as="script"`; state.islands.forEach((island) => { - const chunk = buildCache.getIslandChunkName(island.name); - if (chunk !== null) { - link += `, <${ - encodeURI(`${basePath}${chunk}`) - }>; rel="modulepreload"; as="script"`; - } + link += `, <${ + encodeURI(`${basePath}${island.file}`) + }>; rel="modulepreload"; as="script"`; }); if (link !== "") { diff --git a/src/router.ts b/src/router.ts index 015e2ebc324..59251e0c5b1 100644 --- a/src/router.ts +++ b/src/router.ts @@ -288,7 +288,8 @@ export function mergePath(basePath: string, path: string): string { if (basePath.endsWith("*")) basePath = basePath.slice(0, -1); if (basePath === "/") basePath = ""; - if (path === "*" || path === "/*") path = "/*"; + if (path === "*") path = ""; + else if (path === "/*") path = "/*"; const s = (basePath !== "" && path === "/") ? "" : path; return basePath + s; diff --git a/src/router_test.ts b/src/router_test.ts index b6beffcc033..79c6dbbe33e 100644 --- a/src/router_test.ts +++ b/src/router_test.ts @@ -161,6 +161,6 @@ Deno.test("mergePath", () => { expect(mergePath("/foo/bar", "/baz")).toEqual("/foo/bar/baz"); expect(mergePath("*", "/baz")).toEqual("/baz"); expect(mergePath("/*", "/baz")).toEqual("/baz"); - expect(mergePath("/foo", "*")).toEqual("/foo/*"); + expect(mergePath("/foo", "*")).toEqual("/foo"); expect(mergePath("/foo", "/*")).toEqual("/foo/*"); }); diff --git a/src/runtime/server/preact_hooks.tsx b/src/runtime/server/preact_hooks.tsx index 6775a25d78e..42c86844f08 100644 --- a/src/runtime/server/preact_hooks.tsx +++ b/src/runtime/server/preact_hooks.tsx @@ -14,7 +14,6 @@ import type { Stringifiers } from "../../jsonify/stringify.ts"; import type { PageProps } from "../../render.ts"; import { Partial, type PartialProps } from "../shared.ts"; import { stringify } from "../../jsonify/stringify.ts"; -import type { ServerIslandRegistry } from "../../context.ts"; import type { Island } from "../../context.ts"; import { assetHashingHook, @@ -90,7 +89,6 @@ export class RenderState { constructor( // deno-lint-ignore no-explicit-any public ctx: PageProps, - public islandRegistry: ServerIslandRegistry, public buildCache: BuildCache, public partialId: string, ) { @@ -203,7 +201,7 @@ options[OptionsType.DIFF] = (vnode) => { } else if ( !PATCHED.has(vnode) && !hasIslandOwner(RENDER_STATE, vnode) ) { - const island = RENDER_STATE.islandRegistry.get(vnode.type); + const island = RENDER_STATE.buildCache.islandRegistry.get(vnode.type); if (island === undefined) { // Not an island, but we might need to preserve keys if (vnode.key !== undefined) { @@ -324,7 +322,7 @@ function hasIslandOwner(current: RenderState, vnode: VNode): boolean { let tmpVNode = vnode; let owner; while ((owner = current.owners.get(tmpVNode)) !== undefined) { - if (current.islandRegistry.has(owner.type as ComponentType)) { + if (current.buildCache.islandRegistry.has(owner.type as ComponentType)) { return true; } tmpVNode = owner; @@ -425,23 +423,16 @@ export interface PartialStateJson { } function FreshRuntimeScript() { - const { islands, nonce, ctx, islandProps, partialId, buildCache } = - RENDER_STATE!; + const { islands, nonce, ctx, islandProps, partialId } = RENDER_STATE!; const basePath = ctx.config.basePath; const islandArr = Array.from(islands); if (ctx.url.searchParams.has(PARTIAL_SEARCH_PARAM)) { const islands = islandArr.map((island) => { - const chunk = buildCache.getIslandChunkName(island.name); - if (chunk === null) { - throw new Error( - `Could not find chunk for ${island.name} ${island.file}#${island.exportName}`, - ); - } return { exportName: island.exportName, - chunk, + chunk: island.file, name: island.name, }; }); @@ -464,16 +455,10 @@ function FreshRuntimeScript() { ); } else { const islandImports = islandArr.map((island) => { - const chunk = buildCache.getIslandChunkName(island.name); - if (chunk === null) { - throw new Error( - `Could not find chunk for ${island.name} ${island.file}#${island.exportName}`, - ); - } const named = island.exportName === "default" ? island.name : `{ ${island.exportName} }`; - return `import ${named} from "${`${basePath}${chunk}`}";`; + return `import ${named} from "${`${basePath}${island.file}`}";`; }).join(""); const islandObj = "{" + islandArr.map((island) => island.name) diff --git a/src/test_utils.ts b/src/test_utils.ts index 35908758a07..6ce8c1a090e 100644 --- a/src/test_utils.ts +++ b/src/test_utils.ts @@ -1,9 +1,12 @@ -import { Context } from "./context.ts"; +import { Context, type ServerIslandRegistry } from "./context.ts"; import type { FsAdapter } from "./fs.ts"; -import { type BuildCache, ProdBuildCache } from "./build_cache.ts"; +import type { BuildCache, StaticFile } from "./build_cache.ts"; import type { ResolvedFreshConfig } from "./config.ts"; import type { WalkEntry } from "@std/fs/walk"; import { DEFAULT_CONN_INFO } from "./app.ts"; +import type { Command } from "./commands.ts"; +import { fsItemsToCommands, type FsRouteFile } from "./fs_routes.ts"; +import type { AnyComponent } from "preact"; const STUB = {} as unknown as Deno.ServeHandlerInfo; @@ -61,20 +64,16 @@ export class FakeServer { } const DEFAULT_CONFIG: ResolvedFreshConfig = { - build: { - outDir: "", - }, + root: "", mode: "production", basePath: "", - root: "", - staticDir: "", }; export function serveMiddleware( middleware: (ctx: Context) => Response | Promise, options: { config?: ResolvedFreshConfig; - buildCache?: BuildCache; + buildCache?: BuildCache; next?: () => Promise; route?: string | null; } = {}, @@ -84,7 +83,7 @@ export function serveMiddleware( (() => new Response("not found", { status: 404 })); const config = options.config ?? DEFAULT_CONFIG; const buildCache = options.buildCache ?? - new ProdBuildCache(config, new Map(), new Map(), true); + new MockBuildCache([]); const ctx = new Context( req, @@ -94,7 +93,6 @@ export function serveMiddleware( {}, config, () => Promise.resolve(next()), - new Map(), buildCache, ); return await middleware(ctx); @@ -144,3 +142,25 @@ export async function withTmpDir( }, }; } + +export class MockBuildCache implements BuildCache { + #files: FsRouteFile[]; + root = ""; + islandRegistry: ServerIslandRegistry = new Map(); + + constructor(files: FsRouteFile[]) { + this.#files = files; + } + + getFsRoutes(): Command[] { + return fsItemsToCommands(this.#files); + } + + readFile(_pathname: string): Promise { + return Promise.resolve(null); + } + + getIslandChunkName(_fn: AnyComponent): string | null { + return null; + } +} diff --git a/src/utils.ts b/src/utils.ts index 8337216b6aa..73d54f9cf87 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -47,3 +47,23 @@ export function escapeScript( .replaceAll(SCRIPT_ESCAPE, "<\\/$1") .replaceAll(COMMENT_ESCAPE, options.json ? "\\u003C!--" : "\\x3C!--"); } + +export class UniqueNamer { + seen = new Map(); + + getUniqueName(name: string): string { + const count = this.seen.get(name); + if (count === undefined) { + this.seen.set(name, 1); + } else { + this.seen.set(name, count + 1); + name = `${name}_${count}`; + } + + return name; + } + + getNames() { + return this.seen.keys(); + } +} diff --git a/tests/active_links_test.tsx b/tests/active_links_test.tsx index b522aed09cb..2d5262e8a07 100644 --- a/tests/active_links_test.tsx +++ b/tests/active_links_test.tsx @@ -1,35 +1,24 @@ import { App, staticFiles } from "fresh"; import { - allIslandApp, + ALL_ISLAND_DIR, assertNotSelector, assertSelector, buildProd, Doc, - getIsland, parseHtml, withBrowserApp, } from "./test_utils.tsx"; -import { SelfCounter } from "./fixtures_islands/SelfCounter.tsx"; -import { PartialInIsland } from "./fixtures_islands/PartialInIsland.tsx"; -import { JsonIsland } from "./fixtures_islands/JsonIsland.tsx"; import { FakeServer } from "../src/test_utils.ts"; import { Partial } from "fresh/runtime"; -import { getBuildCache, setBuildCache } from "../src/app.ts"; -await buildProd(allIslandApp); +const allIslandCache = await buildProd({ islandDir: ALL_ISLAND_DIR }); function testApp(): App { - const selfCounter = getIsland("SelfCounter.tsx"); - const partialInIsland = getIsland("PartialInIsland.tsx"); - const jsonIsland = getIsland("JsonIsland.tsx"); - const app = new App() - .island(selfCounter, "SelfCounter", SelfCounter) - .island(partialInIsland, "PartialInIsland", PartialInIsland) - .island(jsonIsland, "JsonIsland", JsonIsland) .use(staticFiles()); - setBuildCache(app, getBuildCache(allIslandApp)); + + allIslandCache(app); return app; } diff --git a/tests/fixture_precompile/invalid/dev.ts b/tests/fixture_precompile/invalid/dev.ts index 85f6aab81cc..f19d7be282a 100644 --- a/tests/fixture_precompile/invalid/dev.ts +++ b/tests/fixture_precompile/invalid/dev.ts @@ -1,8 +1,7 @@ import { Builder } from "../../../src/dev/mod.ts"; -import { app } from "./main.tsx"; const builder = new Builder(); -await builder.listen(app, { +await builder.listen(() => import("./main.tsx"), { port: 4001, }); diff --git a/tests/islands_test.tsx b/tests/islands_test.tsx index 78d606c386f..3f6b61e2df1 100644 --- a/tests/islands_test.tsx +++ b/tests/islands_test.tsx @@ -1,4 +1,4 @@ -import { App, fsRoutes, staticFiles } from "fresh"; +import { App, staticFiles } from "fresh"; import { Counter } from "./fixtures_islands/Counter.tsx"; import { IslandInIsland } from "./fixtures_islands/IslandInIsland.tsx"; import { JsonIsland } from "./fixtures_islands/JsonIsland.tsx"; @@ -12,42 +12,38 @@ import { JsxChildrenIsland } from "./fixtures_islands/JsxChildrenIsland.tsx"; import { NodeProcess } from "./fixtures_islands/NodeProcess.tsx"; import { signal } from "@preact/signals"; import { - allIslandApp, + ALL_ISLAND_DIR, buildProd, Doc, - getIsland, + ISLAND_GROUP_DIR, withBrowserApp, } from "./test_utils.tsx"; import { parseHtml, waitForText } from "./test_utils.tsx"; import { expect } from "@std/expect"; import { JsxConditional } from "./fixtures_islands/JsxConditional.tsx"; import { FnIsland } from "./fixtures_islands/FnIsland.tsx"; -import { FragmentIsland } from "./fixtures_islands/FragmentIsland.tsx"; import { EscapeIsland } from "./fixtures_islands/EscapeIsland.tsx"; -import * as path from "@std/path"; -import { setBuildCache } from "../src/app.ts"; -import { getBuildCache } from "../src/app.ts"; import type { FreshConfig } from "../src/config.ts"; import { FreshAttrs } from "./fixtures_islands/FreshAttrs.tsx"; import { FakeServer } from "../src/test_utils.ts"; import { PARTIAL_SEARCH_PARAM } from "../src/constants.ts"; -await buildProd(allIslandApp); +const allIslandCache = await buildProd({ islandDir: ALL_ISLAND_DIR }); +const islandGroupdCache = await buildProd({ root: ISLAND_GROUP_DIR }); -function testApp(config?: FreshConfig) { +function testApp(config?: FreshConfig): App { const app = new App(config); - setBuildCache(app, getBuildCache(allIslandApp)); app.use(staticFiles()); + + allIslandCache(app); + return app; } Deno.test({ name: "islands - should make signals interactive", fn: async () => { - const counterIsland = getIsland("Counter.tsx"); - const app = testApp() - .island(counterIsland, "Counter", Counter) .get("/", (ctx) => { const sig = signal(3); return ctx.render( @@ -69,11 +65,7 @@ Deno.test({ Deno.test({ name: "islands - revive multiple islands from one island file", fn: async () => { - const multipleIslands = getIsland("Multiple.tsx"); - const app = testApp() - .island(multipleIslands, "Multiple1", Multiple1) - .island(multipleIslands, "Multiple2", Multiple2) .get("/", (ctx) => { return ctx.render( @@ -98,10 +90,7 @@ Deno.test({ Deno.test({ name: "islands - revive multiple islands with shared signal", fn: async () => { - const counterIsland = getIsland("Counter.tsx"); - const app = testApp() - .island(counterIsland, "Counter", Counter) .get("/", (ctx) => { const sig = signal(0); return ctx.render( @@ -126,10 +115,7 @@ Deno.test({ Deno.test({ name: "islands - import json", fn: async () => { - const jsonIsland = getIsland("JsonIsland.tsx"); - const app = testApp() - .island(jsonIsland, "JsonIsland", Counter) .get("/", (ctx) => { return ctx.render( @@ -153,10 +139,7 @@ Deno.test({ Deno.test({ name: "islands - returns null", fn: async () => { - const nullIsland = getIsland("NullIsland.tsx"); - const app = testApp() - .island(nullIsland, "NullIsland", NullIsland) .get("/", (ctx) => { return ctx.render( @@ -175,12 +158,7 @@ Deno.test({ Deno.test({ name: "islands - only instantiate top level island", fn: async () => { - const counter = getIsland("Counter.tsx"); - const islandInIsland = getIsland("IslandInIsland.tsx"); - const app = testApp() - .island(counter, "Counter", Counter) - .island(islandInIsland, "IslandInIsland", IslandInIsland) .get("/", (ctx) => { return ctx.render( @@ -204,10 +182,7 @@ Deno.test({ Deno.test({ name: "islands - pass null JSX props to islands", fn: async () => { - const jsxIsland = getIsland("JsxIsland.tsx"); - const app = testApp() - .island(jsxIsland, "JsxIsland", JsxIsland) .get("/", (ctx) => { return ctx.render( @@ -231,10 +206,7 @@ Deno.test({ Deno.test({ name: "islands - pass JSX props to islands", fn: async () => { - const jsxIsland = getIsland("JsxIsland.tsx"); - const app = testApp() - .island(jsxIsland, "JsxIsland", JsxIsland) .get("/", (ctx) => { return ctx.render( @@ -260,10 +232,7 @@ Deno.test({ Deno.test({ name: "islands - never serialize children prop", fn: async () => { - const jsxChildrenIsland = getIsland("JsxChildrenIsland.tsx"); - const app = testApp() - .island(jsxChildrenIsland, "JsxChildrenIsland", JsxChildrenIsland) .get("/", (ctx) => { return ctx.render( @@ -294,12 +263,7 @@ Deno.test({ Deno.test({ name: "islands - instantiate islands in jsx children", fn: async () => { - const passThrough = getIsland("PassThrough.tsx"); - const selfCounter = getIsland("SelfCounter.tsx"); - const app = testApp() - .island(passThrough, "PassThrough", PassThrough) - .island(selfCounter, "SelfCounter", SelfCounter) .get("/", (ctx) => { return ctx.render( @@ -325,12 +289,7 @@ Deno.test({ Deno.test({ name: "islands - instantiate islands in jsx children with slots", fn: async () => { - const counterWithSlots = getIsland("CounterWithSlots.tsx"); - const selfCounter = getIsland("SelfCounter.tsx"); - const app = testApp() - .island(counterWithSlots, "CounterWithSlots", CounterWithSlots) - .island(selfCounter, "SelfCounter", SelfCounter) .get("/", (ctx) => { return ctx.render( @@ -367,12 +326,7 @@ Deno.test({ Deno.test({ name: "islands - nested children slots", fn: async () => { - const passThrough = getIsland("PassThrough.tsx"); - const selfCounter = getIsland("SelfCounter.tsx"); - const app = testApp() - .island(passThrough, "PassThrough", PassThrough) - .island(selfCounter, "SelfCounter", SelfCounter) .get("/", (ctx) => { return ctx.render( @@ -408,12 +362,7 @@ Deno.test({ Deno.test({ name: "islands - conditional jsx children", fn: async () => { - const jsxConditional = getIsland("JsxConditional.tsx"); - const selfCounter = getIsland("SelfCounter.tsx"); - const app = testApp() - .island(jsxConditional, "JsxConditional", JsxConditional) - .island(selfCounter, "SelfCounter", SelfCounter) .get("/", (ctx) => { return ctx.render( @@ -450,10 +399,7 @@ Deno.test({ Deno.test({ name: "islands - revive DOM attributes", fn: async () => { - const jsxConditional = getIsland("JsxConditional.tsx"); - const app = testApp() - .island(jsxConditional, "JsxConditional", JsxConditional) .get("/", (ctx) => { return ctx.render( @@ -523,12 +469,7 @@ Deno.test({ Deno.test({ name: "islands - revive island with fn inside", fn: async () => { - const fragmentIsland = getIsland("FragmentIsland.tsx"); - const fnIsland = getIsland("FnIsland.tsx"); - const app = testApp() - .island(fragmentIsland, "FragmentIsland", FragmentIsland) - .island(fnIsland, "FnIsland", FnIsland) .get("/", (ctx) => { return ctx.render( @@ -552,10 +493,7 @@ Deno.test({ Deno.test({ name: "islands - escape props", fn: async () => { - const escapeIsland = getIsland("EscapeIsland.tsx"); - const app = testApp() - .island(escapeIsland, "EscapeIsland", EscapeIsland) .get("/", (ctx) => { return ctx.render( @@ -648,10 +586,7 @@ Deno.test({ Deno.test({ name: "islands - stub Node 'process.env'", fn: async () => { - const nodeProcess = getIsland("NodeProcess.tsx"); - const app = testApp() - .island(nodeProcess, "NodeProcess", NodeProcess) .get("/", (ctx) => ctx.render( @@ -675,10 +610,7 @@ Deno.test({ Deno.test({ name: "islands - in base path", fn: async () => { - const selfCounter = getIsland("SelfCounter.tsx"); - const app = testApp({ basePath: "/foo" }) - .island(selfCounter, "SelfCounter", SelfCounter) .get("/", (ctx) => ctx.render( @@ -699,10 +631,7 @@ Deno.test({ Deno.test({ name: "islands - preserve f-* attributes", fn: async () => { - const freshAttrs = getIsland("FreshAttrs.tsx"); - const app = testApp() - .island(freshAttrs, "FreshAttrs", FreshAttrs) .get("/", (ctx) => ctx.render( @@ -728,16 +657,11 @@ Deno.test({ Deno.test({ name: "fsRoutes - load islands from group folder", fn: async () => { - const app = testApp(); - - await fsRoutes(app, { - dir: path.join( - import.meta.dirname!, - "fixture_island_groups", - ), - loadIsland: (path) => import("./fixture_island_groups/islands/" + path), - loadRoute: (path) => import("./fixture_island_groups/routes/" + path), - }); + const app = new App() + .use(staticFiles()) + .fsRoutes(); + + islandGroupdCache(app); await withBrowserApp(app, async (page, address) => { await page.goto(`${address}/foo`, { waitUntil: "load" }); @@ -755,10 +679,7 @@ Deno.test({ Deno.test({ name: "islands - adds preload HTTP headers", fn: async () => { - const selfCounter = getIsland("SelfCounter.tsx"); - const app = testApp() - .island(selfCounter, "SelfCounter", SelfCounter) .get("/", (ctx) => ctx.render( @@ -771,6 +692,7 @@ Deno.test({ await res.body?.cancel(); const link = res.headers.get("Link"); + expect(link).toMatch( /<\/_fresh\/js\/[a-zA-Z0-9]+\/fresh-runtime\.js>; rel="modulepreload"; as="script", <\/_fresh\/js\/[a-zA-Z0-9]+\/SelfCounter\.js>; rel="modulepreload"; as="script"/, ); diff --git a/tests/partials_test.tsx b/tests/partials_test.tsx index eb66bd66737..eb70545ca88 100644 --- a/tests/partials_test.tsx +++ b/tests/partials_test.tsx @@ -1,14 +1,13 @@ import { App, staticFiles } from "fresh"; import { Partial } from "fresh/runtime"; import { - allIslandApp, + ALL_ISLAND_DIR, assertMetaContent, assertNotSelector, buildProd, charset, Doc, favicon, - getIsland, parseHtml, waitFor, waitForText, @@ -21,29 +20,20 @@ import { FakeServer } from "../src/test_utils.ts"; import { JsonIsland } from "./fixtures_islands/JsonIsland.tsx"; import { OptOutPartialLink } from "./fixtures_islands/OptOutPartialLink.tsx"; import * as path from "@std/path"; -import { getBuildCache, setBuildCache } from "../src/app.ts"; import { retry } from "@std/async/retry"; const loremIpsum = await Deno.readTextFile( path.join(import.meta.dirname!, "lorem_ipsum.txt"), ); -await buildProd(allIslandApp); +const applyBuildCache = await buildProd({ islandDir: ALL_ISLAND_DIR }); function testApp(): App { - const selfCounter = getIsland("SelfCounter.tsx"); - const partialInIsland = getIsland("PartialInIsland.tsx"); - const jsonIsland = getIsland("JsonIsland.tsx"); - const optOutPartialLink = getIsland("OptOutPartialLink.tsx"); - const app = new App() - .island(selfCounter, "SelfCounter", SelfCounter) - .island(partialInIsland, "PartialInIsland", PartialInIsland) - .island(jsonIsland, "JsonIsland", JsonIsland) - .island(optOutPartialLink, "OptOutPartialLink", OptOutPartialLink) .use(staticFiles()); - setBuildCache(app, getBuildCache(allIslandApp)); + applyBuildCache(app); + return app; } @@ -305,7 +295,7 @@ Deno.test({ ); }); - await buildProd(app); + applyBuildCache(app); const server = new FakeServer(app.handler()); let checked = false; try { @@ -452,7 +442,7 @@ Deno.test({ await page.locator(".partial-update").wait(); const doc = parseHtml(await page.content()); - const raw = JSON.parse(doc.querySelector("pre")?.textContent!); + const raw = JSON.parse(doc.querySelector("pre")!.textContent!); expect(raw).toEqual({ foo: 123 }); assertNotSelector(doc, ".output"); diff --git a/tests/test_utils.tsx b/tests/test_utils.tsx index f64136e3bfe..569f58b3281 100644 --- a/tests/test_utils.tsx +++ b/tests/test_utils.tsx @@ -1,32 +1,13 @@ -import { App, getIslandRegistry, setBuildCache } from "../src/app.ts"; +import { type App, setBuildCache } from "../src/app.ts"; import { launch, type Page } from "@astral/astral"; import * as colors from "@std/fmt/colors"; import { DOMParser, HTMLElement } from "linkedom"; -import { Builder } from "../src/dev/builder.ts"; +import { Builder, type BuildOptions } from "../src/dev/builder.ts"; import { TextLineStream } from "@std/streams/text-line-stream"; import * as path from "@std/path"; import type { ComponentChildren } from "preact"; import { expect } from "@std/expect"; -import { ProdBuildCache } from "../src/build_cache.ts"; -import { Counter } from "./fixtures_islands/Counter.tsx"; -import { CounterWithSlots } from "./fixtures_islands/CounterWithSlots.tsx"; -import { EscapeIsland } from "./fixtures_islands/EscapeIsland.tsx"; -import { FnIsland } from "./fixtures_islands/FnIsland.tsx"; -import { FragmentIsland } from "./fixtures_islands/FragmentIsland.tsx"; -import { IslandInIsland } from "./fixtures_islands/IslandInIsland.tsx"; -import { JsonIsland } from "./fixtures_islands/JsonIsland.tsx"; -import { JsxChildrenIsland } from "./fixtures_islands/JsxChildrenIsland.tsx"; -import { JsxConditional } from "./fixtures_islands/JsxConditional.tsx"; -import { JsxIsland } from "./fixtures_islands/JsxIsland.tsx"; -import { NullIsland } from "./fixtures_islands/NullIsland.tsx"; -import { PartialInIsland } from "./fixtures_islands/PartialInIsland.tsx"; -import { PassThrough } from "./fixtures_islands/PassThrough.tsx"; -import { SelfCounter } from "./fixtures_islands/SelfCounter.tsx"; -import { Multiple1, Multiple2 } from "./fixtures_islands/Multiple.tsx"; -import { Foo } from "./fixture_island_groups/routes/foo/(_islands)/Foo.tsx"; -import { NodeProcess } from "./fixtures_islands/NodeProcess.tsx"; -import { FreshAttrs } from "./fixtures_islands/FreshAttrs.tsx"; -import { OptOutPartialLink } from "./fixtures_islands/OptOutPartialLink.tsx"; +import { mergeReadableStreams } from "@std/streams"; const browser = await launch({ args: [ @@ -35,17 +16,9 @@ const browser = await launch({ ? ["--no-sandbox"] : []), ], - headless: true, + headless: Deno.env.get("HEADLESS") !== "false", }); -export function getIsland(pathname: string) { - return path.join( - import.meta.dirname!, - "fixtures_islands", - pathname, - ); -} - export const charset = ; export const favicon = ( @@ -71,17 +44,23 @@ export function Doc(props: { children?: ComponentChildren; title?: string }) { ); } -export async function buildProd(app: App) { +export const ALL_ISLAND_DIR = path.join( + import.meta.dirname!, + "fixtures_islands", +); +export const ISLAND_GROUP_DIR = path.join( + import.meta.dirname!, + "fixture_island_groups", +); + +export async function buildProd( + options: Omit, +): Promise<(app: App) => void> { const outDir = await Deno.makeTempDir(); - // FIXME: Sharing build output path is weird - app.config.build.outDir = outDir; - const builder = new Builder({}); - await builder.build(app); - const cache = await ProdBuildCache.fromSnapshot( - app.config, - getIslandRegistry(app).size, - ); - setBuildCache(app, cache); + const builder = new Builder({ outDir, ...options }); + const cache = await builder.buildForTests(); + + return (app) => setBuildCache(app, cache); } export async function withBrowserApp( @@ -128,15 +107,21 @@ export async function withChildProcessServer( args: ["task", task], stdin: "null", stdout: "piped", - stderr: "inherit", + stderr: "piped", cwd: dir, signal: aborter.signal, }).spawn(); - const lines: ReadableStream = cp.stdout + const linesStdout: ReadableStream = cp.stdout .pipeThrough(new TextDecoderStream()) .pipeThrough(new TextLineStream()); + const linesStderr: ReadableStream = cp.stderr + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new TextLineStream()); + + const lines = mergeReadableStreams(linesStdout, linesStderr); + const output: string[] = []; let address = ""; let found = false; @@ -348,40 +333,49 @@ export function getStdOutput( return { stdout, stderr }; } -export const allIslandApp = new App() - .island(getIsland("Counter.tsx"), "Counter", Counter) - .island( - getIsland("CounterWithSlots.tsx"), - "CounterWithSlots", - CounterWithSlots, - ) - .island(getIsland("EscapeIsland.tsx"), "EscapeIsland", EscapeIsland) - .island(getIsland("FnIsland.tsx"), "FnIsland", FnIsland) - .island(getIsland("FragmentIsland.tsx"), "FragmentIsland", FragmentIsland) - .island(getIsland("IslandInIsland.tsx"), "IslandInIsland", IslandInIsland) - .island(getIsland("JsonIsland.tsx"), "JsonIsland", JsonIsland) - .island( - getIsland("JsxChildrenIsland.tsx"), - "JsxChildrenIsland", - JsxChildrenIsland, - ) - .island(getIsland("JsxConditional.tsx"), "JsxConditional", JsxConditional) - .island(getIsland("JsxIsland.tsx"), "JsxIsland", JsxIsland) - .island(getIsland("Multiple.tsx"), "Multiple1", Multiple1) - .island(getIsland("Multiple.tsx"), "Multiple2", Multiple2) - .island(getIsland("NullIsland.tsx"), "NullIsland", NullIsland) - .island(getIsland("PartialInIsland.tsx"), "PartialInIsland", PartialInIsland) - .island(getIsland("PassThrough.tsx"), "PassThrough", PassThrough) - .island(getIsland("SelfCounter.tsx"), "SelfCounter", SelfCounter) - .island(getIsland("NodeProcess.tsx"), "NodeProcess", NodeProcess) - .island(getIsland("FreshAttrs.tsx"), "FreshAttrs", FreshAttrs) - .island( - getIsland("OptOutPartialLink.tsx"), - "OptOutPartialLink", - OptOutPartialLink, - ) - .island( - getIsland("../fixture_island_groups/routes/foo/(_islands)/Foo.tsx"), - "Foo", - Foo, - ); +const ISLAND_FIXTURE_DIR = path.join(import.meta.dirname!, "fixtures_islands"); +const allIslandBuilder = new Builder({}); +for await (const entry of Deno.readDirSync(ISLAND_FIXTURE_DIR)) { + if (entry.name.endsWith(".json")) continue; + + const spec = path.join(ISLAND_FIXTURE_DIR, entry.name); + allIslandBuilder.registerIsland(spec); +} + +// export const allIslandApp = new App() +// .island(getIsland("Counter.tsx"), "Counter", Counter) +// .island( +// getIsland("CounterWithSlots.tsx"), +// "CounterWithSlots", +// CounterWithSlots, +// ) +// .island(getIsland("EscapeIsland.tsx"), "EscapeIsland", EscapeIsland) +// .island(getIsland("FnIsland.tsx"), "FnIsland", FnIsland) +// .island(getIsland("FragmentIsland.tsx"), "FragmentIsland", FragmentIsland) +// .island(getIsland("IslandInIsland.tsx"), "IslandInIsland", IslandInIsland) +// .island(getIsland("JsonIsland.tsx"), "JsonIsland", JsonIsland) +// .island( +// getIsland("JsxChildrenIsland.tsx"), +// "JsxChildrenIsland", +// JsxChildrenIsland, +// ) +// .island(getIsland("JsxConditional.tsx"), "JsxConditional", JsxConditional) +// .island(getIsland("JsxIsland.tsx"), "JsxIsland", JsxIsland) +// .island(getIsland("Multiple.tsx"), "Multiple1", Multiple1) +// .island(getIsland("Multiple.tsx"), "Multiple2", Multiple2) +// .island(getIsland("NullIsland.tsx"), "NullIsland", NullIsland) +// .island(getIsland("PartialInIsland.tsx"), "PartialInIsland", PartialInIsland) +// .island(getIsland("PassThrough.tsx"), "PassThrough", PassThrough) +// .island(getIsland("SelfCounter.tsx"), "SelfCounter", SelfCounter) +// .island(getIsland("NodeProcess.tsx"), "NodeProcess", NodeProcess) +// .island(getIsland("FreshAttrs.tsx"), "FreshAttrs", FreshAttrs) +// .island( +// getIsland("OptOutPartialLink.tsx"), +// "OptOutPartialLink", +// OptOutPartialLink, +// ) +// .island( +// getIsland("../fixture_island_groups/routes/foo/(_islands)/Foo.tsx"), +// "Foo", +// Foo, +// ); diff --git a/www/deno.json b/www/deno.json index b39c49db766..45085d43b89 100644 --- a/www/deno.json +++ b/www/deno.json @@ -2,6 +2,6 @@ "tasks": { "start": "deno run -A --watch=static/,routes/,../src,../docs dev.ts", "build": "deno run -A dev.ts build", - "preview": "deno run -A main.ts" + "preview": "deno serve -A _fresh/server.js" } } diff --git a/www/dev.ts b/www/dev.ts index f90bd13b635..6bced63dedb 100755 --- a/www/dev.ts +++ b/www/dev.ts @@ -1,14 +1,13 @@ #!/usr/bin/env -S deno run -A --watch=static/,routes/ import { Builder } from "fresh/dev"; -import { app } from "./main.ts"; import { tailwind } from "@fresh/plugin-tailwind"; const builder = new Builder({ target: "safari12" }); -tailwind(builder, app); +tailwind(builder); if (Deno.args.includes("build")) { - await builder.build(app); + await builder.build(); } else { - await builder.listen(app); + await builder.listen(() => import("./main.ts")); } diff --git a/www/main.ts b/www/main.ts index cfdbe7eb120..164a4892ff3 100644 --- a/www/main.ts +++ b/www/main.ts @@ -1,13 +1,9 @@ -import { App, fsRoutes, staticFiles, trailingSlashes } from "fresh"; +import { App, staticFiles, trailingSlashes } from "fresh"; -export const app = new App({ root: import.meta.url }) +export const app = new App() .use(staticFiles()) - .use(trailingSlashes("never")); - -await fsRoutes(app, { - loadIsland: (path) => import(`./islands/${path}`), - loadRoute: (path) => import(`./routes/${path}`), -}); + .use(trailingSlashes("never")) + .fsRoutes(); if (import.meta.main) { await app.listen(); diff --git a/www/main_test.ts b/www/main_test.ts index 87ad4d1b689..21651194e65 100644 --- a/www/main_test.ts +++ b/www/main_test.ts @@ -4,7 +4,9 @@ import { buildProd, withBrowserApp } from "../tests/test_utils.tsx"; import { expect } from "@std/expect"; import { retry } from "@std/async/retry"; -await buildProd(app); +const applyCache = await buildProd({ root: import.meta.dirname! }); +applyCache(app); + const handler = app.handler(); Deno.test("CORS should not set on GET /fresh-badge.svg", async () => { From de6fbd294796c98f48181bc1ac2228c88b731690 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Wed, 23 Jul 2025 18:01:34 +0200 Subject: [PATCH 02/21] chore: remove dead comments --- tests/test_utils.tsx | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/tests/test_utils.tsx b/tests/test_utils.tsx index 569f58b3281..44493545885 100644 --- a/tests/test_utils.tsx +++ b/tests/test_utils.tsx @@ -341,41 +341,3 @@ for await (const entry of Deno.readDirSync(ISLAND_FIXTURE_DIR)) { const spec = path.join(ISLAND_FIXTURE_DIR, entry.name); allIslandBuilder.registerIsland(spec); } - -// export const allIslandApp = new App() -// .island(getIsland("Counter.tsx"), "Counter", Counter) -// .island( -// getIsland("CounterWithSlots.tsx"), -// "CounterWithSlots", -// CounterWithSlots, -// ) -// .island(getIsland("EscapeIsland.tsx"), "EscapeIsland", EscapeIsland) -// .island(getIsland("FnIsland.tsx"), "FnIsland", FnIsland) -// .island(getIsland("FragmentIsland.tsx"), "FragmentIsland", FragmentIsland) -// .island(getIsland("IslandInIsland.tsx"), "IslandInIsland", IslandInIsland) -// .island(getIsland("JsonIsland.tsx"), "JsonIsland", JsonIsland) -// .island( -// getIsland("JsxChildrenIsland.tsx"), -// "JsxChildrenIsland", -// JsxChildrenIsland, -// ) -// .island(getIsland("JsxConditional.tsx"), "JsxConditional", JsxConditional) -// .island(getIsland("JsxIsland.tsx"), "JsxIsland", JsxIsland) -// .island(getIsland("Multiple.tsx"), "Multiple1", Multiple1) -// .island(getIsland("Multiple.tsx"), "Multiple2", Multiple2) -// .island(getIsland("NullIsland.tsx"), "NullIsland", NullIsland) -// .island(getIsland("PartialInIsland.tsx"), "PartialInIsland", PartialInIsland) -// .island(getIsland("PassThrough.tsx"), "PassThrough", PassThrough) -// .island(getIsland("SelfCounter.tsx"), "SelfCounter", SelfCounter) -// .island(getIsland("NodeProcess.tsx"), "NodeProcess", NodeProcess) -// .island(getIsland("FreshAttrs.tsx"), "FreshAttrs", FreshAttrs) -// .island( -// getIsland("OptOutPartialLink.tsx"), -// "OptOutPartialLink", -// OptOutPartialLink, -// ) -// .island( -// getIsland("../fixture_island_groups/routes/foo/(_islands)/Foo.tsx"), -// "Foo", -// Foo, -// ); From 386d3d22a2236e0f502f699d7f9db07e63920f23 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Wed, 23 Jul 2025 18:08:53 +0200 Subject: [PATCH 03/21] lint --- src/build_cache.ts | 4 ++-- src/context.ts | 1 - src/fs_routes.ts | 3 +++ src/render.ts | 2 -- src/utils.ts | 12 ++++++------ 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/build_cache.ts b/src/build_cache.ts index a3595ee9d24..3bf5bc9c9e1 100644 --- a/src/build_cache.ts +++ b/src/build_cache.ts @@ -71,7 +71,7 @@ export class ProdBuildCache implements BuildCache { } export class IslandPreparer { - namer = new UniqueNamer(); + #namer = new UniqueNamer(); prepare( registry: ServerIslandRegistry, @@ -83,7 +83,7 @@ export class IslandPreparer { if (typeof value !== "function") continue; const islandName = name === "default" ? modName : name; - const uniqueName = this.namer.getUniqueName(islandName); + const uniqueName = this.#namer.getUniqueName(islandName); const fn = value as AnyComponent; registry.set(fn, { diff --git a/src/context.ts b/src/context.ts index 098379b2562..469e083fa88 100644 --- a/src/context.ts +++ b/src/context.ts @@ -273,7 +273,6 @@ export class Context { vnode ?? h(Fragment, null), this, state, - this.#buildCache, headers, ); } catch (err) { diff --git a/src/fs_routes.ts b/src/fs_routes.ts index 8cbb1a2150c..1c9bd04fd20 100644 --- a/src/fs_routes.ts +++ b/src/fs_routes.ts @@ -116,6 +116,7 @@ export function fsItemsToCommands( { component: mod.default ?? undefined, config: mod.config ?? undefined, + // deno-lint-ignore no-explicit-any handler: (handlers as any) ?? undefined, }, true, @@ -126,6 +127,7 @@ export function fsItemsToCommands( commands.push(newNotFoundCmd({ config: mod.config, component: mod.default, + // deno-lint-ignore no-explicit-any handler: handlers as any ?? undefined, })); continue; @@ -144,6 +146,7 @@ export function fsItemsToCommands( ...mod.config, routeOverride: mod.config?.routeOverride ?? routePattern, }, + // deno-lint-ignore no-explicit-any handler: (handlers as any) ?? undefined, component: mod.default, }, diff --git a/src/render.ts b/src/render.ts index 7fabcec1156..d5000898c72 100644 --- a/src/render.ts +++ b/src/render.ts @@ -12,7 +12,6 @@ import { } from "./runtime/server/preact_hooks.tsx"; import type { Context } from "./context.ts"; import { recordSpanError, tracer } from "./otel.ts"; -import type { BuildCache } from "./build_cache.ts"; import { DEV_ERROR_OVERLAY_URL } from "./constants.ts"; import { renderToString } from "preact-render-to-string"; import { BUILD_ID } from "./runtime/build_id.ts"; @@ -62,7 +61,6 @@ export function preactRender( vnode: VNode, ctx: PageProps, state: RenderState, - buildCache: BuildCache, headers: Headers, ) { try { diff --git a/src/utils.ts b/src/utils.ts index 73d54f9cf87..86bb5de89a5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -49,21 +49,21 @@ export function escapeScript( } export class UniqueNamer { - seen = new Map(); + #seen = new Map(); getUniqueName(name: string): string { - const count = this.seen.get(name); + const count = this.#seen.get(name); if (count === undefined) { - this.seen.set(name, 1); + this.#seen.set(name, 1); } else { - this.seen.set(name, count + 1); + this.#seen.set(name, count + 1); name = `${name}_${count}`; } return name; } - getNames() { - return this.seen.keys(); + getNames(): string[] { + return Array.from(this.#seen.keys()); } } From 0937ca3e2d8d39fe09c780f9029ed6b6f019c246 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Wed, 23 Jul 2025 18:26:41 +0200 Subject: [PATCH 04/21] fix: error on same island files in different folders --- src/dev/builder.ts | 2 +- src/runtime/server/preact_hooks.tsx | 4 +- src/utils.ts | 4 -- .../routes/both/(_islands)/Foo.tsx | 15 +++++++ .../routes/both/index.tsx | 11 +++++ .../routes/foo/(_islands)/Foo.tsx | 2 +- tests/islands_test.tsx | 45 ++++++++++++++++--- 7 files changed, 69 insertions(+), 14 deletions(-) create mode 100644 tests/fixture_island_groups/routes/both/(_islands)/Foo.tsx create mode 100644 tests/fixture_island_groups/routes/both/index.tsx diff --git a/src/dev/builder.ts b/src/dev/builder.ts index 63f47141148..99af09533d0 100644 --- a/src/dev/builder.ts +++ b/src/dev/builder.ts @@ -280,7 +280,7 @@ export class Builder { const prefix = `/_fresh/js/${BUILD_ID}/`; - for (const name of namer.getNames()) { + for (const name of buildCache.islandModNameToChunk.keys()) { const chunkName = output.entryToChunk.get(name); if (chunkName === undefined) { throw new Error(`Could not find chunk for island ${name}`); diff --git a/src/runtime/server/preact_hooks.tsx b/src/runtime/server/preact_hooks.tsx index 42c86844f08..09fd780f514 100644 --- a/src/runtime/server/preact_hooks.tsx +++ b/src/runtime/server/preact_hooks.tsx @@ -457,7 +457,9 @@ function FreshRuntimeScript() { const islandImports = islandArr.map((island) => { const named = island.exportName === "default" ? island.name - : `{ ${island.exportName} }`; + : island.exportName === island.name + ? `{ ${island.exportName} }` + : `{ ${island.exportName} as ${island.name} }`; return `import ${named} from "${`${basePath}${island.file}`}";`; }).join(""); diff --git a/src/utils.ts b/src/utils.ts index 86bb5de89a5..212fcddc336 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -62,8 +62,4 @@ export class UniqueNamer { return name; } - - getNames(): string[] { - return Array.from(this.#seen.keys()); - } } diff --git a/tests/fixture_island_groups/routes/both/(_islands)/Foo.tsx b/tests/fixture_island_groups/routes/both/(_islands)/Foo.tsx new file mode 100644 index 00000000000..4e9372fe34e --- /dev/null +++ b/tests/fixture_island_groups/routes/both/(_islands)/Foo.tsx @@ -0,0 +1,15 @@ +import { useSignal } from "@preact/signals"; +import { useEffect } from "preact/hooks"; + +export function Foo() { + const active = useSignal(false); + useEffect(() => { + active.value = true; + }, []); + + return ( +
+ {active.value ? "it works" : "it doesn't work"} +
+ ); +} diff --git a/tests/fixture_island_groups/routes/both/index.tsx b/tests/fixture_island_groups/routes/both/index.tsx new file mode 100644 index 00000000000..a0604aca64c --- /dev/null +++ b/tests/fixture_island_groups/routes/both/index.tsx @@ -0,0 +1,11 @@ +import { Foo } from "./(_islands)/Foo.tsx"; +import { Foo as Foo2 } from "../foo/(_islands)/Foo.tsx"; + +export default function Home() { + return ( +
+ + +
+ ); +} diff --git a/tests/fixture_island_groups/routes/foo/(_islands)/Foo.tsx b/tests/fixture_island_groups/routes/foo/(_islands)/Foo.tsx index d5b5a360263..5d10b48d5d0 100644 --- a/tests/fixture_island_groups/routes/foo/(_islands)/Foo.tsx +++ b/tests/fixture_island_groups/routes/foo/(_islands)/Foo.tsx @@ -8,7 +8,7 @@ export function Foo() { }, []); return ( -
+
{active.value ? "it works" : "it doesn't work"}
); diff --git a/tests/islands_test.tsx b/tests/islands_test.tsx index 3f6b61e2df1..e576a964692 100644 --- a/tests/islands_test.tsx +++ b/tests/islands_test.tsx @@ -32,14 +32,25 @@ const allIslandCache = await buildProd({ islandDir: ALL_ISLAND_DIR }); const islandGroupdCache = await buildProd({ root: ISLAND_GROUP_DIR }); function testApp(config?: FreshConfig): App { - const app = new App(config); - app.use(staticFiles()); + const app = new App(config) + .use(staticFiles()) + .fsRoutes(); allIslandCache(app); return app; } +function testGroupApp(config?: FreshConfig): App { + const app = new App(config) + .use(staticFiles()) + .fsRoutes(); + + islandGroupdCache(app); + + return app; +} + Deno.test({ name: "islands - should make signals interactive", fn: async () => { @@ -657,11 +668,7 @@ Deno.test({ Deno.test({ name: "fsRoutes - load islands from group folder", fn: async () => { - const app = new App() - .use(staticFiles()) - .fsRoutes(); - - islandGroupdCache(app); + const app = testGroupApp(); await withBrowserApp(app, async (page, address) => { await page.goto(`${address}/foo`, { waitUntil: "load" }); @@ -676,6 +683,30 @@ Deno.test({ }, }); +Deno.test({ + name: "fsRoutes - load islands from group folder with same name", + fn: async () => { + const app = testGroupApp(); + + await withBrowserApp(app, async (page, address) => { + await page.goto(`${address}/both`, { waitUntil: "load" }); + + await page.locator(".ready").wait(); + + // Page would error here + let text = await page + .locator("#foo.ready") + .evaluate((el) => el.textContent!); + expect(text).toEqual("it works"); + + text = await page + .locator("#foo-both.ready") + .evaluate((el) => el.textContent!); + expect(text).toEqual("it works"); + }); + }, +}); + Deno.test({ name: "islands - adds preload HTTP headers", fn: async () => { From 7cb796ff5748afb184dd0c425d06afb44f6d775b Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Wed, 23 Jul 2025 18:29:55 +0200 Subject: [PATCH 05/21] spelling --- src/app.ts | 4 ++-- src/commands.ts | 2 +- tests/islands_test.tsx | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app.ts b/src/app.ts index 9b5d2e06448..93dda2ab704 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,7 +11,7 @@ import { HttpError } from "./error.ts"; import type { LayoutConfig, Route } from "./types.ts"; import type { RouteComponent } from "./segments.ts"; import { - applyComands, + applyCommands, type Command, CommandType, DEFAULT_NOT_ALLOWED_METHOD, @@ -356,7 +356,7 @@ export class App { const router = new UrlPatternRouter>(); - const { rootMiddlewares } = applyComands( + const { rootMiddlewares } = applyCommands( router, this.#commands, this.config.basePath, diff --git a/src/commands.ts b/src/commands.ts index 238935f5367..e66fb6ceacb 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -180,7 +180,7 @@ export type Command = | HandlerCommand | FsRouteCommand; -export function applyComands( +export function applyCommands( router: Router>, commands: Command[], basePath: string, diff --git a/tests/islands_test.tsx b/tests/islands_test.tsx index e576a964692..e015b0b6eb1 100644 --- a/tests/islands_test.tsx +++ b/tests/islands_test.tsx @@ -29,7 +29,7 @@ import { FakeServer } from "../src/test_utils.ts"; import { PARTIAL_SEARCH_PARAM } from "../src/constants.ts"; const allIslandCache = await buildProd({ islandDir: ALL_ISLAND_DIR }); -const islandGroupdCache = await buildProd({ root: ISLAND_GROUP_DIR }); +const islandGroupCache = await buildProd({ root: ISLAND_GROUP_DIR }); function testApp(config?: FreshConfig): App { const app = new App(config) @@ -46,7 +46,7 @@ function testGroupApp(config?: FreshConfig): App { .use(staticFiles()) .fsRoutes(); - islandGroupdCache(app); + islandGroupCache(app); return app; } From ecf37985f98d2b04167bda3df1ff5ef83d3070a1 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Wed, 23 Jul 2025 18:33:32 +0200 Subject: [PATCH 06/21] chore: add fs route test --- src/fs_routes_test.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/fs_routes_test.tsx b/src/fs_routes_test.tsx index 090026e50bb..fca05eed28b 100644 --- a/src/fs_routes_test.tsx +++ b/src/fs_routes_test.tsx @@ -1542,3 +1542,25 @@ Deno.test("fsRoutes - call correct middleware", async () => { text = await res.text(); expect(text).toEqual("_middleware"); }); + +// Issue: https://github.com/denoland/fresh/issues/2045 +Deno.test("fsRoutes - merge group methods", async () => { + const server = await createServer({ + "routes/(foo)/bar/index.ts": { + handler: { + POST: () => new Response("POST ok"), + }, + }, + "routes/bar/index.ts": { + handler: { + GET: () => new Response("GET ok"), + }, + }, + }); + + let res = await server.get("/bar"); + expect(await res.text()).toEqual("GET ok"); + + res = await server.post("/bar"); + expect(await res.text()).toEqual("POST ok"); +}); From 4fffc15b4b46a70664dd79cd7821f6c8ed1fe2b8 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Thu, 24 Jul 2025 11:04:08 +0200 Subject: [PATCH 07/21] fix: island path in generated output --- src/dev/dev_build_cache.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/dev/dev_build_cache.ts b/src/dev/dev_build_cache.ts index 77be03ee9ee..055f875fc5b 100644 --- a/src/dev/dev_build_cache.ts +++ b/src/dev/dev_build_cache.ts @@ -365,7 +365,11 @@ import staticFileData from "./static-files.json" with { type: "json" }; // Import islands ${ - islands.map((item) => `import * as ${item.name} from "${item.server}";`) + islands + .map((item) => { + const spec = path.relative(outDir, item.server); + return `import * as ${item.name} from "${spec}";`; + }) .join("\n") } From e5c7aebf96eb667f36142c316e59ca466e8e45a7 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Thu, 24 Jul 2025 11:05:34 +0200 Subject: [PATCH 08/21] chore: update deploy entry --- .github/workflows/deploy.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d6f8c353514..fc7b609a698 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,7 +22,6 @@ jobs: uses: denoland/setup-deno@v2 with: cache: true - deno-version: rc - name: Build step working-directory: ./www @@ -32,5 +31,5 @@ jobs: uses: denoland/deployctl@v1 with: project: "fresh" - entrypoint: "./www/main.ts" + entrypoint: "./www/_fresh/server.js" root: "." From b6907e64a214aa1a5ec23743ea9f7d4b7d26207f Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Thu, 24 Jul 2025 11:17:10 +0200 Subject: [PATCH 09/21] chore: remove `import.meta.main` --- init/src/init.ts | 5 +---- www/main.ts | 4 ---- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/init/src/init.ts b/init/src/init.ts index c8e532b1ab1..e48df1c5907 100644 --- a/init/src/init.ts +++ b/init/src/init.ts @@ -373,10 +373,7 @@ app.use(exampleLoggerMiddleware); // Include file-system based routes here app.fsRoutes(); - -if (import.meta.main) { - await app.listen(); -}`; +`; await writeFile("main.ts", MAIN_TS); const COMPONENTS_BUTTON_TSX = diff --git a/www/main.ts b/www/main.ts index 164a4892ff3..9b4a83a1bef 100644 --- a/www/main.ts +++ b/www/main.ts @@ -4,7 +4,3 @@ export const app = new App() .use(staticFiles()) .use(trailingSlashes("never")) .fsRoutes(); - -if (import.meta.main) { - await app.listen(); -} From 44e2ae33c44424e9872149a0bfc839d72c556978 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Thu, 24 Jul 2025 11:18:36 +0200 Subject: [PATCH 10/21] fix: incorrect file check --- src/fs_routes.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/fs_routes.ts b/src/fs_routes.ts index 1c9bd04fd20..8f4fc439d3c 100644 --- a/src/fs_routes.ts +++ b/src/fs_routes.ts @@ -36,8 +36,9 @@ export interface FsRouteFile { // deno-lint-ignore no-explicit-any function isFreshFile(mod: any): mod is FreshFsMod { - return mod !== null && typeof mod === "object" && - typeof mod.default === "function" || + if (mod === null || typeof mod !== "object") return false; + + return typeof mod.default === "function" || typeof mod.config === "object" || typeof mod.handlers === "object" || typeof mod.handlers === "function" || typeof mod.handler === "object" || typeof mod.handler === "function"; From ede24b03f4164549345aceabf7f65d848e8f740a Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Thu, 24 Jul 2025 11:32:35 +0200 Subject: [PATCH 11/21] fix: init + update --- init/src/init.ts | 7 +++---- init/src/init_test.ts | 41 +++++++++++++++++++++------------------ update/src/update.ts | 4 ++++ update/src/update_test.ts | 2 +- 4 files changed, 30 insertions(+), 24 deletions(-) diff --git a/init/src/init.ts b/init/src/init.ts index e48df1c5907..51d1329f26f 100644 --- a/init/src/init.ts +++ b/init/src/init.ts @@ -158,11 +158,11 @@ ENV DENO_DEPLOYMENT_ID=\${GIT_REVISION} WORKDIR /app COPY . . -RUN deno cache main.ts +RUN deno cache _fresh/server.js EXPOSE 8000 -CMD ["run", "-A", "main.ts"] +CMD ["run", "-A", "_fresh/server.ts"] `; await writeFile("Dockerfile", DOCKERFILE_TEXT); @@ -372,8 +372,7 @@ const exampleLoggerMiddleware = define.middleware((ctx) => { app.use(exampleLoggerMiddleware); // Include file-system based routes here -app.fsRoutes(); -`; +app.fsRoutes();`; await writeFile("main.ts", MAIN_TS); const COMPONENTS_BUTTON_TSX = diff --git a/init/src/init_test.ts b/init/src/init_test.ts index e4c9001b48b..9cfa53ccd86 100644 --- a/init/src/init_test.ts +++ b/init/src/init_test.ts @@ -139,28 +139,31 @@ Deno.test({ }, }); -Deno.test("init with tailwind - fmt, lint, and type check project", async () => { - await using tmp = await withTmpDir(); - const dir = tmp.dir; - using _promptStub = stubPrompt("."); - using _confirmStub = stubConfirm({ - [CONFIRM_TAILWIND_MESSAGE]: true, - }); +Deno.test( + "init with tailwind - fmt, lint, and type check project", + async () => { + await using tmp = await withTmpDir(); + const dir = tmp.dir; + using _promptStub = stubPrompt("."); + using _confirmStub = stubConfirm({ + [CONFIRM_TAILWIND_MESSAGE]: true, + }); - await initProject(dir, [], {}); - await expectProjectFile(dir, "main.ts"); - await expectProjectFile(dir, "dev.ts"); + await initProject(dir, [], {}); + await expectProjectFile(dir, "main.ts"); + await expectProjectFile(dir, "dev.ts"); - await patchProject(dir); + await patchProject(dir); - const check = await new Deno.Command(Deno.execPath(), { - args: ["task", "check"], - cwd: dir, - stderr: "inherit", - stdout: "inherit", - }).output(); - expect(check.code).toEqual(0); -}); + const check = await new Deno.Command(Deno.execPath(), { + args: ["task", "check"], + cwd: dir, + stderr: "inherit", + stdout: "inherit", + }).output(); + expect(check.code).toEqual(0); + }, +); Deno.test("init - can start dev server", async () => { await using tmp = await withTmpDir(); diff --git a/update/src/update.ts b/update/src/update.ts index ba63c4b0a7a..d97d0360d1d 100644 --- a/update/src/update.ts +++ b/update/src/update.ts @@ -135,6 +135,10 @@ export async function updateProject(dir: string) { ) { tasks.check = "deno fmt --check && deno lint && deno check"; } + + if (tasks.preview === "deno run -A main.ts") { + tasks.preview = "deno run -A _fresh/server.js"; + } } }); diff --git a/update/src/update_test.ts b/update/src/update_test.ts index ce8d2ddb0a2..82738b61776 100644 --- a/update/src/update_test.ts +++ b/update/src/update_test.ts @@ -132,7 +132,7 @@ Deno.test("update - 1.x project deno.json tasks + lock", async () => { .toEqual({ build: "deno run -A dev.ts build", check: "deno fmt --check && deno lint && deno check", - preview: "deno run -A main.ts", + preview: "deno run -A _fresh/server.js", start: "deno run -A --watch=static/,routes/ dev.ts", update: "deno run -A -r jsr:@fresh/update .", }); From 67ddbdfd2f85f351748d69c7f14759336a9f6df7 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Thu, 24 Jul 2025 11:32:46 +0200 Subject: [PATCH 12/21] docs: update Fresh 1 -> 2 migration --- docs/canary/examples/migration-guide.md | 28 ++++++++----------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/docs/canary/examples/migration-guide.md b/docs/canary/examples/migration-guide.md index d01bd1ed71f..c17545cbecd 100644 --- a/docs/canary/examples/migration-guide.md +++ b/docs/canary/examples/migration-guide.md @@ -53,21 +53,20 @@ The full `dev.ts` file for newly generated Fresh 2 projects looks like this: ```ts import { Builder } from "fresh/dev"; import { tailwind } from "@fresh/plugin-tailwind"; -import { app } from "./main.ts"; // Pass development only configuration here const builder = new Builder({ target: "safari12" }); // Example: Enabling the tailwind plugin for Fresh -tailwind(builder, app); +tailwind(builder); // Create optimized assets for the browser when // running `deno run -A dev.ts build` if (Deno.args.includes("build")) { - await builder.build(app); + await builder.build(); } else { // ...otherwise start the development server - await builder.listen(app); + await builder.listen(() => import("./main.ts")); } ``` @@ -82,18 +81,9 @@ import { App, fsRoutes, staticFiles } from "fresh"; export const app = new App() // Add static file serving middleware - .use(staticFiles()); - -// Enable file-system based routing -await fsRoutes(app, { - loadIsland: (path) => import(`./islands/${path}`), - loadRoute: (path) => import(`./routes/${path}`), -}); - -// If this module is called directly, start the server -if (import.meta.main) { - await app.listen(); -} + .use(staticFiles()) + // Enable file-system based routing + .fsRoutes(); ``` ## Merging error pages @@ -226,9 +216,9 @@ Same is true for handlers: All the various context interfaces have been consolidated and simplified: -| Fresh 1.x | Fresh 2.x | -| --------------------------------------------- | -------------- | -| `AppContext`, `LayoutContext`, `RouteContext` | `FreshContext` | +| Fresh 1.x | Fresh 2.x | +| --------------------------------------------- | ------------------------------------------ | +| `AppContext`, `LayoutContext`, `RouteContext` | [`Context`](/docs/canary/concepts/context) | ### Context methods From 1e16891424e165a8c7bacbd13f1fbcd914c43803 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Thu, 24 Jul 2025 12:17:54 +0200 Subject: [PATCH 13/21] fix: remove unused `app.config.staticDir` --- src/config.ts | 8 -------- src/dev/builder_test.ts | 4 +--- tests/fixture_precompile/valid/main.tsx | 2 +- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/config.ts b/src/config.ts index 92c847e1213..8a312cd57da 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,14 +7,6 @@ export interface FreshConfig { * @default undefined */ basePath?: string; - /** - * The directory to serve static files from. - * - * This can be an absolute path, a file URL or a relative path. - * Relative paths are resolved against the `root` option. - * @default "static" - */ - staticDir?: string; } /** diff --git a/src/dev/builder_test.ts b/src/dev/builder_test.ts index 1744c8c4322..05671cfcf29 100644 --- a/src/dev/builder_test.ts +++ b/src/dev/builder_test.ts @@ -213,9 +213,7 @@ Deno.test({ outDir: path.join(tmp, "dist"), staticDir: tmp, }); - const app = new App({ - staticDir: tmp, - }); + const app = new App(); const abort = new AbortController(); const port = 8011; await builder.listen(() => Promise.resolve(app), { diff --git a/tests/fixture_precompile/valid/main.tsx b/tests/fixture_precompile/valid/main.tsx index 7beef0698fd..fd325efc759 100644 --- a/tests/fixture_precompile/valid/main.tsx +++ b/tests/fixture_precompile/valid/main.tsx @@ -1,6 +1,6 @@ import { App } from "../../../src/app.ts"; -const app = new App({ staticDir: "./static" }).get( +const app = new App().get( "/", (ctx) => ctx.render( From 975889e03c5e4397eeb23c584d0a385135d481e6 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Thu, 24 Jul 2025 12:36:34 +0200 Subject: [PATCH 14/21] fix: don't expose internal build cache --- src/dev/builder.ts | 72 ++++++++++++++++++++++++++++++-------------- tests/test_utils.tsx | 6 ++-- 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/src/dev/builder.ts b/src/dev/builder.ts index 99af09533d0..69615fd03e6 100644 --- a/src/dev/builder.ts +++ b/src/dev/builder.ts @@ -173,6 +173,8 @@ export class Builder { setBuildCache(devApp, buildCache); + // Boot in parallel to spin up the server quicker. We'll hold + // requests until the required assets are processed. await Promise.all([ devApp.listen(options), this.#build(buildCache, true), @@ -180,34 +182,58 @@ export class Builder { return; } - async build(): Promise { - this.config.mode = "production"; - - await this.#crawlFsItems(); - - const buildCache = new DiskBuildCache( - this.config, - this.#fsRoutes, - this.#transformer, - ); - - return await this.#build(buildCache, false); - } - - async buildForTests(): Promise> { - this.config.mode = "production"; + /** + * Build optimized assets for your app. By default this will create + * a production build. + * + * This can also be used for testing to apply a snapshot to a particular + * {@linkcode App} instance. + * + * @example + * ```ts + * const builder = new Builder(); + * const applySnapshot = await builder.build({ snapshot: "memory" }); + * + * Deno.test("My Test", () => { + * const app = new App() + * .get("/", () => new Response("hello")) + * + * applySnapshot(app) + * + * // ... your usual testing + * }) + * ``` + * @param options + * @returns Apply a snapshot to a particular {@linkcode App} instance. + */ + async build( + options?: { + mode?: ResolvedBuildConfig["mode"]; + snapshot?: "disk" | "memory"; + }, + ): Promise<(app: App) => void> { + this.config.mode = options?.mode ?? "production"; await this.#crawlFsItems(); - const buildCache = new MemoryBuildCache( - this.config, - this.#fsRoutes, - this.#transformer, - ); + const buildCache = options?.snapshot === "memory" + ? new MemoryBuildCache( + this.config, + this.#fsRoutes, + this.#transformer, + ) + : new DiskBuildCache( + this.config, + this.#fsRoutes, + this.#transformer, + ); - await this.#build(buildCache, false); + await this.#build(buildCache, this.config.mode === "development"); await buildCache.prepare(); - return buildCache; + + return (app) => { + setBuildCache(app, buildCache); + }; } async #crawlFsItems() { diff --git a/tests/test_utils.tsx b/tests/test_utils.tsx index 44493545885..24cb8526dd4 100644 --- a/tests/test_utils.tsx +++ b/tests/test_utils.tsx @@ -1,4 +1,4 @@ -import { type App, setBuildCache } from "../src/app.ts"; +import type { App } from "../src/app.ts"; import { launch, type Page } from "@astral/astral"; import * as colors from "@std/fmt/colors"; import { DOMParser, HTMLElement } from "linkedom"; @@ -58,9 +58,7 @@ export async function buildProd( ): Promise<(app: App) => void> { const outDir = await Deno.makeTempDir(); const builder = new Builder({ outDir, ...options }); - const cache = await builder.buildForTests(); - - return (app) => setBuildCache(app, cache); + return await builder.build({ mode: "production", snapshot: "memory" }); } export async function withBrowserApp( From fab1a4f56b5c7121bf3660c9cba485a8b3b6ccbe Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Thu, 24 Jul 2025 12:45:30 +0200 Subject: [PATCH 15/21] more fixes --- examples/README.md | 10 ---------- init/src/init.ts | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/examples/README.md b/examples/README.md index 8f27ff82ca4..2e76a69aa0f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,16 +15,6 @@ import { DemoIsland } from "jsr:@fresh/examples/island"; export const app = new App({ root: import.meta.url }) .use(staticFiles()); -// Register the island -app.island( - // Module specifier for esbuild, could also be a file path - "jsr:@fresh/examples/island", - // Name of the island - "DemoIsland", - // Island component function - DemoIsland, -); - // Use the island somewhere in your components app.get("/", (ctx) => ctx.render()); diff --git a/init/src/init.ts b/init/src/init.ts index 51d1329f26f..b5542517ba5 100644 --- a/init/src/init.ts +++ b/init/src/init.ts @@ -162,7 +162,7 @@ RUN deno cache _fresh/server.js EXPOSE 8000 -CMD ["run", "-A", "_fresh/server.ts"] +CMD ["run", "-A", "_fresh/server.js"] `; await writeFile("Dockerfile", DOCKERFILE_TEXT); From 7333646ae547580c21d6c37d283ec85f41bfcdd4 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Thu, 24 Jul 2025 12:46:14 +0200 Subject: [PATCH 16/21] docs: update entry --- docs/canary/deployment/production.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/canary/deployment/production.md b/docs/canary/deployment/production.md index 7f83cce074d..2fc0850b7c7 100644 --- a/docs/canary/deployment/production.md +++ b/docs/canary/deployment/production.md @@ -29,7 +29,7 @@ To run Fresh in production mode, run the `start` task: ```sh Terminal deno task start # or -deno run -A main.ts +deno run -A _fresh/server.js ``` Fresh will automatically pick up the optimized assets in the `_fresh` directory. From cbe91e50b254f69aa6b4df86b8155a7814580db7 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Thu, 24 Jul 2025 12:47:53 +0200 Subject: [PATCH 17/21] chore: remove unused method --- src/dev/dev_build_cache.ts | 11 ----------- src/middlewares/static_files_test.ts | 4 ---- src/test_utils.ts | 5 ----- 3 files changed, 20 deletions(-) diff --git a/src/dev/dev_build_cache.ts b/src/dev/dev_build_cache.ts index 055f875fc5b..e86ae4a6ba0 100644 --- a/src/dev/dev_build_cache.ts +++ b/src/dev/dev_build_cache.ts @@ -12,7 +12,6 @@ import { fsAdapter } from "../fs.ts"; import type { FileTransformer } from "./file_transformer.ts"; import { assertInDir } from "../utils.ts"; import type { ResolvedBuildConfig } from "./builder.ts"; -import type { AnyComponent } from "preact"; import { fsItemsToCommands, type FsRouteFile } from "../fs_routes.ts"; import type { Command } from "../commands.ts"; import type { ServerIslandRegistry } from "../context.ts"; @@ -154,12 +153,6 @@ export class MemoryBuildCache implements DevBuildCache { return null; } - getIslandChunkName(_fn: AnyComponent): string | null { - // FIXME - // return this.#islands.get(fn) ?? null; - return null; - } - addUnprocessedFile(pathname: string, dir: string): void { this.#unprocessedFiles.set( pathname, @@ -232,10 +225,6 @@ export class DiskBuildCache implements DevBuildCache { return []; } - getIslandChunkName(_fn: AnyComponent): string | null { - return null; - } - addUnprocessedFile(pathname: string, dir: string): void { this.#unprocessedFiles.set( pathname.replaceAll(WINDOWS_SEPARATOR, "/"), diff --git a/src/middlewares/static_files_test.ts b/src/middlewares/static_files_test.ts index 834f8153c19..9c6368b4859 100644 --- a/src/middlewares/static_files_test.ts +++ b/src/middlewares/static_files_test.ts @@ -4,7 +4,6 @@ import type { BuildCache, StaticFile } from "../build_cache.ts"; import { expect } from "@std/expect"; import { ASSET_CACHE_BUST_KEY } from "../runtime/shared_internal.tsx"; import { BUILD_ID } from "../runtime/build_id.ts"; -import type { AnyComponent } from "preact"; import type { Command } from "../commands.ts"; import type { ServerIslandRegistry } from "../context.ts"; @@ -38,9 +37,6 @@ class MockBuildCache implements BuildCache { async readFile(pathname: string): Promise { return this.files.get(pathname) ?? null; } - getIslandChunkName(_islandName: AnyComponent): string | null { - return null; - } } Deno.test("static files - 200", async () => { diff --git a/src/test_utils.ts b/src/test_utils.ts index 6ce8c1a090e..b5606c01fe7 100644 --- a/src/test_utils.ts +++ b/src/test_utils.ts @@ -6,7 +6,6 @@ import type { WalkEntry } from "@std/fs/walk"; import { DEFAULT_CONN_INFO } from "./app.ts"; import type { Command } from "./commands.ts"; import { fsItemsToCommands, type FsRouteFile } from "./fs_routes.ts"; -import type { AnyComponent } from "preact"; const STUB = {} as unknown as Deno.ServeHandlerInfo; @@ -159,8 +158,4 @@ export class MockBuildCache implements BuildCache { readFile(_pathname: string): Promise { return Promise.resolve(null); } - - getIslandChunkName(_fn: AnyComponent): string | null { - return null; - } } From 379dc87e1c35713abc623bac009d0c532d457cd6 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Thu, 24 Jul 2025 12:51:31 +0200 Subject: [PATCH 18/21] fix: serve command --- docs/canary/deployment/production.md | 2 +- init/src/init.ts | 2 +- update/src/update.ts | 2 +- update/src/update_test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/canary/deployment/production.md b/docs/canary/deployment/production.md index 2fc0850b7c7..fe2567d5301 100644 --- a/docs/canary/deployment/production.md +++ b/docs/canary/deployment/production.md @@ -29,7 +29,7 @@ To run Fresh in production mode, run the `start` task: ```sh Terminal deno task start # or -deno run -A _fresh/server.js +deno serve -A _fresh/server.js ``` Fresh will automatically pick up the optimized assets in the `_fresh` directory. diff --git a/init/src/init.ts b/init/src/init.ts index b5542517ba5..7db6353b0d8 100644 --- a/init/src/init.ts +++ b/init/src/init.ts @@ -162,7 +162,7 @@ RUN deno cache _fresh/server.js EXPOSE 8000 -CMD ["run", "-A", "_fresh/server.js"] +CMD ["serve", "-A", "_fresh/server.js"] `; await writeFile("Dockerfile", DOCKERFILE_TEXT); diff --git a/update/src/update.ts b/update/src/update.ts index d97d0360d1d..6479dc6faf0 100644 --- a/update/src/update.ts +++ b/update/src/update.ts @@ -137,7 +137,7 @@ export async function updateProject(dir: string) { } if (tasks.preview === "deno run -A main.ts") { - tasks.preview = "deno run -A _fresh/server.js"; + tasks.preview = "deno serve -A _fresh/server.js"; } } }); diff --git a/update/src/update_test.ts b/update/src/update_test.ts index 82738b61776..cec70b51c2c 100644 --- a/update/src/update_test.ts +++ b/update/src/update_test.ts @@ -132,7 +132,7 @@ Deno.test("update - 1.x project deno.json tasks + lock", async () => { .toEqual({ build: "deno run -A dev.ts build", check: "deno fmt --check && deno lint && deno check", - preview: "deno run -A _fresh/server.js", + preview: "deno serve -A _fresh/server.js", start: "deno run -A --watch=static/,routes/ dev.ts", update: "deno run -A -r jsr:@fresh/update .", }); From b658794720f1fac57e7e03dc867a349b72b543d0 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Thu, 24 Jul 2025 13:03:25 +0200 Subject: [PATCH 19/21] fix: expose root in transform args now that it's gone on `app.config` --- src/dev/builder.ts | 6 ++++-- src/dev/dev_build_cache_test.ts | 4 ++-- src/dev/file_transformer.ts | 19 +++++++------------ src/dev/file_transformer_test.ts | 25 ++++++++++++++++++------- 4 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/dev/builder.ts b/src/dev/builder.ts index 69615fd03e6..f8a1d0f08d5 100644 --- a/src/dev/builder.ts +++ b/src/dev/builder.ts @@ -7,7 +7,7 @@ import { bundleJs } from "./esbuild.ts"; import { liveReload } from "./middlewares/live_reload.ts"; import { cssAssetHash, - FreshFileTransformer, + FileTransformer, type OnTransformOptions, } from "./file_transformer.ts"; import type { TransformFn } from "./file_transformer.ts"; @@ -93,7 +93,7 @@ const TEST_FILE_PATTERN = /[._]test\.(?:[tj]sx?|[mc][tj]s)$/; // deno-lint-ignore no-explicit-any export class Builder { - #transformer = new FreshFileTransformer(fsAdapter); + #transformer: FileTransformer; #addedInternalTransforms = false; config: ResolvedBuildConfig; #islandSpecifiers = new Set(); @@ -109,6 +109,8 @@ export class Builder { this.#fsRoutes = { dir: routeDir, files: [], id: "default" }; + this.#transformer = new FileTransformer(fsAdapter, root); + this.config = { target: options?.target ?? ["chrome99", "firefox99", "safari15"], root, diff --git a/src/dev/dev_build_cache_test.ts b/src/dev/dev_build_cache_test.ts index 0ab23a475e0..78ccefd78be 100644 --- a/src/dev/dev_build_cache_test.ts +++ b/src/dev/dev_build_cache_test.ts @@ -1,6 +1,6 @@ import { expect } from "@std/expect"; import { MemoryBuildCache } from "./dev_build_cache.ts"; -import { FreshFileTransformer } from "./file_transformer.ts"; +import { FileTransformer } from "./file_transformer.ts"; import { createFakeFs, withTmpDir } from "../test_utils.ts"; import type { ResolvedBuildConfig } from "./builder.ts"; @@ -20,7 +20,7 @@ Deno.test({ staticDir: "", target: "latest", }; - const fileTransformer = new FreshFileTransformer(createFakeFs({})); + const fileTransformer = new FileTransformer(createFakeFs({}), tmp); const buildCache = new MemoryBuildCache( config, { dir: "", files: [], id: "" }, diff --git a/src/dev/file_transformer.ts b/src/dev/file_transformer.ts index a1d525aa710..3f821fecb0c 100644 --- a/src/dev/file_transformer.ts +++ b/src/dev/file_transformer.ts @@ -23,6 +23,7 @@ export interface OnTransformArgs { text: string; content: Uint8Array; mode: TransformMode; + root: string; } export type TransformFn = ( args: OnTransformArgs, @@ -56,21 +57,14 @@ interface TransformReq { inputFiles: string[]; } -export interface FileTransformer { - onTransform(options: OnTransformOptions, callback: TransformFn): void; - process( - filePath: string, - mode: TransformMode, - target: string | string[], - ): Promise; -} - -export class FreshFileTransformer implements FileTransformer { +export class FileTransformer { #transformers: Transformer[] = []; #fs: FsAdapter; + #root: string; - constructor(fs: FsAdapter) { + constructor(fs: FsAdapter, root: string) { this.#fs = fs; + this.#root = root; } onTransform(options: OnTransformOptions, callback: TransformFn): void { @@ -156,6 +150,7 @@ export class FreshFileTransformer implements FileTransformer { mode, target, content: req!.content, + root: this.#root, get text() { return new TextDecoder().decode(req!.content); }, @@ -252,7 +247,7 @@ export class FreshFileTransformer implements FileTransformer { const CSS_URL_REGEX = /url\(("[^"]+"|'[^']+'|[^)]+)\)/g; -export function cssAssetHash(transformer: FreshFileTransformer) { +export function cssAssetHash(transformer: FileTransformer) { transformer.onTransform({ pluginName: "fresh-css", filter: /\.css$/, diff --git a/src/dev/file_transformer_test.ts b/src/dev/file_transformer_test.ts index e8537fa2f41..b5bb424ec9e 100644 --- a/src/dev/file_transformer_test.ts +++ b/src/dev/file_transformer_test.ts @@ -1,14 +1,11 @@ import { expect } from "@std/expect"; import type { FsAdapter } from "../fs.ts"; -import { - FreshFileTransformer, - type ProcessedFile, -} from "./file_transformer.ts"; +import { FileTransformer, type ProcessedFile } from "./file_transformer.ts"; import { delay } from "../test_utils.ts"; -function testTransformer(files: Record) { +function testTransformer(files: Record, root = "/") { const mockFs: FsAdapter = { - cwd: () => "/", + cwd: () => root, isDirectory: () => Promise.resolve(false), mkdirp: () => Promise.resolve(), walk: async function* foo() { @@ -21,7 +18,7 @@ function testTransformer(files: Record) { return Promise.resolve(buf); }, }; - return new FreshFileTransformer(mockFs); + return new FileTransformer(mockFs, root); } function consumeResult(result: ProcessedFile[]) { @@ -228,3 +225,17 @@ Deno.test( ]); }, ); + +Deno.test("FileTransformer - pass root to args", async () => { + const transformer = testTransformer({ "foo.txt": "foo" }, "//"); + + let root = ""; + transformer.onTransform({ pluginName: "A", filter: /.*/ }, (args) => { + root = args.root; + return undefined; + }); + + await transformer.process("foo.txt", "development", ""); + + expect(root).toEqual("//"); +}); From b05581270474524a3291461fb861d7a356e54495 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Thu, 24 Jul 2025 13:18:40 +0200 Subject: [PATCH 20/21] fix: JSR import --- src/dev/dev_build_cache.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/dev/dev_build_cache.ts b/src/dev/dev_build_cache.ts index e86ae4a6ba0..8c14c1973d4 100644 --- a/src/dev/dev_build_cache.ts +++ b/src/dev/dev_build_cache.ts @@ -176,7 +176,8 @@ export class MemoryBuildCache implements DevBuildCache { await Promise.all( Array.from(this.islandModNameToChunk.entries()).map( async ([name, chunk]) => { - const mod = await import(chunk.server); + const fileUrl = path.toFileUrl(chunk.server); + const mod = await import(fileUrl.href); if (chunk.browser === null) { throw new Error(`Unexpected missing browser chunk`); @@ -191,9 +192,10 @@ export class MemoryBuildCache implements DevBuildCache { async prepare(): Promise { // Load FS routes const files = await Promise.all(this.#fsRoutes.files.map(async (file) => { + const fileUrl = path.toFileUrl(file.filePath); return { ...file, - mod: await import(file.filePath), + mod: await import(fileUrl.href), }; })); this.#commands = fsItemsToCommands(files); From a7805cecb601803b56724403e8dbbc45e7cb4dea Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Thu, 24 Jul 2025 14:59:53 +0200 Subject: [PATCH 21/21] fix: windows paths --- src/dev/dev_build_cache.ts | 6 +++--- src/utils.ts | 5 +++++ src/utils_test.ts | 8 +++++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/dev/dev_build_cache.ts b/src/dev/dev_build_cache.ts index 8c14c1973d4..cafec941a22 100644 --- a/src/dev/dev_build_cache.ts +++ b/src/dev/dev_build_cache.ts @@ -10,7 +10,7 @@ import { encodeHex } from "@std/encoding/hex"; import { crypto } from "@std/crypto"; import { fsAdapter } from "../fs.ts"; import type { FileTransformer } from "./file_transformer.ts"; -import { assertInDir } from "../utils.ts"; +import { assertInDir, pathToSpec } from "../utils.ts"; import type { ResolvedBuildConfig } from "./builder.ts"; import { fsItemsToCommands, type FsRouteFile } from "../fs_routes.ts"; import type { Command } from "../commands.ts"; @@ -358,7 +358,7 @@ import staticFileData from "./static-files.json" with { type: "json" }; ${ islands .map((item) => { - const spec = path.relative(outDir, item.server); + const spec = pathToSpec(path.relative(outDir, item.server)); return `import * as ${item.name} from "${spec}";`; }) .join("\n") @@ -368,7 +368,7 @@ ${ ${ this.#fsRoutes.files .map((item, i) => { - const spec = path.relative(outDir, item.filePath); + const spec = pathToSpec(path.relative(outDir, item.filePath)); return `import * as fsRoute_${i} from "${spec}"`; }) .join("\n") diff --git a/src/utils.ts b/src/utils.ts index 212fcddc336..5105da7fe20 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -63,3 +63,8 @@ export class UniqueNamer { return name; } } + +const PATH_TO_SPEC = /[\\/]+/g; +export function pathToSpec(str: string): string { + return str.replaceAll(PATH_TO_SPEC, "/"); +} diff --git a/src/utils_test.ts b/src/utils_test.ts index 8bf2b5e4603..cbbcd594f03 100644 --- a/src/utils_test.ts +++ b/src/utils_test.ts @@ -1,5 +1,5 @@ import { expect } from "@std/expect"; -import { escapeScript, pathToExportName } from "./utils.ts"; +import { escapeScript, pathToExportName, pathToSpec } from "./utils.ts"; Deno.test("filenameToExportName", () => { expect(pathToExportName("/islands/foo.tsx")).toBe("foo"); @@ -52,3 +52,9 @@ Deno.test("escapeScript - json", () => { "\\u003C!--<\\/ScRIpt>", ); }); + +Deno.test("pathToSpec", () => { + expect(pathToSpec("/foo/bar")).toEqual("/foo/bar"); + expect(pathToSpec("\\foo\\bar")).toEqual("/foo/bar"); + expect(pathToSpec("\\\\foo//bar")).toEqual("/foo/bar"); +});