diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 355d986144f..a9c8b627465 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -44,20 +44,7 @@ jobs:
- name: Spell-check
if: startsWith(matrix.os, 'ubuntu') && matrix.deno == 'v1.x'
uses: crate-ci/typos@master
-
- - name: Cache dependencies and Chrome
- uses: actions/cache@v4
- with:
- path: |
- ${{ matrix.cache_path }}deps
- ${{ matrix.cache_path }}deno_puppeteer
- key: ${{ runner.os }}-${{ hashFiles('**/*deps.ts', 'tests/fixture_twind_hydrate/twind.config.ts') }}
-
- - name: Install Chromium
- run: deno run -A --unstable https://deno.land/x/puppeteer@16.2.0/install.ts
- env:
- PUPPETEER_PRODUCT: chrome
-
+
- name: Type check project
run: deno task check:types
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 00000000000..e88e74f34d3
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,36 @@
+name: Publish JSR
+
+on:
+ push:
+ branches:
+ - fresh-2.0-merge
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Deno
+ uses: denoland/setup-deno@v1
+ with:
+ deno-version: v1.x
+
+ - name: Publish Fresh
+ run: deno publish
+
+ - name: Publish @fresh/init
+ working-directory: ./init
+ run: deno publish
+
+ - name: Publish @fresh/plugin-tailwindcss
+ working-directory: ./plugin-tailwindcss
+ run: deno publish
+
+ - name: Publish @fresh/update
+ working-directory: ./update
+ run: deno publish
diff --git a/.gitignore b/.gitignore
index e3856e793ed..43123f17746 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,5 @@ _fresh/
tmp/
vendor/
node_modules/
+.docs/
.DS_Store
diff --git a/.vscode/import_map.json b/.vscode/import_map.json
deleted file mode 100644
index 5a999301b8c..00000000000
--- a/.vscode/import_map.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "scopes": {
- "THIS FILE EXISTS ONLY FOR VSCODE! IT IS NOT USED AT RUNTIME": {}
- },
- "imports": {
- "$fresh/": "../",
- "twind": "https://esm.sh/twind@0.16.19",
- "twind/": "https://esm.sh/twind@0.16.19/",
- "preact": "https://esm.sh/preact@10.22.0",
- "preact/": "https://esm.sh/preact@10.22.0/",
- "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
- "@preact/signals-core": "https://esm.sh/@preact/signals-core@1.5.1",
- "@preact/signals-core@1.2.3": "https://esm.sh/@preact/signals-core@1.2.3",
- "@preact/signals-core@1.3.0": "https://esm.sh/@preact/signals-core@1.3.0",
- "$prism": "https://esm.sh/prismjs@1.29.0",
- "$prism/": "https://esm.sh/prismjs@1.29.0/",
- "$std/": "https://deno.land/std@0.216.0/",
- "$ga4": "https://raw.githubusercontent.com/denoland/ga4/main/mod.ts",
- "$marked-mangle": "https://esm.sh/marked-mangle@1.0.1",
- "tailwindcss": "npm:tailwindcss@3.4.1",
- "tailwindcss/": "npm:/tailwindcss@3.4.1/",
- "tailwindcss/plugin": "npm:/tailwindcss@3.4.1/plugin.js"
- }
-}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index ac698eaf0f8..2f0e1fa6dd1 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,7 +1,6 @@
{
"deno.enable": true,
"deno.lint": true,
- "deno.importMap": "./.vscode/import_map.json",
"deno.codeLens.test": true,
"deno.documentPreloadLimit": 2000,
"editor.formatOnSave": true,
@@ -21,5 +20,10 @@
"[markdown]": {
"editor.defaultFormatter": "denoland.vscode-deno"
},
- "css.customData": [".vscode/tailwind.json"]
+ "css.customData": [
+ ".vscode/tailwind.json"
+ ],
+ "[json]": {
+ "editor.defaultFormatter": "denoland.vscode-deno"
+ }
}
diff --git a/_typos.toml b/_typos.toml
index 36c7512abad..f1be79c5c25 100644
--- a/_typos.toml
+++ b/_typos.toml
@@ -3,6 +3,7 @@ extend-exclude = [
"tests/fixture_partials/routes/scroll_restoration/index.tsx",
"www/static/fonts/FixelVariable.woff2",
"www/static/fonts/FixelVariableItalic.woff2",
+ "tests/lorem_ipsum.txt",
]
[default]
diff --git a/deno.json b/deno.json
index 859483eb6ab..ed31a3eb2ee 100644
--- a/deno.json
+++ b/deno.json
@@ -1,24 +1,83 @@
{
+ "name": "@fresh/core",
+ "version": "2.0.0-alpha.9",
+ "exports": {
+ ".": "./src/mod.ts",
+ "./runtime": "./src/runtime/shared.ts",
+ "./client": "./src/runtime/client/mod.tsx",
+ "./client-dev": "./src/runtime/client/dev.ts",
+ "./dev": "./src/dev/mod.ts"
+ },
"lock": false,
"tasks": {
- "test": "deno test -A --parallel --trace-ops",
+ "test": "deno test -A --parallel src/ init/ update/ && deno test -A tests/ www/main_test.ts",
"fixture": "deno run -A --watch=static/,routes/ tests/fixture/dev.ts",
"www": "deno task --cwd=www start",
+ "build-www": "deno task --cwd=www build",
"screenshot": "deno run -A www/utils/screenshot.ts",
- "check:types": "deno check **/*.ts && deno check **/*.tsx",
+ "check:types": "deno check src/**/*.ts src/**/*.tsx tests/**/*.ts tests/**/*.tsx",
"ok": "deno fmt --check && deno lint && deno task check:types && deno task test",
- "install-puppeteer": "PUPPETEER_PRODUCT=chrome deno run -A --unstable https://deno.land/x/puppeteer@16.2.0/install.ts && PUPPETEER_PRODUCT=firefox deno run -A --unstable https://deno.land/x/puppeteer@16.2.0/install.ts",
"test:www": "deno test -A tests/www/",
"manifests": "deno run -A genAllManifest.ts"
},
- "exclude": [
- "**/_fresh/*",
- "**/tmp/*"
- ],
- "importMap": "./.vscode/import_map.json",
+ "exclude": ["**/_fresh/*", "**/tmp/*", "*/tests_OLD/**"],
+ "publish": {
+ "include": [
+ "src/**",
+ "deno.json",
+ "README.md",
+ "LICENSE",
+ "www/static/fresh-badge.svg",
+ "www/static/fresh-badge-dark.svg",
+ "*.todo"
+ ],
+ "exclude": ["**/*_test.*", "src/__OLD/**", "*.todo"]
+ },
+ "imports": {
+ "$ga4": "https://raw.githubusercontent.com/denoland/ga4/main/mod.ts",
+ "$marked-mangle": "https://esm.sh/marked-mangle@1.0.1",
+ "$std/": "https://deno.land/std@0.216.0/",
+ "@astral/astral": "jsr:@astral/astral@^0.4.0",
+ "@fresh/core": "./src/mod.ts",
+ "@fresh/core/client": "./src/runtime/client/mod.tsx",
+ "@fresh/core/client-dev": "./src/runtime/client/dev.ts",
+ "@fresh/core/dev": "./src/dev/mod.ts",
+ "@fresh/core/runtime": "./src/runtime/shared.ts",
+ "@fresh/plugin-tailwind": "./plugin-tailwindcss/src/mod.ts",
+ "@luca/esbuild-deno-loader": "jsr:@luca/esbuild-deno-loader@^0.10.3",
+ "@preact/signals": "npm:@preact/signals@^1.2.3",
+ "@std/async": "jsr:@std/async@^0.224.1",
+ "@std/cli": "jsr:@std/cli@^0.221.0",
+ "@std/crypto": "jsr:@std/crypto@^0.221.0",
+ "@std/datetime": "jsr:@std/datetime@^0.224.0",
+ "@std/encoding": "jsr:@std/encoding@^0.221.0",
+ "@std/expect": "jsr:@std/expect@^0.224.0",
+ "@std/fmt": "jsr:@std/fmt@^0.224.0",
+ "@std/fs": "jsr:@std/fs@^0.221.0",
+ "@std/html": "jsr:@std/html@^0.224.0",
+ "@std/jsonc": "jsr:@std/jsonc@^0.221.0",
+ "@std/media-types": "jsr:@std/media-types@^0.221.0",
+ "@std/path": "jsr:@std/path@^0.221.0",
+ "@std/semver": "jsr:@std/semver@^0.223.0",
+ "@std/streams": "jsr:@std/streams@^0.221.0",
+ "autoprefixer": "npm:autoprefixer@10.4.17",
+ "cssnano": "npm:cssnano@6.0.3",
+ "esbuild": "npm:esbuild@0.20.2",
+ "esbuild-wasm": "npm:esbuild-wasm@0.20.2",
+ "linkedom": "npm:linkedom@^0.16.11",
+ "postcss": "npm:postcss@8.4.35",
+ "preact": "npm:preact@^10.22.0",
+ "preact-render-to-string": "npm:preact-render-to-string@^6.4.2",
+ "tailwindcss": "npm:tailwindcss@^3.4.1",
+ "tailwindcss/plugin": "npm:/tailwindcss@^3.4.1/plugin.js",
+ "ts-morph": "npm:ts-morph@^22.0.0",
+ "twind": "https://esm.sh/twind@0.16.19",
+ "twind/": "https://esm.sh/twind@0.16.19/"
+ },
"compilerOptions": {
- "jsx": "react-jsx",
- "jsxImportSource": "preact"
+ "jsx": "precompile",
+ "jsxImportSource": "preact",
+ "jsxPrecompileSkipElements": ["a", "img", "source", "body", "html", "head"]
},
"lint": {
"rules": { "exclude": ["no-window"] }
diff --git a/dev.ts b/dev.ts
deleted file mode 100644
index 1e775bc0f10..00000000000
--- a/dev.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-import { dev } from "./src/dev/dev_command.ts";
-export default dev;
diff --git a/genAllManifest.ts b/genAllManifest.ts
deleted file mode 100644
index 713f4285af8..00000000000
--- a/genAllManifest.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { walk } from "./src/dev/deps.ts";
-import { manifest } from "./src/dev/mod.ts";
-import type { FreshConfig } from "./src/server/mod.ts";
-
-const skippedFixtures: string[] = [
- "fixture_invalid_handlers",
- "fixture_update_check",
-];
-
-async function runGenerateInFixtures() {
- for await (const entry of walk(Deno.cwd(), { maxDepth: 10 })) {
- if (entry.isDirectory && entry.name.startsWith("fixture")) {
- if (skippedFixtures.includes(entry.name)) {
- console.log(`Skipping ${entry.path}\n`);
- continue;
- }
- console.log(`Processing ${entry.path}`);
-
- try {
- const configPath = `${entry.path}/fresh.config.ts`;
-
- let config: FreshConfig;
- try {
- config = (await import(configPath)).default;
- } catch {
- console.warn(
- `No fresh.config.ts found or error in reading it at ${configPath}, using empty config.`,
- );
- config = {};
- }
-
- await manifest(entry.path, config.router?.ignoreFilePattern);
- console.log(`Manifest generated successfully in ${entry.path}\n`);
- } catch (error) {
- console.error(`Failed to process ${entry.path}:`, error);
- console.log();
- }
- }
- }
-}
-
-runGenerateInFixtures();
diff --git a/init.ts b/init.ts
deleted file mode 100644
index caa65de9f38..00000000000
--- a/init.ts
+++ /dev/null
@@ -1,822 +0,0 @@
-import { basename, colors, join, parse, resolve } from "./src/dev/deps.ts";
-import { error } from "./src/dev/error.ts";
-import { collect, ensureMinDenoVersion, generate } from "./src/dev/mod.ts";
-import {
- dotenvImports,
- freshImports,
- tailwindImports,
- twindImports,
-} from "./src/dev/imports.ts";
-
-ensureMinDenoVersion();
-
-const help = `fresh-init
-
-Initialize a new Fresh project. This will create all the necessary files for a
-new project.
-
-To generate a project in the './foobar' subdirectory:
- fresh-init ./foobar
-
-To generate a project in the current directory:
- fresh-init .
-
-USAGE:
- fresh-init [DIRECTORY]
-
-OPTIONS:
- --force Overwrite existing files
- --tailwind Use Tailwind for styling
- --twind Use Twind for styling
- --vscode Setup project for VS Code
- --docker Setup Project to use Docker
-`;
-
-const CONFIRM_EMPTY_MESSAGE =
- "The target directory is not empty (files could get overwritten). Do you want to continue anyway?";
-
-const USE_VSCODE_MESSAGE = "Do you use VS Code?";
-
-const flags = parse(Deno.args, {
- boolean: ["force", "tailwind", "twind", "vscode", "docker", "help"],
- default: {
- force: null,
- tailwind: null,
- twind: null,
- vscode: null,
- docker: null,
- },
- alias: {
- help: "h",
- },
-});
-
-if (flags.help) {
- console.log(help);
- Deno.exit(0);
-}
-
-if (flags.tailwind && flags.twind) {
- error("Cannot use Tailwind and Twind at the same time.");
-}
-
-console.log();
-console.log(
- colors.bgRgb8(
- colors.rgb8(" 🍋 Fresh: The next-gen web framework. ", 0),
- 121,
- ),
-);
-console.log();
-
-let unresolvedDirectory = Deno.args[0];
-if (flags._.length !== 1) {
- const userInput = prompt("Project Name:", "fresh-project");
- if (!userInput) {
- error(help);
- }
-
- unresolvedDirectory = userInput;
-}
-
-const resolvedDirectory = resolve(unresolvedDirectory);
-
-try {
- const dir = [...Deno.readDirSync(resolvedDirectory)];
- const isEmpty = dir.length === 0 ||
- dir.length === 1 && dir[0].name === ".git";
- if (
- !isEmpty &&
- !(flags.force === null ? confirm(CONFIRM_EMPTY_MESSAGE) : flags.force)
- ) {
- error("Directory is not empty.");
- }
-} catch (err) {
- if (!(err instanceof Deno.errors.NotFound)) {
- throw err;
- }
-}
-console.log("%cLet's set up your new Fresh project.\n", "font-weight: bold");
-
-let useTailwind = flags.tailwind || false;
-let useTwind = flags.twind || false;
-
-if (flags.tailwind == null && flags.twind == null) {
- if (confirm("Do you want to use a styling library?")) {
- console.log();
- console.log(`1. ${colors.cyan("tailwindcss")} (recommended)`);
- console.log(`2. ${colors.cyan("Twind")}`);
- console.log();
- switch (
- (prompt("Which styling library do you want to use? [1]") || "1").trim()
- ) {
- case "2":
- useTwind = true;
- break;
- default:
- useTailwind = true;
- }
- }
-}
-
-const useVSCode = flags.vscode === null
- ? confirm(USE_VSCODE_MESSAGE)
- : flags.vscode;
-
-const useDocker = flags.docker;
-
-await Promise.all([
- Deno.mkdir(join(resolvedDirectory, "routes", "api"), { recursive: true }),
- Deno.mkdir(join(resolvedDirectory, "islands"), { recursive: true }),
- Deno.mkdir(join(resolvedDirectory, "static"), { recursive: true }),
- Deno.mkdir(join(resolvedDirectory, "components"), { recursive: true }),
-]);
-if (useVSCode) {
- await Deno.mkdir(join(resolvedDirectory, ".vscode"), { recursive: true });
-}
-
-const GITIGNORE = `# dotenv environment variable files
-.env
-.env.development.local
-.env.test.local
-.env.production.local
-.env.local
-
-# Fresh build directory
-_fresh/
-# npm dependencies
-node_modules/
-`;
-
-await Deno.writeTextFile(
- join(resolvedDirectory, ".gitignore"),
- GITIGNORE,
-);
-
-if (useDocker) {
- const DENO_VERSION = Deno.version.deno;
- const DOCKERFILE_TEXT = `
-FROM denoland/deno:${DENO_VERSION}
-
-ARG GIT_REVISION
-ENV DENO_DEPLOYMENT_ID=\${GIT_REVISION}
-
-WORKDIR /app
-
-COPY . .
-RUN deno cache main.ts
-
-EXPOSE 8000
-
-CMD ["run", "-A", "main.ts"]
-
-`;
-
- await Deno.writeTextFile(
- join(resolvedDirectory, "Dockerfile"),
- DOCKERFILE_TEXT,
- );
-}
-
-const ROUTES_INDEX_TSX = `import { useSignal } from "@preact/signals";
-import Counter from "../islands/Counter.tsx";
-
-export default function Home() {
- const count = useSignal(3);
- return (
-
-
-
-
Welcome to Fresh
-
- Try updating this message in the
- ./routes/index.tsx
file, and refresh.
-
-
-
-
- );
-}
-`;
-
-const COMPONENTS_BUTTON_TSX = `import { JSX } from "preact";
-import { IS_BROWSER } from "$fresh/runtime.ts";
-
-export function Button(props: JSX.HTMLAttributes) {
- return (
-
- );
-}
-`;
-
-const ISLANDS_COUNTER_TSX = `import type { Signal } from "@preact/signals";
-import { Button } from "../components/Button.tsx";
-
-interface CounterProps {
- count: Signal;
-}
-
-export default function Counter(props: CounterProps) {
- return (
-
-
props.count.value -= 1}>-1
-
{props.count}
-
props.count.value += 1}>+1
-
- );
-}
-`;
-
-// 404 page
-const ROUTES_404_PAGE = `import { Head } from "$fresh/runtime.ts";
-
-export default function Error404() {
- return (
- <>
-
- 404 - Page not found
-
-
-
-
-
404 - Page not found
-
- The page you were looking for doesn't exist.
-
-
Go back home
-
-
- >
- );
-}
-`;
-await Promise.all([
- Deno.writeTextFile(
- join(resolvedDirectory, "routes", "index.tsx"),
- ROUTES_INDEX_TSX,
- ),
- Deno.writeTextFile(
- join(resolvedDirectory, "components", "Button.tsx"),
- COMPONENTS_BUTTON_TSX,
- ),
- Deno.writeTextFile(
- join(resolvedDirectory, "islands", "Counter.tsx"),
- ISLANDS_COUNTER_TSX,
- ),
- Deno.writeTextFile(
- join(resolvedDirectory, "routes", "_404.tsx"),
- ROUTES_404_PAGE,
- ),
-]);
-
-const ROUTES_GREET_TSX = `import { PageProps } from "$fresh/server.ts";
-
-export default function Greet(props: PageProps) {
- return Hello {props.params.name}
;
-}
-`;
-await Deno.mkdir(join(resolvedDirectory, "routes", "greet"), {
- recursive: true,
-});
-await Deno.writeTextFile(
- join(resolvedDirectory, "routes", "greet", "[name].tsx"),
- ROUTES_GREET_TSX,
-);
-
-const ROUTES_API_JOKE_TS = `import { FreshContext } from "$fresh/server.ts";
-
-// Jokes courtesy of https://punsandoneliners.com/randomness/programmer-jokes/
-const JOKES = [
- "Why do Java developers often wear glasses? They can't C#.",
- "A SQL query walks into a bar, goes up to two tables and says “can I join you?”",
- "Wasn't hard to crack Forrest Gump's password. 1forrest1.",
- "I love pressing the F5 key. It's refreshing.",
- "Called IT support and a chap from Australia came to fix my network connection. I asked “Do you come from a LAN down under?”",
- "There are 10 types of people in the world. Those who understand binary and those who don't.",
- "Why are assembly programmers often wet? They work below C level.",
- "My favourite computer based band is the Black IPs.",
- "What programme do you use to predict the music tastes of former US presidential candidates? An Al Gore Rhythm.",
- "An SEO expert walked into a bar, pub, inn, tavern, hostelry, public house.",
-];
-
-export const handler = (_req: Request, _ctx: FreshContext): Response => {
- const randomIndex = Math.floor(Math.random() * JOKES.length);
- const body = JOKES[randomIndex];
- return new Response(body);
-};
-`;
-await Deno.writeTextFile(
- join(resolvedDirectory, "routes", "api", "joke.ts"),
- ROUTES_API_JOKE_TS,
-);
-
-const TAILWIND_CONFIG_TS = `import { type Config } from "tailwindcss";
-
-export default {
- content: [
- "{routes,islands,components}/**/*.{ts,tsx,js,jsx}",
- ],
-} satisfies Config;
-`;
-if (useTailwind) {
- await Deno.writeTextFile(
- join(resolvedDirectory, "tailwind.config.ts"),
- TAILWIND_CONFIG_TS,
- );
-}
-
-const TWIND_CONFIG_TS = `import { defineConfig, Preset } from "@twind/core";
-import presetTailwind from "@twind/preset-tailwind";
-import presetAutoprefix from "@twind/preset-autoprefix";
-
-export default {
- ...defineConfig({
- presets: [presetTailwind() as Preset, presetAutoprefix() as Preset],
- }),
- selfURL: import.meta.url,
-};
-`;
-if (useTwind) {
- await Deno.writeTextFile(
- join(resolvedDirectory, "twind.config.ts"),
- TWIND_CONFIG_TS,
- );
-}
-
-const NO_TAILWIND_STYLES = `
-*,
-*::before,
-*::after {
- box-sizing: border-box;
-}
-* {
- margin: 0;
-}
-button {
- color: inherit;
-}
-button, [role="button"] {
- cursor: pointer;
-}
-code {
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
- "Liberation Mono", "Courier New", monospace;
- font-size: 1em;
-}
-img,
-svg {
- display: block;
-}
-img,
-video {
- max-width: 100%;
- height: auto;
-}
-
-html {
- line-height: 1.5;
- -webkit-text-size-adjust: 100%;
- font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
- "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif,
- "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
-}
-.transition-colors {
- transition-property: background-color, border-color, color, fill, stroke;
- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
- transition-duration: 150ms;
-}
-.my-6 {
- margin-bottom: 1.5rem;
- margin-top: 1.5rem;
-}
-.text-4xl {
- font-size: 2.25rem;
- line-height: 2.5rem;
-}
-.mx-2 {
- margin-left: 0.5rem;
- margin-right: 0.5rem;
-}
-.my-4 {
- margin-bottom: 1rem;
- margin-top: 1rem;
-}
-.mx-auto {
- margin-left: auto;
- margin-right: auto;
-}
-.px-4 {
- padding-left: 1rem;
- padding-right: 1rem;
-}
-.py-8 {
- padding-bottom: 2rem;
- padding-top: 2rem;
-}
-.bg-\\[\\#86efac\\] {
- background-color: #86efac;
-}
-.text-3xl {
- font-size: 1.875rem;
- line-height: 2.25rem;
-}
-.py-6 {
- padding-bottom: 1.5rem;
- padding-top: 1.5rem;
-}
-.px-2 {
- padding-left: 0.5rem;
- padding-right: 0.5rem;
-}
-.py-1 {
- padding-bottom: 0.25rem;
- padding-top: 0.25rem;
-}
-.border-gray-500 {
- border-color: #6b7280;
-}
-.bg-white {
- background-color: #fff;
-}
-.flex {
- display: flex;
-}
-.gap-8 {
- grid-gap: 2rem;
- gap: 2rem;
-}
-.font-bold {
- font-weight: 700;
-}
-.max-w-screen-md {
- max-width: 768px;
-}
-.flex-col {
- flex-direction: column;
-}
-.items-center {
- align-items: center;
-}
-.justify-center {
- justify-content: center;
-}
-.border-2 {
- border-width: 2px;
-}
-.rounded {
- border-radius: 0.25rem;
-}
-.hover\\:bg-gray-200:hover {
- background-color: #e5e7eb;
-}
-.tabular-nums {
- font-variant-numeric: tabular-nums;
-}
-`;
-
-const APP_WRAPPER = `import { type PageProps } from "$fresh/server.ts";
-export default function App({ Component }: PageProps) {
- return (
-
-
-
-
- ${basename(resolvedDirectory)}
- ${useTwind ? "" : ` `}
-
-
-
-
-
- );
-}
-`;
-
-await Deno.writeTextFile(
- join(resolvedDirectory, "routes", "_app.tsx"),
- APP_WRAPPER,
-);
-
-const TAILWIND_CSS = `@tailwind base;
-@tailwind components;
-@tailwind utilities;`;
-
-const cssStyles = useTailwind ? TAILWIND_CSS : NO_TAILWIND_STYLES;
-if (!useTwind) {
- await Deno.writeTextFile(
- join(resolvedDirectory, "static", "styles.css"),
- cssStyles,
- );
-}
-
-const STATIC_LOGO =
- `
-
-
-
-
- `;
-
-await Deno.writeTextFile(
- join(resolvedDirectory, "static", "logo.svg"),
- STATIC_LOGO,
-);
-
-try {
- const faviconArrayBuffer = await fetch("https://fresh.deno.dev/favicon.ico")
- .then((d) => d.arrayBuffer());
- await Deno.writeFile(
- join(resolvedDirectory, "static", "favicon.ico"),
- new Uint8Array(faviconArrayBuffer),
- );
-} catch {
- // Skip this and be silent if there is a network issue.
-}
-
-let FRESH_CONFIG_TS = `import { defineConfig } from "$fresh/server.ts";\n`;
-if (useTailwind) {
- FRESH_CONFIG_TS += `import tailwind from "$fresh/plugins/tailwind.ts";
-`;
-}
-if (useTwind) {
- FRESH_CONFIG_TS += `import twind from "$fresh/plugins/twindv1.ts";
-import twindConfig from "./twind.config.ts";
-`;
-}
-
-FRESH_CONFIG_TS += `
-export default defineConfig({${
- useTailwind
- ? `\n plugins: [tailwind()],\n`
- : useTwind
- ? `\n plugins: [twind(twindConfig)],\n`
- : ""
-}});
-`;
-const CONFIG_TS_PATH = join(resolvedDirectory, "fresh.config.ts");
-await Deno.writeTextFile(CONFIG_TS_PATH, FRESH_CONFIG_TS);
-
-let MAIN_TS = `///
-///
-///
-///
-///
-
-import "$std/dotenv/load.ts";
-
-import { start } from "$fresh/server.ts";
-import manifest from "./fresh.gen.ts";
-import config from "./fresh.config.ts";
-`;
-
-MAIN_TS += `
-await start(manifest, config);\n`;
-const MAIN_TS_PATH = join(resolvedDirectory, "main.ts");
-await Deno.writeTextFile(MAIN_TS_PATH, MAIN_TS);
-
-const DEV_TS = `#!/usr/bin/env -S deno run -A --watch=static/,routes/
-
-import dev from "$fresh/dev.ts";
-import config from "./fresh.config.ts";
-
-import "$std/dotenv/load.ts";
-
-await dev(import.meta.url, "./main.ts", config);
-`;
-const DEV_TS_PATH = join(resolvedDirectory, "dev.ts");
-await Deno.writeTextFile(DEV_TS_PATH, DEV_TS);
-try {
- await Deno.chmod(DEV_TS_PATH, 0o777);
-} catch {
- // this throws on windows
-}
-
-const config = {
- lock: false,
- tasks: {
- check:
- "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
- cli: "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -",
- manifest: "deno task cli manifest $(pwd)",
- start: "deno run -A --watch=static/,routes/ dev.ts",
- build: "deno run -A dev.ts build",
- preview: "deno run -A main.ts",
- update: "deno run -A -r https://fresh.deno.dev/update .",
- },
- lint: {
- rules: {
- tags: ["fresh", "recommended"],
- },
- },
- exclude: ["**/_fresh/*"],
- imports: {} as Record,
- compilerOptions: {
- jsx: "react-jsx",
- jsxImportSource: "preact",
- },
-};
-freshImports(config.imports);
-if (useTailwind) {
- tailwindImports(config.imports);
- // Tailwind editor plugin expects the `node_modules` directory
- // to be present, otherwise intellisense doesn't work.
- // TODO: Have a better deno config type
- // deno-lint-ignore no-explicit-any
- (config as any).nodeModulesDir = true;
-}
-if (useTwind) {
- twindImports(config.imports);
-}
-dotenvImports(config.imports);
-
-const DENO_CONFIG = JSON.stringify(config, null, 2) + "\n";
-
-await Deno.writeTextFile(join(resolvedDirectory, "deno.json"), DENO_CONFIG);
-
-const README_MD = `# Fresh project
-
-Your new Fresh project is ready to go. You can follow the Fresh "Getting
-Started" guide here: https://fresh.deno.dev/docs/getting-started
-
-### Usage
-
-Make sure to install Deno: https://deno.land/manual/getting_started/installation
-
-Then start the project:
-
-\`\`\`
-deno task start
-\`\`\`
-
-This will watch the project directory and restart as necessary.
-`;
-await Deno.writeTextFile(
- join(resolvedDirectory, "README.md"),
- README_MD,
-);
-
-const vscodeSettings = {
- "deno.enable": true,
- "deno.lint": true,
- "editor.defaultFormatter": "denoland.vscode-deno",
- "[typescriptreact]": {
- "editor.defaultFormatter": "denoland.vscode-deno",
- },
- "[typescript]": {
- "editor.defaultFormatter": "denoland.vscode-deno",
- },
- "[javascriptreact]": {
- "editor.defaultFormatter": "denoland.vscode-deno",
- },
- "[javascript]": {
- "editor.defaultFormatter": "denoland.vscode-deno",
- },
- "css.customData": useTailwind ? [".vscode/tailwind.json"] : undefined,
-};
-
-const VSCODE_SETTINGS = JSON.stringify(vscodeSettings, null, 2) + "\n";
-
-if (useVSCode) {
- await Deno.writeTextFile(
- join(resolvedDirectory, ".vscode", "settings.json"),
- VSCODE_SETTINGS,
- );
-}
-
-const vscodeExtensions = {
- recommendations: ["denoland.vscode-deno"],
-};
-
-if (useTailwind) {
- vscodeExtensions.recommendations.push("bradlc.vscode-tailwindcss");
-}
-
-const VSCODE_EXTENSIONS = JSON.stringify(vscodeExtensions, null, 2) + "\n";
-
-if (useVSCode) {
- await Deno.writeTextFile(
- join(resolvedDirectory, ".vscode", "extensions.json"),
- VSCODE_EXTENSIONS,
- );
-}
-
-const tailwindCustomData = {
- "version": 1.1,
- "atDirectives": [
- {
- "name": "@tailwind",
- "description":
- "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
- "references": [
- {
- "name": "Tailwind Documentation",
- "url":
- "https://tailwindcss.com/docs/functions-and-directives#tailwind",
- },
- ],
- },
- {
- "name": "@apply",
- "description":
- "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.",
- "references": [
- {
- "name": "Tailwind Documentation",
- "url": "https://tailwindcss.com/docs/functions-and-directives#apply",
- },
- ],
- },
- {
- "name": "@responsive",
- "description":
- "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
- "references": [
- {
- "name": "Tailwind Documentation",
- "url":
- "https://tailwindcss.com/docs/functions-and-directives#responsive",
- },
- ],
- },
- {
- "name": "@screen",
- "description":
- "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
- "references": [
- {
- "name": "Tailwind Documentation",
- "url": "https://tailwindcss.com/docs/functions-and-directives#screen",
- },
- ],
- },
- {
- "name": "@variants",
- "description":
- "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
- "references": [
- {
- "name": "Tailwind Documentation",
- "url":
- "https://tailwindcss.com/docs/functions-and-directives#variants",
- },
- ],
- },
- ],
-};
-const TAILWIND_CUSTOMDATA = JSON.stringify(tailwindCustomData, null, 2) + "\n";
-
-if (useVSCode && useTailwind) {
- await Deno.writeTextFile(
- join(resolvedDirectory, ".vscode", "tailwind.json"),
- TAILWIND_CUSTOMDATA,
- );
-}
-
-const manifest = await collect(resolvedDirectory);
-await generate(resolvedDirectory, manifest);
-
-// Specifically print unresolvedDirectory, rather than resolvedDirectory in order to
-// not leak personal info (e.g. `/Users/MyName`)
-console.log("\n%cProject initialized!\n", "color: green; font-weight: bold");
-
-if (unresolvedDirectory !== ".") {
- console.log(
- `Enter your project directory using %ccd ${unresolvedDirectory}%c.`,
- "color: cyan",
- "",
- );
-}
-console.log(
- "Run %cdeno task start%c to start the project. %cCTRL-C%c to stop.",
- "color: cyan",
- "",
- "color: cyan",
- "",
-);
-console.log();
-console.log(
- "Stuck? Join our Discord %chttps://discord.gg/deno",
- "color: cyan",
- "",
-);
-console.log();
-console.log(
- "%cHappy hacking! 🦕",
- "color: gray",
-);
diff --git a/init/README.md b/init/README.md
new file mode 100644
index 00000000000..eec288dd023
--- /dev/null
+++ b/init/README.md
@@ -0,0 +1,10 @@
+# Create a new Fresh project.
+
+This is a CLI tool to bootstrap a new Fresh project. To do so, run this command:
+
+```sh
+deno run -Ar jsr:@fresh/init
+```
+
+Go to [https://fresh.deno.dev/](https://fresh.deno.dev/) for more information
+about Fresh.
diff --git a/init/deno.json b/init/deno.json
new file mode 100644
index 00000000000..3083932771b
--- /dev/null
+++ b/init/deno.json
@@ -0,0 +1,22 @@
+{
+ "name": "@fresh/init",
+ "version": "0.0.1-alpha.7",
+ "exports": {
+ ".": "./src/mod.ts"
+ },
+ "lock": false,
+ "exclude": ["**/tmp/*"],
+ "publish": {
+ "include": [
+ "src/**/*.ts",
+ "deno.json",
+ "README.md"
+ ],
+ "exclude": ["**/*_test.*", "*.todo"]
+ },
+ "imports": {
+ "@std/cli": "jsr:@std/cli@^0.221.0",
+ "@std/fmt": "jsr:@std/fmt@^0.221.0",
+ "@std/path": "jsr:@std/path@^0.221.0"
+ }
+}
diff --git a/init/src/init.ts b/init/src/init.ts
new file mode 100644
index 00000000000..a1cb1adb13c
--- /dev/null
+++ b/init/src/init.ts
@@ -0,0 +1,705 @@
+import * as colors from "@std/fmt/colors";
+import * as path from "@std/path";
+
+export const enum InitStep {
+ ProjectName = "ProjectName",
+ Force = "Force",
+ Tailwind = "Tailwind",
+ VSCode = "VSCode",
+ Docker = "Docker",
+}
+
+export class InitError extends Error {}
+
+function error(tty: MockTTY, message: string): never {
+ tty.logError(`%cerror%c: ${message}`, "color: red; font-weight: bold", "");
+ throw new InitError();
+}
+
+export const HELP_TEXT = `@fresh/init
+
+Initialize a new Fresh project. This will create all the necessary files for a
+new project.
+
+To generate a project in the './foobar' subdirectory:
+ deno run -Ar jsr:@fresh/init ./foobar
+
+To generate a project in the current directory:
+ deno run -Ar jsr:@fresh/init .
+
+USAGE:
+ deno run -Ar jsr:@fresh/init [DIRECTORY]
+
+OPTIONS:
+ --force Overwrite existing files
+ --tailwind Use Tailwind for styling
+ --vscode Setup project for VS Code
+ --docker Setup Project to use Docker
+`;
+
+export interface MockTTY {
+ prompt(
+ step: InitStep,
+ message?: string | undefined,
+ _default?: string | undefined,
+ ): string | null;
+ confirm(step: InitStep, message?: string | undefined): boolean;
+ log(...args: unknown[]): void;
+ logError(...args: unknown[]): void;
+}
+
+const realTTY: MockTTY = {
+ prompt(_step, message, _default) {
+ return prompt(message, _default);
+ },
+ confirm(_step, message) {
+ return confirm(message);
+ },
+ log(...args) {
+ console.log(...args);
+ },
+ logError(...args) {
+ console.error(...args);
+ },
+};
+
+export async function initProject(
+ cwd = Deno.cwd(),
+ input: (string | number)[],
+ flags: {
+ docker?: boolean | null;
+ force?: boolean | null;
+ tailwind?: boolean | null;
+ vscode?: boolean | null;
+ } = {},
+ tty: MockTTY = realTTY,
+): Promise {
+ tty.log();
+ tty.log(
+ colors.bgRgb8(
+ colors.rgb8(" 🍋 Fresh: The next-gen web framework. ", 0),
+ 121,
+ ),
+ );
+ tty.log();
+
+ let unresolvedDirectory = Deno.args[0];
+ if (input.length !== 1) {
+ const userInput = tty.prompt(
+ InitStep.ProjectName,
+ "Project Name:",
+ "fresh-project",
+ );
+ if (!userInput) {
+ error(tty, HELP_TEXT);
+ }
+
+ unresolvedDirectory = userInput;
+ }
+
+ const CONFIRM_EMPTY_MESSAGE =
+ "The target directory is not empty (files could get overwritten). Do you want to continue anyway?";
+
+ const projectDir = path.resolve(cwd, unresolvedDirectory);
+
+ try {
+ const dir = [...Deno.readDirSync(projectDir)];
+ const isEmpty = dir.length === 0 ||
+ dir.length === 1 && dir[0].name === ".git";
+ if (
+ !isEmpty &&
+ !(flags.force === null
+ ? tty.confirm(InitStep.Force, CONFIRM_EMPTY_MESSAGE)
+ : flags.force)
+ ) {
+ error(tty, "Directory is not empty.");
+ }
+ } catch (err) {
+ if (!(err instanceof Deno.errors.NotFound)) {
+ throw err;
+ }
+ }
+
+ const useDocker = flags.docker;
+ let useTailwind = flags.tailwind || false;
+ if (flags.tailwind == null) {
+ if (
+ tty.confirm(
+ InitStep.Tailwind,
+ `Set up ${colors.cyan("Tailwind CSS")} for styling?`,
+ )
+ ) {
+ useTailwind = true;
+ }
+ }
+
+ const USE_VSCODE_MESSAGE = `Do you use ${colors.cyan("VS Code")}?`;
+ const useVSCode = flags.vscode == null
+ ? tty.confirm(InitStep.VSCode, USE_VSCODE_MESSAGE)
+ : flags.vscode;
+
+ const writeFile = async (
+ pathname: string,
+ content:
+ | string
+ | Uint8Array
+ | ReadableStream
+ | Record,
+ ) => await writeProjectFile(projectDir, pathname, content);
+
+ const GITIGNORE = `# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# Fresh build directory
+_fresh/
+# npm dependencies
+node_modules/
+`;
+
+ await writeFile(".gitignore", GITIGNORE);
+
+ if (useDocker) {
+ const DENO_VERSION = Deno.version.deno;
+ const DOCKERFILE_TEXT = `
+FROM denoland/deno:${DENO_VERSION}
+
+ARG GIT_REVISION
+ENV DENO_DEPLOYMENT_ID=\${GIT_REVISION}
+
+WORKDIR /app
+
+COPY . .
+RUN deno cache main.tsx
+
+EXPOSE 8000
+
+CMD ["run", "-A", "main.tsx"]
+
+`;
+ await writeFile("Dockerfile", DOCKERFILE_TEXT);
+ }
+
+ const TAILWIND_CONFIG_TS = `import { type Config } from "tailwindcss";
+
+export default {
+ content: [
+ "{routes,islands,components}/**/*.{ts,tsx}",
+ ],
+} satisfies Config;
+`;
+ if (useTailwind) {
+ await writeFile("tailwind.config.ts", TAILWIND_CONFIG_TS);
+ }
+
+ const NO_TAILWIND_STYLES = `
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+* {
+ margin: 0;
+}
+button {
+ color: inherit;
+}
+button, [role="button"] {
+ cursor: pointer;
+}
+code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
+ "Liberation Mono", "Courier New", monospace;
+ font-size: 1em;
+}
+img,
+svg {
+ display: block;
+}
+img,
+video {
+ max-width: 100%;
+ height: auto;
+}
+
+html {
+ line-height: 1.5;
+ -webkit-text-size-adjust: 100%;
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+ "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif,
+ "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+}
+.transition-colors {
+ transition-property: background-color, border-color, color, fill, stroke;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 150ms;
+}
+.my-6 {
+ margin-bottom: 1.5rem;
+ margin-top: 1.5rem;
+}
+.text-4xl {
+ font-size: 2.25rem;
+ line-height: 2.5rem;
+}
+.mx-2 {
+ margin-left: 0.5rem;
+ margin-right: 0.5rem;
+}
+.my-4 {
+ margin-bottom: 1rem;
+ margin-top: 1rem;
+}
+.mx-auto {
+ margin-left: auto;
+ margin-right: auto;
+}
+.px-4 {
+ padding-left: 1rem;
+ padding-right: 1rem;
+}
+.py-8 {
+ padding-bottom: 2rem;
+ padding-top: 2rem;
+}
+.bg-\\[\\#86efac\\] {
+ background-color: #86efac;
+}
+.text-3xl {
+ font-size: 1.875rem;
+ line-height: 2.25rem;
+}
+.py-6 {
+ padding-bottom: 1.5rem;
+ padding-top: 1.5rem;
+}
+.px-2 {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+}
+.py-1 {
+ padding-bottom: 0.25rem;
+ padding-top: 0.25rem;
+}
+.border-gray-500 {
+ border-color: #6b7280;
+}
+.bg-white {
+ background-color: #fff;
+}
+.flex {
+ display: flex;
+}
+.gap-8 {
+ grid-gap: 2rem;
+ gap: 2rem;
+}
+.font-bold {
+ font-weight: 700;
+}
+.max-w-screen-md {
+ max-width: 768px;
+}
+.flex-col {
+ flex-direction: column;
+}
+.items-center {
+ align-items: center;
+}
+.justify-center {
+ justify-content: center;
+}
+.border-2 {
+ border-width: 2px;
+}
+.rounded {
+ border-radius: 0.25rem;
+}
+.hover\\:bg-gray-200:hover {
+ background-color: #e5e7eb;
+}
+.tabular-nums {
+ font-variant-numeric: tabular-nums;
+}
+`;
+
+ const TAILWIND_CSS = `@tailwind base;
+@tailwind components;
+@tailwind utilities;`;
+
+ const cssStyles = useTailwind ? TAILWIND_CSS : NO_TAILWIND_STYLES;
+ await writeFile("static/styles.css", cssStyles);
+
+ const STATIC_LOGO =
+ `
+
+
+
+
+ `;
+ await writeFile("static/logo.svg", STATIC_LOGO);
+
+ try {
+ const res = await fetch("https://fresh.deno.dev/favicon.ico");
+ const buf = await res.arrayBuffer();
+ await writeFile("static/favicon.ico", new Uint8Array(buf));
+ } catch {
+ // Skip this and be silent if there is a network issue.
+ }
+
+ const MAIN_TSX = `import { App, staticFiles, fsRoutes } from "@fresh/core";
+import { State } from "./utils.ts";
+
+export const app = new App()
+ .use(staticFiles())
+ .get("/api/:joke", () => new Response("Hello World"))
+ .get("/greet/:name", (ctx) => {
+ return ctx.render(Hello {ctx.params.name} );
+ });
+
+await fsRoutes(app, {
+ loadIsland: (path) => import(\`./islands/\${path}\`),
+ loadRoute: (path) => import(\`./routes/\${path}\`),
+});
+
+if (import.meta.main) {
+ await app.listen();
+}
+`;
+ await writeFile("main.tsx", MAIN_TSX);
+
+ const COMPONENTS_BUTTON_TSX = `import { ComponentChildren } from "preact";
+
+export interface ButtonProps {
+ onClick?: () => void;
+ children?: ComponentChildren;
+ disabled?: boolean
+}
+
+export function Button(props: ButtonProps) {
+ return (
+
+ );
+}
+`;
+ await writeFile("components/Button.tsx", COMPONENTS_BUTTON_TSX);
+
+ const UTILS_TS = `import { createHelpers } from "@fresh/core";
+
+// deno-lint-ignore no-empty-interface
+export interface State {}
+
+export const helpers = createHelpers();
+`;
+ await writeFile("utils.ts", UTILS_TS);
+
+ const ROUTES_HOME = `import { useSignal } from "@preact/signals";
+import { helpers } from "../utils.ts";
+import Counter from "../islands/Counter.tsx";
+
+export default helpers.definePage(function Home() {
+ const count = useSignal(3);
+
+ return (
+
+
+
+
Welcome to Fresh
+
+ Try updating this message in the
+ ./routes/index.tsx
file, and refresh.
+
+
+
+
+ );
+})`;
+ await writeFile("routes/index.tsx", ROUTES_HOME);
+
+ const APP_WRAPPER = `import { type FreshContext } from "@fresh/core";
+
+export default function App({ Component }: FreshContext) {
+ return (
+
+
+
+
+ ${path.basename(projectDir)}
+
+
+
+
+
+
+ );
+}`;
+ await writeFile("routes/_app.tsx", APP_WRAPPER);
+
+ const ISLANDS_COUNTER_TSX = `import type { Signal } from "@preact/signals";
+import { Button } from "../components/Button.tsx";
+
+interface CounterProps {
+ count: Signal;
+}
+
+export default function Counter(props: CounterProps) {
+ return (
+
+
props.count.value -= 1}>-1
+
{props.count}
+
props.count.value += 1}>+1
+
+ );
+}
+`;
+ await writeFile("islands/Counter.tsx", ISLANDS_COUNTER_TSX);
+
+ 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/core/dev";
+import { app } from "./main.tsx";
+
+const builder = new Builder();
+${useTailwind ? "tailwind(builder, app, {});\n" : "\n"}
+
+if (Deno.args.includes("build")) {
+ await builder.build(app);
+} else {
+ await builder.listen(app);
+}
+`;
+ await writeFile("dev.ts", DEV_TS);
+
+ const denoJson = {
+ tasks: {
+ check:
+ "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
+ dev: "deno run -A --watch=static/,routes/ dev.ts",
+ build: "deno run -A dev.ts build",
+ start: "deno run -A main.ts",
+ update: "deno run -A -r jsr:@fresh/update .",
+ },
+ lint: {
+ rules: {
+ tags: ["fresh", "recommended"],
+ },
+ },
+ exclude: ["**/_fresh/*"],
+ imports: {
+ "@fresh/core": "jsr:@fresh/core@^2.0.0-alpha.8",
+ "@fresh/plugin-tailwind": "jsr:@fresh/plugin-tailwind@^0.0.1-alpha.6",
+ "preact": "npm:preact@^10.22.0",
+ "@preact/signals": "npm:@preact/signals@^1.2.3",
+ } as Record,
+ compilerOptions: {
+ jsx: "react-jsx",
+ jsxImportSource: "preact",
+ },
+ };
+
+ if (useTailwind) {
+ denoJson.imports["tailwindcss"] = "npm:tailwindcss@3.4.3";
+ denoJson.imports["tailwindcss/plugin"] = "npm:tailwindcss@3.4.3/plugin.js";
+ }
+
+ await writeFile("deno.json", denoJson);
+
+ const README_MD = `# Fresh project
+
+Your new Fresh project is ready to go. You can follow the Fresh "Getting
+Started" guide here: https://fresh.deno.dev/docs/getting-started
+
+### Usage
+
+Make sure to install Deno: https://deno.land/manual/getting_started/installation
+
+Then start the project:
+
+\`\`\`
+deno task start
+\`\`\`
+
+This will watch the project directory and restart as necessary.
+`;
+ await writeFile("README.md", README_MD);
+
+ if (useVSCode) {
+ const vscodeSettings = {
+ "deno.enable": true,
+ "deno.lint": true,
+ "editor.defaultFormatter": "denoland.vscode-deno",
+ "[typescriptreact]": {
+ "editor.defaultFormatter": "denoland.vscode-deno",
+ },
+ "[typescript]": {
+ "editor.defaultFormatter": "denoland.vscode-deno",
+ },
+ "[javascriptreact]": {
+ "editor.defaultFormatter": "denoland.vscode-deno",
+ },
+ "[javascript]": {
+ "editor.defaultFormatter": "denoland.vscode-deno",
+ },
+ "css.customData": useTailwind ? [".vscode/tailwind.json"] : undefined,
+ };
+
+ await writeFile(".vscode/settings.json", vscodeSettings);
+
+ const recommendations = ["denoland.vscode-deno"];
+ if (useTailwind) recommendations.push("bradlc.vscode-tailwindcss");
+ await writeFile(".vscode/extensions.json", { recommendations });
+
+ if (useTailwind) {
+ const tailwindCustomData = {
+ "version": 1.1,
+ "atDirectives": [
+ {
+ "name": "@tailwind",
+ "description":
+ "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
+ "references": [
+ {
+ "name": "Tailwind Documentation",
+ "url":
+ "https://tailwindcss.com/docs/functions-and-directives#tailwind",
+ },
+ ],
+ },
+ {
+ "name": "@apply",
+ "description":
+ "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.",
+ "references": [
+ {
+ "name": "Tailwind Documentation",
+ "url":
+ "https://tailwindcss.com/docs/functions-and-directives#apply",
+ },
+ ],
+ },
+ {
+ "name": "@responsive",
+ "description":
+ "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
+ "references": [
+ {
+ "name": "Tailwind Documentation",
+ "url":
+ "https://tailwindcss.com/docs/functions-and-directives#responsive",
+ },
+ ],
+ },
+ {
+ "name": "@screen",
+ "description":
+ "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
+ "references": [
+ {
+ "name": "Tailwind Documentation",
+ "url":
+ "https://tailwindcss.com/docs/functions-and-directives#screen",
+ },
+ ],
+ },
+ {
+ "name": "@variants",
+ "description":
+ "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
+ "references": [
+ {
+ "name": "Tailwind Documentation",
+ "url":
+ "https://tailwindcss.com/docs/functions-and-directives#variants",
+ },
+ ],
+ },
+ ],
+ };
+
+ await writeFile(".vscode/tailwind.json", tailwindCustomData);
+ }
+ }
+
+ // Specifically print unresolvedDirectory, rather than resolvedDirectory in order to
+ // not leak personal info (e.g. `/Users/MyName`)
+ tty.log("\n%cProject initialized!\n", "color: green; font-weight: bold");
+
+ if (unresolvedDirectory !== ".") {
+ tty.log(
+ `Enter your project directory using %ccd ${unresolvedDirectory}%c.`,
+ "color: cyan",
+ "",
+ );
+ }
+ tty.log(
+ "Run %cdeno task start%c to start the project. %cCTRL-C%c to stop.",
+ "color: cyan",
+ "",
+ "color: cyan",
+ "",
+ );
+ tty.log();
+ tty.log(
+ "Stuck? Join our Discord %chttps://discord.gg/deno",
+ "color: cyan",
+ "",
+ );
+ tty.log();
+ tty.log(
+ "%cHappy hacking! 🦕",
+ "color: gray",
+ );
+}
+
+async function writeProjectFile(
+ projectDir: string,
+ pathname: string,
+ content:
+ | string
+ | Uint8Array
+ | ReadableStream
+ | Record,
+) {
+ const filePath = path.join(
+ projectDir,
+ ...pathname.split("/").filter(Boolean),
+ );
+ try {
+ await Deno.mkdir(
+ path.dirname(filePath),
+ { recursive: true },
+ );
+ if (typeof content === "string") {
+ let formatted = content;
+ if (!content.endsWith("\n\n")) {
+ formatted += "\n";
+ }
+ await Deno.writeTextFile(filePath, formatted);
+ } else if (
+ content instanceof Uint8Array || content instanceof ReadableStream
+ ) {
+ await Deno.writeFile(filePath, content);
+ } else {
+ await Deno.writeTextFile(
+ filePath,
+ JSON.stringify(content, null, 2) + "\n",
+ );
+ }
+ } catch (err) {
+ if (!(err instanceof Deno.errors.AlreadyExists)) {
+ throw err;
+ }
+ }
+}
diff --git a/init/src/init_test.ts b/init/src/init_test.ts
new file mode 100644
index 00000000000..da61f54c28c
--- /dev/null
+++ b/init/src/init_test.ts
@@ -0,0 +1,163 @@
+import { expect } from "@std/expect";
+import { initProject, InitStep, type MockTTY } from "./init.ts";
+import * as path from "@std/path";
+import { withBrowser } from "../../tests/test_utils.tsx";
+import { waitForText } from "../../tests/test_utils.tsx";
+import { withChildProcessServer } from "../../tests/test_utils.tsx";
+
+async function withTmpDir(fn: (dir: string) => void | Promise) {
+ const dir = await Deno.makeTempDir();
+
+ try {
+ await fn(dir);
+ } finally {
+ await Deno.remove(dir, { recursive: true });
+ }
+}
+
+function mockUserInput(steps: Record) {
+ const errorOutput: unknown[][] = [];
+ const tty: MockTTY = {
+ confirm(step, _msg) {
+ return Boolean(steps[step]);
+ },
+ prompt(step, _msg, def) {
+ const setting = typeof steps[step] === "string"
+ ? steps[step] as string
+ : null;
+ return setting ?? def ?? null;
+ },
+ log: () => {},
+ logError: (...args) => {
+ errorOutput.push(args);
+ },
+ };
+ return {
+ errorOutput,
+ tty,
+ };
+}
+
+async function expectProjectFile(dir: string, pathname: string) {
+ const filePath = path.join(dir, ...pathname.split("/").filter(Boolean));
+ const stat = await Deno.stat(filePath);
+ if (!stat.isFile) {
+ throw new Error(`Not a project file: ${filePath}`);
+ }
+}
+
+async function readProjectFile(dir: string, pathname: string): Promise {
+ const filePath = path.join(dir, ...pathname.split("/").filter(Boolean));
+ const content = await Deno.readTextFile(filePath);
+ return content;
+}
+
+Deno.test("init - new project", async () => {
+ await withTmpDir(async (dir) => {
+ const mock = mockUserInput({});
+ await initProject(dir, [], {}, mock.tty);
+ });
+});
+
+Deno.test("init - create project dir", async () => {
+ await withTmpDir(async (dir) => {
+ const mock = mockUserInput({ [InitStep.ProjectName]: "fresh-init" });
+ await initProject(dir, [], {}, mock.tty);
+
+ const root = path.join(dir, "fresh-init");
+ await expectProjectFile(root, "deno.json");
+ await expectProjectFile(root, "main.tsx");
+ await expectProjectFile(root, "dev.ts");
+ await expectProjectFile(root, ".gitignore");
+ await expectProjectFile(root, "static/styles.css");
+ });
+});
+
+Deno.test("init - with tailwind", async () => {
+ await withTmpDir(async (dir) => {
+ const mock = mockUserInput({
+ [InitStep.ProjectName]: ".",
+ [InitStep.Tailwind]: true,
+ });
+ await initProject(dir, [], {}, mock.tty);
+
+ const css = await readProjectFile(dir, "static/styles.css");
+ expect(css).toMatch(/@tailwind/);
+
+ const main = await readProjectFile(dir, "main.tsx");
+ const dev = await readProjectFile(dir, "dev.ts");
+ expect(main).not.toMatch(/tailwind/);
+ expect(dev).toMatch(/tailwind/);
+ });
+});
+
+Deno.test("init - with vscode", async () => {
+ await withTmpDir(async (dir) => {
+ const mock = mockUserInput({
+ [InitStep.ProjectName]: ".",
+ [InitStep.VSCode]: true,
+ });
+ await initProject(dir, [], {}, mock.tty);
+
+ await expectProjectFile(dir, ".vscode/settings.json");
+ await expectProjectFile(dir, ".vscode/extensions.json");
+ });
+});
+
+// TODO: Testing this with JSR isn't as easy anymore as it was before
+Deno.test.ignore("init - can start dev server", async () => {
+ await withTmpDir(async (dir) => {
+ const mock = mockUserInput({
+ [InitStep.ProjectName]: ".",
+ });
+ await initProject(dir, [], {}, mock.tty);
+ await expectProjectFile(dir, "main.tsx");
+ await expectProjectFile(dir, "dev.ts");
+
+ await withChildProcessServer(
+ dir,
+ path.join(dir, "dev.ts"),
+ async (address) => {
+ await withBrowser(async (page) => {
+ await page.goto(address);
+ await page.locator("button").click();
+ await waitForText(page, "button + p", "2");
+ });
+ },
+ );
+ });
+});
+
+// TODO: Testing this with JSR isn't as easy anymore as it was before
+Deno.test.ignore("init - can start build project", async () => {
+ await withTmpDir(async (dir) => {
+ const mock = mockUserInput({
+ [InitStep.ProjectName]: ".",
+ });
+ await initProject(dir, [], {}, mock.tty);
+ await expectProjectFile(dir, "main.tsx");
+ await expectProjectFile(dir, "dev.ts");
+
+ // Build
+ await new Deno.Command(Deno.execPath(), {
+ args: ["run", "-A", path.join(dir, "dev.ts"), "build"],
+ stdin: "null",
+ stdout: "piped",
+ stderr: "piped",
+ cwd: dir,
+ }).output();
+
+ await withChildProcessServer(
+ dir,
+ path.join(dir, "main.tsx"),
+ async (address) => {
+ console.log({ address });
+ await withBrowser(async (page) => {
+ await page.goto(address);
+ await page.locator("button").click();
+ await waitForText(page, "button + p", "2");
+ });
+ },
+ );
+ });
+});
diff --git a/init/src/mod.ts b/init/src/mod.ts
new file mode 100644
index 00000000000..b9bc17a1fd1
--- /dev/null
+++ b/init/src/mod.ts
@@ -0,0 +1,26 @@
+import { parseArgs } from "@std/cli/parse-args";
+import { initProject } from "./init.ts";
+import { InitError } from "./init.ts";
+
+const flags = parseArgs(Deno.args, {
+ boolean: ["force", "tailwind", "twind", "vscode", "docker", "help"],
+ default: {
+ force: null,
+ tailwind: null,
+ twind: null,
+ vscode: null,
+ docker: null,
+ },
+ alias: {
+ help: "h",
+ },
+});
+
+try {
+ await initProject(Deno.cwd(), flags._, flags);
+} catch (err) {
+ if (err instanceof InitError) {
+ Deno.exit(1);
+ }
+ throw err;
+}
diff --git a/plugin-tailwindcss/README.md b/plugin-tailwindcss/README.md
new file mode 100644
index 00000000000..1e72b669926
--- /dev/null
+++ b/plugin-tailwindcss/README.md
@@ -0,0 +1,29 @@
+# Tailwind CSS plugin for Fresh
+
+A Tailwind CSS plugin to use in Fresh.
+
+```ts
+// dev.ts
+
+import { tailwind } from "@fresh/plugin-tailwind";
+import { FreshDevApp } from "@fresh/core/dev";
+import { app } from "./main.ts";
+
+const devApp = new FreshDevApp();
+
+// Enable Tailwind CSS
+tailwind(devApp);
+
+devApp.mountApp("/", app);
+
+if (Deno.args.includes("build")) {
+ await devApp.build({
+ target: "safari12",
+ });
+} else {
+ await devApp.listen();
+}
+```
+
+To learn more about Fresh go to
+[https://fresh.deno.dev/](https://fresh.deno.dev/).
diff --git a/plugin-tailwindcss/deno.json b/plugin-tailwindcss/deno.json
new file mode 100644
index 00000000000..f1fd45536fe
--- /dev/null
+++ b/plugin-tailwindcss/deno.json
@@ -0,0 +1,15 @@
+{
+ "name": "@fresh/plugin-tailwind",
+ "version": "0.0.1-alpha.6",
+ "exports": {
+ ".": "./src/mod.ts"
+ },
+ "imports": {
+ "@fresh/core": "jsr:@fresh/core@^2.0.0-alpha.1",
+ "@std/path": "jsr:@std/path@^0.221.0",
+ "autoprefixer": "npm:autoprefixer@10.4.17",
+ "cssnano": "npm:cssnano@6.0.3",
+ "postcss": "npm:postcss@8.4.35",
+ "tailwindcss": "npm:tailwindcss@^3.4.1"
+ }
+}
diff --git a/plugins/tailwind/compiler.ts b/plugin-tailwindcss/src/compiler.ts
similarity index 80%
rename from plugins/tailwind/compiler.ts
rename to plugin-tailwindcss/src/compiler.ts
index f1c08e1dd1d..76a4fb0fb0d 100644
--- a/plugins/tailwind/compiler.ts
+++ b/plugin-tailwindcss/src/compiler.ts
@@ -1,10 +1,10 @@
-import { ResolvedFreshConfig } from "../../server.ts";
-import tailwindCss, { Config } from "tailwindcss";
-import postcss from "npm:postcss@8.4.35";
-import cssnano from "npm:cssnano@6.0.3";
-import autoprefixer from "npm:autoprefixer@10.4.17";
-import * as path from "https://deno.land/std@0.216.0/path/mod.ts";
-import { TailwindPluginOptions } from "./types.ts";
+import tailwindCss, { type Config } from "tailwindcss";
+import postcss from "postcss";
+import cssnano from "cssnano";
+import autoprefixer from "autoprefixer";
+import * as path from "@std/path";
+import type { TailwindPluginOptions } from "./types.ts";
+import type { ResolvedFreshConfig } from "@fresh/core";
const CONFIG_EXTENSIONS = ["ts", "js", "mjs"];
@@ -51,7 +51,8 @@ export async function initTailwind(
throw new Error(`Expected tailwind "content" option to be an array`);
}
- tailwindConfig.content = tailwindConfig.content.map((pattern) => {
+ // deno-lint-ignore no-explicit-any
+ tailwindConfig.content = tailwindConfig.content.map((pattern: any) => {
if (typeof pattern === "string") {
const relative = path.relative(Deno.cwd(), path.dirname(configPath));
@@ -70,7 +71,7 @@ export async function initTailwind(
autoprefixer(options.autoprefixer) as any,
];
- if (!config.dev) {
+ if (config.mode === "build") {
plugins.push(cssnano());
}
diff --git a/plugin-tailwindcss/src/mod.ts b/plugin-tailwindcss/src/mod.ts
new file mode 100644
index 00000000000..05f37fefa6a
--- /dev/null
+++ b/plugin-tailwindcss/src/mod.ts
@@ -0,0 +1,26 @@
+import type { TailwindPluginOptions } from "./types.ts";
+import { initTailwind } from "./compiler.ts";
+import type { Builder } from "@fresh/core/dev";
+import type { App } from "@fresh/core";
+
+export function tailwind(
+ builder: Builder,
+ app: App,
+ options: TailwindPluginOptions = {},
+): void {
+ const processor = initTailwind(app.config, options);
+
+ builder.onTransformStaticFile(
+ { pluginName: "tailwind", filter: /\.css$/ },
+ async (args) => {
+ const instance = await processor;
+ const res = await instance.process(args.text, {
+ from: args.path,
+ });
+ return {
+ content: res.content,
+ map: res.map?.toString(),
+ };
+ },
+ );
+}
diff --git a/plugins/tailwind/types.ts b/plugin-tailwindcss/src/types.ts
similarity index 100%
rename from plugins/tailwind/types.ts
rename to plugin-tailwindcss/src/types.ts
diff --git a/plugins/tailwind.ts b/plugins/tailwind.ts
deleted file mode 100644
index 8668bbace77..00000000000
--- a/plugins/tailwind.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { Plugin, PluginMiddleware, ResolvedFreshConfig } from "../server.ts";
-import type postcss from "npm:postcss@8.4.35";
-import * as path from "https://deno.land/std@0.216.0/path/mod.ts";
-import { walk } from "https://deno.land/std@0.216.0/fs/walk.ts";
-import { TailwindPluginOptions } from "./tailwind/types.ts";
-
-async function initTailwind(
- config: ResolvedFreshConfig,
- options: TailwindPluginOptions,
-) {
- return await (await import("./tailwind/compiler.ts")).initTailwind(
- config,
- options,
- );
-}
-
-export default function tailwind(
- options: TailwindPluginOptions = {},
-): Plugin {
- let staticDir = path.join(Deno.cwd(), "static");
- let processor: postcss.Processor | null = null;
-
- const cache = new Map();
-
- const tailwindMiddleware: PluginMiddleware = {
- path: "/",
- middleware: {
- handler: async (_req, ctx) => {
- const pathname = ctx.url.pathname;
-
- if (pathname.endsWith(".css.map")) {
- const cached = cache.get(pathname);
- if (cached) return Response.json(cached.map);
- }
-
- if (!pathname.endsWith(".css") || !processor) {
- return ctx.next();
- }
-
- let cached = cache.get(pathname);
- if (!cached) {
- const filePath = path.join(
- staticDir,
- pathname.replace(ctx.config.basePath, ""),
- );
- let text = "";
- try {
- text = await Deno.readTextFile(filePath);
- const res = await processor.process(text, {
- from: undefined,
- });
-
- cached = {
- content: res.content,
- map: res.map?.toString() ?? "",
- };
- cache.set(pathname, cached);
- } catch (err) {
- // If the file is not found than it's likely a virtual file
- // by the user that they respond to via a middleware.
- if (err instanceof Deno.errors.NotFound) {
- return ctx.next();
- }
-
- cached = {
- content: text,
- map: "",
- };
- console.error(err);
- }
- }
-
- return new Response(cached!.content, {
- status: 200,
- headers: {
- "Content-Type": "text/css",
- "Cache-Control": "no-cache, no-store, max-age=0, must-revalidate",
- },
- });
- },
- },
- };
-
- const middlewares: Plugin["middlewares"] = [];
-
- return {
- name: "tailwind",
- async configResolved(config) {
- if (config.dev) {
- staticDir = config.staticDir;
- processor = await initTailwind(config, options);
- middlewares.push(tailwindMiddleware);
- }
- },
- middlewares,
- async buildStart(config) {
- staticDir = config.staticDir;
- const outDir = path.join(config.build.outDir, "static");
-
- processor = await initTailwind(config, options);
-
- const files = walk(config.staticDir, {
- exts: ["css"],
- includeDirs: false,
- includeFiles: true,
- });
-
- for await (const file of files) {
- const content = await Deno.readTextFile(file.path);
- const result = await processor.process(content, {
- from: undefined,
- });
-
- const relFilePath = path.relative(staticDir, file.path);
- const outPath = path.join(outDir, relFilePath);
-
- try {
- await Deno.mkdir(path.dirname(outPath), { recursive: true });
- } catch (err) {
- if (!(err instanceof Deno.errors.AlreadyExists)) {
- throw err;
- }
- }
-
- await Deno.writeTextFile(outPath, result.content);
- }
- },
- };
-}
diff --git a/plugins/twind.ts b/plugins/twind.ts
deleted file mode 100644
index 3c8c9d2d843..00000000000
--- a/plugins/twind.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { virtualSheet } from "twind/sheets";
-import { Plugin } from "../server.ts";
-
-import { Options, setup, STYLE_ELEMENT_ID } from "./twind/shared.ts";
-export type { Options };
-
-export default function twind(options: Options): Plugin {
- const sheet = virtualSheet();
- setup(options, sheet);
- const main = `data:application/javascript,import hydrate from "${
- new URL("./twind/main.ts", import.meta.url).href
- }";
-import options from "${options.selfURL}";
-export default function(state) { hydrate(options, state); }`;
- return {
- name: "twind",
- entrypoints: { "main": main },
- async renderAsync(ctx) {
- sheet.reset(undefined);
- await ctx.renderAsync();
- const cssTexts = [...sheet.target];
- const snapshot = sheet.reset();
- const precedences = snapshot[1] as number[];
-
- const cssText = cssTexts.map((cssText, i) =>
- `${cssText}/*${precedences[i].toString(36)}*/`
- ).join("\n");
-
- const mappings: (string | [string, string])[] = [];
- for (
- const [key, value] of (snapshot[3] as Map).entries()
- ) {
- if (key === value) {
- mappings.push(key);
- } else {
- mappings.push([key, value]);
- }
- }
-
- return {
- scripts: [{ entrypoint: "main", state: mappings }],
- styles: [{ cssText, id: STYLE_ELEMENT_ID }],
- };
- },
- };
-}
diff --git a/plugins/twind/main.ts b/plugins/twind/main.ts
deleted file mode 100644
index 8943564f073..00000000000
--- a/plugins/twind/main.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { Sheet } from "twind";
-import { Options, setup, STYLE_ELEMENT_ID } from "./shared.ts";
-
-type State = [string, string][];
-
-export default function hydrate(options: Options, state: State) {
- const el = document.getElementById(STYLE_ELEMENT_ID) as HTMLStyleElement;
- const rules = new Set();
- const precedences: number[] = [];
- const mappings = new Map(
- state.map((v) => typeof v === "string" ? [v, v] : v),
- );
- // deno-lint-ignore no-explicit-any
- const sheetState: any[] = [precedences, rules, mappings, true];
- const target = el.sheet!;
- const ruleText = Array.from(target.cssRules).map((r) => r.cssText);
- for (const r of ruleText) {
- const m = r.lastIndexOf("/*");
- const precedence = parseInt(r.slice(m + 2, -2), 36);
- const rule = r.slice(0, m);
- rules.add(rule);
- precedences.push(precedence);
- }
- const sheet: Sheet = {
- target,
- insert: (rule, index) => target.insertRule(rule, index),
- init: (cb) => cb(sheetState.shift()),
- };
- setup(options, sheet);
-}
diff --git a/plugins/twind/shared.ts b/plugins/twind/shared.ts
deleted file mode 100644
index 4caa81d6a2a..00000000000
--- a/plugins/twind/shared.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { JSX, options as preactOptions, VNode } from "preact";
-import { Configuration, setup as twSetup, Sheet, tw } from "twind";
-
-type PreactOptions = typeof preactOptions & { __b?: (vnode: VNode) => void };
-
-export const STYLE_ELEMENT_ID = "__FRSH_TWIND";
-
-export interface Options extends Omit {
- /** The import.meta.url of the module defining these options. */
- selfURL: string;
-}
-
-declare module "preact" {
- namespace JSX {
- interface DOMAttributes {
- class?: string;
- className?: string;
- }
- }
-}
-
-export function setup(options: Options, sheet: Sheet) {
- const config: Configuration = {
- ...options,
- mode: "silent",
- sheet,
- };
- twSetup(config);
-
- // Hook into options._diff which is called whenever a new comparison
- // starts in Preact.
- const originalHook = (preactOptions as PreactOptions).__b;
- (preactOptions as PreactOptions).__b = (
- // deno-lint-ignore no-explicit-any
- vnode: VNode>,
- ) => {
- if (typeof vnode.type === "string" && typeof vnode.props === "object") {
- const { props } = vnode;
- const classes: string[] = [];
- if (props.class) {
- classes.push(tw(props.class));
- props.class = undefined;
- }
- if (props.className) {
- classes.push(tw(props.className));
- props.className = undefined;
- }
- if (classes.length) {
- props.class = classes.join(" ");
- }
- }
-
- originalHook?.(vnode);
- };
-}
diff --git a/plugins/twindv1.ts b/plugins/twindv1.ts
deleted file mode 100644
index 7f1efa5e724..00000000000
--- a/plugins/twindv1.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { stringify, virtual } from "$fresh/plugins/twindv1_deps.ts";
-import { Plugin } from "$fresh/server.ts";
-
-import {
- Options,
- setup,
- STYLE_ELEMENT_ID,
-} from "$fresh/plugins/twindv1/shared.ts";
-
-import { BaseTheme } from "$fresh/plugins/twindv1_deps.ts";
-export type { Options };
-
-export default function twindv1(
- options: Options,
-): Plugin {
- const sheet = virtual(true);
- setup(options, sheet);
- const main = `data:application/javascript,import hydrate from "${
- new URL("./twindv1/main.ts", import.meta.url).href
- }";
-import options from "${options.selfURL}";
-export default function(state) { hydrate(options, state); }`;
- return {
- name: "twind",
- entrypoints: { "main": main },
- async renderAsync(ctx) {
- await ctx.renderAsync();
- const cssText = stringify(sheet.target);
- return {
- scripts: [{ entrypoint: "main", state: [] }],
- styles: [{ cssText, id: STYLE_ELEMENT_ID }],
- };
- },
- };
-}
diff --git a/plugins/twindv1/main.ts b/plugins/twindv1/main.ts
deleted file mode 100644
index f6f871a6421..00000000000
--- a/plugins/twindv1/main.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { cssom, getSheet, setup, TwindConfig } from "../twindv1_deps.ts";
-import { STYLE_ELEMENT_ID } from "./shared.ts";
-
-export default function hydrate(options: TwindConfig) {
- const elem = document.getElementById(STYLE_ELEMENT_ID) as HTMLStyleElement;
- const sheet = cssom(elem);
-
- sheet.resume = getSheet().resume.bind(sheet);
- document.querySelector('[data-twind="claimed"]')?.remove();
-
- setup(options, sheet);
-}
diff --git a/plugins/twindv1/shared.ts b/plugins/twindv1/shared.ts
deleted file mode 100644
index fb03888fe2d..00000000000
--- a/plugins/twindv1/shared.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { JSX, options as preactOptions, VNode } from "preact";
-import {
- BaseTheme,
- setup as twSetup,
- Sheet,
- tw,
- TwindConfig,
-} from "$fresh/plugins/twindv1_deps.ts";
-
-type PreactOptions = typeof preactOptions & { __b?: (vnode: VNode) => void };
-
-export const STYLE_ELEMENT_ID = "__FRSH_TWIND";
-
-export interface Options
- extends TwindConfig {
- /** The import.meta.url of the module defining these options. */
- selfURL: string;
-}
-
-declare module "preact" {
- namespace JSX {
- interface DOMAttributes {
- class?: string;
- className?: string;
- }
- }
-}
-
-export function setup(
- { selfURL: _selfURL, ...config }: Options,
- sheet: Sheet,
-) {
- twSetup(config, sheet);
-
- // Hook into options._diff which is called whenever a new comparison
- // starts in Preact.
- const originalHook = (preactOptions as PreactOptions).__b;
- (preactOptions as PreactOptions).__b = (
- // deno-lint-ignore no-explicit-any
- vnode: VNode>,
- ) => {
- if (typeof vnode.type === "string" && typeof vnode.props === "object") {
- const { props } = vnode;
- const classes: string[] = [];
- if (props.class) {
- classes.push(tw(props.class));
- props.class = undefined;
- }
- if (props.className) {
- classes.push(tw(props.className));
- props.className = undefined;
- }
- if (classes.length) {
- props.class = classes.join(" ");
- }
- }
-
- originalHook?.(vnode);
- };
-}
diff --git a/plugins/twindv1_deps.ts b/plugins/twindv1_deps.ts
deleted file mode 100644
index 163f4d421a3..00000000000
--- a/plugins/twindv1_deps.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "https://esm.sh/@twind/core@1.1.3";
diff --git a/runtime.ts b/runtime.ts
deleted file mode 100644
index df42b5f9950..00000000000
--- a/runtime.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import "./src/types.ts";
-export * from "./src/runtime/utils.ts";
-export * from "./src/runtime/head.ts";
-export * from "./src/runtime/csp.ts";
-export * from "./src/runtime/Partial.tsx";
diff --git a/server.ts b/server.ts
deleted file mode 100644
index e81fecdd210..00000000000
--- a/server.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-import "./src/types.ts";
-export * from "./src/server/mod.ts";
diff --git a/src/app.ts b/src/app.ts
new file mode 100644
index 00000000000..d2a7b4515fc
--- /dev/null
+++ b/src/app.ts
@@ -0,0 +1,319 @@
+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 { FreshReqContext } from "./context.ts";
+import {
+ mergePaths,
+ 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 * as path from "@std/path";
+import { type ComponentType, h } from "preact";
+import type { ServerIslandRegistry } from "./context.ts";
+import { renderToString } from "preact-render-to-string";
+import { FinishSetup, ForgotBuild } from "./finish_setup.tsx";
+import { HttpError } from "./error.ts";
+
+// TODO: Completed type clashes in older Deno versions
+// deno-lint-ignore no-explicit-any
+export const DEFAULT_CONN_INFO: any = {
+ localAddr: { transport: "tcp", hostname: "localhost", port: 8080 },
+ remoteAddr: { transport: "tcp", hostname: "localhost", port: 1234 },
+};
+
+const DEFAULT_NOT_FOUND = () => {
+ throw new HttpError(404);
+};
+const DEFAULT_NOT_ALLOWED_METHOD = () => {
+ throw new HttpError(405);
+};
+
+export type ListenOptions = Partial & {
+ remoteAddress?: string;
+};
+
+export interface RouteCacheEntry {
+ params: Record;
+ handler: MiddlewareFn;
+}
+
+export let getRouter: (app: App) => Router>;
+// 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 class App {
+ #router: Router> = new UrlPatternRouter<
+ MiddlewareFn
+ >();
+ #islandRegistry: ServerIslandRegistry = new Map();
+ #buildCache: BuildCache | null = null;
+ #islandNames = new Set();
+
+ static {
+ getRouter = (app) => app.#router;
+ getIslandRegistry = (app) => app.#islandRegistry;
+ getBuildCache = (app) => app.#buildCache;
+ setBuildCache = (app, cache) => app.#buildCache = cache;
+ }
+
+ /**
+ * The final resolved Fresh configuration.
+ */
+ 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"
+ ? path.basename(filePath, path.extname(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;
+ }
+
+ use(middleware: MiddlewareFn): this {
+ this.#router.addMiddleware(middleware);
+ return this;
+ }
+
+ get(path: string, ...middlewares: MiddlewareFn[]): this {
+ return this.#addRoutes("GET", path, middlewares);
+ }
+ post(path: string, ...middlewares: MiddlewareFn[]): this {
+ return this.#addRoutes("POST", path, middlewares);
+ }
+ patch(path: string, ...middlewares: MiddlewareFn[]): this {
+ return this.#addRoutes("PATCH", path, middlewares);
+ }
+ put(path: string, ...middlewares: MiddlewareFn[]): this {
+ return this.#addRoutes("PUT", path, middlewares);
+ }
+ delete(path: string, ...middlewares: MiddlewareFn[]): this {
+ return this.#addRoutes("DELETE", path, middlewares);
+ }
+ head(path: string, ...middlewares: MiddlewareFn[]): this {
+ return this.#addRoutes("HEAD", path, middlewares);
+ }
+ all(path: string, ...middlewares: MiddlewareFn[]): this {
+ return this.#addRoutes("ALL", path, middlewares);
+ }
+
+ mountApp(path: string, app: App): this {
+ const routes = app.#router._routes;
+ app.#islandRegistry.forEach((value, key) => {
+ this.#islandRegistry.set(key, value);
+ });
+
+ const middlewares = app.#router._middlewares;
+
+ // Special case when user calls one of these:
+ // - `app.mounApp("/", otherApp)`
+ // - `app.mounApp("*", otherApp)`
+ const isSelf = path === "*" || path === "/";
+ if (isSelf && middlewares.length > 0) {
+ this.#router._middlewares.push(...middlewares);
+ }
+
+ for (let i = 0; i < routes.length; i++) {
+ const route = routes[i];
+
+ const merged = typeof route.path === "string"
+ ? mergePaths(path, route.path)
+ : route.path;
+ const combined = isSelf
+ ? route.handlers
+ : middlewares.concat(route.handlers);
+ this.#router.add(route.method, merged, combined);
+ }
+
+ return this;
+ }
+
+ #addRoutes(
+ method: Method | "ALL",
+ pathname: string | URLPattern,
+ middlewares: MiddlewareFn[],
+ ): this {
+ const merged = typeof pathname === "string"
+ ? mergePaths(this.config.basePath, pathname)
+ : pathname;
+ this.#router.add(method, merged, middlewares);
+ return this;
+ }
+
+ async handler(): Promise<
+ (request: Request, info?: Deno.ServeHandlerInfo) => Promise
+ > {
+ if (this.#buildCache === null) {
+ this.#buildCache = await ProdBuildCache.fromSnapshot(this.config);
+ }
+
+ if (
+ !this.#buildCache.hasSnapshot && this.config.mode === "production" &&
+ DENO_DEPLOYMENT_ID !== undefined
+ ) {
+ return missingBuildHandler;
+ }
+
+ return async (
+ req: Request,
+ conn: Deno.ServeHandlerInfo | Deno.ServeUnixHandlerInfo =
+ DEFAULT_CONN_INFO,
+ ) => {
+ const url = new URL(req.url);
+ // Prevent open redirect attacks
+ url.pathname = url.pathname.replace(/\/+/g, "/");
+
+ const method = req.method.toUpperCase() as Method;
+ const matched = this.#router.match(method, url);
+
+ const next = matched.patternMatch && !matched.methodMatch
+ ? DEFAULT_NOT_ALLOWED_METHOD
+ : DEFAULT_NOT_FOUND;
+
+ const { params, handlers } = matched;
+ const ctx = new FreshReqContext(
+ req,
+ this.config,
+ next,
+ this.#islandRegistry,
+ this.#buildCache!,
+ conn,
+ );
+
+ ctx.params = params;
+
+ try {
+ if (handlers.length === 1 && handlers[0].length === 1) {
+ return handlers[0][0](ctx);
+ }
+
+ ctx.next = next;
+ return await runMiddlewares(handlers, ctx);
+ } catch (err) {
+ if (err instanceof HttpError) {
+ if (err.status >= 500) {
+ console.error(err);
+ }
+ return new Response(err.message, { status: err.status });
+ }
+
+ console.error(err);
+ return new Response("Internal server error", { status: 500 });
+ }
+ };
+ }
+
+ async listen(options: ListenOptions = {}): Promise {
+ if (!options.onListen) {
+ options.onListen = (params) => {
+ const pathname = (this.config.basePath) + "/";
+ const protocol = options.key && options.cert ? "https:" : "http:";
+ // Work around https://github.com/denoland/deno/issues/23650
+ const hostname = params.hostname.startsWith("::")
+ ? `[${params.hostname}]`
+ : params.hostname;
+ const address = colors.cyan(
+ `${protocol}//${hostname}:${params.port}${pathname}`,
+ );
+ const localLabel = colors.bold("Local:");
+
+ // Print more concise output for deploy logs
+ if (DENO_DEPLOYMENT_ID) {
+ console.log(
+ colors.bgRgb8(colors.rgb8(" 🍋 Fresh ready ", 0), 121),
+ `${localLabel} ${address}`,
+ );
+ } else {
+ console.log();
+ console.log(
+ colors.bgRgb8(colors.rgb8(" 🍋 Fresh ready ", 0), 121),
+ );
+ const sep = options.remoteAddress ? "" : "\n";
+ const space = options.remoteAddress ? " " : "";
+ console.log(` ${localLabel} ${space}${address}${sep}`);
+ if (options.remoteAddress) {
+ const remoteLabel = colors.bold("Remote:");
+ const remoteAddress = colors.cyan(options.remoteAddress);
+ console.log(` ${remoteLabel} ${remoteAddress}\n`);
+ }
+ }
+ };
+ }
+
+ const handler = await this.handler();
+ if (options.port) {
+ await Deno.serve(options, handler);
+ } else {
+ // No port specified, check for a free port. Instead of picking just
+ // any port we'll check if the next one is free for UX reasons.
+ // That way the user only needs to increment a number when running
+ // multiple apps vs having to remember completely different ports.
+ let firstError;
+ for (let port = 8000; port < 8020; port++) {
+ try {
+ await Deno.serve({ ...options, port }, handler);
+ firstError = undefined;
+ break;
+ } catch (err) {
+ if (err instanceof Deno.errors.AddrInUse) {
+ // Throw first EADDRINUSE error
+ // if no port is free
+ if (!firstError) {
+ firstError = err;
+ }
+ continue;
+ }
+
+ throw err;
+ }
+ }
+
+ if (firstError) {
+ throw firstError;
+ }
+ }
+ }
+}
+
+// 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 });
+};
diff --git a/src/app_test.tsx b/src/app_test.tsx
new file mode 100644
index 00000000000..b77da4e5ef8
--- /dev/null
+++ b/src/app_test.tsx
@@ -0,0 +1,472 @@
+import { expect } from "@std/expect";
+import { App, setBuildCache } from "./app.ts";
+import { FakeServer } from "./test_utils.ts";
+import { ProdBuildCache } from "./build_cache.ts";
+
+Deno.test("FreshApp - .use()", async () => {
+ const app = new App<{ text: string }>()
+ .use((ctx) => {
+ ctx.state.text = "A";
+ return ctx.next();
+ })
+ .use((ctx) => {
+ ctx.state.text += "B";
+ return ctx.next();
+ })
+ .get("/", (ctx) => new Response(ctx.state.text));
+
+ const server = new FakeServer(await app.handler());
+
+ const res = await server.get("/");
+ expect(await res.text()).toEqual("AB");
+});
+
+Deno.test("FreshApp - .use() #2", async () => {
+ const app = new App<{ text: string }>()
+ .use(() => new Response("ok #1"))
+ .get("/foo/bar", () => new Response("ok #2"))
+ .get("/", () => new Response("ok #3"));
+
+ const server = new FakeServer(await app.handler());
+
+ const res = await server.get("/");
+ expect(await res.text()).toEqual("ok #1");
+});
+
+Deno.test("FreshApp - .get()", async () => {
+ const app = new App()
+ .post("/", () => new Response("ok"))
+ .post("/foo", () => new Response("ok"))
+ .get("/", () => new Response("ok"))
+ .get("/foo", () => new Response("ok"));
+
+ const server = new FakeServer(await app.handler());
+
+ let res = await server.get("/");
+ expect(await res.text()).toEqual("ok");
+
+ res = await server.get("/foo");
+ expect(await res.text()).toEqual("ok");
+});
+
+Deno.test("FreshApp - .get() with basePath", async () => {
+ const app = new App({ basePath: "/foo/bar" })
+ .get("/", () => new Response("ok"))
+ .get("/foo", () => new Response("ok"));
+
+ const server = new FakeServer(await app.handler());
+
+ let res = await server.get("/");
+ expect(res.status).toEqual(404);
+ res = await server.get("/foo");
+ expect(res.status).toEqual(404);
+
+ res = await server.get("/foo/bar");
+ expect(res.status).toEqual(200);
+ res = await server.get("/foo/bar/foo");
+ expect(res.status).toEqual(200);
+});
+
+Deno.test("FreshApp - .post()", async () => {
+ const app = new App<{ text: string }>()
+ .get("/", () => new Response("fail"))
+ .get("/foo", () => new Response("fail"))
+ .post("/", () => new Response("ok"))
+ .post("/foo", () => new Response("ok"));
+
+ const server = new FakeServer(await app.handler());
+
+ let res = await server.post("/");
+ expect(await res.text()).toEqual("ok");
+
+ res = await server.post("/foo");
+ expect(await res.text()).toEqual("ok");
+});
+
+Deno.test("FreshApp - .post() with basePath", async () => {
+ const app = new App({ basePath: "/foo/bar" })
+ .post("/", () => new Response("ok"))
+ .post("/foo", () => new Response("ok"));
+
+ const server = new FakeServer(await app.handler());
+
+ let res = await server.post("/");
+ expect(res.status).toEqual(404);
+ res = await server.post("/foo");
+ expect(res.status).toEqual(404);
+
+ res = await server.post("/foo/bar");
+ expect(res.status).toEqual(200);
+ res = await server.post("/foo/bar/foo");
+ expect(res.status).toEqual(200);
+});
+
+Deno.test("FreshApp - .patch()", async () => {
+ const app = new App<{ text: string }>()
+ .get("/", () => new Response("fail"))
+ .get("/foo", () => new Response("fail"))
+ .patch("/", () => new Response("ok"))
+ .patch("/foo", () => new Response("ok"));
+
+ const server = new FakeServer(await app.handler());
+
+ let res = await server.patch("/");
+ expect(await res.text()).toEqual("ok");
+
+ res = await server.patch("/foo");
+ expect(await res.text()).toEqual("ok");
+});
+
+Deno.test("FreshApp - .patch() with basePath", async () => {
+ const app = new App({ basePath: "/foo/bar" })
+ .patch("/", () => new Response("ok"))
+ .patch("/foo", () => new Response("ok"));
+
+ const server = new FakeServer(await app.handler());
+
+ let res = await server.patch("/");
+ expect(res.status).toEqual(404);
+ res = await server.patch("/foo");
+ expect(res.status).toEqual(404);
+
+ res = await server.patch("/foo/bar");
+ expect(res.status).toEqual(200);
+ res = await server.patch("/foo/bar/foo");
+ expect(res.status).toEqual(200);
+});
+
+Deno.test("FreshApp - .put()", async () => {
+ const app = new App<{ text: string }>()
+ .get("/", () => new Response("fail"))
+ .get("/foo", () => new Response("fail"))
+ .put("/", () => new Response("ok"))
+ .put("/foo", () => new Response("ok"));
+
+ const server = new FakeServer(await app.handler());
+
+ let res = await server.put("/");
+ expect(await res.text()).toEqual("ok");
+
+ res = await server.put("/foo");
+ expect(await res.text()).toEqual("ok");
+});
+
+Deno.test("FreshApp - .put() with basePath", async () => {
+ const app = new App({ basePath: "/foo/bar" })
+ .put("/", () => new Response("ok"))
+ .put("/foo", () => new Response("ok"));
+
+ const server = new FakeServer(await app.handler());
+
+ let res = await server.put("/");
+ expect(res.status).toEqual(404);
+ res = await server.put("/foo");
+ expect(res.status).toEqual(404);
+
+ res = await server.put("/foo/bar");
+ expect(res.status).toEqual(200);
+ res = await server.put("/foo/bar/foo");
+ expect(res.status).toEqual(200);
+});
+
+Deno.test("FreshApp - .delete()", async () => {
+ const app = new App<{ text: string }>()
+ .get("/", () => new Response("fail"))
+ .get("/foo", () => new Response("fail"))
+ .delete("/", () => new Response("ok"))
+ .delete("/foo", () => new Response("ok"));
+
+ const server = new FakeServer(await app.handler());
+
+ let res = await server.delete("/");
+ expect(await res.text()).toEqual("ok");
+
+ res = await server.delete("/foo");
+ expect(await res.text()).toEqual("ok");
+});
+
+Deno.test("FreshApp - .delete() with basePath", async () => {
+ const app = new App({ basePath: "/foo/bar" })
+ .delete("/", () => new Response("ok"))
+ .delete("/foo", () => new Response("ok"));
+
+ const server = new FakeServer(await app.handler());
+
+ let res = await server.delete("/");
+ expect(res.status).toEqual(404);
+ res = await server.delete("/foo");
+ expect(res.status).toEqual(404);
+
+ res = await server.delete("/foo/bar");
+ expect(res.status).toEqual(200);
+ res = await server.delete("/foo/bar/foo");
+ expect(res.status).toEqual(200);
+});
+
+Deno.test("FreshApp - wrong method match", async () => {
+ const app = new App<{ text: string }>()
+ .get("/", () => new Response("ok"))
+ .post("/", () => new Response("ok"));
+
+ const server = new FakeServer(await app.handler());
+
+ let res = await server.put("/");
+ expect(res.status).toEqual(405);
+ expect(await res.text()).toEqual("Method Not Allowed");
+
+ res = await server.post("/");
+ expect(res.status).toEqual(200);
+ expect(await res.text()).toEqual("ok");
+});
+
+Deno.test("FreshApp - methods with middleware", async () => {
+ const app = new App<{ text: string }>()
+ .use((ctx) => {
+ ctx.state.text = "A";
+ return ctx.next();
+ })
+ .get("/", (ctx) => new Response(ctx.state.text))
+ .post("/", (ctx) => new Response(ctx.state.text));
+
+ const server = new FakeServer(await app.handler());
+
+ let res = await server.get("/");
+ expect(await res.text()).toEqual("A");
+
+ res = await server.post("/");
+ expect(await res.text()).toEqual("A");
+});
+
+Deno.test("FreshApp - .mountApp() compose apps", async () => {
+ const innerApp = new App<{ text: string }>()
+ .use((ctx) => {
+ ctx.state.text = "A";
+ return ctx.next();
+ })
+ .get("/", (ctx) => new Response(ctx.state.text))
+ .post("/", (ctx) => new Response(ctx.state.text));
+
+ const app = new App<{ text: string }>()
+ .get("/", () => new Response("ok"))
+ .mountApp("/foo", innerApp);
+
+ const server = new FakeServer(await app.handler());
+
+ let res = await server.get("/");
+ expect(await res.text()).toEqual("ok");
+
+ res = await server.get("/foo");
+ expect(await res.text()).toEqual("A");
+
+ res = await server.post("/foo");
+ expect(await res.text()).toEqual("A");
+});
+
+Deno.test("FreshApp - .mountApp() self mount, no middleware", async () => {
+ const innerApp = new App<{ text: string }>()
+ .use((ctx) => {
+ ctx.state.text = "A";
+ return ctx.next();
+ })
+ .get("/foo", (ctx) => new Response(ctx.state.text))
+ .post("/foo", (ctx) => new Response(ctx.state.text));
+
+ const app = new App<{ text: string }>()
+ .get("/", () => new Response("ok"))
+ .mountApp("/", innerApp);
+
+ const server = new FakeServer(await app.handler());
+
+ let res = await server.get("/");
+ expect(await res.text()).toEqual("ok");
+
+ res = await server.get("/foo");
+ expect(await res.text()).toEqual("A");
+
+ res = await server.post("/foo");
+ expect(await res.text()).toEqual("A");
+});
+
+Deno.test(
+ "FreshApp - .mountApp() self mount, with middleware",
+ async () => {
+ const innerApp = new App<{ text: string }>()
+ .use(function B(ctx) {
+ ctx.state.text += "B";
+ return ctx.next();
+ })
+ .get("/foo", (ctx) => new Response(ctx.state.text))
+ .post("/foo", (ctx) => new Response(ctx.state.text));
+
+ const app = new App<{ text: string }>()
+ .use(function A(ctx) {
+ ctx.state.text = "A";
+ return ctx.next();
+ })
+ .get("/", () => new Response("ok"))
+ .mountApp("/", innerApp);
+
+ const server = new FakeServer(await app.handler());
+
+ let res = await server.get("/");
+ expect(await res.text()).toEqual("ok");
+
+ res = await server.get("/foo");
+ expect(await res.text()).toEqual("AB");
+
+ res = await server.post("/foo");
+ expect(await res.text()).toEqual("AB");
+ },
+);
+
+Deno.test(
+ "FreshApp - .mountApp() self mount, different order",
+ async () => {
+ const innerApp = new App<{ text: string }>()
+ .get("/foo", (ctx) => new Response(ctx.state.text))
+ .use(function B(ctx) {
+ ctx.state.text += "B";
+ return ctx.next();
+ })
+ .post("/foo", (ctx) => new Response(ctx.state.text));
+
+ const app = new App<{ text: string }>()
+ .use(function A(ctx) {
+ ctx.state.text = "A";
+ return ctx.next();
+ })
+ .get("/", () => new Response("ok"))
+ .mountApp("/", innerApp);
+
+ const server = new FakeServer(await app.handler());
+
+ let res = await server.get("/");
+ expect(await res.text()).toEqual("ok");
+
+ res = await server.get("/foo");
+ expect(await res.text()).toEqual("AB");
+
+ res = await server.post("/foo");
+ expect(await res.text()).toEqual("AB");
+ },
+);
+
+Deno.test("FreshApp - .mountApp() self mount empty", async () => {
+ const innerApp = new App<{ text: string }>()
+ .use((ctx) => {
+ ctx.state.text = "A";
+ return ctx.next();
+ })
+ .get("/foo", (ctx) => new Response(ctx.state.text));
+
+ const app = new App<{ text: string }>()
+ .mountApp("/", innerApp);
+
+ const server = new FakeServer(await app.handler());
+
+ const res = await server.get("/foo");
+ expect(await res.text()).toEqual("A");
+});
+
+Deno.test(
+ "FreshApp - .mountApp() self mount with middleware",
+ async () => {
+ const innerApp = new App<{ text: string }>()
+ .use(function Inner(ctx) {
+ ctx.state.text += "_Inner";
+ return ctx.next();
+ })
+ .get("/", (ctx) => new Response(ctx.state.text));
+
+ const app = new App<{ text: string }>()
+ .use(function Outer(ctx) {
+ ctx.state.text = "Outer";
+ return ctx.next();
+ })
+ .mountApp("/", innerApp);
+
+ const server = new FakeServer(await app.handler());
+
+ const res = await server.get("/");
+ expect(await res.text()).toEqual("Outer_Inner");
+ },
+);
+
+Deno.test("FreshApp - catches errors", async () => {
+ let thrownErr: unknown | null = null;
+ const app = new App<{ text: string }>()
+ .use(async (ctx) => {
+ ctx.state.text = "A";
+ try {
+ return await ctx.next();
+ } catch (err) {
+ thrownErr = err;
+ throw err;
+ }
+ })
+ .get("/", () => {
+ throw new Error("fail");
+ });
+
+ const server = new FakeServer(await app.handler());
+
+ const res = await server.get("/");
+ expect(res.status).toEqual(500);
+ expect(thrownErr).toBeInstanceOf(Error);
+});
+
+// TODO: Find a better way to test this
+Deno.test.ignore("FreshApp - 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",
+ },
+ }),
+ );
+
+ const server = new FakeServer(await 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("FreshApp - sets error on context", async () => {
+ const thrown: [unknown, unknown][] = [];
+ const app = new App()
+ .use(async (ctx) => {
+ try {
+ return await ctx.next();
+ } catch (err) {
+ thrown.push([err, ctx.error]);
+ throw err;
+ }
+ })
+ .use(async (ctx) => {
+ try {
+ return await ctx.next();
+ } catch (err) {
+ thrown.push([err, ctx.error]);
+ throw err;
+ }
+ })
+ .get("/", () => {
+ throw "";
+ });
+
+ const server = new FakeServer(await app.handler());
+
+ const res = await server.get("/");
+ await res.body?.cancel();
+ expect(thrown.length).toEqual(2);
+ expect(thrown[0][0]).toEqual(thrown[0][1]);
+ expect(thrown[1][0]).toEqual(thrown[1][1]);
+});
diff --git a/src/build/aot_snapshot.ts b/src/build/aot_snapshot.ts
deleted file mode 100644
index a17d7fea8da..00000000000
--- a/src/build/aot_snapshot.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { ResolvedFreshConfig } from "../server/types.ts";
-import { colors, join } from "../server/deps.ts";
-import type { BuildSnapshot, BuildSnapshotJson } from "./mod.ts";
-import { setBuildId } from "../server/build_id.ts";
-
-export class AotSnapshot implements BuildSnapshot {
- #files: Map;
- #dependencies: Map;
-
- constructor(
- files: Map,
- dependencies: Map,
- ) {
- this.#files = files;
- this.#dependencies = dependencies;
- }
-
- get paths(): string[] {
- return Array.from(this.#files.keys());
- }
-
- async read(path: string): Promise | null> {
- const filePath = this.#files.get(path);
- if (filePath !== undefined) {
- try {
- const file = await Deno.open(filePath, { read: true });
- return file.readable;
- } catch (_err) {
- return null;
- }
- }
-
- // Handler will turn this into a 404
- return null;
- }
-
- dependencies(path: string): string[] {
- return this.#dependencies.get(path) ?? [];
- }
-}
-
-export async function loadAotSnapshot(
- config: ResolvedFreshConfig,
-): Promise {
- const snapshotDirPath = config.build.outDir;
- try {
- if ((await Deno.stat(snapshotDirPath)).isDirectory) {
- console.log(
- `Using snapshot found at ${colors.cyan(snapshotDirPath)}`,
- );
-
- const snapshotPath = join(snapshotDirPath, "snapshot.json");
- const json = JSON.parse(
- await Deno.readTextFile(snapshotPath),
- ) as BuildSnapshotJson;
- setBuildId(json.build_id);
-
- const dependencies = new Map(
- Object.entries(json.files),
- );
-
- const files = new Map();
- Object.keys(json.files).forEach((name) => {
- const filePath = join(snapshotDirPath, name);
- files.set(name, filePath);
- });
-
- return new AotSnapshot(files, dependencies);
- }
- return null;
- } catch (err) {
- if (!(err instanceof Deno.errors.NotFound)) {
- throw err;
- }
- return null;
- }
-}
diff --git a/src/build/deps.ts b/src/build/deps.ts
deleted file mode 100644
index a23100149da..00000000000
--- a/src/build/deps.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-// -- $std --
-export {
- fromFileUrl,
- join,
- relative,
- toFileUrl,
-} from "https://deno.land/std@0.216.0/path/mod.ts";
-export { escape as regexpEscape } from "https://deno.land/std@0.216.0/regexp/escape.ts";
-export { denoPlugins } from "jsr:@luca/esbuild-deno-loader@0.10.3";
-export { assertEquals } from "https://deno.land/std@0.216.0/assert/mod.ts";
diff --git a/src/build/esbuild.ts b/src/build/esbuild.ts
deleted file mode 100644
index 51e36e4b978..00000000000
--- a/src/build/esbuild.ts
+++ /dev/null
@@ -1,217 +0,0 @@
-import {
- type BuildOptions,
- type OnLoadOptions,
- type Plugin,
-} from "https://deno.land/x/esbuild@v0.20.2/mod.js";
-import { denoPlugins, fromFileUrl, regexpEscape, relative } from "./deps.ts";
-import { Builder, BuildSnapshot } from "./mod.ts";
-
-export interface EsbuildBuilderOptions {
- /** The build ID. */
- buildID: string;
- /** The entrypoints, mapped from name to URL. */
- entrypoints: Record;
- /** Whether or not this is a dev build. */
- dev: boolean;
- /** The path to the deno.json / deno.jsonc config file. */
- configPath: string;
- /** The JSX configuration. */
- jsx?: string;
- jsxImportSource?: string;
- target: string | string[];
- absoluteWorkingDir: string;
- basePath?: string;
-}
-
-let esbuild: typeof import("https://deno.land/x/esbuild@v0.20.2/mod.js");
-
-export async function initializeEsbuild() {
- esbuild =
- // deno-lint-ignore no-deprecated-deno-api
- Deno.run === undefined ||
- Deno.env.get("FRESH_ESBUILD_LOADER") === "portable"
- ? await import("https://deno.land/x/esbuild@v0.20.2/wasm.js")
- : await import("https://deno.land/x/esbuild@v0.20.2/mod.js");
- const esbuildWasmURL =
- new URL("./esbuild_v0.20.2.wasm", import.meta.url).href;
-
- // deno-lint-ignore no-deprecated-deno-api
- if (Deno.run === undefined) {
- await esbuild.initialize({
- wasmURL: esbuildWasmURL,
- worker: false,
- });
- } else {
- await esbuild.initialize({});
- }
- return esbuild;
-}
-
-export class EsbuildBuilder implements Builder {
- #options: EsbuildBuilderOptions;
-
- constructor(options: EsbuildBuilderOptions) {
- this.#options = options;
- }
-
- async build(): Promise {
- const opts = this.#options;
-
- // Lazily initialize esbuild
- const esbuild = await initializeEsbuild();
-
- try {
- const absWorkingDir = opts.absoluteWorkingDir;
-
- // In dev-mode we skip identifier minification to be able to show proper
- // component names in Preact DevTools instead of single characters.
- const minifyOptions: Partial = opts.dev
- ? {
- minifyIdentifiers: false,
- minifySyntax: true,
- minifyWhitespace: true,
- }
- : { minify: true };
-
- const bundle = await esbuild.build({
- entryPoints: opts.entrypoints,
-
- platform: "browser",
- target: this.#options.target,
-
- format: "esm",
- bundle: true,
- splitting: true,
- treeShaking: true,
- sourcemap: opts.dev ? "linked" : false,
- ...minifyOptions,
-
- jsx: opts.jsx === "react"
- ? "transform"
- : opts.jsx === "react-native" || opts.jsx === "preserve"
- ? "preserve"
- : !opts.jsxImportSource
- ? "transform"
- : "automatic",
- jsxImportSource: opts.jsxImportSource ?? "preact",
-
- absWorkingDir,
- outdir: ".",
- write: false,
- metafile: true,
-
- plugins: [
- devClientUrlPlugin(opts.basePath),
- buildIdPlugin(opts.buildID),
- ...denoPlugins({ configPath: opts.configPath }),
- ],
- });
-
- const files = new Map();
- const dependencies = new Map();
-
- if (bundle.outputFiles) {
- for (const file of bundle.outputFiles) {
- const path = relative(absWorkingDir, file.path);
- files.set(path, file.contents);
- }
- }
-
- files.set(
- "metafile.json",
- new TextEncoder().encode(JSON.stringify(bundle.metafile)),
- );
-
- if (bundle.metafile) {
- const metaOutputs = new Map(Object.entries(bundle.metafile.outputs));
-
- for (const [path, entry] of metaOutputs.entries()) {
- const imports = entry.imports
- .filter(({ kind }) => kind === "import-statement")
- .map(({ path }) => path);
- dependencies.set(path, imports);
- }
- }
-
- return new EsbuildSnapshot(files, dependencies);
- } finally {
- await esbuild.stop();
- }
- }
-}
-
-function devClientUrlPlugin(basePath?: string): Plugin {
- return {
- name: "dev-client-url",
- setup(build) {
- build.onLoad(
- { filter: /client\.ts$/, namespace: "file" },
- async (args) => {
- // Load the original script
- const contents = await Deno.readTextFile(args.path);
-
- // Replace the URL
- const modifiedContents = contents.replace(
- "/_frsh/alive",
- `${basePath}/_frsh/alive`,
- );
-
- return {
- contents: modifiedContents,
- loader: "ts",
- };
- },
- );
- },
- };
-}
-
-function buildIdPlugin(buildId: string): Plugin {
- const file = import.meta.resolve("../runtime/build_id.ts");
- const url = new URL(file);
- let options: OnLoadOptions;
- if (url.protocol === "file:") {
- const path = fromFileUrl(url);
- const filter = new RegExp(`^${regexpEscape(path)}$`);
- options = { filter, namespace: "file" };
- } else {
- const namespace = url.protocol.slice(0, -1);
- const path = url.href.slice(namespace.length + 1);
- const filter = new RegExp(`^${regexpEscape(path)}$`);
- options = { filter, namespace };
- }
- return {
- name: "fresh-build-id",
- setup(build) {
- build.onLoad(
- options,
- () => ({ contents: `export const BUILD_ID = "${buildId}";` }),
- );
- },
- };
-}
-
-export class EsbuildSnapshot implements BuildSnapshot {
- #files: Map;
- #dependencies: Map;
-
- constructor(
- files: Map,
- dependencies: Map,
- ) {
- this.#files = files;
- this.#dependencies = dependencies;
- }
-
- get paths(): string[] {
- return Array.from(this.#files.keys());
- }
-
- read(path: string): Uint8Array | null {
- return this.#files.get(path) ?? null;
- }
-
- dependencies(path: string): string[] {
- return this.#dependencies.get(path) ?? [];
- }
-}
diff --git a/src/build/esbuild_test.ts b/src/build/esbuild_test.ts
deleted file mode 100644
index 66420e0e25d..00000000000
--- a/src/build/esbuild_test.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { assertEquals } from "./deps.ts";
-import { fromFileUrl, join, toFileUrl } from "../server/deps.ts";
-import { EsbuildBuilder } from "./esbuild.ts";
-
-const denoJson = join(
- fromFileUrl(import.meta.url),
- "..",
- "..",
- "..",
- "deno.json",
-);
-
-const mainEntry = toFileUrl(join(
- fromFileUrl(import.meta.url),
- "..",
- "..",
- "runtime",
- "entrypoints",
- "client.ts",
-)).href;
-
-Deno.test("esbuild", async (t) => {
- await t.step("esbuild snapshot with cwd=Deno.cwd()", async () => {
- const builder = new EsbuildBuilder({
- absoluteWorkingDir: Deno.cwd(),
- buildID: "foo",
- configPath: denoJson,
- dev: false,
- entrypoints: {
- main: mainEntry,
- },
- jsx: "react-jsx",
- target: "es2020",
- });
-
- const snapshot = await builder.build();
- assertEquals(snapshot.paths, ["main.js", "metafile.json"]);
- });
-
- await t.step({
- name: "esbuild snapshot with cwd=/",
- ignore: Deno.build.os === "windows",
- fn: async () => {
- const builder = new EsbuildBuilder({
- absoluteWorkingDir: "/",
- buildID: "foo",
- configPath: denoJson,
- dev: false,
- entrypoints: {
- main: mainEntry,
- },
- jsx: "react-jsx",
- target: "es2020",
- });
-
- const snapshot = await builder.build();
- assertEquals(snapshot.paths, ["main.js", "metafile.json"]);
- },
- });
-});
diff --git a/src/build/esbuild_v0.20.2.wasm b/src/build/esbuild_v0.20.2.wasm
deleted file mode 100644
index 8ff72c225b2..00000000000
Binary files a/src/build/esbuild_v0.20.2.wasm and /dev/null differ
diff --git a/src/build/mod.ts b/src/build/mod.ts
deleted file mode 100644
index 1013ddb5d2b..00000000000
--- a/src/build/mod.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-export {
- EsbuildBuilder,
- type EsbuildBuilderOptions,
- EsbuildSnapshot,
-} from "./esbuild.ts";
-export { AotSnapshot } from "./aot_snapshot.ts";
-export interface Builder {
- build(): Promise;
-}
-
-export interface BuildSnapshot {
- /** The list of files contained in this snapshot, not prefixed by a slash. */
- readonly paths: string[];
-
- /** For a given file, return it's contents.
- * @throws If the file is not contained in this snapshot. */
- read(
- path: string,
- ):
- | ReadableStream
- | Uint8Array
- | null
- | Promise | Uint8Array | null>;
-
- /** For a given entrypoint, return it's list of dependencies.
- *
- * Returns an empty array if the entrypoint does not exist. */
- dependencies(path: string): string[];
-}
-
-export interface BuildSnapshotJson {
- build_id: string;
- files: Record;
-}
diff --git a/src/build_cache.ts b/src/build_cache.ts
new file mode 100644
index 00000000000..0ee1bf09ebe
--- /dev/null
+++ b/src/build_cache.ts
@@ -0,0 +1,110 @@
+import * as path from "@std/path";
+import type { ResolvedFreshConfig } from "./config.ts";
+import { setBuildId } from "./runtime/build_id.ts";
+
+export interface FileSnapshot {
+ generated: boolean;
+ hash: string | null;
+}
+
+export interface BuildSnapshot {
+ version: number;
+ buildId: string;
+ staticFiles: Record;
+ islands: Record;
+}
+
+export interface StaticFile {
+ hash: string | null;
+ size: number;
+ readable: ReadableStream | Uint8Array;
+}
+
+export interface BuildCache {
+ hasSnapshot: boolean;
+ readFile(pathname: string): Promise;
+ getIslandChunkName(islandName: string): string | null;
+}
+
+export class ProdBuildCache implements BuildCache {
+ static async fromSnapshot(config: ResolvedFreshConfig) {
+ const snapshotPath = path.join(config.build.outDir, "snapshot.json");
+
+ const staticFiles = new Map();
+ const islandToChunk = new Map();
+
+ let hasSnapshot = false;
+ try {
+ const content = await Deno.readTextFile(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]);
+ }
+ } catch (err) {
+ if (!(err instanceof Deno.errors.NotFound)) {
+ throw err;
+ }
+ }
+
+ return new ProdBuildCache(config, islandToChunk, staticFiles, hasSnapshot);
+ }
+
+ #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;
+ }
+
+ async readFile(pathname: string): Promise {
+ const info = this.#fileInfo.get(pathname);
+ if (info === undefined) return null;
+
+ const base = info.generated
+ ? this.#config.build.outDir
+ : this.#config.staticDir;
+ const filePath = info.generated
+ ? path.join(base, "static", pathname)
+ : path.join(base, pathname);
+
+ // Check if path resolves outside of intended directory.
+ if (path.relative(base, filePath).startsWith(".")) {
+ return null;
+ }
+
+ const [stat, file] = await Promise.all([
+ Deno.stat(filePath),
+ Deno.open(filePath),
+ ]);
+
+ return {
+ hash: info.hash,
+ size: stat.size,
+ readable: file.readable,
+ };
+ }
+
+ getIslandChunkName(islandName: string): string | null {
+ return this.#islands.get(islandName) ?? null;
+ }
+}
diff --git a/src/compat/server.ts b/src/compat/server.ts
new file mode 100644
index 00000000000..b0757ad1a42
--- /dev/null
+++ b/src/compat/server.ts
@@ -0,0 +1,59 @@
+import type { VNode } from "preact";
+import type { FreshContext } from "../context.ts";
+import type { HandlerByMethod } from "../handlers.ts";
+
+/**
+ * @deprecated Use {@link FreshContext} instead
+ */
+export type AppProps = FreshContext;
+/**
+ * @deprecated Use {@link FreshContext} instead
+ */
+export type LayoutProps = FreshContext;
+/**
+ * @deprecated Use {@link FreshContext} instead
+ */
+export type UnknownPageProps = FreshContext<
+ Data,
+ T
+>;
+/**
+ * @deprecated Use {@link FreshContext} instead
+ */
+export type ErrorPageProps = FreshContext;
+
+/**
+ * @deprecated Use {@link FreshContext} instead
+ */
+// deno-lint-ignore no-explicit-any
+export type RouteContext> = FreshContext<
+ T,
+ S
+>;
+
+// deno-lint-ignore no-explicit-any
+export type Handlers> =
+ HandlerByMethod;
+
+function defineFn(
+ fn: (
+ ctx: FreshContext,
+ ) => Request | VNode | null | Promise,
+): (
+ ctx: FreshContext,
+) => Request | VNode | null | Promise {
+ return fn;
+}
+
+/**
+ * @deprecated Use {@link definePage} instead
+ */
+export const defineApp = defineFn;
+/**
+ * @deprecated Use {@link definePage} instead
+ */
+export const defineRoute = defineFn;
+/**
+ * @deprecated Use {@link definePage} instead
+ */
+export const defineLayout = defineFn;
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 00000000000..399e00bad43
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,74 @@
+import * as path from "@std/path";
+import { MODE, type Mode } from "./runtime/server/mod.tsx";
+
+export interface FreshPlugin {
+ name: string;
+}
+
+export interface FreshConfig {
+ 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.
+ */
+ outDir?: string;
+ };
+ /**
+ * Serve fresh from a base path instead of from the root.
+ * "/foo/bar" -> http://localhost:8000/foo/bar
+ * @default {undefined}
+ */
+ basePath?: string;
+ staticDir?: string;
+}
+
+/**
+ * The final resolved Fresh configuration where fields the user didn't specify are set to the default values.
+ */
+export interface ResolvedFreshConfig {
+ root: string;
+ build: {
+ outDir: string;
+ };
+ basePath: string;
+ staticDir: string;
+ /**
+ * Tells you in which mode Fresh is currently running in.
+ */
+ mode: Mode;
+}
+
+export function parseRootPath(root: string): string {
+ if (root.startsWith("file://")) {
+ root = path.fromFileUrl(root);
+ }
+
+ const ext = path.extname(root);
+ if (
+ ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx" ||
+ ext === ".mjs"
+ ) {
+ root = path.dirname(root);
+ }
+
+ return root;
+}
+
+export function normalizeConfig(options: FreshConfig): ResolvedFreshConfig {
+ const root = options.root ? parseRootPath(options.root) : Deno.cwd();
+
+ return {
+ root,
+ build: {
+ outDir: options.build?.outDir ?? path.join(root, "_fresh"),
+ },
+ basePath: options.basePath ?? "",
+ staticDir: options.staticDir ?? path.join(root, "static"),
+ mode: MODE,
+ };
+}
+
+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
new file mode 100644
index 00000000000..1233c3d7897
--- /dev/null
+++ b/src/config_test.ts
@@ -0,0 +1,14 @@
+import { expect } from "@std/expect";
+import { parseRootPath } from "./config.ts";
+
+// FIXME: Windows
+Deno.test.ignore("parseRootPath", () => {
+ expect(parseRootPath("file:///foo/bar")).toEqual("/foo/bar");
+ expect(parseRootPath("file:///foo/bar.ts")).toEqual("/foo");
+ expect(parseRootPath("/foo/bar")).toEqual("/foo/bar");
+ expect(parseRootPath("/foo/bar.ts")).toEqual("/foo");
+ expect(parseRootPath("/foo/bar.tsx")).toEqual("/foo");
+ expect(parseRootPath("/foo/bar.js")).toEqual("/foo");
+ expect(parseRootPath("/foo/bar.jsx")).toEqual("/foo");
+ expect(parseRootPath("/foo/bar.mjs")).toEqual("/foo");
+});
diff --git a/src/constants.ts b/src/constants.ts
index ae57771bd58..e075f235df0 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -1,13 +1,5 @@
-export const PARTIAL_SEARCH_PARAM = "fresh-partial";
-export const PARTIAL_ATTR = "f-partial";
-export const LOADING_ATTR = "f-loading";
-export const CLIENT_NAV_ATTR = "f-client-nav";
-export const DATA_KEY_ATTR = "data-fresh-key";
-export const DATA_CURRENT = "data-current";
-export const DATA_ANCESTOR = "data-ancestor";
-
-export const enum PartialMode {
- REPLACE,
- APPEND,
- PREPEND,
-}
+export const INTERNAL_PREFIX = "/_frsh";
+export const DEV_CLIENT_URL = `${INTERNAL_PREFIX}/fresh_dev_client.js`;
+export const DEV_ERROR_OVERLAY_URL = `${INTERNAL_PREFIX}/error_overlay`;
+export const ALIVE_URL = `${INTERNAL_PREFIX}/alive`;
+export const JS_PREFIX = `/js`;
diff --git a/src/context.ts b/src/context.ts
new file mode 100644
index 00000000000..5c4fb5bebf5
--- /dev/null
+++ b/src/context.ts
@@ -0,0 +1,203 @@
+import { type ComponentType, h, isValidElement, type VNode } from "preact";
+import type { ResolvedFreshConfig } from "./config.ts";
+import { renderToString } from "preact-render-to-string";
+import type { BuildCache } from "./build_cache.ts";
+import {
+ FreshScripts,
+ RenderState,
+ setRenderState,
+} from "./runtime/server/preact_hooks.tsx";
+import { DEV_ERROR_OVERLAY_URL } from "./constants.ts";
+
+export interface Island {
+ file: string | URL;
+ name: string;
+ exportName: string;
+ fn: ComponentType;
+}
+
+export type ServerIslandRegistry = Map;
+
+/**
+ * The context passed to every middleware. It is unique for every request.
+ */
+export interface FreshContext {
+ /** Reference to the resolved Fresh configuration */
+ readonly config: ResolvedFreshConfig;
+ state: State;
+ data: Data;
+ /** The original incoming `Request` object` */
+ req: Request;
+ /**
+ * The request url parsed into an `URL` instance. This is typically used
+ * to apply logic based on the pathname of the incoming url or when
+ * certain search parameters are set.
+ */
+ url: URL;
+ params: Record;
+ error: unknown;
+ info?: Deno.ServeHandlerInfo | Deno.ServeUnixHandlerInfo;
+ /**
+ * Return a redirect response to the specified path. This is the
+ * preferred way to do redirects in Fresh.
+ *
+ * ```ts
+ * ctx.redirect("/foo/bar") // redirect user to "/foo/bar"
+ *
+ * // Disallows protocol relative URLs for improved security. This
+ * // redirects the user to `/evil.com` which is safe,
+ * // instead of redirecting to `http://evil.com`.
+ * ctx.redirect("//evil.com/");
+ * ```
+ */
+ redirect(path: string, status?: number): Response;
+ /**
+ * Call the next middleware.
+ * ```ts
+ * const myMiddleware: Middleware = (ctx) => {
+ * // do something
+ *
+ * // Call the next middleware
+ * return ctx.next();
+ * }
+ *
+ * const myMiddleware2: Middleware = async (ctx) => {
+ * // do something before the next middleware
+ * doSomething()
+ *
+ * const res = await ctx.next();
+ *
+ * // do something after the middleware
+ * doSomethingAfter()
+ *
+ * // Return the `Response`
+ * return res
+ * }
+ */
+ next(): Promise;
+ render(vnode: VNode, init?: ResponseInit): Response | Promise;
+}
+
+export let getBuildCache: (ctx: FreshContext) => BuildCache;
+
+export class FreshReqContext implements FreshContext {
+ url: URL;
+ params = {} as Record;
+ state = {} as State;
+ data = {} as never;
+ error: Error | null = null;
+ #islandRegistry: ServerIslandRegistry;
+ #buildCache: BuildCache;
+
+ static {
+ getBuildCache = (ctx) => (ctx as FreshReqContext).#buildCache;
+ }
+
+ constructor(
+ public req: Request,
+ public config: ResolvedFreshConfig,
+ public next: FreshContext["next"],
+ islandRegistry: ServerIslandRegistry,
+ buildCache: BuildCache,
+ public info: Deno.ServeHandlerInfo | Deno.ServeUnixHandlerInfo,
+ ) {
+ this.#islandRegistry = islandRegistry;
+ this.#buildCache = buildCache;
+ this.url = new URL(req.url);
+ }
+
+ redirect(pathOrUrl: string, status = 302): Response {
+ let location = pathOrUrl;
+
+ // Disallow protocol relative URLs
+ if (pathOrUrl !== "/" && pathOrUrl.startsWith("/")) {
+ let idx = pathOrUrl.indexOf("?");
+ if (idx === -1) {
+ idx = pathOrUrl.indexOf("#");
+ }
+
+ const pathname = idx > -1 ? pathOrUrl.slice(0, idx) : pathOrUrl;
+ const search = idx > -1 ? pathOrUrl.slice(idx) : "";
+
+ // Remove double slashes to prevent open redirect vulnerability.
+ location = `${pathname.replaceAll(/\/+/g, "/")}${search}`;
+ }
+
+ return new Response(null, {
+ status,
+ headers: {
+ location,
+ },
+ });
+ }
+
+ render(
+ // deno-lint-ignore no-explicit-any
+ vnode: VNode,
+ init: ResponseInit | undefined = {},
+ ): Response | Promise {
+ if (arguments.length === 0) {
+ throw new Error(`No arguments passed to: ctx.render()`);
+ } else if (vnode !== null && !isValidElement(vnode)) {
+ throw new Error(`Non-JSX element passed to: ctx.render()`);
+ }
+
+ const headers = init.headers !== undefined
+ ? init.headers instanceof Headers
+ ? init.headers
+ : new Headers(init.headers)
+ : new Headers();
+
+ headers.set("Content-Type", "text/html; charset=utf-8");
+ const responseInit: ResponseInit = { status: init.status ?? 200, headers };
+
+ let partialId = "";
+ if (this.url.searchParams.has("fresh-partial")) {
+ partialId = crypto.randomUUID();
+ headers.set("X-Fresh-Id", partialId);
+ }
+
+ const html = preactRender(
+ vnode,
+ this,
+ this.#islandRegistry,
+ this.#buildCache,
+ partialId,
+ );
+ return new Response(html, responseInit);
+ }
+}
+
+function preactRender(
+ vnode: VNode,
+ ctx: FreshContext,
+ islandRegistry: ServerIslandRegistry,
+ buildCache: BuildCache,
+ partialId: string,
+) {
+ const state = new RenderState(ctx, islandRegistry, buildCache, partialId);
+ setRenderState(state);
+ try {
+ let res = renderToString(vnode);
+ // We require a the full outer DOM structure so that browser put
+ // comment markers in the right place in the DOM.
+ if (!state.renderedHtmlBody) {
+ let scripts = "";
+ if (ctx.url.pathname !== ctx.config.basePath + DEV_ERROR_OVERLAY_URL) {
+ scripts = renderToString(h(FreshScripts, null));
+ }
+ res = `${res}${scripts}`;
+ }
+ if (!state.renderedHtmlHead) {
+ res = ` ${res}`;
+ }
+ if (!state.renderedHtmlTag) {
+ res = `${res}`;
+ }
+
+ return `${res}`;
+ } finally {
+ state.clear();
+ setRenderState(null);
+ }
+}
diff --git a/src/context_test.tsx b/src/context_test.tsx
new file mode 100644
index 00000000000..773f7ddf4de
--- /dev/null
+++ b/src/context_test.tsx
@@ -0,0 +1,71 @@
+import { expect } from "@std/expect";
+import { FreshReqContext } from "./context.ts";
+import { App } from "@fresh/core";
+import { asset } from "@fresh/core/runtime";
+import { FakeServer } from "./test_utils.ts";
+import { BUILD_ID } from "./runtime/build_id.ts";
+import { parseHtml } from "../tests/test_utils.tsx";
+
+Deno.test("FreshReqContext.prototype.redirect", () => {
+ let res = FreshReqContext.prototype.redirect("/");
+ expect(res.status).toEqual(302);
+ expect(res.headers.get("Location")).toEqual("/");
+
+ res = FreshReqContext.prototype.redirect("//evil.com");
+ expect(res.status).toEqual(302);
+ expect(res.headers.get("Location")).toEqual("/evil.com");
+
+ res = FreshReqContext.prototype.redirect("//evil.com/foo//bar");
+ expect(res.status).toEqual(302);
+ expect(res.headers.get("Location")).toEqual("/evil.com/foo/bar");
+
+ res = FreshReqContext.prototype.redirect("https://deno.com");
+ expect(res.status).toEqual(302);
+ expect(res.headers.get("Location")).toEqual("https://deno.com");
+
+ res = FreshReqContext.prototype.redirect("/", 307);
+ expect(res.status).toEqual(307);
+});
+
+Deno.test("render asset()", async () => {
+ const app = new App()
+ .get("/", (ctx) =>
+ ctx.render(
+ <>
+ {asset("/foo")}
+
+
+ >,
+ ));
+
+ const server = new FakeServer(await app.handler());
+ const res = await server.get("/");
+ const doc = parseHtml(await res.text());
+
+ expect(doc.querySelector(".raw")!.textContent).toContain(BUILD_ID);
+ expect(doc.querySelector("img")!.src).toContain(BUILD_ID);
+ expect(doc.querySelector("img")!.srcset).toContain(BUILD_ID);
+ expect(doc.querySelector("source")!.src).toContain(BUILD_ID);
+});
+
+Deno.test("ctx.render - throw with no arguments", async () => {
+ const app = new App()
+ // deno-lint-ignore no-explicit-any
+ .get("/", (ctx) => (ctx as any).render());
+ const server = new FakeServer(await app.handler());
+ const res = await server.get("/");
+
+ await res.body?.cancel();
+ expect(res.status).toEqual(500);
+});
+
+Deno.test("ctx.render - throw with invalid first arg", async () => {
+ const app = new App()
+ // deno-lint-ignore no-explicit-any
+ .get("/", (ctx) => (ctx as any).render({}));
+ const server = new FakeServer(await app.handler());
+ const res = await server.get("/");
+
+ await res.body?.cancel();
+ expect(res.status).toEqual(500);
+});
diff --git a/src/dev/build.ts b/src/dev/build.ts
deleted file mode 100644
index 32d4a56ef7d..00000000000
--- a/src/dev/build.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { getServerContext } from "../server/context.ts";
-import { join } from "../server/deps.ts";
-import { colors, emptyDir } from "./deps.ts";
-import { BuildSnapshotJson } from "../build/mod.ts";
-import { BUILD_ID } from "../server/build_id.ts";
-import { InternalFreshState } from "../server/types.ts";
-
-export async function build(
- state: InternalFreshState,
-) {
- const outDir = state.config.build.outDir;
- const plugins = state.config.plugins;
-
- // Ensure that build dir is empty
- await emptyDir(outDir);
-
- // Create a directory for static assets produced during the build
- await Deno.mkdir(join(outDir, "static"));
-
- await Promise.all(
- plugins.map((plugin) => plugin.configResolved?.(state.config)),
- );
- await Promise.all(plugins.map((plugin) => plugin.buildStart?.(state.config)));
-
- // Bundle assets
- const ctx = await getServerContext(state);
- const snapshot = await ctx.buildSnapshot();
-
- // Write output files to disk
- await Promise.all(snapshot.paths.map(async (fileName) => {
- const data = await snapshot.read(fileName);
- if (data === null) return;
-
- return Deno.writeFile(join(outDir, fileName), data);
- }));
-
- // Write dependency snapshot file to disk
- const jsonSnapshot: BuildSnapshotJson = {
- build_id: BUILD_ID,
- files: {},
- };
- for (const filePath of snapshot.paths) {
- const dependencies = snapshot.dependencies(filePath);
- jsonSnapshot.files[filePath] = dependencies;
- }
-
- const snapshotPath = join(outDir, "snapshot.json");
- await Deno.writeTextFile(snapshotPath, JSON.stringify(jsonSnapshot, null, 2));
-
- console.log(
- `Assets written to: ${colors.green(outDir)}`,
- );
-
- await Promise.all(plugins.map((plugin) => plugin.buildEnd?.()));
-}
diff --git a/src/dev/builder.ts b/src/dev/builder.ts
new file mode 100644
index 00000000000..9a59a670472
--- /dev/null
+++ b/src/dev/builder.ts
@@ -0,0 +1,250 @@
+import {
+ App,
+ getBuildCache,
+ getIslandRegistry,
+ 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,
+ 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 { 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";
+
+export interface BuildOptions {
+ /**
+ * This sets the target environment for the generated code. Newer
+ * language constructs will be transformed to match the specified
+ * support range. See https://esbuild.github.io/api/#target
+ * @default {"es2022"}
+ */
+ target?: string | string[];
+}
+
+export interface FreshBuilder {
+ onTransformStaticFile(
+ options: OnTransformOptions,
+ callback: TransformFn,
+ ): void;
+ build(app: App, options?: BuildOptions): Promise;
+ listen(app: App, options?: ListenOptions & BuildOptions): Promise;
+}
+
+export class Builder implements FreshBuilder {
+ #transformer = new FreshFileTransformer(fsAdapter);
+ #addedInternalTransforms = false;
+ #options: { target: string | string[] };
+
+ constructor(options: BuildOptions = {}) {
+ this.#options = {
+ target: options.target ?? ["chrome99", "firefox99", "safari15"],
+ };
+ }
+
+ onTransformStaticFile(
+ options: OnTransformOptions,
+ callback: TransformFn,
+ ): void {
+ this.#transformer.onTransform(options, callback);
+ }
+
+ async listen(app: App, options: ListenOptions = {}): Promise {
+ // Run update check in background
+ updateCheck(DAY).catch(() => {});
+
+ const devApp = new App(app.config)
+ .use(liveReload())
+ .use(devErrorOverlay())
+ .mountApp("*", app);
+
+ setBuildCache(
+ devApp,
+ new MemoryBuildCache(
+ devApp.config,
+ BUILD_ID,
+ this.#transformer,
+ this.#options.target,
+ ),
+ );
+
+ await Promise.all([
+ devApp.listen(options),
+ this.#build(devApp, true),
+ ]);
+ return;
+ }
+
+ async build(app: App): Promise {
+ setBuildCache(
+ app,
+ new DiskBuildCache(
+ app.config,
+ BUILD_ID,
+ this.#transformer,
+ this.#options.target,
+ ),
+ );
+ return await this.#build(app, false);
+ }
+
+ async #build(app: App, dev: boolean): Promise {
+ const { build } = app.config;
+ const staticOutDir = path.join(build.outDir, "static");
+
+ 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 entryPoints: Record = {
+ "fresh-runtime": dev ? "@fresh/core/client-dev" : "@fresh/core/client",
+ };
+ 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 readDenoConfig(app.config.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(", ")
+ }`,
+ );
+ }
+ }
+
+ const output = await bundleJs({
+ cwd: app.config.root,
+ outDir: staticOutDir,
+ dev: dev ?? false,
+ target,
+ buildId: BUILD_ID,
+ entryPoints,
+ jsxImportSource,
+ denoJsonPath: denoJson.filePath,
+ });
+
+ for (let i = 0; i < output.files.length; i++) {
+ const file = output.files[i];
+ const pathname = `/${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, `/${chunk}`);
+ }
+
+ await buildCache.flush();
+
+ if (!dev) {
+ console.log(
+ `Assets written to: ${colors.cyan(build.outDir)}`,
+ );
+ }
+ }
+}
+
+export interface DenoConfig {
+ imports?: Record;
+ importMap?: string;
+ tasks?: Record;
+ lint?: {
+ rules: { tags?: string[] };
+ exclude?: string[];
+ };
+ fmt?: {
+ exclude?: string[];
+ };
+ exclude?: string[];
+ compilerOptions?: {
+ jsx?: string;
+ jsxImportSource?: string;
+ jsxPrecompileSkipElements?: string[];
+ };
+}
+
+export async function readDenoConfig(
+ 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);
+ if (name.endsWith(".jsonc")) {
+ return { config: JSONC.parse(file) as DenoConfig, filePath };
+ } else {
+ return { config: JSON.parse(file), filePath };
+ }
+ } catch (err) {
+ if (!(err instanceof Deno.errors.NotFound)) {
+ throw err;
+ }
+ }
+ }
+ const parent = path.dirname(dir);
+ if (parent === dir) {
+ throw new Error(
+ `Could not find a deno.json file in the current directory or any parent directory.`,
+ );
+ }
+ dir = parent;
+ }
+}
diff --git a/src/dev/builder_test.ts b/src/dev/builder_test.ts
new file mode 100644
index 00000000000..249be227e86
--- /dev/null
+++ b/src/dev/builder_test.ts
@@ -0,0 +1,65 @@
+import { expect } from "@std/expect";
+import * as path from "@std/path";
+import { Builder } from "./builder.ts";
+import { App } from "../app.ts";
+
+Deno.test("Builder - chain onTransformStaticFile", async () => {
+ const logs: string[] = [];
+ const builder = new Builder();
+ builder.onTransformStaticFile(
+ { pluginName: "A", filter: /\.css$/ },
+ () => {
+ logs.push("A");
+ },
+ );
+ builder.onTransformStaticFile(
+ { pluginName: "B", filter: /\.css$/ },
+ () => {
+ logs.push("B");
+ },
+ );
+ builder.onTransformStaticFile(
+ { pluginName: "C", filter: /\.css$/ },
+ () => {
+ logs.push("C");
+ },
+ );
+
+ const tmp = await Deno.makeTempDir();
+ 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);
+
+ expect(logs).toEqual(["A", "B", "C"]);
+});
+
+Deno.test({
+ name: "Builder - hashes CSS urls by default",
+ fn: async () => {
+ const builder = new Builder();
+ const tmp = await Deno.makeTempDir();
+ 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);
+
+ const css = await Deno.readTextFile(
+ path.join(tmp, "dist", "static", "foo.css"),
+ );
+ expect(css).toContain('body { background: url("/foo.jpg?__frsh_c=');
+ },
+ sanitizeOps: false,
+ sanitizeResources: false,
+});
diff --git a/src/dev/cli.ts b/src/dev/cli.ts
deleted file mode 100644
index e00352deca0..00000000000
--- a/src/dev/cli.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { join, toFileUrl } from "./deps.ts";
-import { manifest } from "./mod.ts";
-import { type FreshConfig } from "../server/mod.ts";
-
-const args = Deno.args;
-
-switch (args[0]) {
- case "manifest": {
- if (args[1]) {
- const CONFIG_TS_PATH = join(args[1], "fresh.config.ts");
- const url = toFileUrl(CONFIG_TS_PATH).toString();
- const config: FreshConfig = (await import(url)).default;
- await manifest(args[1], config?.router?.ignoreFilePattern);
- } else {
- console.error("Missing input for manifest command");
- Deno.exit(1);
- }
- break;
- }
- default: {
- console.error("Invalid command");
- Deno.exit(1);
- }
-}
diff --git a/src/dev/deps.ts b/src/dev/deps.ts
deleted file mode 100644
index 707aad328e8..00000000000
--- a/src/dev/deps.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-// std
-export {
- basename,
- dirname,
- extname,
- fromFileUrl,
- join,
- relative,
- resolve,
- SEPARATOR,
- toFileUrl,
-} from "https://deno.land/std@0.216.0/path/mod.ts";
-export { normalize } from "https://deno.land/std@0.216.0/path/posix/mod.ts";
-export { DAY, WEEK } from "https://deno.land/std@0.216.0/datetime/constants.ts";
-export * as colors from "https://deno.land/std@0.216.0/fmt/colors.ts";
-export {
- walk,
- type WalkEntry,
- WalkError,
-} from "https://deno.land/std@0.216.0/fs/walk.ts";
-export { parse } from "https://deno.land/std@0.216.0/flags/mod.ts";
-export {
- greaterOrEqual,
- lessThan,
- parse as semverParse,
-} from "https://deno.land/std@0.216.0/semver/mod.ts";
-export { emptyDir, existsSync } from "https://deno.land/std@0.216.0/fs/mod.ts";
-export * as JSONC from "https://deno.land/std@0.216.0/jsonc/mod.ts";
-export { assertEquals } from "https://deno.land/std@0.216.0/assert/mod.ts";
-
-// ts-morph
-export { Node, Project } from "https://deno.land/x/ts_morph@21.0.1/mod.ts";
diff --git a/src/dev/dev_build_cache.ts b/src/dev/dev_build_cache.ts
new file mode 100644
index 00000000000..5f200683848
--- /dev/null
+++ b/src/dev/dev_build_cache.ts
@@ -0,0 +1,292 @@
+import type { BuildCache, StaticFile } from "../build_cache.ts";
+import * as path from "@std/path";
+import 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";
+
+export interface MemoryFile {
+ hash: string | null;
+ content: Uint8Array;
+}
+
+export interface DevBuildCache extends BuildCache {
+ islands: Map;
+
+ addUnprocessedFile(pathname: string): void;
+
+ addProcessedFile(
+ pathname: string,
+ content: Uint8Array,
+ hash: string | null,
+ ): Promise;
+
+ flush(): Promise;
+}
+
+export class MemoryBuildCache implements DevBuildCache {
+ hasSnapshot = true;
+ islands = new Map();
+ #processedFiles = new Map();
+ #unprocessedFiles = new Map();
+ #ready = Promise.withResolvers();
+
+ constructor(
+ public config: ResolvedFreshConfig,
+ public buildId: string,
+ public transformer: FreshFileTransformer,
+ public target: string | string[],
+ ) {
+ }
+
+ async readFile(pathname: string): Promise {
+ await this.#ready.promise;
+ const processed = this.#processedFiles.get(pathname);
+ if (processed !== undefined) {
+ return {
+ hash: processed.hash,
+ readable: processed.content,
+ size: processed.content.byteLength,
+ };
+ }
+
+ const unprocessed = this.#unprocessedFiles.get(pathname);
+ if (unprocessed !== undefined) {
+ try {
+ const [stat, file] = await Promise.all([
+ Deno.stat(unprocessed),
+ Deno.open(unprocessed, { read: true }),
+ ]);
+
+ return {
+ hash: null,
+ size: stat.size,
+ readable: file.readable,
+ };
+ } catch (_err) {
+ 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);
+ if (relative.startsWith(".")) {
+ throw new Error(
+ `Processed file resolved outside of static dir ${entry}`,
+ );
+ }
+
+ // Might be a file that we still need to process
+ const transformed = await this.transformer.process(
+ entry,
+ "development",
+ this.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);
+ if (relative.startsWith(".")) {
+ throw new Error(
+ `Processed file resolved outside of static dir ${file.path}`,
+ );
+ }
+ const pathname = `/${relative}`;
+
+ this.addProcessedFile(pathname, file.content, null);
+ }
+ if (this.#processedFiles.has(pathname)) {
+ return this.readFile(pathname);
+ }
+ } else {
+ try {
+ 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);
+ return this.readFile(pathname);
+ }
+ } catch (err) {
+ if (!(err instanceof Deno.errors.NotFound)) {
+ throw err;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ getIslandChunkName(islandName: string): string | null {
+ return this.islands.get(islandName) ?? null;
+ }
+
+ addUnprocessedFile(pathname: string): void {
+ this.#unprocessedFiles.set(
+ pathname,
+ path.join(this.config.staticDir, pathname),
+ );
+ }
+
+ // deno-lint-ignore require-await
+ async addProcessedFile(
+ pathname: string,
+ content: Uint8Array,
+ hash: string | null,
+ ): Promise {
+ this.#processedFiles.set(pathname, { content, hash });
+ }
+
+ // deno-lint-ignore require-await
+ async flush(): Promise {
+ this.#ready.resolve();
+ }
+}
+
+// await fsAdapter.mkdirp(staticOutDir);
+export class DiskBuildCache implements DevBuildCache {
+ hasSnapshot = true;
+ islands = new Map();
+ #processedFiles = new Map();
+ #unprocessedFiles = new Map();
+ #transformer: FreshFileTransformer;
+ #target: string | string[];
+
+ constructor(
+ public config: ResolvedFreshConfig,
+ public buildId: string,
+ transformer: FreshFileTransformer,
+ target: string | string[],
+ ) {
+ this.#transformer = transformer;
+ this.#target = target;
+ }
+
+ getIslandChunkName(islandName: string): string | null {
+ return this.islands.get(islandName) ?? null;
+ }
+
+ addUnprocessedFile(pathname: string): void {
+ this.#unprocessedFiles.set(
+ pathname,
+ path.join(this.config.staticDir, pathname),
+ );
+ }
+
+ async addProcessedFile(
+ pathname: string,
+ content: Uint8Array,
+ hash: string | null,
+ ) {
+ this.#processedFiles.set(pathname, hash);
+
+ const outDir = pathname === "/metafile.json"
+ ? this.config.build.outDir
+ : path.join(this.config.build.outDir, "static");
+ const filePath = path.join(outDir, pathname);
+ assertInDir(filePath, outDir);
+
+ await fsAdapter.mkdirp(path.dirname(filePath));
+ await Deno.writeFile(filePath, content);
+ }
+
+ // deno-lint-ignore require-await
+ async readFile(_pathname: string): Promise {
+ throw new Error("Not implemented in build mode");
+ }
+
+ async flush(): Promise {
+ const staticDir = this.config.staticDir;
+
+ if (await fsAdapter.isDirectory(staticDir)) {
+ const entries = fsAdapter.walk(staticDir, {
+ includeDirs: false,
+ includeFiles: true,
+ followSymlinks: false,
+ // Skip any folder or file starting with a "."
+ skip: [/\/\.[^/]+(\/|$)/],
+ });
+
+ for await (const entry of entries) {
+ const result = await this.#transformer.process(
+ entry.path,
+ "production",
+ this.#target,
+ );
+
+ if (result !== null) {
+ for (let i = 0; i < result.length; i++) {
+ const file = result[i];
+ assertInDir(file.path, staticDir);
+ const pathname = `/${path.relative(staticDir, file.path)}`;
+ await this.addProcessedFile(pathname, file.content, null);
+ }
+ } else {
+ const relative = path.relative(staticDir, entry.path);
+ const pathname = `/${relative}`;
+ this.addUnprocessedFile(pathname);
+ }
+ }
+ }
+
+ const snapshot: BuildSnapshot = {
+ version: 1,
+ buildId: this.buildId,
+ islands: {},
+ staticFiles: {},
+ };
+
+ for (const [name, chunk] of this.islands.entries()) {
+ snapshot.islands[name] = chunk;
+ }
+
+ for (const [name, filePath] of this.#unprocessedFiles.entries()) {
+ const file = await Deno.open(filePath);
+ const hash = await hashContent(file.readable);
+
+ snapshot.staticFiles[name] = {
+ hash,
+ generated: false,
+ };
+ }
+
+ for (const [name, maybeHash] of this.#processedFiles.entries()) {
+ let hash = maybeHash;
+
+ // Ignore esbuild meta file. It's not intended for serving
+ if (name === "/metafile.json") {
+ continue;
+ }
+
+ 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] = {
+ hash,
+ generated: true,
+ };
+ }
+
+ await Deno.writeTextFile(
+ path.join(this.config.build.outDir, "snapshot.json"),
+ JSON.stringify(snapshot, null, 2),
+ );
+ }
+}
+
+async function hashContent(
+ content: Uint8Array | ReadableStream,
+): Promise {
+ const hashBuf = await crypto.subtle.digest(
+ "SHA-256",
+ content,
+ );
+ return encodeHex(hashBuf);
+}
diff --git a/src/dev/dev_command.ts b/src/dev/dev_command.ts
deleted file mode 100644
index 591dd2b3df1..00000000000
--- a/src/dev/dev_command.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { updateCheck } from "./update_check.ts";
-import { DAY, dirname, fromFileUrl, join, toFileUrl } from "./deps.ts";
-import { FreshConfig, Manifest as ServerManifest } from "../server/mod.ts";
-import { build } from "./build.ts";
-import { collect, ensureMinDenoVersion, generate, Manifest } from "./mod.ts";
-import { startServer } from "../server/boot.ts";
-import { getInternalFreshState } from "../server/config.ts";
-import { getServerContext } from "../server/context.ts";
-
-export async function dev(
- base: string,
- entrypoint: string,
- config?: FreshConfig,
-) {
- ensureMinDenoVersion();
-
- // Run update check in background
- updateCheck(DAY).catch(() => {});
-
- const dir = dirname(fromFileUrl(base));
-
- let currentManifest: Manifest;
- const prevManifest = Deno.env.get("FRSH_DEV_PREVIOUS_MANIFEST");
- if (prevManifest) {
- currentManifest = JSON.parse(prevManifest);
- } else {
- currentManifest = { islands: [], routes: [] };
- }
- const newManifest = await collect(dir, config?.router?.ignoreFilePattern);
- Deno.env.set("FRSH_DEV_PREVIOUS_MANIFEST", JSON.stringify(newManifest));
-
- const manifestChanged =
- !arraysEqual(newManifest.routes, currentManifest.routes) ||
- !arraysEqual(newManifest.islands, currentManifest.islands);
-
- if (manifestChanged) await generate(dir, newManifest);
-
- const manifest = (await import(toFileUrl(join(dir, "fresh.gen.ts")).href))
- .default as ServerManifest;
-
- if (Deno.args.includes("build")) {
- const state = await getInternalFreshState(
- manifest,
- config ?? {},
- );
- state.config.dev = false;
- state.loadSnapshot = false;
- state.build = true;
- await build(state);
- } else if (config) {
- const state = await getInternalFreshState(
- manifest,
- config,
- );
- state.config.dev = true;
- state.loadSnapshot = false;
- const ctx = await getServerContext(state);
- await startServer(ctx.handler(), {
- ...state.config.server,
- basePath: state.config.basePath,
- });
- } else {
- // Legacy entry point: Back then `dev.ts` would call `main.ts` but
- // this causes duplicate plugin instantiation if both `dev.ts` and
- // `main.ts` instantiate plugins.
- Deno.env.set("__FRSH_LEGACY_DEV", "true");
- entrypoint = new URL(entrypoint, base).href;
- await import(entrypoint);
- }
-}
-
-function arraysEqual(a: T[], b: T[]): boolean {
- if (a.length !== b.length) return false;
- for (let i = 0; i < a.length; ++i) {
- if (a[i] !== b[i]) return false;
- }
- return true;
-}
diff --git a/src/dev/error.ts b/src/dev/error.ts
deleted file mode 100644
index 7f7d72243ee..00000000000
--- a/src/dev/error.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export function printError(message: string) {
- console.error(`%cerror%c: ${message}`, "color: red; font-weight: bold", "");
-}
-
-export function error(message: string): never {
- printError(message);
- Deno.exit(1);
-}
diff --git a/src/dev/esbuild.ts b/src/dev/esbuild.ts
new file mode 100644
index 00000000000..b587b610d5f
--- /dev/null
+++ b/src/dev/esbuild.ts
@@ -0,0 +1,201 @@
+import { denoPlugins } from "@luca/esbuild-deno-loader";
+import type { Plugin as EsbuildPlugin } from "esbuild";
+import * as path from "@std/path";
+
+export interface FreshBundleOptions {
+ dev: boolean;
+ cwd: string;
+ buildId: string;
+ outDir: string;
+ denoJsonPath: string;
+ entryPoints: Record;
+ target: string | string[];
+ jsxImportSource?: string;
+}
+
+export interface BuildOutput {
+ entryToChunk: Map;
+ dependencies: Map;
+ files: Array<{ hash: string | null; contents: Uint8Array; path: string }>;
+}
+
+let esbuild: null | typeof import("esbuild-wasm") = null;
+
+const PREACT_ENV = Deno.env.get("PREACT_PATH");
+
+export async function bundleJs(
+ options: FreshBundleOptions,
+): Promise {
+ if (esbuild === null) {
+ esbuild = Deno.env.get("FRESH_ESBUILD_LOADER") === "portable"
+ ? await import("esbuild-wasm")
+ : await import("esbuild");
+
+ await esbuild.initialize({});
+ }
+
+ try {
+ await Deno.mkdir(options.cwd, { recursive: true });
+ } catch (err) {
+ if (!(err instanceof Deno.errors.AlreadyExists)) {
+ throw err;
+ }
+ }
+
+ const bundle = await esbuild.build({
+ entryPoints: options.entryPoints,
+
+ platform: "browser",
+ target: options.target,
+
+ format: "esm",
+ bundle: true,
+ splitting: true,
+ treeShaking: true,
+ sourcemap: options.dev ? "linked" : false,
+ minify: !options.dev,
+
+ jsxDev: options.dev,
+ jsx: "automatic",
+ jsxImportSource: options.jsxImportSource ?? "preact",
+
+ absWorkingDir: options.cwd,
+ outdir: ".",
+ write: false,
+ metafile: true,
+
+ plugins: [
+ preactDebugger(PREACT_ENV),
+ buildIdPlugin(options.buildId),
+ windowsPathFixer(),
+ ...denoPlugins({ configPath: options.denoJsonPath }),
+ ],
+ });
+
+ const files: BuildOutput["files"] = [];
+ for (let i = 0; i < bundle.outputFiles.length; i++) {
+ const outputFile = bundle.outputFiles[i];
+ const relative = path.relative(options.cwd, outputFile.path);
+ files.push({
+ path: relative,
+ contents: outputFile.contents,
+ hash: outputFile.hash,
+ });
+ }
+
+ files.push({
+ path: "metafile.json",
+ contents: new TextEncoder().encode(JSON.stringify(bundle.metafile)),
+ hash: null,
+ });
+
+ const entryToChunk = new Map();
+ const dependencies = new Map();
+
+ const entryToName = new Map(
+ Array.from(Object.entries(options.entryPoints)).map(
+ (entry) => [entry[1], entry[0]],
+ ),
+ );
+
+ if (bundle.metafile) {
+ const metaOutputs = new Map(Object.entries(bundle.metafile.outputs));
+
+ for (const [entryPath, entry] of metaOutputs.entries()) {
+ const imports = entry.imports
+ .filter(({ kind }) => kind === "import-statement")
+ .map(({ path }) => path);
+ dependencies.set(entryPath, imports);
+
+ if (entryPath !== "fresh-runtime.js" && entry.entryPoint !== undefined) {
+ const filePath = path.join(options.cwd, entry.entryPoint);
+
+ const name = entryToName.get(filePath)!;
+ entryToChunk.set(name, entryPath);
+ }
+ }
+ }
+
+ if (!options.dev) {
+ await esbuild.stop();
+ }
+
+ return {
+ files,
+ entryToChunk,
+ dependencies,
+ };
+}
+
+function buildIdPlugin(buildId: string): EsbuildPlugin {
+ return {
+ name: "fresh-build-id",
+ setup(build) {
+ build.onResolve({ filter: /runtime[/\\]+build_id\.ts$/ }, (args) => {
+ return {
+ path: args.path,
+ namespace: "fresh-internal",
+ };
+ });
+ build.onLoad({
+ filter: /runtime[/\\]build_id\.ts$/,
+ namespace: "fresh-internal",
+ }, () => {
+ return {
+ contents: `export const BUILD_ID = "${buildId}";`,
+ };
+ });
+ },
+ };
+}
+
+function toPreactModPath(mod: string): string {
+ if (mod === "preact/debug") {
+ return path.join("debug", "dist", "debug.module.js");
+ } else if (mod === "preact/hooks") {
+ return path.join("hooks", "dist", "hooks.module.js");
+ } else if (mod === "preact/devtools") {
+ return path.join("devtools", "dist", "devtools.module.js");
+ } else if (mod === "preact/compat") {
+ return path.join("compat", "dist", "compat.module.js");
+ } else if (mod === "preact/jsx-runtime" || mod === "preact/jsx-dev-runtime") {
+ return path.join("jsx-runtime", "dist", "jsxRuntime.module.js");
+ } else {
+ return path.join("dist", "preact.module.js");
+ }
+}
+
+function preactDebugger(preactPath: string | undefined): EsbuildPlugin {
+ return {
+ name: "fresh-preact-debugger",
+ setup(build) {
+ if (preactPath === undefined) return;
+
+ build.onResolve({ filter: /^preact/ }, (args) => {
+ const resolved = path.resolve(preactPath, toPreactModPath(args.path));
+
+ return {
+ path: resolved,
+ };
+ });
+ },
+ };
+}
+
+function windowsPathFixer(): EsbuildPlugin {
+ return {
+ name: "fresh-fix-windows",
+ setup(build) {
+ if (Deno.build.os === "windows") {
+ build.onResolve({ filter: /\.*/ }, (args) => {
+ if (args.path.startsWith("\\")) {
+ const normalized = path.resolve(args.path);
+ return {
+ path: normalized,
+ };
+ }
+ });
+ }
+ },
+ };
+}
diff --git a/src/dev/file_transformer.ts b/src/dev/file_transformer.ts
new file mode 100644
index 00000000000..2335f71768d
--- /dev/null
+++ b/src/dev/file_transformer.ts
@@ -0,0 +1,247 @@
+import type { FsAdapter } from "../fs.ts";
+import { BUILD_ID } from "../runtime/build_id.ts";
+import { assetInternal } from "../runtime/shared_internal.tsx";
+
+export type TransformMode = "development" | "production";
+
+export interface OnTransformOptions {
+ pluginName: string;
+ filter: RegExp;
+}
+
+export interface OnTransformResult {
+ content: string | Uint8Array;
+ path?: string;
+ map?: string | Uint8Array;
+}
+
+export interface OnTransformArgs {
+ path: string;
+ target: string | string[];
+ text: string;
+ content: Uint8Array;
+ mode: TransformMode;
+}
+export type TransformFn = (
+ args: OnTransformArgs,
+) =>
+ | void
+ | OnTransformResult
+ | Array<{ path: string } & Omit>
+ | Promise<
+ | void
+ | OnTransformResult
+ | Array<{ path: string } & Omit>
+ >;
+
+export interface Transformer {
+ options: OnTransformOptions;
+ fn: TransformFn;
+}
+
+export interface ProcessedFile {
+ path: string;
+ content: Uint8Array;
+ map: Uint8Array | null;
+ inputFiles: string[];
+}
+
+interface TransformReq {
+ newFile: boolean;
+ filePath: string;
+ content: Uint8Array;
+ map: null | Uint8Array;
+ inputFiles: string[];
+}
+
+export class FreshFileTransformer {
+ #transformers: Transformer[] = [];
+ #fs: FsAdapter;
+
+ constructor(fs: FsAdapter) {
+ this.#fs = fs;
+ }
+
+ onTransform(options: OnTransformOptions, callback: TransformFn): void {
+ this.#transformers.push({ options, fn: callback });
+ }
+
+ async process(
+ filePath: string,
+ mode: TransformMode,
+ target: string | string[],
+ ): Promise {
+ // Pre-check if we have any transformer for this file at all
+ let hasTransformer = false;
+ for (let i = 0; i < this.#transformers.length; i++) {
+ if (this.#transformers[i].options.filter.test(filePath)) {
+ hasTransformer = true;
+ break;
+ }
+ }
+
+ if (!hasTransformer) {
+ return null;
+ }
+
+ let content: Uint8Array;
+ try {
+ content = await this.#fs.readFile(filePath);
+ } catch (err) {
+ if (err instanceof Deno.errors.NotFound) {
+ return null;
+ }
+
+ throw err;
+ }
+
+ const queue: TransformReq[] = [{
+ newFile: false,
+ content,
+ filePath,
+ map: null,
+ inputFiles: [filePath],
+ }];
+ const outFiles: ProcessedFile[] = [];
+
+ const seen = new Set();
+
+ let req: TransformReq | undefined = undefined;
+ while ((req = queue.pop()) !== undefined) {
+ if (seen.has(req.filePath)) continue;
+ seen.add(req.filePath);
+
+ let transformed = false;
+ for (let i = 0; i < this.#transformers.length; i++) {
+ const transformer = this.#transformers[i];
+
+ const { options, fn } = transformer;
+ options.filter.lastIndex = 0;
+ if (!options.filter.test(req.filePath)) {
+ continue;
+ }
+
+ const result = await fn({
+ path: req.filePath,
+ mode,
+ target,
+ content: req!.content,
+ get text() {
+ return new TextDecoder().decode(req!.content);
+ },
+ });
+
+ if (result !== undefined) {
+ if (Array.isArray(result)) {
+ for (let i = 0; i < result.length; i++) {
+ const item = result[i];
+ if (item.path === undefined) {
+ throw new Error(
+ `The ".path" property must be set when returning multiple files in a transformer. [${transformer.options.pluginName}]`,
+ );
+ }
+
+ const outContent = typeof item.content === "string"
+ ? new TextEncoder().encode(item.content)
+ : item.content;
+
+ const outMap = item.map !== undefined
+ ? typeof item.map === "string"
+ ? new TextEncoder().encode(item.map)
+ : item.map
+ : null;
+
+ if (req.filePath === item.path) {
+ if (req.content === outContent && req.map === outMap) {
+ continue;
+ }
+
+ transformed = true;
+ req.content = outContent;
+ req.map = outMap;
+ } else {
+ let found = false;
+ for (let i = 0; i < queue.length; i++) {
+ const req = queue[i];
+ if (req.filePath === item.path) {
+ found = true;
+ transformed = true;
+ req.content = outContent;
+ req.map = outMap;
+ }
+ }
+
+ if (!found) {
+ queue.push({
+ newFile: true,
+ filePath: item.path,
+ content: outContent,
+ map: outMap,
+ inputFiles: req.inputFiles.slice(),
+ });
+ }
+ }
+ }
+ } else {
+ const outContent = typeof result.content === "string"
+ ? new TextEncoder().encode(result.content)
+ : result.content;
+
+ const outMap = result.map !== undefined
+ ? typeof result.map === "string"
+ ? new TextEncoder().encode(result.map)
+ : result.map
+ : null;
+
+ if (req.content === outContent && req.map === outMap) {
+ continue;
+ }
+
+ transformed = true;
+ req.content = outContent;
+ req.map = outMap;
+ req.filePath = result.path ?? req.filePath;
+ }
+ }
+ }
+
+ // TODO: Keep transforming until no one processes anymore
+ if (transformed || req.newFile) {
+ outFiles.push({
+ content: req.content,
+ map: req.map,
+ path: req.filePath,
+ inputFiles: req.inputFiles,
+ });
+ }
+ }
+
+ return outFiles.length > 0 ? outFiles : null;
+ }
+}
+
+const CSS_URL_REGEX = /url\((["'][^'"]+["']|[^)]+)\)/g;
+
+export function cssAssetHash(transformer: FreshFileTransformer) {
+ transformer.onTransform({
+ pluginName: "fresh-css",
+ filter: /\.css$/,
+ }, (args) => {
+ const replaced = args.text.replaceAll(CSS_URL_REGEX, (_, str) => {
+ let rawUrl = str;
+ if (str[0] === "'" || str[0] === '"') {
+ rawUrl = str.slice(1, -1);
+ }
+
+ if (rawUrl.length === 0) {
+ return str;
+ }
+
+ return `url(${JSON.stringify(assetInternal(rawUrl, BUILD_ID))})`;
+ });
+
+ return {
+ content: replaced,
+ };
+ });
+}
diff --git a/src/dev/file_transformer_test.ts b/src/dev/file_transformer_test.ts
new file mode 100644
index 00000000000..cc25e3deca0
--- /dev/null
+++ b/src/dev/file_transformer_test.ts
@@ -0,0 +1,229 @@
+import { expect } from "@std/expect";
+import type { FsAdapter } from "../fs.ts";
+import {
+ FreshFileTransformer,
+ type ProcessedFile,
+} from "./file_transformer.ts";
+import { delay } from "../test_utils.ts";
+
+function testTransformer(files: Record) {
+ const mockFs: FsAdapter = {
+ isDirectory: () => Promise.resolve(false),
+ mkdirp: () => Promise.resolve(),
+ walk: async function* foo() {
+ },
+ readFile: (file) => {
+ if (file instanceof URL) throw new Error("Not supported");
+ // deno-lint-ignore no-explicit-any
+ const content = (files as any)[file];
+ const buf = new TextEncoder().encode(content);
+ return Promise.resolve(buf);
+ },
+ };
+ return new FreshFileTransformer(mockFs);
+}
+
+function consumeResult(result: ProcessedFile[]) {
+ const out: {
+ path: string;
+ content: string;
+ map: string | null;
+ inputFiles: string[];
+ }[] = [];
+ for (let i = 0; i < result.length; i++) {
+ const file = result[i];
+
+ out.push({
+ path: file.path,
+ content: typeof file.content === "string"
+ ? file.content
+ : new TextDecoder().decode(file.content),
+ map: file.map !== null
+ ? typeof file.map === "string"
+ ? file.map
+ : new TextDecoder().decode(file.map)
+ : null,
+ inputFiles: file.inputFiles,
+ });
+ }
+
+ return out.sort((a, b) => a.path.localeCompare(b.path));
+}
+
+Deno.test("FileTransformer - transform sync", async () => {
+ const transformer = testTransformer({
+ "foo.txt": "foo",
+ });
+
+ transformer.onTransform({ pluginName: "foo", filter: /.*/ }, (args) => {
+ return {
+ content: args.text + "bar",
+ };
+ });
+
+ const result = await transformer.process("foo.txt", "development", "");
+ const files = await consumeResult(result!);
+ expect(files).toEqual([
+ { content: "foobar", map: null, path: "foo.txt", inputFiles: ["foo.txt"] },
+ ]);
+});
+
+Deno.test("FileTransformer - transform async", async () => {
+ const transformer = testTransformer({
+ "foo.txt": "foo",
+ });
+
+ transformer.onTransform({ pluginName: "foo", filter: /.*/ }, async (args) => {
+ await delay(1);
+ return {
+ content: args.text + "bar",
+ };
+ });
+
+ const result = await transformer.process("foo.txt", "development", "");
+ const files = await consumeResult(result!);
+ expect(files).toEqual([
+ { content: "foobar", map: null, path: "foo.txt", inputFiles: ["foo.txt"] },
+ ]);
+});
+
+Deno.test("FileTransformer - transform return Uint8Array", async () => {
+ const transformer = testTransformer({
+ "foo.txt": "foo",
+ });
+
+ transformer.onTransform({ pluginName: "foo", filter: /.*/ }, () => {
+ return {
+ content: new TextEncoder().encode("foobar"),
+ };
+ });
+
+ const result = await transformer.process("foo.txt", "development", "");
+ const files = await consumeResult(result!);
+ expect(files).toEqual([
+ { content: "foobar", map: null, path: "foo.txt", inputFiles: ["foo.txt"] },
+ ]);
+});
+
+Deno.test("FileTransformer - pass transformed content", async () => {
+ const transformer = testTransformer({
+ "input.txt": "input",
+ });
+
+ transformer.onTransform({ pluginName: "A", filter: /.*/ }, (args) => {
+ return {
+ content: args.text + " -> A",
+ };
+ });
+ transformer.onTransform({ pluginName: "B", filter: /.*/ }, (args) => {
+ return {
+ content: args.text + " -> B",
+ };
+ });
+
+ const result = await transformer.process("input.txt", "development", "");
+ const files = await consumeResult(result!);
+ expect(files).toEqual([
+ {
+ content: "input -> A -> B",
+ map: null,
+ path: "input.txt",
+ inputFiles: ["input.txt"],
+ },
+ ]);
+});
+
+Deno.test(
+ "FileTransformer - pass transformed content with multiple",
+ async () => {
+ const transformer = testTransformer({
+ "input.txt": "input",
+ });
+
+ transformer.onTransform({ pluginName: "A", filter: /.*/ }, (args) => {
+ return [{
+ path: args.path,
+ content: args.text + " -> A",
+ }];
+ });
+ transformer.onTransform({ pluginName: "B", filter: /.*/ }, (args) => {
+ return {
+ content: args.text + " -> B",
+ };
+ });
+
+ const result = await transformer.process("input.txt", "development", "");
+ const files = await consumeResult(result!);
+ expect(files).toEqual([
+ {
+ content: "input -> A -> B",
+ map: null,
+ path: "input.txt",
+ inputFiles: ["input.txt"],
+ },
+ ]);
+ },
+);
+
+Deno.test("FileTransformer - return multiple results", async () => {
+ const transformer = testTransformer({
+ "foo.txt": "foo",
+ });
+
+ const received: string[] = [];
+ transformer.onTransform({ pluginName: "A", filter: /foo\.txt$/ }, () => {
+ return [{
+ path: "a.txt",
+ content: "A",
+ }, {
+ path: "b.txt",
+ content: "B",
+ }];
+ });
+ transformer.onTransform({ pluginName: "B", filter: /.*/ }, (args) => {
+ received.push(args.path);
+ });
+
+ const result = await transformer.process("foo.txt", "development", "");
+ const files = await consumeResult(result!);
+ expect(files).toEqual([
+ { content: "A", map: null, path: "a.txt", inputFiles: ["foo.txt"] },
+ { content: "B", map: null, path: "b.txt", inputFiles: ["foo.txt"] },
+ ]);
+ expect(received).toEqual(["foo.txt", "b.txt", "a.txt"]);
+});
+
+Deno.test(
+ "FileTransformer - track input files through temporary results",
+ async () => {
+ const transformer = testTransformer({
+ "foo.txt": "foo",
+ });
+
+ transformer.onTransform({ pluginName: "A", filter: /foo\.txt$/ }, () => {
+ return [{
+ path: "a.txt",
+ content: "A",
+ }, {
+ path: "b.txt",
+ content: "B",
+ }];
+ });
+ transformer.onTransform(
+ { pluginName: "B", filter: /[ab]\.txt$/ },
+ (args) => {
+ return {
+ path: "c" + args.path,
+ content: args.text + "C",
+ };
+ },
+ );
+
+ const result = await transformer.process("foo.txt", "development", "");
+ const files = await consumeResult(result!);
+ expect(files).toEqual([
+ { content: "AC", map: null, path: "ca.txt", inputFiles: ["foo.txt"] },
+ { content: "BC", map: null, path: "cb.txt", inputFiles: ["foo.txt"] },
+ ]);
+ },
+);
diff --git a/src/dev/imports.ts b/src/dev/imports.ts
deleted file mode 100644
index 0e6f4e3d5ce..00000000000
--- a/src/dev/imports.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-export const RECOMMENDED_PREACT_VERSION = "10.22.0";
-export const RECOMMENDED_PREACT_SIGNALS_VERSION = "1.2.2";
-export const RECOMMENDED_PREACT_SIGNALS_CORE_VERSION = "1.5.1";
-export const RECOMMENDED_TWIND_CORE_VERSION = "1.1.3";
-export const RECOMMENDED_TWIND_PRESET_AUTOPREFIX_VERSION = "1.0.7";
-export const RECOMMENDED_TWIND_PRESET_TAILWIND_VERSION = "1.1.4";
-export const RECOMMENDED_STD_VERSION = "0.216.0";
-export const RECOMMENDED_TAILIWIND_VERSION = "3.4.1";
-
-export function freshImports(imports: Record) {
- imports["$fresh/"] = new URL("../../", import.meta.url).href;
- imports["preact"] = `https://esm.sh/preact@${RECOMMENDED_PREACT_VERSION}`;
- imports["preact/"] = `https://esm.sh/preact@${RECOMMENDED_PREACT_VERSION}/`;
- imports["@preact/signals"] =
- `https://esm.sh/*@preact/signals@${RECOMMENDED_PREACT_SIGNALS_VERSION}`;
- imports["@preact/signals-core"] =
- `https://esm.sh/*@preact/signals-core@${RECOMMENDED_PREACT_SIGNALS_CORE_VERSION}`;
-}
-
-export function twindImports(imports: Record) {
- imports["@twind/core"] =
- `https://esm.sh/@twind/core@${RECOMMENDED_TWIND_CORE_VERSION}`;
- imports["@twind/preset-tailwind"] =
- `https://esm.sh/@twind/preset-tailwind@${RECOMMENDED_TWIND_PRESET_TAILWIND_VERSION}/`;
- imports["@twind/preset-autoprefix"] =
- `https://esm.sh/@twind/preset-autoprefix@${RECOMMENDED_TWIND_PRESET_AUTOPREFIX_VERSION}/`;
-}
-
-export function tailwindImports(imports: Record) {
- imports["tailwindcss"] = `npm:tailwindcss@${RECOMMENDED_TAILIWIND_VERSION}`;
- imports["tailwindcss/"] =
- `npm:/tailwindcss@${RECOMMENDED_TAILIWIND_VERSION}/`;
- imports["tailwindcss/plugin"] =
- `npm:/tailwindcss@${RECOMMENDED_TAILIWIND_VERSION}/plugin.js`;
-}
-
-export function dotenvImports(imports: Record) {
- imports["$std/"] = `https://deno.land/std@${RECOMMENDED_STD_VERSION}/`;
-}
diff --git a/src/dev/manifest.ts b/src/dev/manifest.ts
deleted file mode 100644
index 51f2f40550a..00000000000
--- a/src/dev/manifest.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import { stringToIdentifier } from "../server/init_safe_deps.ts";
-import { extname, join, normalize } from "./deps.ts";
-
-/**
- * Import specifiers must have forward slashes
- */
-function toImportSpecifier(file: string) {
- let specifier = normalize(file).replace(/\\/g, "/");
- if (!specifier.startsWith(".")) {
- specifier = "./" + specifier;
- }
- return specifier;
-}
-
-// Create a valid JS identifier out of the project relative specifier.
-// Note that we only need to deal with strings that _must_ have been
-// valid file names in Windows, macOS and Linux and every identifier we
-// create here will be prefixed with at least one "$". This greatly
-// simplifies the invalid characters we have to account for.
-export function specifierToIdentifier(specifier: string, used: Set) {
- specifier = specifier.replace(/^(?:\.\/routes|\.\/islands)\//, "");
- const ext = extname(specifier);
- if (ext) specifier = specifier.slice(0, -ext.length);
-
- // Turn the specifier into a readable JS identifier
- let ident = stringToIdentifier(specifier);
-
- if (used.has(ident)) {
- let check = ident;
- let i = 1;
- while (used.has(check)) {
- check = `${ident}_${i++}`;
- }
- ident = check;
- }
-
- used.add(ident);
- return ident;
-}
-
-export interface Manifest {
- routes: string[];
- islands: string[];
-}
-
-export async function generate(directory: string, manifest: Manifest) {
- const { routes, islands } = manifest;
-
- // Keep track of which identifier we've already used
- const used = new Set();
-
- const normalizedRoutes = new Map();
- for (let i = 0; i < routes.length; i++) {
- const file = routes[i];
- const specifier = toImportSpecifier(file);
- const identifier = specifierToIdentifier(specifier, used);
- normalizedRoutes.set(specifier, identifier);
- }
-
- const normalizedIslands: { specifier: string; identifier: string }[] = [];
- for (let i = 0; i < islands.length; i++) {
- const file = islands[i];
- const specifier = toImportSpecifier(file);
- const identifier = specifierToIdentifier(specifier, used);
- normalizedIslands.push({ specifier, identifier });
- }
-
- const output = `// DO NOT EDIT. This file is generated by Fresh.
-// This file SHOULD be checked into source version control.
-// This file is automatically updated during development when running \`dev.ts\`.
-
-${
- Array.from(normalizedRoutes.entries()).map(([specifier, identifier]) =>
- `import * as $${identifier} from "${specifier}";`
- ).join(
- "\n",
- )
- }
-${
- normalizedIslands.map(({ specifier, identifier }) =>
- `import * as $${identifier} from "${specifier}";`
- )
- .join("\n")
- }
-import type { Manifest } from "$fresh/server.ts";
-
-const manifest = {
- routes: {
- ${
- Array.from(normalizedRoutes.entries()).map(([specifier, identifier]) =>
- `${JSON.stringify(`${specifier}`)}: $${identifier},`
- )
- .join("\n ")
- }
- },
- islands: {
- ${
- normalizedIslands.map(({ specifier, identifier }) =>
- `${JSON.stringify(`${specifier}`)}: $${identifier},`
- )
- .join("\n ")
- }
- },
- baseUrl: import.meta.url,
-} satisfies Manifest;
-
-export default manifest;
-`;
-
- const proc = new Deno.Command(Deno.execPath(), {
- args: ["fmt", "-"],
- stdin: "piped",
- stdout: "piped",
- stderr: "null",
- }).spawn();
-
- const raw = new ReadableStream({
- start(controller) {
- controller.enqueue(new TextEncoder().encode(output));
- controller.close();
- },
- });
- await raw.pipeTo(proc.stdin);
- const { stdout } = await proc.output();
-
- const manifestStr = new TextDecoder().decode(stdout);
- const manifestPath = join(directory, "./fresh.gen.ts");
-
- await Deno.writeTextFile(manifestPath, manifestStr);
- console.log(
- `%cThe manifest has been generated for ${routes.length} routes and ${islands.length} islands.`,
- "color: blue; font-weight: bold",
- );
-}
diff --git a/src/dev/manifest_test.ts b/src/dev/manifest_test.ts
deleted file mode 100644
index b428dd3674b..00000000000
--- a/src/dev/manifest_test.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { assertEquals } from "./deps.ts";
-import { specifierToIdentifier } from "./manifest.ts";
-
-const run = specifierToIdentifier;
-
-Deno.test("specifierToIdentifier", () => {
- const used = new Set();
- assertEquals(run("foo/bar.ts", used), "foo_bar");
- assertEquals(run("foo/bar.json.ts", used), "foo_bar_json");
- assertEquals(run("foo/[id]/bar", used), "foo_id_bar");
- assertEquals(run("foo/[...all]/bar", used), "foo_all_bar");
- assertEquals(run("foo/[[optional]]/bar", used), "foo_optional_bar");
- assertEquals(run("foo/as-df/bar", used), "foo_as_df_bar");
- assertEquals(run("foo/as@df", used), "foo_as_df");
- assertEquals(run("foo/foo.bar.baz.tsx", used), "foo_foo_bar_baz");
- assertEquals(run("404", used), "_404");
- assertEquals(run("foo/_middleware", used), "foo_middleware");
-});
-
-Deno.test("specifierToIdentifier deals with duplicates", () => {
- const used = new Set();
- assertEquals(run("foo/bar", used), "foo_bar");
- assertEquals(run("foo/bar", used), "foo_bar_1");
-});
diff --git a/src/server/code_frame.ts b/src/dev/middlewares/error_overlay/code_frame.tsx
similarity index 83%
rename from src/server/code_frame.ts
rename to src/dev/middlewares/error_overlay/code_frame.tsx
index 6827dafcd05..3a54cf20372 100644
--- a/src/server/code_frame.ts
+++ b/src/dev/middlewares/error_overlay/code_frame.tsx
@@ -1,4 +1,5 @@
-import { colors, fromFileUrl } from "./deps.ts";
+import * as path from "@std/path";
+import * as colors from "@std/fmt/colors";
function tabs2Spaces(str: string) {
return str.replace(/^\t+/, (tabs) => " ".repeat(tabs.length));
@@ -85,7 +86,10 @@ export interface StackFrame {
line: number;
column: number;
}
-export function getFirstUserFile(stack: string): StackFrame | undefined {
+function getFirstUserFile(
+ stack: string,
+ rootDir: string,
+): StackFrame | undefined {
const lines = stack.split("\n");
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(STACK_FRAME);
@@ -96,6 +100,11 @@ export function getFirstUserFile(stack: string): StackFrame | undefined {
const column = +match[4];
if (file.startsWith("file://")) {
+ const filePath = path.fromFileUrl(file);
+ if (path.relative(rootDir, filePath).startsWith(".")) {
+ continue;
+ }
+
return {
fnName,
file,
@@ -107,20 +116,18 @@ export function getFirstUserFile(stack: string): StackFrame | undefined {
}
}
-export async function getCodeFrame(error: Error) {
- if (!error.stack) return;
-
- const file = getFirstUserFile(error.stack);
+export function getCodeFrame(stack: string, rootDir: string) {
+ const file = getFirstUserFile(stack, rootDir);
if (file) {
try {
- const filePath = fromFileUrl(file.file);
- const text = await Deno.readTextFile(filePath);
+ const filePath = path.fromFileUrl(file.file);
+ const text = Deno.readTextFileSync(filePath);
return createCodeFrame(
text,
file.line - 1,
file.column - 1,
);
- } catch {
+ } catch (err) {
// Ignore
}
}
diff --git a/src/dev/middlewares/error_overlay/middleware.tsx b/src/dev/middlewares/error_overlay/middleware.tsx
new file mode 100644
index 00000000000..e74554b5412
--- /dev/null
+++ b/src/dev/middlewares/error_overlay/middleware.tsx
@@ -0,0 +1,32 @@
+import { DEV_ERROR_OVERLAY_URL } from "../../../constants.ts";
+import { HttpError } from "../../../error.ts";
+import type { MiddlewareFn } from "../../../middlewares/mod.ts";
+import { FreshScripts } from "../../../runtime/server/preact_hooks.tsx";
+import { ErrorOverlay } from "./overlay.tsx";
+
+export function devErrorOverlay(): MiddlewareFn {
+ return async (ctx) => {
+ const { config, url } = ctx;
+ if (url.pathname === config.basePath + DEV_ERROR_OVERLAY_URL) {
+ return ctx.render( );
+ }
+
+ try {
+ return await ctx.next();
+ } catch (err) {
+ if (ctx.req.headers.get("accept")?.includes("text/html")) {
+ let init: ResponseInit | undefined;
+ if (err instanceof HttpError) {
+ if (err.status < 500) throw err;
+ init = { status: err.status };
+ }
+
+ // At this point we're pretty sure to have a server error
+ console.error(err);
+
+ return ctx.render( , init);
+ }
+ throw err;
+ }
+ };
+}
diff --git a/src/dev/middlewares/error_overlay/middleware_test.ts b/src/dev/middlewares/error_overlay/middleware_test.ts
new file mode 100644
index 00000000000..f2b49fcc950
--- /dev/null
+++ b/src/dev/middlewares/error_overlay/middleware_test.ts
@@ -0,0 +1,57 @@
+import { expect } from "@std/expect";
+import { App } from "../../../app.ts";
+import { FakeServer } from "../../../test_utils.ts";
+import { devErrorOverlay } from "./middleware.tsx";
+import { HttpError } from "../../../error.ts";
+
+Deno.test("error overlay - show when error is thrown", async () => {
+ const app = new App();
+ app.use(devErrorOverlay());
+ app.config.mode = "development";
+
+ app.get("/", () => {
+ throw new Error("fail");
+ });
+
+ const server = new FakeServer(await app.handler());
+ const res = await server.get("/", {
+ headers: {
+ accept: "text/html",
+ },
+ });
+ const content = await res.text();
+ expect(content).toContain("fresh-error-overlay");
+});
+
+Deno.test("error overlay - should not be visible for HttpError <500", async () => {
+ const app = new App();
+ app.use(devErrorOverlay());
+ app.config.mode = "development";
+
+ app
+ .get("/", () => {
+ throw new HttpError(404);
+ })
+ .get("/500", () => {
+ throw new HttpError(500);
+ });
+
+ const server = new FakeServer(await app.handler());
+ let res = await server.get("/", {
+ headers: {
+ accept: "text/html",
+ },
+ });
+ let content = await res.text();
+ expect(content).not.toContain("fresh-error-overlay");
+ expect(res.status).toEqual(404);
+
+ res = await server.get("/500", {
+ headers: {
+ accept: "text/html",
+ },
+ });
+ content = await res.text();
+ expect(content).toContain("fresh-error-overlay");
+ expect(res.status).toEqual(500);
+});
diff --git a/src/server/error_overlay.tsx b/src/dev/middlewares/error_overlay/overlay.tsx
similarity index 91%
rename from src/server/error_overlay.tsx
rename to src/dev/middlewares/error_overlay/overlay.tsx
index a46da4a3709..e6c317df992 100644
--- a/src/server/error_overlay.tsx
+++ b/src/dev/middlewares/error_overlay/overlay.tsx
@@ -1,6 +1,4 @@
-import { ComponentChildren, h } from "preact";
-import { render } from "./render.ts";
-import { PageProps } from "../server/mod.ts";
+import type { ComponentChildren } from "preact";
// Just to get some syntax highlighting
const css = (arr: TemplateStringsArray, ...exts: never[]) => {
@@ -118,12 +116,18 @@ function CodeFrame(props: { codeFrame: string }) {
);
}
-export function ErrorOverlay(props: PageProps) {
+const DEFAULT_MESSAGE = "Internal Server Error";
+
+export function ErrorOverlay(props: { url: URL }) {
const url = props.url;
- const title = url.searchParams.get("message") || "Internal Server Error";
+ const title = url.searchParams.get("message") || DEFAULT_MESSAGE;
const stack = url.searchParams.get("stack");
const codeFrame = url.searchParams.get("code-frame");
+ if (title === DEFAULT_MESSAGE && !stack && !codeFrame) {
+ return null;
+ }
+
return (
<>
diff --git a/src/dev/middlewares/live_reload.ts b/src/dev/middlewares/live_reload.ts
new file mode 100644
index 00000000000..8e182e420d4
--- /dev/null
+++ b/src/dev/middlewares/live_reload.ts
@@ -0,0 +1,39 @@
+import type { MiddlewareFn } from "../../middlewares/mod.ts";
+import { ALIVE_URL } from "../../constants.ts";
+
+// Live reload: Send updates to browser
+export function liveReload
(): MiddlewareFn {
+ const revision = Date.now();
+
+ return (ctx) => {
+ const { config, req, url } = ctx;
+
+ const aliveUrl = config.basePath + ALIVE_URL;
+
+ if (url.pathname === aliveUrl) {
+ if (req.headers.get("upgrade") !== "websocket") {
+ return new Response(null, { status: 501 });
+ }
+
+ // TODO: When a change is made the Deno server restarts,
+ // so for now the WebSocket connection is only used for
+ // the client to know when the server is back up. Once we
+ // have HMR we'll actively start sending messages back
+ // and forth.
+ const { response, socket } = Deno.upgradeWebSocket(req);
+
+ socket.addEventListener("open", () => {
+ socket.send(
+ JSON.stringify({
+ type: "initial-state",
+ revision,
+ }),
+ );
+ });
+
+ return response;
+ }
+
+ return ctx.next();
+ };
+}
diff --git a/src/dev/mod.ts b/src/dev/mod.ts
index d2d47bb3624..7046d68823a 100644
--- a/src/dev/mod.ts
+++ b/src/dev/mod.ts
@@ -1,111 +1,10 @@
-import {
- greaterOrEqual,
- join,
- relative,
- semverParse,
- walk,
- WalkEntry,
-} from "./deps.ts";
-export { generate, type Manifest } from "./manifest.ts";
-import { generate, type Manifest } from "./manifest.ts";
-import { error } from "./error.ts";
-const MIN_DENO_VERSION = "1.31.0";
-const TEST_FILE_PATTERN = /[._]test\.(?:[tj]sx?|[mc][tj]s)$/;
+import { setMode } from "../runtime/server/mod.tsx";
-export function ensureMinDenoVersion() {
- // Check that the minimum supported Deno version is being used.
- if (
- !greaterOrEqual(
- semverParse(Deno.version.deno),
- semverParse(MIN_DENO_VERSION),
- )
- ) {
- let message =
- `Deno version ${MIN_DENO_VERSION} or higher is required. Please update Deno.\n\n`;
+export { Builder, type FreshBuilder } from "./builder.ts";
+export {
+ type OnTransformArgs,
+ type OnTransformOptions,
+ type TransformFn,
+} from "./file_transformer.ts";
- if (Deno.execPath().includes("homebrew")) {
- message +=
- "You seem to have installed Deno via homebrew. To update, run: `brew upgrade deno`\n";
- } else {
- message += "To update, run: `deno upgrade`\n";
- }
-
- error(message);
- }
-}
-
-async function collectDir(
- dir: string,
- callback: (entry: WalkEntry, dir: string) => void,
- ignoreFilePattern = TEST_FILE_PATTERN,
-): Promise {
- // Check if provided path is a directory
- try {
- const stat = await Deno.stat(dir);
- if (!stat.isDirectory) return;
- } catch (err) {
- if (err instanceof Deno.errors.NotFound) return;
- throw err;
- }
-
- const routesFolder = walk(dir, {
- includeDirs: false,
- includeFiles: true,
- exts: ["tsx", "jsx", "ts", "js"],
- skip: [ignoreFilePattern],
- });
-
- for await (const entry of routesFolder) {
- callback(entry, dir);
- }
-}
-
-const GROUP_REG = /[/\\\\]\((_[^/\\\\]+)\)[/\\\\]/;
-export async function collect(
- directory: string,
- ignoreFilePattern?: RegExp,
-): Promise {
- const filePaths = new Set();
-
- const routes: string[] = [];
- const islands: string[] = [];
- await Promise.all([
- collectDir(join(directory, "./routes"), (entry, dir) => {
- const rel = join("routes", relative(dir, entry.path));
- const normalized = rel.slice(0, rel.lastIndexOf("."));
-
- // A `(_islands)` path segment is a local island folder.
- // Any route path segment wrapped in `(_...)` is ignored
- // during route collection.
- const match = normalized.match(GROUP_REG);
- if (match && match[1].startsWith("_")) {
- if (match[1] === "_islands") {
- islands.push(rel);
- }
- return;
- }
-
- if (filePaths.has(normalized)) {
- throw new Error(
- `Route conflict detected. Multiple files have the same name: ${dir}${normalized}`,
- );
- }
- filePaths.add(normalized);
- routes.push(rel);
- }, ignoreFilePattern),
- collectDir(join(directory, "./islands"), (entry, dir) => {
- const rel = join("islands", relative(dir, entry.path));
- islands.push(rel);
- }, ignoreFilePattern),
- ]);
-
- routes.sort();
- islands.sort();
-
- return { routes, islands };
-}
-
-export async function manifest(path: string, ignoreFilePattern?: RegExp) {
- const manifest = await collect(path, ignoreFilePattern);
- await generate(path, manifest);
-}
+setMode("development");
diff --git a/src/dev/update_check.ts b/src/dev/update_check.ts
index a20cf23e78e..4d6596296c5 100644
--- a/src/dev/update_check.ts
+++ b/src/dev/update_check.ts
@@ -1,4 +1,6 @@
-import { colors, join, lessThan, semverParse } from "./deps.ts";
+import * as semver from "@std/semver";
+import * as colors from "@std/fmt/colors";
+import * as path from "@std/path";
export interface CheckFile {
last_checked: string;
@@ -33,7 +35,7 @@ function getHomeDir(): string | null {
function getFreshCacheDir(): string | null {
const home = getHomeDir();
- if (home) return join(home, "fresh");
+ if (home) return path.join(home, "fresh");
return null;
}
@@ -47,10 +49,9 @@ async function fetchLatestVersion() {
}
async function readCurrentVersion() {
- const versions = (await import("../../versions.json", {
+ return (await import("../../deno.json", {
with: { type: "json" },
- })).default as string[];
- return versions[0];
+ })).default.version;
}
export async function updateCheck(
@@ -70,7 +71,7 @@ export async function updateCheck(
const home = getCacheDir();
if (!home) return;
- const filePath = join(home, "latest.json");
+ const filePath = path.join(home, "latest.json");
try {
await Deno.mkdir(home, { recursive: true });
} catch (err) {
@@ -113,12 +114,12 @@ export async function updateCheck(
}
// Only show update message if current version is smaller than latest
- const currentVersion = semverParse(checkFile.current_version);
- const latestVersion = semverParse(checkFile.latest_version);
+ const currentVersion = semver.parse(checkFile.current_version);
+ const latestVersion = semver.parse(checkFile.latest_version);
if (
(!checkFile.last_shown ||
Date.now() >= new Date(checkFile.last_shown).getTime() + interval) &&
- lessThan(currentVersion, latestVersion)
+ semver.lessThan(currentVersion, latestVersion)
) {
checkFile.last_shown = new Date().toISOString();
diff --git a/src/dev/update_check_test.ts b/src/dev/update_check_test.ts
new file mode 100644
index 00000000000..a4de6b6dbde
--- /dev/null
+++ b/src/dev/update_check_test.ts
@@ -0,0 +1,394 @@
+import * as path from "@std/path";
+import denoJson from "../../deno.json" with { type: "json" };
+import { WEEK } from "@std/datetime";
+import { getStdOutput } from "../../tests/test_utils.tsx";
+import { expect } from "@std/expect";
+import type { CheckFile } from "./update_check.ts";
+
+const CURRENT_VERSION = denoJson.version;
+
+const cwd = import.meta.dirname!;
+
+Deno.test("stores update check file in $HOME/fresh", async () => {
+ const tmpDirName = await Deno.makeTempDir();
+ const filePath = path.join(tmpDirName, "latest.json");
+
+ await new Deno.Command(Deno.execPath(), {
+ args: [
+ "run",
+ "-A",
+ path.join(cwd, "../../tests/fixture_update_check/mod.ts"),
+ ],
+ cwd,
+ env: {
+ CI: "false",
+ TEST_HOME: tmpDirName,
+ },
+ }).output();
+
+ const text = JSON.parse(await Deno.readTextFile(filePath));
+ expect(text).toEqual({
+ current_version: CURRENT_VERSION,
+ latest_version: "99.99.999",
+ last_checked: text.last_checked,
+ last_shown: text.last_shown,
+ });
+
+ await Deno.remove(tmpDirName, { recursive: true });
+});
+
+Deno.test("skips update check on specific environment variables", async (t) => {
+ const envs = ["FRESH_NO_UPDATE_CHECK", "CI", "DENO_DEPLOYMENT_ID"];
+
+ for (const env of envs) {
+ await t.step(`checking ${env}`, async () => {
+ const tmpDirName = await Deno.makeTempDir();
+ const out = await new Deno.Command(Deno.execPath(), {
+ args: [
+ "run",
+ "-A",
+ path.join(cwd, "../../tests/fixture_update_check/mod.ts"),
+ ],
+ cwd,
+ env: {
+ [env]: "true",
+ TEST_HOME: tmpDirName,
+ LATEST_VERSION: "1.30.0",
+ },
+ stderr: "piped",
+ stdout: "piped",
+ }).output();
+
+ const { stdout } = getStdOutput(out);
+ expect(stdout).not.toMatch(/Fresh 1\.30\.0 is available/);
+
+ await Deno.remove(tmpDirName, { recursive: true });
+ });
+ }
+});
+
+Deno.test("shows update message on version mismatch", async () => {
+ const tmpDirName = await Deno.makeTempDir();
+ const filePath = path.join(tmpDirName, "latest.json");
+
+ await Deno.writeTextFile(
+ filePath,
+ JSON.stringify({
+ current_version: "1.1.0",
+ latest_version: "1.1.0",
+ last_checked: new Date(0).toISOString(),
+ }),
+ );
+
+ const out = await new Deno.Command(Deno.execPath(), {
+ args: [
+ "run",
+ "-A",
+ path.join(cwd, "../../tests/fixture_update_check/mod.ts"),
+ ],
+ cwd,
+ env: {
+ CI: "false",
+ TEST_HOME: tmpDirName,
+ LATEST_VERSION: "999.999.0",
+ },
+ stderr: "piped",
+ stdout: "piped",
+ }).output();
+
+ const { stdout } = getStdOutput(out);
+ expect(stdout).toMatch(/Fresh 999\.999\.0 is available/);
+
+ // Updates check file
+ const text = JSON.parse(await Deno.readTextFile(filePath));
+ expect(text).toEqual({
+ current_version: CURRENT_VERSION,
+ latest_version: "999.999.0",
+ last_checked: text.last_checked,
+ last_shown: text.last_shown,
+ });
+
+ await Deno.remove(tmpDirName, { recursive: true });
+});
+
+Deno.test("only fetch new version defined by interval", async (t) => {
+ const tmpDirName = await Deno.makeTempDir();
+
+ await t.step("fetches latest version initially", async () => {
+ const out = await new Deno.Command(Deno.execPath(), {
+ args: [
+ "run",
+ "-A",
+ path.join(cwd, "../../tests/fixture_update_check/mod.ts"),
+ ],
+ cwd,
+ env: {
+ CI: "false",
+ UPDATE_INTERVAL: "100000",
+ TEST_HOME: tmpDirName,
+ LATEST_VERSION: "1.30.0",
+ },
+ stderr: "piped",
+ stdout: "piped",
+ }).output();
+
+ const { stdout } = getStdOutput(out);
+ expect(stdout).toMatch(/fetching latest version/);
+ });
+
+ await t.step("should not fetch if interval has not passed", async () => {
+ const out = await new Deno.Command(Deno.execPath(), {
+ args: [
+ "run",
+ "-A",
+ path.join(cwd, "../../tests/fixture_update_check/mod.ts"),
+ ],
+ cwd,
+ env: {
+ CI: "false",
+ UPDATE_INTERVAL: "100000",
+ TEST_HOME: tmpDirName,
+ LATEST_VERSION: "1.30.0",
+ },
+ stderr: "piped",
+ stdout: "piped",
+ }).output();
+
+ const { stdout } = getStdOutput(out);
+ expect(stdout).not.toMatch(/fetching latest version/);
+ });
+
+ await t.step("fetches if interval has passed", async () => {
+ const out = await new Deno.Command(Deno.execPath(), {
+ args: [
+ "run",
+ "-A",
+ path.join(cwd, "../../tests/fixture_update_check/mod.ts"),
+ ],
+ cwd,
+ env: {
+ CI: "false",
+ UPDATE_INTERVAL: "1 ",
+ TEST_HOME: tmpDirName,
+ LATEST_VERSION: "1.30.0",
+ },
+ }).output();
+
+ const { stdout } = getStdOutput(out);
+ expect(stdout).toMatch(/fetching latest version/);
+ });
+
+ await Deno.remove(tmpDirName, { recursive: true });
+});
+
+Deno.test("updates current version in cache file", async () => {
+ const tmpDirName = await Deno.makeTempDir();
+
+ const checkFile: CheckFile = {
+ current_version: "1.2.0",
+ latest_version: "1.2.0",
+ last_checked: new Date(Date.now() - WEEK).toISOString(),
+ };
+
+ await Deno.writeTextFile(
+ path.join(tmpDirName, "latest.json"),
+ JSON.stringify(checkFile, null, 2),
+ );
+
+ const out = await new Deno.Command(Deno.execPath(), {
+ args: [
+ "run",
+ "-A",
+ path.join(cwd, "../../tests/fixture_update_check/mod.ts"),
+ ],
+ cwd,
+ env: {
+ CI: "false",
+ TEST_HOME: tmpDirName,
+ LATEST_VERSION: CURRENT_VERSION,
+ },
+ stderr: "piped",
+ stdout: "piped",
+ }).output();
+
+ const { stdout } = getStdOutput(out);
+ expect(stdout).not.toMatch(/Fresh .* is available/);
+
+ await Deno.remove(tmpDirName, { recursive: true });
+});
+
+Deno.test("only shows update message when current < latest", async () => {
+ const tmpDirName = await Deno.makeTempDir();
+
+ const checkFile: CheckFile = {
+ current_version: "9999.999.0",
+ latest_version: "1.2.0",
+ last_checked: new Date().toISOString(),
+ };
+
+ await Deno.writeTextFile(
+ path.join(tmpDirName, "latest.json"),
+ JSON.stringify(checkFile, null, 2),
+ );
+
+ const out = await new Deno.Command(Deno.execPath(), {
+ args: [
+ "run",
+ "-A",
+ path.join(cwd, "../../tests/fixture_update_check/mod.ts"),
+ ],
+ cwd,
+ env: {
+ CI: "false",
+ TEST_HOME: tmpDirName,
+ LATEST_VERSION: CURRENT_VERSION,
+ CURRENT_VERSION: "99999.9999.0",
+ },
+ stderr: "piped",
+ stdout: "piped",
+ }).output();
+
+ const { stdout } = getStdOutput(out);
+ expect(stdout).not.toMatch(/Fresh .* is available/);
+
+ await Deno.remove(tmpDirName, { recursive: true });
+});
+
+Deno.test("migrates to last_shown property", async () => {
+ const tmpDirName = await Deno.makeTempDir();
+
+ const checkFile: CheckFile = {
+ latest_version: "1.4.0",
+ current_version: "1.2.0",
+ last_checked: new Date().toISOString(),
+ };
+
+ await Deno.writeTextFile(
+ path.join(tmpDirName, "latest.json"),
+ JSON.stringify(checkFile, null, 2),
+ );
+
+ const out = await new Deno.Command(Deno.execPath(), {
+ args: [
+ "run",
+ "-A",
+ path.join(cwd, "../../tests/fixture_update_check/mod.ts"),
+ ],
+ cwd,
+ env: {
+ CI: "false",
+ TEST_HOME: tmpDirName,
+ CURRENT_VERSION: "1.2.0",
+ LATEST_VERSION: "99999.9999.0",
+ },
+ stderr: "piped",
+ stdout: "piped",
+ }).output();
+
+ const { stdout } = getStdOutput(out);
+ expect(stdout).toMatch(/Fresh .* is available/);
+
+ const checkFileAfter = JSON.parse(
+ await Deno.readTextFile(
+ path.join(tmpDirName, "latest.json"),
+ ),
+ );
+
+ // Check if last version was written
+ expect(typeof checkFileAfter.last_shown).toEqual("string");
+
+ await Deno.remove(tmpDirName, { recursive: true });
+});
+
+Deno.test("doesn't show update if last_shown + interval >= today", async () => {
+ const tmpDirName = await Deno.makeTempDir();
+
+ const todayMinus1Hour = new Date();
+ todayMinus1Hour.setHours(todayMinus1Hour.getHours() - 1);
+
+ const checkFile: CheckFile = {
+ current_version: "1.2.0",
+ latest_version: "1.6.0",
+ last_checked: new Date().toISOString(),
+ last_shown: todayMinus1Hour.toISOString(),
+ };
+
+ await Deno.writeTextFile(
+ path.join(tmpDirName, "latest.json"),
+ JSON.stringify(checkFile, null, 2),
+ );
+
+ const out = await new Deno.Command(Deno.execPath(), {
+ args: [
+ "run",
+ "-A",
+ path.join(cwd, "../../tests/fixture_update_check/mod.ts"),
+ ],
+ cwd,
+ env: {
+ CI: "false",
+ TEST_HOME: tmpDirName,
+ CURRENT_VERSION: "1.2.0",
+ LATEST_VERSION: "99999.9999.0",
+ },
+ stderr: "piped",
+ stdout: "piped",
+ }).output();
+
+ const { stdout } = getStdOutput(out);
+ expect(stdout).not.toMatch(/Fresh .* is available/);
+
+ await Deno.remove(tmpDirName, { recursive: true });
+});
+
+Deno.test(
+ "shows update if last_shown + interval < today",
+ async () => {
+ const tmpDirName = await Deno.makeTempDir();
+
+ const yesterday = new Date();
+ yesterday.setDate(yesterday.getDate() - 1);
+
+ const checkFile: CheckFile = {
+ current_version: "1.2.0",
+ latest_version: "99.999.99",
+ last_checked: new Date().toISOString(),
+ last_shown: yesterday.toISOString(),
+ };
+
+ await Deno.writeTextFile(
+ path.join(tmpDirName, "latest.json"),
+ JSON.stringify(checkFile, null, 2),
+ );
+
+ const out = await new Deno.Command(Deno.execPath(), {
+ args: [
+ "run",
+ "-A",
+ path.join(cwd, "../../tests/fixture_update_check/mod.ts"),
+ ],
+ cwd,
+ env: {
+ CI: "false",
+ TEST_HOME: tmpDirName,
+ CURRENT_VERSION: CURRENT_VERSION,
+ LATEST_VERSION: "99999.9999.0",
+ },
+ stderr: "piped",
+ stdout: "piped",
+ }).output();
+
+ const { stdout } = getStdOutput(out);
+ expect(stdout).toMatch(/Fresh .* is available/);
+
+ const checkFileAfter = JSON.parse(
+ await Deno.readTextFile(
+ path.join(tmpDirName, "latest.json"),
+ ),
+ );
+
+ expect(checkFileAfter.last_shown).not.toEqual(yesterday.toISOString());
+
+ await Deno.remove(tmpDirName, { recursive: true });
+ },
+);
diff --git a/src/error.ts b/src/error.ts
new file mode 100644
index 00000000000..e6e2c149531
--- /dev/null
+++ b/src/error.ts
@@ -0,0 +1,155 @@
+import { MODE } from "./runtime/server/mod.tsx";
+
+export function getMessage(status: number): string {
+ switch (status) {
+ case 100:
+ return "Continue";
+ case 101:
+ return "Switching Protocols";
+ case 102:
+ return "Processing (WebDAV)";
+ case 103:
+ return "Early Hints";
+ case 200:
+ return "OK";
+ case 201:
+ return "Created";
+ case 202:
+ return "Accepted";
+ case 203:
+ return "Non-Authoritative Information";
+ case 204:
+ return "No Content";
+ case 205:
+ return "Reset Content";
+ case 206:
+ return "Partial Content";
+ case 207:
+ return "Multi-Status (WebDAV)";
+ case 208:
+ return "Already Reported (WebDAV)";
+ case 226:
+ return "IM Used (HTTP Delta encoding)";
+ case 300:
+ return "Multiple Choices";
+ case 301:
+ return "Moved Permanently";
+ case 302:
+ return "Found";
+ case 303:
+ return "See Other";
+ case 304:
+ return "Not Modified";
+ case 305:
+ return "Use Proxy Deprecated";
+ case 306:
+ return "unused";
+ case 307:
+ return "Temporary Redirect";
+ case 308:
+ return "Permanent Redirect";
+ case 400:
+ return "Bad Request";
+ case 401:
+ return "Unauthorized";
+ case 402:
+ return "Payment Required Experimental";
+ case 403:
+ return "Forbidden";
+ case 404:
+ return "Not Found";
+ case 405:
+ return "Method Not Allowed";
+ case 406:
+ return "Not Acceptable";
+ case 407:
+ return "Proxy Authentication Required";
+ case 408:
+ return "Request Timeout";
+ case 409:
+ return "Conflict";
+ case 410:
+ return "Gone";
+ case 411:
+ return "Length Required";
+ case 412:
+ return "Precondition Failed";
+ case 413:
+ return "Payload Too Large";
+ case 414:
+ return "URI Too Long";
+ case 415:
+ return "Unsupported Media Type";
+ case 416:
+ return "Range Not Satisfiable";
+ case 417:
+ return "Expectation Failed";
+ case 418:
+ return "I'm a teapot";
+ case 421:
+ return "Misdirected Request";
+ case 422:
+ return "Unprocessable Content (WebDAV)";
+ case 423:
+ return "Locked (WebDAV)";
+ case 424:
+ return "Failed Dependency (WebDAV)";
+ case 425:
+ return "Too Early Experimental";
+ case 426:
+ return "Upgrade Required";
+ case 428:
+ return "Precondition Required";
+ case 429:
+ return "Too Many Requests";
+ case 431:
+ return "Request Header Fields Too Large";
+ case 451:
+ return "Unavailable For Legal Reasons";
+ case 500:
+ return "Internal Server Error";
+ case 501:
+ return "Not Implemented";
+ case 502:
+ return "Bad Gateway";
+ case 503:
+ return "Service Unavailable";
+ case 504:
+ return "Gateway Timeout";
+ case 505:
+ return "HTTP Version Not Supported";
+ case 506:
+ return "Variant Also Negotiates";
+ case 507:
+ return "Insufficient Storage (WebDAV)";
+ case 508:
+ return "Loop Detected (WebDAV)";
+ case 510:
+ return "Not Extended";
+ case 511:
+ return "Network Authentication Required";
+ default:
+ return "Internal Server Error";
+ }
+}
+
+export class HttpError {
+ #error: Error | null = null;
+ name = "HttpError";
+ message: string;
+
+ constructor(
+ public status: number,
+ message: string = getMessage(status),
+ public options?: ErrorOptions,
+ ) {
+ this.message = message;
+ if (MODE !== "production") {
+ this.#error = new Error();
+ }
+ }
+
+ get stack(): string | undefined {
+ return this.#error?.stack;
+ }
+}
diff --git a/src/error_test.ts b/src/error_test.ts
new file mode 100644
index 00000000000..da86e97b832
--- /dev/null
+++ b/src/error_test.ts
@@ -0,0 +1,27 @@
+import { expect } from "@std/expect";
+import { MODE, setMode } from "./runtime/server/mod.tsx";
+import { HttpError } from "./error.ts";
+
+Deno.test("HttpError - contains stack in development", () => {
+ const tmp = MODE;
+ setMode("development");
+ try {
+ const err = new HttpError(404);
+ expect(err.status).toEqual(404);
+ expect(typeof err.stack).toEqual("string");
+ } finally {
+ setMode(tmp);
+ }
+});
+
+Deno.test("HttpError - contains no stack in production", () => {
+ const tmp = MODE;
+ setMode("production");
+ try {
+ const err = new HttpError(404);
+ expect(err.status).toEqual(404);
+ expect(err.stack).toEqual(undefined);
+ } finally {
+ setMode(tmp);
+ }
+});
diff --git a/src/finish_setup.tsx b/src/finish_setup.tsx
new file mode 100644
index 00000000000..b3673d22059
--- /dev/null
+++ b/src/finish_setup.tsx
@@ -0,0 +1,54 @@
+import type { ComponentChildren } from "preact";
+
+export function FinishSetup() {
+ return (
+
+
+
+
Finish setting up Fresh
+
+
+ Go to your project in Deno Deploy and click the{" "}
+ Settings
tab.
+
+
+ In the Git Integration section, enter deno task build
+ {" "}
+ in the Build Command
input.
+
+
+ Save the changes.
+
+
+
+
+
+ );
+}
+
+export function ForgotBuild() {
+ return (
+
+
+
+
Missing build directory
+
+ Did you forget to run deno task build
?
+
+
+
+
+ );
+}
+
+function Doc(props: { children?: ComponentChildren }) {
+ return (
+
+
+
+ Finish setting up Fresh
+
+ {props.children}
+
+ );
+}
diff --git a/src/fs.ts b/src/fs.ts
new file mode 100644
index 00000000000..35c4d149ec5
--- /dev/null
+++ b/src/fs.ts
@@ -0,0 +1,39 @@
+import { walk, type WalkEntry, type WalkOptions } from "@std/fs/walk";
+
+export interface FreshFile {
+ size: number;
+ readable: ReadableStream;
+}
+
+export interface FsAdapter {
+ walk(
+ root: string | URL,
+ options?: WalkOptions,
+ ): AsyncIterableIterator;
+ isDirectory(path: string | URL): Promise;
+ mkdirp(dir: string): Promise;
+ readFile(path: string | URL): Promise;
+}
+
+export const fsAdapter: FsAdapter = {
+ walk,
+ async isDirectory(path) {
+ try {
+ const stat = await Deno.stat(path);
+ return stat.isDirectory;
+ } catch (err) {
+ if (err instanceof Deno.errors.NotFound) return false;
+ throw err;
+ }
+ },
+ async mkdirp(dir: string) {
+ try {
+ await Deno.mkdir(dir, { recursive: true });
+ } catch (err) {
+ if (!(err instanceof Deno.errors.AlreadyExists)) {
+ throw err;
+ }
+ }
+ },
+ readFile: Deno.readFile,
+};
diff --git a/src/handlers.ts b/src/handlers.ts
new file mode 100644
index 00000000000..662d46c807b
--- /dev/null
+++ b/src/handlers.ts
@@ -0,0 +1,172 @@
+import type { FreshContext } from "./context.ts";
+import type { Method } from "./router.ts";
+
+export interface Render {
+ data: T;
+ headers?: HeadersInit;
+ status?: number;
+}
+
+/**
+ * A handler function that can be used to specify how a given route should
+ * handle requests.
+ *
+ * The handler function can either return a {@link Response} object, or some
+ * data that can be rendered by a page component. See {@link HandlerFn} for more
+ * information.
+ *
+ * ### Per method handlers
+ *
+ * A route handler can be specific to a given HTTP method (GET, POST, PUT,
+ * DELETE, etc). To define a method-specific handler, specify an object that
+ * maps method names to functions that conform to the {@link HandlerFn}
+ * signature.
+ *
+ * ```ts
+ * export const handlers = defineHandlers({
+ * GET: (ctx) => {
+ * return new Response("Hello from a GET request!");
+ * },
+ * POST: (ctx) => {
+ * return new Response("Hello from a POST request!");
+ * }
+ * });
+ * ```
+ *
+ * Any requests to methods not specified in the handler object will result in a
+ * 405 Method Not Allowed response. If you want to handle these requests, you
+ * can define a catch-all handler.
+ *
+ * If a GET handler is specified, but no HEAD handler is specified, a HEAD
+ * handler will automatically be generated that calls the GET handler and
+ * strips the response body.
+ *
+ * ### Catch-all handlers
+ *
+ * A route handler can also catch all requests in a route. To define a catch-all
+ * handler, specify a function that conforms to the {@link HandlerFn} signature.
+ * This function will be called for all requests, regardless of the method.
+ *
+ * ```ts
+ * export const handlers = defineHandlers((ctx) => {
+ * return new Response(`Hello from a ${ctx.req.method} request!`);
+ * });
+ * ```
+ */
+export type RouteHandler =
+ | HandlerFn
+ | HandlerByMethod;
+
+export function isHandlerByMethod(
+ handler: RouteHandler,
+): handler is HandlerByMethod {
+ return handler !== null && typeof handler === "object";
+}
+
+/**
+ * A handler function that is invoked when a request is made to a route. The
+ * handler function is passed a {@link FreshContext} object that contains the
+ * original request object, as well as any state related to the current request.
+ *
+ * The handler function can either return a {@link Response} object, which will
+ * be sent back to the client, or some data that will be passed to the routes'
+ * page component for rendering.
+ *
+ * ### Returning a Response
+ *
+ * If the handler function returns a {@link Response} object, the response will
+ * be sent back to the client. This can be used to send back static content, or
+ * to redirect the client to another URL.
+ *
+ * ```ts
+ * export const handler = defineHandler((ctx) => {
+ * return new Response("Hello, world!");
+ * });
+ * ```
+ *
+ * ### Returning data
+ *
+ * If the handler function returns an object with a `data` property, the data
+ * will be passed to the page component, where it can be rendered into HTML.
+ *
+ * ```ts
+ * export const handler = defineHandler((ctx) => {
+ * return { data: { message: "Hello, world!" } };
+ * });
+ *
+ * export default definePage(({ data }) => {
+ * return {data.message} ;
+ * });
+ * ```
+ *
+ * When returning data, you can also specify additional properties that will be
+ * used when constructing the response object from the HTML generated by the
+ * page component. For example, you can specify custom headers, a custom status
+ * code, or a list of elements to include in the ``.
+ *
+ * ```tsx
+ * export const handler = defineHandler((ctx) => {
+ * return {
+ * data: { message: "Hello, world!" },
+ * headers: { "Cache-Control": "public, max-age=3600" },
+ * status: 201,
+ * head: [Hello, world! ],
+ * };
+ * });
+ * ```
+ *
+ * ### Asynchronous handlers
+ *
+ * The handler function can also be asynchronous. This can be useful if you need
+ * to fetch data from an external source, or perform some other asynchronous
+ * operation before returning a response.
+ *
+ * ```ts
+ * export const handler = defineHandler(async (ctx) => {
+ * const resp = await fetch("https://api.example.com/data").;
+ * if (!resp.ok) {
+ * throw new Error("Failed to fetch data");
+ * }
+ * const data = await resp.json();
+ * return { data };
+ * });
+ * ```
+ *
+ * If you initiate multiple asynchronous operations in a handler, you can use
+ * `Promise.all` to wait for all of them to complete at the same time. This can
+ * speed up the response time of your handler, as it allows you to perform
+ * multiple operations concurrently.
+ *
+ * ```ts
+ * export const handler = defineHandler(async (ctx) => {
+ * const [resp1, resp2] = await Promise.all([
+ * fetch("https://api.example.com/data1")
+ * .then((resp) => resp.json()),
+ * fetch("https://api.example.com/data2")
+ * .then((resp) => resp.json()),
+ * ]);
+ * return { data: { data1, data2 } };
+ * });
+ * ```
+ */
+export interface HandlerFn {
+ (ctx: FreshContext):
+ | Response
+ | Render
+ | void
+ | Promise | void>;
+}
+
+/**
+ * A set of handler functions that routes requests based on the HTTP method.
+ *
+ * See {@link RouteHandler} for more information on how to use this type.
+ */
+export type HandlerByMethod = {
+ [M in Method]?: HandlerFn;
+};
+
+export type RouteData<
+ Handler extends RouteHandler,
+> = Handler extends (RouteHandler) ? Data
+ : never;
diff --git a/src/helpers.ts b/src/helpers.ts
new file mode 100644
index 00000000000..111e327d263
--- /dev/null
+++ b/src/helpers.ts
@@ -0,0 +1,155 @@
+import type { AnyComponent } from "preact";
+import type { HandlerByMethod, RouteHandler } from "./handlers.ts";
+import type { FreshContext } from "./context.ts";
+import type { Middleware } from "./middlewares/mod.ts";
+
+/**
+ * A set of helper functions that enable better type inference and code
+ * completion when defining routes and middleware.
+ *
+ * To create a helpers object, call {@link createHelpers}.
+ */
+export interface Helpers {
+ /**
+ * Define a {@link RouteHandler} object. This function returns the passed
+ * input as-is.
+ *
+ * You can use this function to help the TypeScript compiler infer the types
+ * of your route handlers. For example:
+ *
+ * ```ts
+ * export const handler = helpers.defineHandlers((ctx) => {
+ * ctx.url; // ctx is inferred to be a FreshContext object, so this is a URL
+ * return new Response("Hello, world!");
+ * });
+ * ```
+ *
+ * This is particularly useful when combined with the {@link definePage}
+ * helper function, which can infer the data type from the handler function.
+ * For more information, see {@link definePage}.
+ *
+ * You can also pass an explicit type argument to ensure that all data
+ * returned from the render function is of the correct type:
+ *
+ * ```ts
+ * export const handler = helpers.defineHandlers<{ slug: string }>({
+ * async GET(ctx) {
+ * const slug = ctx.params.slug; // slug is inferred to be a string
+ * return { data: { slug } };
+ * },
+ *
+ * // This method will cause a type error because the data object is missing
+ * // the required `slug` property.
+ * async POST(ctx) {
+ * return { data: { } };
+ * },
+ * });
+ * ```
+ *
+ * @typeParam Data The type of data that the handler returns. This will be inferred from the handler methods if not provided.
+ * @typeParam Handlers This will always be inferred from the input object. Do not manually specify this type.
+ */
+ defineHandlers<
+ Data,
+ Handlers extends RouteHandler = RouteHandler,
+ >(
+ handlers: Handlers,
+ ): typeof handlers;
+
+ /**
+ * Define a page component that will be rendered when a route handler returns
+ * data. This function returns the passed input as-is.
+ *
+ * You can use this function to help the TypeScript compiler infer the types
+ * of the data that your page component receives. For example:
+ *
+ * ```ts
+ * export default helpers.definePage((props) => {
+ * const slug = props.params.slug; // Because props is inferred to be a FreshContext object, slug is inferred to be a string
+ * return {slug} ;
+ * });
+ * ```
+ *
+ * This is particularly useful when combined with the {@link defineHandlers}
+ * helper function, in which case the data type will be inferred from the
+ * return type of the handler method.
+ *
+ * ```ts
+ * export const handler = defineHandlers({
+ * async GET(ctx) {
+ * const slug = ctx.params.slug; // slug is inferred to be a string
+ * return { data: { slug } };
+ * },
+ * });
+ *
+ * export default definePage(({ data }) => {
+ * const slug = data.slug; // slug is inferred to be a string here
+ * return {slug} ;
+ * });
+ * ```
+ *
+ * As a rule of thumb, always use this function to define your page
+ * components. If you also have a handler for this route, pass the handler
+ * object as a type argument to this function. If you do not have a handler,
+ * omit the type argument.
+ *
+ * @typeParam Handler The type of the handler object that this page component is associated with. If this route has a handler, pass the handler object as a type argument to this function, e.g. `typeof handler`. If this route does not have a handler, omit this type argument.
+ * @typeParam Data The type of data that the page component receives. This will be inferred from the handler methods if not provided. In very advanced use cases, you can specify `never` to the `Handler` type argument and provide the `Data` type explicitly.
+ */
+ definePage<
+ Handler extends RouteHandler = never,
+ Data = Handler extends HandlerByMethod ? Data : never,
+ >(
+ render: AnyComponent & { Component: () => null }>,
+ ): typeof render;
+
+ /**
+ * Define a {@link Middleware} that will be used to process requests before
+ * they are passed to the route handler. This function returns the passed
+ * input as-is.
+ *
+ * You can use this function to help the TypeScript compiler infer the types
+ * of the context object that your middleware receives. For example:
+ *
+ * ```ts
+ * export const middleware = helpers.defineMiddleware((ctx) => {
+ * ctx.url; // ctx is inferred to be a FreshContext object, so this is a URL
+ * return ctx.next();
+ * });
+ * ```
+ *
+ * You may also pass an array of middleware functions to this function.
+ *
+ * @typeParam M The type of the middleware function. This will be inferred from the input function. Do not manually specify this type.
+ */
+ defineMiddleware>(
+ middleware: M,
+ ): typeof middleware;
+}
+
+/**
+ * Create a set of helper functions that enable better type inference and code
+ * completion when defining routes and middleware.
+ *
+ * To use, call this function in a central file and export the result. In your
+ * route and middleware files, import the {@link Helpers|helpers object} and use
+ * them to define your routes and middleware using the
+ * {@link Helpers.defineHandlers|defineHandlers},
+ * {@link Helpers.definePage|definePage}, and
+ * {@link Helpers.defineMiddleware|defineMiddleware} functions.
+ *
+ * @typeParam State The type of the state object that is passed to all middleware and route handlers.
+ */
+export function createHelpers(): Helpers {
+ return {
+ defineHandlers(handlers) {
+ return handlers;
+ },
+ definePage(render) {
+ return render;
+ },
+ defineMiddleware(middleware) {
+ return middleware;
+ },
+ };
+}
diff --git a/src/jsonify/constants.ts b/src/jsonify/constants.ts
new file mode 100644
index 00000000000..07a273c7ef0
--- /dev/null
+++ b/src/jsonify/constants.ts
@@ -0,0 +1,7 @@
+export const UNDEFINED = -1;
+export const NULL = -2;
+export const NAN = -3;
+export const INFINITY_POS = -4;
+export const INFINITY_NEG = -5;
+export const ZERO_NEG = -6;
+export const HOLE = -7;
diff --git a/src/jsonify/custom_test.ts b/src/jsonify/custom_test.ts
new file mode 100644
index 00000000000..0aa5ffef32d
--- /dev/null
+++ b/src/jsonify/custom_test.ts
@@ -0,0 +1,54 @@
+import { expect } from "@std/expect";
+import { parse } from "./parse.ts";
+import { stringify } from "./stringify.ts";
+import { Signal, signal } from "@preact/signals";
+
+Deno.test("custom parse - Point", () => {
+ class Point {
+ constructor(public x: number, public y: number) {
+ this.x = x;
+ this.y = y;
+ }
+ }
+
+ const str = stringify(new Point(30, 40), {
+ Point: (value) => value instanceof Point ? [value.x, value.y] : undefined,
+ });
+
+ expect(str).toEqual('[["Point",1],[2,3],30,40]');
+
+ const point = parse(str, {
+ Point: ([x, y]: [number, number]) => new Point(x, y),
+ });
+ expect(point).toEqual(new Point(30, 40));
+});
+
+Deno.test("custom parse - Signals", () => {
+ const res = parse('[["Signal",1],2]', {
+ Signal: (value) => signal(value),
+ });
+ expect(res).toBeInstanceOf(Signal);
+ expect(res.peek()).toEqual(2);
+});
+
+Deno.test("custom stringify - Signals", () => {
+ const s = signal(2);
+ expect(stringify(s, {
+ Signal: (s2: unknown) => {
+ return s2 instanceof Signal ? s2.peek() : undefined;
+ },
+ })).toEqual(
+ '[["Signal",1],2]',
+ );
+});
+
+Deno.test("custom stringify - referenced Signals", () => {
+ const s = signal(2);
+ expect(stringify([s, s], {
+ Signal: (s2: unknown) => {
+ return s2 instanceof Signal ? s2.peek() : undefined;
+ },
+ })).toEqual(
+ '[[1,1],["Signal",2],2]',
+ );
+});
diff --git a/src/jsonify/parse.ts b/src/jsonify/parse.ts
new file mode 100644
index 00000000000..66ed83ca89c
--- /dev/null
+++ b/src/jsonify/parse.ts
@@ -0,0 +1,193 @@
+import {
+ HOLE,
+ INFINITY_NEG,
+ INFINITY_POS,
+ NAN,
+ NULL,
+ UNDEFINED,
+ ZERO_NEG,
+} from "./constants.ts";
+
+// deno-lint-ignore no-explicit-any
+export type CustomParser = Record unknown>;
+
+export function parse(
+ value: string,
+ custom?: CustomParser | undefined,
+): T {
+ const data = JSON.parse(value);
+
+ const hydrated = new Array(data.length);
+ // deno-lint-ignore no-explicit-any
+ unpack(data, hydrated, 0, custom) as any;
+ return hydrated[0];
+}
+
+function unpack(
+ arr: unknown[],
+ hydrated: unknown[],
+ idx: number,
+ custom: CustomParser | undefined,
+): void {
+ if (idx in hydrated) return;
+
+ const current = arr[idx];
+ if (typeof current === "number") {
+ switch (current) {
+ case UNDEFINED:
+ hydrated[idx] = undefined;
+ return;
+ case NULL:
+ hydrated[idx] = null;
+ return;
+ case NAN:
+ hydrated[idx] = NaN;
+ return;
+ case INFINITY_POS:
+ hydrated[idx] = Infinity;
+ return;
+ case INFINITY_NEG:
+ hydrated[idx] = -Infinity;
+ return;
+ case ZERO_NEG:
+ hydrated[idx] = -0;
+ return;
+ default:
+ hydrated[idx] = current;
+ return;
+ }
+ } else if (
+ typeof current === "string" || typeof current === "boolean" ||
+ current === null
+ ) {
+ hydrated[idx] = current;
+ return;
+ } else if (Array.isArray(current)) {
+ if (current.length > 0 && typeof current[0] === "string") {
+ const name = current[0];
+ if (custom !== undefined && name in custom) {
+ const fn = custom[name];
+ const ref = current[1];
+ unpack(arr, hydrated, ref, custom);
+ const value = hydrated[ref];
+ hydrated[idx] = fn(value);
+ return;
+ }
+ switch (name) {
+ case "BigInt":
+ hydrated[idx] = BigInt(current[1]);
+ return;
+ case "Date":
+ hydrated[idx] = new Date(current[1]);
+ return;
+ case "RegExp":
+ hydrated[idx] = new RegExp(current[1], current[2]);
+ return;
+ case "Set": {
+ const set = new Set();
+ for (let i = 0; i < current[1].length; i++) {
+ const ref = current[1][i];
+ unpack(arr, hydrated, ref, custom);
+ set.add(hydrated[ref]);
+ }
+ hydrated[idx] = set;
+ return;
+ }
+ case "Map": {
+ const set = new Map();
+ for (let i = 0; i < current[1].length; i++) {
+ const refKey = current[1][i++];
+ unpack(arr, hydrated, refKey, custom);
+ const refValue = current[1][i];
+ unpack(arr, hydrated, refValue, custom);
+
+ set.set(hydrated[refKey], hydrated[refValue]);
+ }
+ hydrated[idx] = set;
+ return;
+ }
+ case "Uint8Array":
+ hydrated[idx] = b64decode(current[1]);
+ return;
+ }
+ } else {
+ const actual = new Array(current.length);
+ hydrated[idx] = actual;
+ for (let i = 0; i < current.length; i++) {
+ const ref = current[i];
+ if (ref < 0) {
+ switch (ref) {
+ case UNDEFINED:
+ actual[i] = undefined;
+ break;
+ case NULL:
+ actual[i] = null;
+ break;
+ case NAN:
+ actual[i] = NaN;
+ break;
+ case INFINITY_POS:
+ actual[i] = Infinity;
+ break;
+ case INFINITY_NEG:
+ actual[i] = -Infinity;
+ break;
+ case ZERO_NEG:
+ actual[i] = -0;
+ break;
+ case HOLE:
+ continue;
+ }
+ } else {
+ unpack(arr, hydrated, ref, custom);
+ actual[i] = hydrated[ref];
+ }
+ }
+ }
+ } else if (typeof current === "object") {
+ const actual: Record = {};
+ hydrated[idx] = actual;
+
+ const keys = Object.keys(current);
+ for (let i = 0; i < keys.length; i++) {
+ const key = keys[i];
+ // deno-lint-ignore no-explicit-any
+ const ref = (current as any)[key];
+ if (ref < 0) {
+ switch (ref) {
+ case UNDEFINED:
+ actual[key] = undefined;
+ break;
+ case NULL:
+ actual[key] = null;
+ break;
+ case NAN:
+ actual[key] = NaN;
+ break;
+ case INFINITY_POS:
+ actual[key] = Infinity;
+ break;
+ case INFINITY_NEG:
+ actual[key] = -Infinity;
+ break;
+ case ZERO_NEG:
+ actual[key] = -0;
+ break;
+ }
+ } else {
+ unpack(arr, hydrated, ref, custom);
+ actual[key] = hydrated[ref];
+ }
+ }
+ }
+}
+
+function b64decode(b64: string): Uint8Array {
+ const binString = atob(b64);
+ const size = binString.length;
+ const bytes = new Uint8Array(size);
+ for (let i = 0; i < size; i++) {
+ bytes[i] = binString.charCodeAt(i);
+ }
+ return bytes;
+}
diff --git a/src/jsonify/parse_test.ts b/src/jsonify/parse_test.ts
new file mode 100644
index 00000000000..8e5a1b88df1
--- /dev/null
+++ b/src/jsonify/parse_test.ts
@@ -0,0 +1,99 @@
+import { expect } from "@std/expect";
+import { parse } from "./parse.ts";
+
+Deno.test("parse - json", () => {
+ expect(parse("[2]")).toEqual(2);
+ expect(parse('["abc"]')).toEqual("abc");
+ expect(parse("[true]")).toEqual(true);
+ expect(parse("[false]")).toEqual(false);
+ expect(parse("[[1,2,3],1,2,3]")).toEqual([1, 2, 3]);
+ expect(parse('[{"a":1,"b":-2,"c":2},1,[-2]]')).toEqual({
+ a: 1,
+ b: null,
+ c: [null],
+ });
+});
+
+Deno.test("parse - hole array", () => {
+ expect(parse("[[1,-7,2],1,3]")).toEqual([1, , 3]);
+});
+
+Deno.test("parse - undefined", () => {
+ expect(parse("[-1]")).toEqual(undefined);
+});
+
+Deno.test("parse - Infinity", () => {
+ expect(parse("[-4]")).toEqual(Infinity);
+ expect(parse("[-5]")).toEqual(-Infinity);
+});
+
+Deno.test("parse - NaN", () => {
+ expect(parse("[-3]")).toEqual(NaN);
+});
+
+Deno.test("parse - -0", () => {
+ expect(parse("[-6]")).toEqual(-0);
+});
+
+Deno.test("parse - bigint", () => {
+ const n = BigInt(9007199254740991);
+ expect(parse('[["BigInt","9007199254740991"]]')).toEqual(
+ n,
+ );
+});
+
+Deno.test("parse - Set", () => {
+ const res = parse('[["Set",[1,2]],1,{"foo":1}]');
+ expect(res).toEqual(
+ new Set([1, { foo: 1 }]),
+ );
+});
+
+Deno.test("parse - Map", () => {
+ expect(parse('[["Map",[1,2,3,4]],1,{"foo":1},2,3]'))
+ .toEqual(
+ new Map([[1, { foo: 1 }], [2, 3]]),
+ );
+});
+
+Deno.test("parse - Date", () => {
+ const date = new Date("1990-05-31");
+ expect(parse('[["Date","1990-05-31T00:00:00.000Z"]]')).toEqual(
+ date,
+ );
+});
+
+Deno.test("parse - RegExp", () => {
+ let reg = /foo["]/;
+ expect(parse('[["RegExp","foo[\\"]", ""]]')).toEqual(reg);
+
+ reg = /foo["]/g;
+ expect(parse('[["RegExp","foo[\\"]", "g"]]')).toEqual(reg);
+});
+
+Deno.test("parse - Uint8Array", () => {
+ const value = new Uint8Array([1, 2, 3]);
+ expect(parse('[["Uint8Array","AQID"]]')).toEqual(
+ value,
+ );
+});
+
+Deno.test("parse - references", () => {
+ const inner = { foo: 123 };
+ const obj = { a: inner, b: [inner, inner] };
+ const res = parse('[{"a":1,"b":3},{"foo":2},123,[1,1]]');
+ expect(res).toEqual(obj);
+ expect(res.a).toEqual(res.b[0]);
+ expect(res.a).toEqual(res.b[1]);
+});
+
+Deno.test("parse - circular references", () => {
+ // deno-lint-ignore no-explicit-any
+ const foo = { foo: null as any };
+ foo.foo = foo;
+ expect(parse('[{"foo":0}]')).toEqual(foo);
+});
+
+Deno.test("parse - object", () => {
+ expect(parse('[{"foo":1},42]')).toEqual({ foo: 42 });
+});
diff --git a/src/jsonify/stringify.ts b/src/jsonify/stringify.ts
new file mode 100644
index 00000000000..5511e8d713f
--- /dev/null
+++ b/src/jsonify/stringify.ts
@@ -0,0 +1,186 @@
+import {
+ INFINITY_NEG,
+ INFINITY_POS,
+ NAN,
+ NULL,
+ UNDEFINED,
+ ZERO_NEG,
+} from "./constants.ts";
+import { HOLE } from "./constants.ts";
+
+// deno-lint-ignore no-explicit-any
+export type Stringifiers = Record any>;
+
+/**
+ * Serializes the following:
+ *
+ * - `null`
+ * - `undefined`
+ * - `boolean`
+ * - `number`
+ * - `bigint`
+ * - `string`
+ * - `array`
+ * - `object` (no prototypes)
+ * - `Uint8Array`
+ * - `Date`
+ * - `RegExp`
+ * - `Set`
+ * - `Map`
+ *
+ * Circular references are supported and objects with the same reference are
+ * serialized only once.
+ */
+export function stringify(data: unknown, custom?: Stringifiers): string {
+ const out: string[] = [];
+ const indexes = new Map();
+ const res = serializeInner(out, indexes, data, custom);
+ if (res < 0) {
+ out.push(String(res));
+ }
+ return `[${out.join(",")}]`;
+}
+
+function serializeInner(
+ out: string[],
+ indexes: Map,
+ value: unknown,
+ custom: Stringifiers | undefined,
+): number {
+ const seenIdx = indexes.get(value);
+ if (seenIdx !== undefined) return seenIdx;
+
+ if (value === undefined) return UNDEFINED;
+ if (value === null) return NULL;
+ if (Number.isNaN(value)) return NAN;
+ if (value === Infinity) return INFINITY_POS;
+ if (value === -Infinity) return INFINITY_NEG;
+ if (value === 0 && 1 / value < 0) return ZERO_NEG;
+
+ const idx = out.length;
+ out.push("");
+ indexes.set(value, idx);
+
+ let str = "";
+
+ if (typeof value === "number") {
+ str += String(value);
+ } else if (typeof value === "boolean") {
+ str += String(value);
+ } else if (typeof value === "bigint") {
+ str += `["BigInt","${value}"]`;
+ } else if (typeof value === "string") {
+ str += JSON.stringify(value);
+ } else if (Array.isArray(value)) {
+ str += "[";
+ for (let i = 0; i < value.length; i++) {
+ if (i in value) {
+ str += serializeInner(out, indexes, value[i], custom);
+ } else {
+ str += HOLE;
+ }
+
+ if (i < value.length - 1) {
+ str += ",";
+ }
+ }
+ str += "]";
+ } else if (typeof value === "object") {
+ if (custom !== undefined) {
+ for (const k in custom) {
+ const fn = custom[k];
+ if (fn === undefined) continue;
+
+ const res = fn(value);
+ if (res === undefined) continue;
+
+ serializeInner(out, indexes, res, custom);
+ str = `["${k}",${idx + 1}]`;
+ out[idx] = str;
+ return idx;
+ }
+ }
+
+ if (value instanceof Date) {
+ str += `["Date","${value.toISOString()}"]`;
+ } else if (value instanceof RegExp) {
+ str += `["RegExp",${JSON.stringify(value.source)}, "${value.flags}"]`;
+ } else if (value instanceof Uint8Array) {
+ str += `["Uint8Array","${b64encode(value)}"]`;
+ } else if (value instanceof Set) {
+ const items = new Array(value.size);
+ let i = 0;
+ value.forEach((v) => {
+ items[i++] = serializeInner(out, indexes, v, custom);
+ });
+ str += `["Set",[${items.join(",")}]]`;
+ } else if (value instanceof Map) {
+ const items = new Array(value.size * 2);
+ let i = 0;
+ value.forEach((v, k) => {
+ items[i++] = serializeInner(out, indexes, k, custom);
+ items[i++] = serializeInner(out, indexes, v, custom);
+ });
+ str += `["Map",[${items.join(",")}]]`;
+ } else {
+ str += "{";
+ const keys = Object.keys(value);
+ for (let i = 0; i < keys.length; i++) {
+ const key = keys[i];
+ str += JSON.stringify(key) + ":";
+ // deno-lint-ignore no-explicit-any
+ str += serializeInner(out, indexes, (value as any)[key], custom);
+
+ if (i < keys.length - 1) {
+ str += ",";
+ }
+ }
+ str += "}";
+ }
+ } else if (typeof value === "function") {
+ throw new Error(`Serializing functions is not supported.`);
+ }
+
+ out[idx] = str;
+ return idx;
+}
+
+// deno-fmt-ignore
+const base64abc = [
+ "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O",
+ "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a", "b", "c", "d",
+ "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s",
+ "t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", "7",
+ "8", "9", "+", "/",
+];
+
+/**
+ * CREDIT: https://gist.github.com/enepomnyaschih/72c423f727d395eeaa09697058238727
+ * Encodes a given Uint8Array, ArrayBuffer or string into RFC4648 base64 representation
+ */
+export function b64encode(buffer: ArrayBuffer): string {
+ const uint8 = new Uint8Array(buffer);
+ let result = "",
+ i;
+ const l = uint8.length;
+ for (i = 2; i < l; i += 3) {
+ result += base64abc[uint8[i - 2] >> 2];
+ result += base64abc[((uint8[i - 2] & 0x03) << 4) | (uint8[i - 1] >> 4)];
+ result += base64abc[((uint8[i - 1] & 0x0f) << 2) | (uint8[i] >> 6)];
+ result += base64abc[uint8[i] & 0x3f];
+ }
+ if (i === l + 1) {
+ // 1 octet yet to write
+ result += base64abc[uint8[i - 2] >> 2];
+ result += base64abc[(uint8[i - 2] & 0x03) << 4];
+ result += "==";
+ }
+ if (i === l) {
+ // 2 octets yet to write
+ result += base64abc[uint8[i - 2] >> 2];
+ result += base64abc[((uint8[i - 2] & 0x03) << 4) | (uint8[i - 1] >> 4)];
+ result += base64abc[(uint8[i - 1] & 0x0f) << 2];
+ result += "=";
+ }
+ return result;
+}
diff --git a/src/jsonify/stringify_test.ts b/src/jsonify/stringify_test.ts
new file mode 100644
index 00000000000..912dda67380
--- /dev/null
+++ b/src/jsonify/stringify_test.ts
@@ -0,0 +1,109 @@
+import { expect } from "@std/expect";
+import { stringify } from "./stringify.ts";
+
+Deno.test("stringify - json", () => {
+ expect(stringify(2)).toEqual("[2]");
+ expect(stringify("abc")).toEqual('["abc"]');
+ expect(stringify(true)).toEqual("[true]");
+ expect(stringify(false)).toEqual("[false]");
+ expect(stringify([1, 2, 3])).toEqual("[[1,2,3],1,2,3]");
+ expect(stringify({ a: 1, b: null, c: [null] })).toEqual(
+ '[{"a":1,"b":-2,"c":2},1,[-2]]',
+ );
+});
+
+Deno.test("stringify - hole array", () => {
+ expect(stringify([1, , 3])).toEqual("[[1,-7,2],1,3]");
+});
+
+Deno.test("stringify - undefined", () => {
+ expect(stringify(undefined)).toEqual("[-1]");
+});
+
+Deno.test("stringify - Infinity", () => {
+ expect(stringify(Infinity)).toEqual("[-4]");
+ expect(stringify(-Infinity)).toEqual("[-5]");
+});
+
+Deno.test("stringify - NaN", () => {
+ expect(stringify(NaN)).toEqual("[-3]");
+});
+
+Deno.test("stringify - -0", () => {
+ expect(stringify(-0)).toEqual("[-6]");
+});
+
+Deno.test("stringify - bigint", () => {
+ const n = BigInt(9007199254740991);
+ expect(stringify(n)).toEqual(
+ '[["BigInt","9007199254740991"]]',
+ );
+});
+
+Deno.test("stringify - Date", () => {
+ const date = new Date("1990-05-31");
+ expect(stringify(date)).toEqual(
+ '[["Date","1990-05-31T00:00:00.000Z"]]',
+ );
+});
+
+Deno.test("stringify - RegExp", () => {
+ let reg = /foo["]/;
+ expect(stringify(reg)).toEqual(
+ '[["RegExp","foo[\\"]", ""]]',
+ );
+
+ reg = /foo["]/g;
+ expect(stringify(reg)).toEqual(
+ '[["RegExp","foo[\\"]", "g"]]',
+ );
+});
+
+Deno.test("stringify - Set", () => {
+ expect(stringify(new Set([1, { foo: 1 }]))).toEqual(
+ '[["Set",[1,2]],1,{"foo":1}]',
+ );
+});
+
+Deno.test("stringify - Map", () => {
+ expect(stringify(new Map([[1, { foo: 1 }], [2, 3]])))
+ .toEqual(
+ '[["Map",[1,2,3,4]],1,{"foo":1},2,3]',
+ );
+});
+
+Deno.test("stringify - Uint8Array", () => {
+ const value = new Uint8Array([1, 2, 3]);
+ expect(stringify(value)).toEqual(
+ '[["Uint8Array","AQID"]]',
+ );
+});
+
+Deno.test("stringify - references", () => {
+ const inner = { foo: 123 };
+ const obj = { a: inner, b: [inner, inner] };
+ expect(stringify(obj)).toEqual(
+ '[{"a":1,"b":3},{"foo":2},123,[1,1]]',
+ );
+});
+
+Deno.test("stringify - circular references", () => {
+ // deno-lint-ignore no-explicit-any
+ const foo = { foo: null as any };
+ foo.foo = foo;
+ expect(stringify(foo)).toEqual(
+ '[{"foo":0}]',
+ );
+});
+
+Deno.test("stringify - object prototype", () => {
+ const obj = { __proto__: 123, foo: 1 };
+ expect(stringify(obj)).toEqual(
+ '[{"foo":1},1]',
+ );
+});
+
+Deno.test("stringify - throw serializing functions", () => {
+ const fn = () => {};
+ expect(() => stringify(fn)).toThrow();
+});
diff --git a/src/middlewares/mod.ts b/src/middlewares/mod.ts
new file mode 100644
index 00000000000..a6b5028f14d
--- /dev/null
+++ b/src/middlewares/mod.ts
@@ -0,0 +1,107 @@
+import type { FreshContext } from "../context.ts";
+import type { App as _App } from "../app.ts";
+
+/**
+ * A middleware function is the basic building block of Fresh. It allows you
+ * to respond to an incoming request in any way you want. You can redirect
+ * routes, serve files, create APIs and much more. Middlewares can be chained by
+ * calling {@linkcode FreshContext.next|ctx.next()} inside of the function.
+ *
+ * Middlewares can be synchronous or asynchronous. If a middleware returns a
+ * {@linkcode Response} object, the response will be sent back to the client. If
+ * a middleware returns a `Promise`, Fresh will wait for the promise
+ * to resolve before sending the response.
+ *
+ * A {@linkcode FreshContext} object is passed to the middleware function. This
+ * object contains the original request object, as well as any state related to
+ * the current request. The context object also contains methods to redirect
+ * the client to another URL, or to call the next middleware in the chain.
+ *
+ * Middlewares can be defined as a single function or an array of functions.
+ * When an array of middlewares is passed to
+ * {@linkcode _App.prototype.use|app.use}, Fresh will call each middleware in the
+ * order they are defined.
+ *
+ * Middlewares can also be defined using the
+ * {@linkcode _App.prototype.defineMiddleware|app.defineMiddleware} method. This
+ * method is optional, but it can be useful for type checking and code
+ * completion. It does not register the middleware with the app.
+ *
+ * ## Examples
+ *
+ * ### Logging middleware
+ *
+ * This example shows how to create a simple middleware that logs incoming
+ * requests.
+ *
+ * ```ts
+ * // Define a middleware function that logs incoming requests. Using the
+ * // `defineMiddleware` method is optional, but it can be useful for type
+ * // checking and code completion. It does not register the middleware with the
+ * // app.
+ * const loggerMiddleware = app.defineMiddleware((ctx) => {
+ * console.log(`${ctx.req.method} ${ctx.req.url}`);
+ * // Call the next middleware
+ * return ctx.next();
+ * });
+ *
+ * // To register the middleware to the app, use `app.use`.
+ * app.use(loggerMiddleware)
+ * ```
+ *
+ * ### Redirect middleware
+ *
+ * This example shows how to create a middleware that redirects requests from
+ * one URL to another.
+ *
+ * ```ts
+ * // Any request to a URL that starts with "/legacy/" will be redirected to
+ * // "/modern".
+ * const redirectMiddleware = app.defineMiddleware((ctx) => {
+ * if (ctx.url.pathname.startsWith("/legacy/")) {
+ * return ctx.redirect("/modern");
+ * }
+ *
+ * // Otherwise call the next middleware
+ * return ctx.next();
+ * });
+ *
+ * // Again, register the middleware with the app.
+ * app.use(redirectMiddleware);
+ * ```
+ */
+export type MiddlewareFn = (
+ ctx: FreshContext,
+) => Response | Promise;
+
+/**
+ * A single middleware function, or an array of middleware functions. For
+ * further information, see {@link MiddlewareFn}.
+ */
+export type Middleware = MiddlewareFn | MiddlewareFn[];
+
+export function runMiddlewares(
+ middlewares: MiddlewareFn[][],
+ ctx: FreshContext,
+): Promise {
+ let fn = ctx.next;
+ let i = middlewares.length;
+ while (i--) {
+ const stack = middlewares[i];
+ let j = stack.length;
+ while (j--) {
+ const local = fn;
+ const next = stack[j];
+ fn = async () => {
+ ctx.next = local;
+ try {
+ return await next(ctx);
+ } catch (err) {
+ ctx.error = err;
+ throw err;
+ }
+ };
+ }
+ }
+ return fn();
+}
diff --git a/src/middlewares/mod_test.ts b/src/middlewares/mod_test.ts
new file mode 100644
index 00000000000..6176a6ff328
--- /dev/null
+++ b/src/middlewares/mod_test.ts
@@ -0,0 +1,136 @@
+import { runMiddlewares } from "./mod.ts";
+import { expect } from "@std/expect";
+import { serveMiddleware } from "../test_utils.ts";
+import type { MiddlewareFn } from "./mod.ts";
+
+Deno.test("runMiddleware", async () => {
+ const middlewares: MiddlewareFn<{ text: string }>[] = [
+ (ctx) => {
+ ctx.state.text = "A";
+ return ctx.next();
+ },
+ (ctx) => {
+ ctx.state.text += "B";
+ return ctx.next();
+ },
+ async (ctx) => {
+ const res = await ctx.next();
+ ctx.state.text += "C"; // This should not show up
+ return res;
+ },
+ (ctx) => {
+ return new Response(ctx.state.text);
+ },
+ ];
+
+ const server = serveMiddleware<{ text: string }>((ctx) =>
+ runMiddlewares([middlewares], ctx)
+ );
+
+ const res = await server.get("/");
+ expect(await res.text()).toEqual("AB");
+});
+
+Deno.test("runMiddleware - middlewares should only be called once", async () => {
+ const A: MiddlewareFn<{ count: number }> = (ctx) => {
+ if (ctx.state.count === undefined) {
+ ctx.state.count = 0;
+ } else {
+ ctx.state.count++;
+ }
+ return ctx.next();
+ };
+
+ const server = serveMiddleware<{ count: number }>((ctx) =>
+ runMiddlewares(
+ [[A, (ctx) => new Response(String(ctx.state.count))]],
+ ctx,
+ )
+ );
+
+ const res = await server.get("/");
+ expect(await res.text()).toEqual("0");
+});
+
+Deno.test("runMiddleware - runs multiple stacks", async () => {
+ type State = { text: string };
+ const A: MiddlewareFn = (ctx) => {
+ ctx.state.text += "A";
+ return ctx.next();
+ };
+ const B: MiddlewareFn = (ctx) => {
+ ctx.state.text += "B";
+ return ctx.next();
+ };
+ const C: MiddlewareFn = (ctx) => {
+ ctx.state.text += "C";
+ return ctx.next();
+ };
+ const D: MiddlewareFn = (ctx) => {
+ ctx.state.text += "D";
+ return ctx.next();
+ };
+
+ const server = serveMiddleware((ctx) => {
+ ctx.state.text = "";
+ return runMiddlewares(
+ [
+ [A, B],
+ [C, D, (ctx) => new Response(String(ctx.state.text))],
+ ],
+ ctx,
+ );
+ });
+
+ const res = await server.get("/");
+ expect(await res.text()).toEqual("ABCD");
+});
+
+Deno.test("runMiddleware - throws errors", async () => {
+ let thrownA: unknown = null;
+ let thrownB: unknown = null;
+ let thrownC: unknown = null;
+
+ const middlewares: MiddlewareFn<{ text: string }>[] = [
+ async (ctx) => {
+ try {
+ return await ctx.next();
+ } catch (err) {
+ thrownA = err;
+ throw err;
+ }
+ },
+ async (ctx) => {
+ try {
+ return await ctx.next();
+ } catch (err) {
+ thrownB = err;
+ throw err;
+ }
+ },
+ async (ctx) => {
+ try {
+ return await ctx.next();
+ } catch (err) {
+ thrownC = err;
+ throw err;
+ }
+ },
+ () => {
+ throw new Error("fail");
+ },
+ ];
+
+ const server = serveMiddleware<{ text: string }>((ctx) =>
+ runMiddlewares([middlewares], ctx)
+ );
+
+ try {
+ await server.get("/");
+ } catch {
+ // ignore
+ }
+ expect(thrownA).toBeInstanceOf(Error);
+ expect(thrownB).toBeInstanceOf(Error);
+ expect(thrownC).toBeInstanceOf(Error);
+});
diff --git a/src/middlewares/static_files.ts b/src/middlewares/static_files.ts
new file mode 100644
index 00000000000..63aa64a1f1f
--- /dev/null
+++ b/src/middlewares/static_files.ts
@@ -0,0 +1,77 @@
+import * as path from "@std/path";
+import { contentType as getContentType } from "@std/media-types/content-type";
+import type { MiddlewareFn } from "@fresh/core";
+import { ASSET_CACHE_BUST_KEY } from "../runtime/shared_internal.tsx";
+import { BUILD_ID } from "../runtime/build_id.ts";
+import { getBuildCache } from "../context.ts";
+
+/**
+ * Fresh middleware to enable file-system based routing.
+ * ```ts
+ * // Enable Fresh static file serving
+ * app.use(freshStaticFles());
+ * ```
+ */
+export function staticFiles(): MiddlewareFn {
+ return async function freshStaticFiles(ctx) {
+ const { req, url } = ctx;
+ const buildCache = getBuildCache(ctx);
+
+ // Fast path bail out
+ const file = await buildCache.readFile(url.pathname);
+ if (url.pathname === "/" || file === null) {
+ // Optimization: Prevent long responses for favicon.ico requests
+ if (url.pathname === "/favicon.ico") {
+ return new Response(null, { status: 404 });
+ }
+ return ctx.next();
+ }
+
+ if (req.method !== "GET" && req.method !== "HEAD") {
+ return new Response("Method Not Allowed", { status: 405 });
+ }
+
+ const cacheKey = url.searchParams.get(ASSET_CACHE_BUST_KEY);
+ if (cacheKey !== null && BUILD_ID !== cacheKey) {
+ url.searchParams.delete(ASSET_CACHE_BUST_KEY);
+ const location = url.pathname + url.search;
+ return new Response(null, {
+ status: 307,
+ headers: {
+ location,
+ },
+ });
+ }
+
+ const ext = path.extname(url.pathname);
+ const etag = file.hash;
+
+ const contentType = getContentType(ext);
+ const headers = new Headers({
+ "Content-Type": contentType ?? "text/plain",
+ vary: "If-None-Match",
+ });
+
+ if (cacheKey === null || ctx.config.mode === "development") {
+ headers.append(
+ "Cache-Control",
+ "no-cache, no-store, max-age=0, must-revalidate",
+ );
+ } else {
+ const ifNoneMatch = req.headers.get("If-None-Match");
+ if (
+ etag !== null &&
+ (ifNoneMatch === etag || ifNoneMatch === "W/" + etag)
+ ) {
+ return new Response(null, { status: 304, headers });
+ }
+ }
+
+ headers.set("Content-Length", String(file.size));
+ if (req.method === "HEAD") {
+ return new Response(null, { status: 200, headers });
+ }
+
+ return new Response(file.readable, { headers });
+ };
+}
diff --git a/src/middlewares/static_files_test.ts b/src/middlewares/static_files_test.ts
new file mode 100644
index 00000000000..4e91286b9cc
--- /dev/null
+++ b/src/middlewares/static_files_test.ts
@@ -0,0 +1,150 @@
+import { staticFiles } from "./static_files.ts";
+import { serveMiddleware } from "../test_utils.ts";
+import type { BuildCache, StaticFile } from "../build_cache.ts";
+import { expect } from "@std/expect";
+import { ASSET_CACHE_BUST_KEY } from "../runtime/shared_internal.tsx";
+import { BUILD_ID } from "../runtime/build_id.ts";
+
+class MockBuildCache implements BuildCache {
+ buildId = "MockId";
+ files = new Map();
+ hasSnapshot = true;
+
+ constructor(files: Record) {
+ const encoder = new TextEncoder();
+ for (const [pathname, info] of Object.entries(files)) {
+ const text = encoder.encode(info.content);
+
+ const normalized = pathname.startsWith("/") ? pathname : "/" + pathname;
+ this.files.set(normalized, {
+ hash: info.hash,
+ size: text.byteLength,
+ readable: text,
+ });
+ }
+ }
+
+ // deno-lint-ignore require-await
+ async readFile(pathname: string): Promise