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 (
-