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: "." 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/docs/canary/deployment/production.md b/docs/canary/deployment/production.md index 7f83cce074d..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 main.ts +deno serve -A _fresh/server.js ``` Fresh will automatically pick up the optimized assets in the `_fresh` directory. 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 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 ffb6f50a69c..7db6353b0d8 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 ["serve", "-A", "_fresh/server.js"] `; await writeFile("Dockerfile", DOCKERFILE_TEXT); @@ -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,14 +371,8 @@ const exampleLoggerMiddleware = define.middleware((ctx) => { }); app.use(exampleLoggerMiddleware); -await fsRoutes(app, { - loadIsland: (path) => import(\`./islands/\${path}\`), - loadRoute: (path) => import(\`./routes/\${path}\`), -}); - -if (import.meta.main) { - await app.listen(); -}`; +// Include file-system based routes here +app.fsRoutes();`; await writeFile("main.ts", MAIN_TS); const COMPONENTS_BUTTON_TSX = @@ -489,14 +483,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 +499,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..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(); @@ -240,5 +243,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..93dda2ab704 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"; + applyCommands, + 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 } = applyCommands( + 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..3bf5bc9c9e1 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..e66fb6ceacb --- /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 applyCommands( + 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..8a312cd57da 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,38 +1,12 @@ 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 * @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; } /** @@ -40,29 +14,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 +35,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..469e083fa88 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, ); @@ -277,7 +273,6 @@ export class Context { vnode ?? h(Fragment, null), this, state, - this.#buildCache, headers, ); } catch (err) { diff --git a/src/dev/builder.ts b/src/dev/builder.ts index 0f627106e1b..f8a1d0f08d5 100644 --- a/src/dev/builder.ts +++ b/src/dev/builder.ts @@ -1,29 +1,31 @@ -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, - FreshFileTransformer, + FileTransformer, 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,99 @@ 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 { - #transformer = new FreshFileTransformer(fsAdapter); +/** + * 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: FileTransformer; #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.#transformer = new FileTransformer(fsAdapter, root); + + 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 +135,144 @@ 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); + // 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(devApp, true), + this.#build(buildCache, true), ]); return; } - async build(app: App): Promise { - setBuildCache( - app, - new DiskBuildCache( - app.config, - BUILD_ID, + /** + * 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 = options?.snapshot === "memory" + ? new MemoryBuildCache( + this.config, + this.#fsRoutes, this.#transformer, - this.#options.target, - ), - ); + ) + : new DiskBuildCache( + this.config, + this.#fsRoutes, + this.#transformer, + ); - return await this.#build(app, false); + await this.#build(buildCache, this.config.mode === "development"); + await buildCache.prepare(); + + return (app) => { + setBuildCache(app, 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 +280,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 buildCache.islandModNameToChunk.keys()) { + 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..05671cfcf29 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 }); + + const specifier = "jsr:@marvinh-test/fresh-island"; + builder.registerIsland(specifier); - await builder.build(app); + 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,20 @@ Deno.test({ Deno.test({ name: "Builder - workspace folder middleware on listen", fn: async () => { - const builder = new Builder(); - const tmp = await Deno.makeTempDir(); - const app = new App({ + await using _tmp = await withTmpDir(); + const tmp = _tmp.dir; + + const builder = new Builder({ + outDir: path.join(tmp, "dist"), staticDir: tmp, - build: { - outDir: path.join(tmp, "dist"), - }, }); + const app = new App(); 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 +232,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 +240,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..cafec941a22 100644 --- a/src/dev/dev_build_cache.ts +++ b/src/dev/dev_build_cache.ts @@ -1,50 +1,79 @@ -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 { assertInDir } from "../utils.ts"; +import type { FileTransformer } from "./file_transformer.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"; +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 +98,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 +113,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 +137,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 +153,10 @@ export class MemoryBuildCache implements DevBuildCache { return null; } - getIslandChunkName(islandName: string): string | null { - return this.islands.get(islandName) ?? 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 +169,68 @@ 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 fileUrl = path.toFileUrl(chunk.server); + const mod = await import(fileUrl.href); + + 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) => { + const fileUrl = path.toFileUrl(file.filePath); + return { + ...file, + mod: await import(fileUrl.href), + }; + })); + 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 { + 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 +242,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 +256,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 +281,7 @@ export class DiskBuildCache implements DevBuildCache { const result = await this.#transformer.process( entry.path, "production", - this.#target, + target, ); if (result !== null) { @@ -237,30 +294,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 +319,118 @@ 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( + 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) => { + const spec = pathToSpec(path.relative(outDir, item.server)); + return `import * as ${item.name} from "${spec}";`; + }) + .join("\n") + } + +// Import routes +${ + this.#fsRoutes.files + .map((item, i) => { + const spec = pathToSpec(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( - getSnapshotPath(this.config), - JSON.stringify(snapshot, null, 2), + 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..78ccefd78be 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 { FileTransformer } 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 fileTransformer = new FileTransformer(createFakeFs({}), tmp); 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..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,12 +57,14 @@ interface TransformReq { inputFiles: string[]; } -export class FreshFileTransformer { +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 { @@ -147,6 +150,7 @@ export class FreshFileTransformer { mode, target, content: req!.content, + root: this.#root, get text() { return new TextDecoder().decode(req!.content); }, @@ -243,7 +247,7 @@ export class FreshFileTransformer { 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("//"); +}); 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..8f4fc439d3c --- /dev/null +++ b/src/fs_routes.ts @@ -0,0 +1,281 @@ +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 { + 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"; +} + +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, + // deno-lint-ignore no-explicit-any + handler: (handlers as any) ?? undefined, + }, + true, + )); + continue; + } + case CommandType.NotFound: { + commands.push(newNotFoundCmd({ + config: mod.config, + component: mod.default, + // deno-lint-ignore no-explicit-any + 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, + }, + // deno-lint-ignore no-explicit-any + 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 95% rename from src/plugins/fs_routes/mod_test.tsx rename to src/fs_routes_test.tsx index 400848dad42..fca05eed28b 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 @@ -1546,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"); +}); 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..9c6368b4859 100644 --- a/src/middlewares/static_files_test.ts +++ b/src/middlewares/static_files_test.ts @@ -4,11 +4,14 @@ 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 { 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,13 +28,15 @@ 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 { - return null; - } } Deno.test("static files - 200", async () => { @@ -135,13 +140,9 @@ Deno.test("static files - disables caching in development", async () => { { buildCache, config: { + root: "", basePath: "", - build: { - outDir: "", - }, mode: "development", - root: ".", - staticDir: "", }, }, ); @@ -163,13 +164,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..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 { @@ -90,12 +88,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..09fd780f514 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,12 @@ 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}`}";`; + : island.exportName === island.name + ? `{ ${island.exportName} }` + : `{ ${island.exportName} as ${island.name} }`; + 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..b5606c01fe7 100644 --- a/src/test_utils.ts +++ b/src/test_utils.ts @@ -1,9 +1,11 @@ -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"; const STUB = {} as unknown as Deno.ServeHandlerInfo; @@ -61,20 +63,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 +82,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 +92,6 @@ export function serveMiddleware( {}, config, () => Promise.resolve(next()), - new Map(), buildCache, ); return await middleware(ctx); @@ -144,3 +141,21 @@ 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); + } +} diff --git a/src/utils.ts b/src/utils.ts index 8337216b6aa..5105da7fe20 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -47,3 +47,24 @@ 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; + } +} + +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"); +}); 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_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/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/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( diff --git a/tests/islands_test.tsx b/tests/islands_test.tsx index 78d606c386f..e015b0b6eb1 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,49 @@ 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 islandGroupCache = await buildProd({ root: ISLAND_GROUP_DIR }); + +function testApp(config?: FreshConfig): App { + 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(); + + islandGroupCache(app); -function testApp(config?: FreshConfig) { - const app = new App(config); - setBuildCache(app, getBuildCache(allIslandApp)); - app.use(staticFiles()); 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 +76,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 +101,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 +126,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 +150,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 +169,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 +193,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 +217,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 +243,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 +274,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 +300,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 +337,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 +373,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 +410,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 +480,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 +504,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 +597,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 +621,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 +642,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 +668,7 @@ 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 = testGroupApp(); await withBrowserApp(app, async (page, address) => { await page.goto(`${address}/foo`, { waitUntil: "load" }); @@ -753,12 +684,33 @@ Deno.test({ }); Deno.test({ - name: "islands - adds preload HTTP headers", + name: "fsRoutes - load islands from group folder with same name", fn: async () => { - const selfCounter = getIsland("SelfCounter.tsx"); + 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 () => { const app = testApp() - .island(selfCounter, "SelfCounter", SelfCounter) .get("/", (ctx) => ctx.render( @@ -771,6 +723,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..24cb8526dd4 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 } 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,21 @@ 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 }); + return await builder.build({ mode: "production", snapshot: "memory" }); } export async function withBrowserApp( @@ -128,15 +105,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 +331,11 @@ 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); +} diff --git a/update/src/update.ts b/update/src/update.ts index ba63c4b0a7a..6479dc6faf0 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 serve -A _fresh/server.js"; + } } }); diff --git a/update/src/update_test.ts b/update/src/update_test.ts index ce8d2ddb0a2..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 main.ts", + preview: "deno serve -A _fresh/server.js", start: "deno run -A --watch=static/,routes/ dev.ts", update: "deno run -A -r jsr:@fresh/update .", }); 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..9b4a83a1bef 100644 --- a/www/main.ts +++ b/www/main.ts @@ -1,14 +1,6 @@ -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}`), -}); - -if (import.meta.main) { - await app.listen(); -} + .use(trailingSlashes("never")) + .fsRoutes(); 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 () => {