This commit is contained in:
monoid 2023-01-14 03:03:22 +09:00
parent 6ce9767e3b
commit e2ee2c6375
56 changed files with 3723 additions and 183 deletions

2
.gitignore vendored
View File

@ -1,3 +1,3 @@
collected_docs.json
db.sqlite
.env
.env

View File

@ -295,6 +295,7 @@
"https://deno.land/x/importmap@0.2.1/mod.ts": "ae3d1cd7eabd18c01a4960d57db471126b020f23b37ef14e1359bbb949227ade",
"https://deno.land/x/marked@1.0.1/mod.ts": "25a04e7c3512622293d84b7287711b990562ce41e44f7fb55af9ca1586e57b15",
"https://deno.land/x/rutt@0.0.13/mod.ts": "af981cfb95131152bf50fc9140fc00cb3efb6563df2eded1d408361d8577df20",
"https://deno.land/x/rutt@0.0.14/mod.ts": "5027b8e8b12acca48b396a25aee74ad7ee94a25c24cda75571d7839cbd41113c",
"https://deno.land/x/sqlite@v3.7.0/build/sqlite.d.ts": "12908ced1670f96d5dc39aebb0d659630136fa6523881e4712cfb20b122dd324",
"https://deno.land/x/sqlite@v3.7.0/build/sqlite.js": "cc55fef9cd124b2acb624899a5fad413834f4701bcfc21ac275844b822466292",
"https://deno.land/x/sqlite@v3.7.0/build/vfs.js": "08533cc78fb29b9d9bd62f6bb93e5ef333407013fed185776808f11223ba0e70",

15
dev.ts
View File

@ -1,5 +1,18 @@
#!/usr/bin/env -S deno run -A --watch=static/,routes/
import dev from "$fresh/dev.ts";
import { connectDB } from "./src/user/db.ts";
import * as users from "./src/user/user.ts";
import dev from "$fresh/dev.ts";
await devUserAdd();
await dev(import.meta.url, "./main.ts");
async function devUserAdd() {
if(Deno.env.get("DB_PATH") === ":memory:") {
const db = await connectDB();
const username = "admin";
const password = "admin";
const new_user = await users.createUser(username, password);
await users.addUser(db, new_user);
}
}

1
fresh-main/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
deno.lock

6
fresh-main/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"recommendations": [
"denoland.vscode-deno",
"sastan.twind-intellisense"
]
}

19
fresh-main/.vscode/import_map.json vendored Normal file
View File

@ -0,0 +1,19 @@
{
"scopes": {
"THIS FILE EXISTS ONLY FOR VSCODE! IT IS NOT USED AT RUNTIME": {}
},
"imports": {
"$fresh/": "../",
"twind": "https://esm.sh/twind@0.16.17",
"twind/": "https://esm.sh/twind@0.16.17/",
"preact": "https://esm.sh/preact@10.11.0",
"preact/": "https://esm.sh/preact@10.11.0/",
"preact-render-to-string": "https://esm.sh/*preact-render-to-string@5.2.4",
"@preact/signals": "https://esm.sh/*@preact/signals@1.0.3",
"@preact/signals-core": "https://esm.sh/@preact/signals-core@1.0.1",
"$std/": "https://deno.land/std@0.150.0/"
}
}

7
fresh-main/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"deno.enable": true,
"deno.lint": true,
"deno.importMap": "./.vscode/import_map.json",
"deno.codeLens.test": true,
"editor.defaultFormatter": "denoland.vscode-deno"
}

View File

@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity and
orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances of
any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email address,
without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at hello@lcas.dev.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by
[Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

21
fresh-main/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Luca Casonato
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

106
fresh-main/README.md Normal file
View File

@ -0,0 +1,106 @@
[Documentation](#-documentation) | [Getting started](#-getting-started)
# fresh
<img align="right" src="https://fresh.deno.dev/logo.svg" height="150px" alt="the fresh logo: a sliced lemon dripping with juice">
**Fresh** is a next generation web framework, built for speed, reliability, and
simplicity.
Some stand-out features:
- Just-in-time rendering on the edge.
- Island based client hydration for maximum interactivity.
- Zero runtime overhead: no JS is shipped to the client by default.
- No build step.
- No configuration necessary.
- TypeScript support out of the box.
- File-system routing à la Next.js.
## 📖 Documentation
The [documentation](https://fresh.deno.dev/docs/) is available on
[fresh.deno.dev](https://fresh.deno.dev/).
## 🚀 Getting started
Install [Deno CLI](https://deno.land/) version 1.25.0 or higher.
You can scaffold a new project by running the Fresh init script. To scaffold a
project in the `deno-fresh-demo` folder, run the following:
```sh
deno run -A -r https://fresh.deno.dev deno-fresh-demo
```
Then navigate to the newly created project folder:
```
cd deno-fresh-demo
```
From within your project folder, start the development server using the
`deno task` command:
```
deno task start
```
Now open http://localhost:8000 in your browser to view the page. You make
changes to the project source code and see them reflected in your browser.
To deploy the project to the live internet, you can use
[Deno Deploy](https://deno.com/deploy):
1. Push your project to GitHub.
2. [Create a Deno Deploy project](https://dash.deno.com/new).
3. [Link](https://deno.com/deploy/docs/projects#enabling) the Deno Deploy
project to the **`main.ts`** file in the root of the created repository.
4. The project will be deployed to a public $project.deno.dev subdomain.
For a more in-depth getting started guide, visit the
[Getting Started](https://fresh.deno.dev/docs/getting-started) page in the Fresh
docs.
## Adding your project to the showcase
If you feel that your project would be helpful to other fresh users, please
consider putting your project on the
[showcase](https://fresh.deno.dev/showcase). However, websites that are just for
promotional purposes may not be listed.
To take a screenshot, run the following command.
```sh
deno task screenshot [url] [your-app-name]
```
Then add your site to
[showcase.json](https://github.com/denoland/fresh/blob/main/www/data/showcase.json),
preferably with source code on GitHub, but not required.
## Badges
![Made with Fresh](./www/static/fresh-badge.svg)
```md
[![Made with Fresh](https://fresh.deno.dev/fresh-badge.svg)](https://fresh.deno.dev)
```
```html
<a href="https://fresh.deno.dev">
<img width="197" height="37" src="https://fresh.deno.dev/fresh-badge.svg" alt="Made with Fresh" />
</a>
```
![Made with Fresh(dark)](./www/static/fresh-badge-dark.svg)
```md
[![Made with Fresh](https://fresh.deno.dev/fresh-badge-dark.svg)](https://fresh.deno.dev)
```
```html
<a href="https://fresh.deno.dev">
<img width="197" height="37" src="https://fresh.deno.dev/fresh-badge-dark.svg" alt="Made with Fresh" />
</a>
```

18
fresh-main/deno.json Normal file
View File

@ -0,0 +1,18 @@
{
"tasks": {
"test": "deno test -A && deno check --config=www/deno.json www/main.ts www/dev.ts && deno check init.ts",
"fixture": "deno run -A --watch=static/,routes/ tests/fixture/dev.ts",
"www": "deno run -A --watch=www/static/,www/routes/,docs/ www/dev.ts",
"screenshot": "deno run -A www/utils/screenshot.ts"
},
"importMap": "./.vscode/import_map.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
},
"test": {
"files": {
"exclude": ["www/"]
}
}
}

2
fresh-main/dev.ts Normal file
View File

@ -0,0 +1,2 @@
import { dev } from "./src/dev/mod.ts";
export default dev;

377
fresh-main/init.ts Normal file
View File

@ -0,0 +1,377 @@
import { 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 { freshImports, 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
--twind Setup project to use 'twind' for styling
--vscode Setup project for VSCode
`;
const CONFIRM_EMPTY_MESSAGE =
"The target directory is not empty (files could get overwritten). Do you want to continue anyway?";
const USE_TWIND_MESSAGE =
"Fresh has built in support for styling using Tailwind CSS. Do you want to use this?";
const USE_VSCODE_MESSAGE = "Do you use VS Code?";
const flags = parse(Deno.args, {
boolean: ["force", "twind", "vscode"],
default: { "force": null, "twind": null, "vscode": null },
});
if (flags._.length !== 1) {
error(help);
}
console.log(
`\n%c 🍋 Fresh: the next-gen web framework. %c\n`,
"background-color: #86efac; color: black; font-weight: bold",
"",
);
const unresolvedDirectory = Deno.args[0];
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");
const useTwind = flags.twind === null
? confirm(USE_TWIND_MESSAGE)
: flags.twind;
const useVSCode = flags.vscode === null
? confirm(USE_VSCODE_MESSAGE)
: flags.vscode;
await Deno.mkdir(join(resolvedDirectory, "routes", "api"), { recursive: true });
await Deno.mkdir(join(resolvedDirectory, "islands"), { recursive: true });
await Deno.mkdir(join(resolvedDirectory, "static"), { recursive: true });
await Deno.mkdir(join(resolvedDirectory, "components"), { recursive: true });
if (useVSCode) {
await Deno.mkdir(join(resolvedDirectory, ".vscode"), { recursive: true });
}
const importMap = { imports: {} as Record<string, string> };
freshImports(importMap.imports);
if (useTwind) twindImports(importMap.imports);
const IMPORT_MAP_JSON = JSON.stringify(importMap, null, 2) + "\n";
await Deno.writeTextFile(
join(resolvedDirectory, "import_map.json"),
IMPORT_MAP_JSON,
);
const ROUTES_INDEX_TSX = `import { Head } from "$fresh/runtime.ts";
import Counter from "../islands/Counter.tsx";
export default function Home() {
return (
<>
<Head>
<title>Fresh App</title>
</Head>
<div${useTwind ? ` class="p-4 mx-auto max-w-screen-md"` : ""}>
<img
src="/logo.svg"
${
useTwind ? `class="w-32 h-32"` : `width="128"\n height="128"`
}
alt="the fresh logo: a sliced lemon dripping with juice"
/>
<p${useTwind ? ` class="my-6"` : ""}>
Welcome to \`fresh\`. Try updating this message in the ./routes/index.tsx
file, and refresh.
</p>
<Counter start={3} />
</div>
</>
);
}
`;
await Deno.writeTextFile(
join(resolvedDirectory, "routes", "index.tsx"),
ROUTES_INDEX_TSX,
);
const COMPONENTS_BUTTON_TSX = `import { JSX } from "preact";
import { IS_BROWSER } from "$fresh/runtime.ts";
export function Button(props: JSX.HTMLAttributes<HTMLButtonElement>) {
return (
<button
{...props}
disabled={!IS_BROWSER || props.disabled}
${
useTwind
? ' class="px-2 py-1 border(gray-100 2) hover:bg-gray-200"\n'
: ""
} />
);
}
`;
await Deno.writeTextFile(
join(resolvedDirectory, "components", "Button.tsx"),
COMPONENTS_BUTTON_TSX,
);
const ISLANDS_COUNTER_TSX = `import { useState } from "preact/hooks";
import { Button } from "../components/Button.tsx";
interface CounterProps {
start: number;
}
export default function Counter(props: CounterProps) {
const [count, setCount] = useState(props.start);
return (
<div${useTwind ? ' class="flex gap-2 w-full"' : ""}>
<p${useTwind ? ' class="flex-grow-1 font-bold text-xl"' : ""}>{count}</p>
<Button onClick={() => setCount(count - 1)}>-1</Button>
<Button onClick={() => setCount(count + 1)}>+1</Button>
</div>
);
}
`;
await Deno.writeTextFile(
join(resolvedDirectory, "islands", "Counter.tsx"),
ISLANDS_COUNTER_TSX,
);
const ROUTES_GREET_TSX = `import { PageProps } from "$fresh/server.ts";
export default function Greet(props: PageProps) {
return <div>Hello {props.params.name}</div>;
}
`;
await Deno.writeTextFile(
join(resolvedDirectory, "routes", "[name].tsx"),
ROUTES_GREET_TSX,
);
const ROUTES_API_JOKE_TS = `import { HandlerContext } 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: HandlerContext): 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 TWIND_CONFIG_TS = `import { Options } from "$fresh/plugins/twind.ts";
export default {
selfURL: import.meta.url,
} as Options;
`;
if (useTwind) {
await Deno.writeTextFile(
join(resolvedDirectory, "twind.config.ts"),
TWIND_CONFIG_TS,
);
}
const STATIC_LOGO =
`<svg width="40" height="40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M34.092 8.845C38.929 20.652 34.092 27 30 30.5c1 3.5-2.986 4.222-4.5 2.5-4.457 1.537-13.512 1.487-20-5C2 24.5 4.73 16.714 14 11.5c8-4.5 16-7 20.092-2.655Z" fill="#FFDB1E"/>
<path d="M14 11.5c6.848-4.497 15.025-6.38 18.368-3.47C37.5 12.5 21.5 22.612 15.5 25c-6.5 2.587-3 8.5-6.5 8.5-3 0-2.5-4-5.183-7.75C2.232 23.535 6.16 16.648 14 11.5Z" fill="#fff" stroke="#FFDB1E"/>
<path d="M28.535 8.772c4.645 1.25-.365 5.695-4.303 8.536-3.732 2.692-6.606 4.21-7.923 4.83-.366.173-1.617-2.252-1.617-1 0 .417-.7 2.238-.934 2.326-1.365.512-4.223 1.29-5.835 1.29-3.491 0-1.923-4.754 3.014-9.122.892-.789 1.478-.645 2.283-.645-.537-.773-.534-.917.403-1.546C17.79 10.64 23 8.77 25.212 8.42c.366.014.82.35.82.629.41-.14 2.095-.388 2.503-.278Z" fill="#FFE600"/>
<path d="M14.297 16.49c.985-.747 1.644-1.01 2.099-2.526.566.121.841-.08 1.29-.701.324.466 1.657.608 2.453.701-.715.451-1.057.852-1.452 2.106-1.464-.611-3.167-.302-4.39.42Z" fill="#fff"/>
</svg>`;
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 nework issue.
}
let MAIN_TS = `/// <reference no-default-lib="true" />
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
/// <reference lib="dom.asynciterable" />
/// <reference lib="deno.ns" />
import { start } from "$fresh/server.ts";
import manifest from "./fresh.gen.ts";
`;
if (useTwind) {
MAIN_TS += `
import twindPlugin from "$fresh/plugins/twind.ts";
import twindConfig from "./twind.config.ts";
`;
}
MAIN_TS += `
await start(manifest${
useTwind ? ", { plugins: [twindPlugin(twindConfig)] }" : ""
});\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";
await dev(import.meta.url, "./main.ts");
`;
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 = {
tasks: {
start: "deno run -A --watch=static/,routes/ dev.ts",
},
importMap: "./import_map.json",
compilerOptions: {
jsx: "react-jsx",
jsxImportSource: "preact",
},
};
const DENO_CONFIG = JSON.stringify(config, null, 2) + "\n";
await Deno.writeTextFile(join(resolvedDirectory, "deno.json"), DENO_CONFIG);
const README_MD = `# fresh project
### Usage
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",
};
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 (useTwind) {
vscodeExtensions.recommendations.push("sastan.twind-intellisense");
}
const VSCODE_EXTENSIONS = JSON.stringify(vscodeExtensions, null, 2) + "\n";
if (useVSCode) {
await Deno.writeTextFile(
join(resolvedDirectory, ".vscode", "extensions.json"),
VSCODE_EXTENSIONS,
);
}
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");
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",
);

View File

@ -0,0 +1,50 @@
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 },
render(ctx) {
sheet.reset(undefined);
const res = ctx.render();
const cssTexts = [...sheet.target];
const snapshot = sheet.reset();
const scripts = [];
let cssText: string;
if (res.requiresHydration) {
const precedences = snapshot[1] as number[];
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<string, string>).entries()
) {
if (key === value) {
mappings.push(key);
} else {
mappings.push([key, value]);
}
}
scripts.push({ entrypoint: "main", state: mappings });
} else {
cssText = cssTexts.join("\n");
}
return {
scripts,
styles: [{ cssText, id: STYLE_ELEMENT_ID }],
};
},
};
}

View File

@ -0,0 +1,30 @@
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<string>();
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);
}

View File

@ -0,0 +1,48 @@
import { JSX, options as preactOptions, VNode } from "preact";
import { Configuration, setup as twSetup, Sheet, tw } from "twind";
export const STYLE_ELEMENT_ID = "__FRSH_TWIND";
export interface Options extends Omit<Configuration, "mode" | "sheet"> {
/** The import.meta.url of the module defining these options. */
selfURL: string;
}
declare module "preact" {
namespace JSX {
interface DOMAttributes<Target extends EventTarget> {
class?: string;
className?: string;
}
}
}
export function setup(options: Options, sheet: Sheet) {
const config: Configuration = {
...options,
mode: "silent",
sheet,
};
twSetup(config);
const originalHook = preactOptions.vnode;
// deno-lint-ignore no-explicit-any
preactOptions.vnode = (vnode: VNode<JSX.DOMAttributes<any>>) => {
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));
}
if (classes.length) {
props.class = classes.join(" ");
}
}
originalHook?.(vnode);
};
}

3
fresh-main/runtime.ts Normal file
View File

@ -0,0 +1,3 @@
export * from "./src/runtime/utils.ts";
export * from "./src/runtime/head.ts";
export * from "./src/runtime/csp.ts";

1
fresh-main/server.ts Normal file
View File

@ -0,0 +1 @@
export * from "./src/server/mod.ts";

View File

@ -0,0 +1,15 @@
// std
export {
dirname,
extname,
fromFileUrl,
join,
resolve,
toFileUrl,
} from "https://deno.land/std@0.150.0/path/mod.ts";
export { walk } from "https://deno.land/std@0.150.0/fs/walk.ts";
export { parse } from "https://deno.land/std@0.150.0/flags/mod.ts";
export { gte } from "https://deno.land/std@0.150.0/semver/mod.ts";
// ts-morph
export { Node, Project } from "https://deno.land/x/ts_morph@16.0.0/mod.ts";

View File

@ -0,0 +1,8 @@
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);
}

View File

@ -0,0 +1,22 @@
export const RECOMMENDED_PREACT_VERSION = "10.11.0";
export const RECOMMENDED_PREACT_RTS_VERSION = "5.2.4";
export const RECOMMENDED_PREACT_SIGNALS_VERSION = "1.0.3";
export const RECOMMENDED_PREACT_SIGNALS_CORE_VERSION = "1.0.1";
export const RECOMMENDED_TWIND_VERSION = "0.16.17";
export function freshImports(imports: Record<string, string>) {
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-render-to-string"] =
`https://esm.sh/*preact-render-to-string@${RECOMMENDED_PREACT_RTS_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<string, string>) {
imports["twind"] = `https://esm.sh/twind@${RECOMMENDED_TWIND_VERSION}`;
imports["twind/"] = `https://esm.sh/twind@${RECOMMENDED_TWIND_VERSION}/`;
}

196
fresh-main/src/dev/mod.ts Normal file
View File

@ -0,0 +1,196 @@
import {
dirname,
extname,
fromFileUrl,
gte,
join,
toFileUrl,
walk,
} from "./deps.ts";
import { error } from "./error.ts";
const MIN_DENO_VERSION = "1.25.0";
export function ensureMinDenoVersion() {
// Check that the minimum supported Deno version is being used.
if (!gte(Deno.version.deno, MIN_DENO_VERSION)) {
let message =
`Deno version ${MIN_DENO_VERSION} or higher is required. Please update Deno.\n\n`;
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);
}
}
interface Manifest {
routes: string[];
islands: string[];
}
export async function collect(directory: string): Promise<Manifest> {
const routesDir = join(directory, "./routes");
const islandsDir = join(directory, "./islands");
const routes = [];
try {
const routesUrl = toFileUrl(routesDir);
// TODO(lucacasonato): remove the extranious Deno.readDir when
// https://github.com/denoland/deno_std/issues/1310 is fixed.
for await (const _ of Deno.readDir(routesDir)) {
// do nothing
}
const routesFolder = walk(routesDir, {
includeDirs: false,
includeFiles: true,
exts: ["tsx", "jsx", "ts", "js"],
});
for await (const entry of routesFolder) {
if (entry.isFile) {
const file = toFileUrl(entry.path).href.substring(
routesUrl.href.length,
);
routes.push(file);
}
}
} catch (err) {
if (err instanceof Deno.errors.NotFound) {
// Do nothing.
} else {
throw err;
}
}
routes.sort();
const islands = [];
try {
const islandsUrl = toFileUrl(islandsDir);
for await (const entry of Deno.readDir(islandsDir)) {
if (entry.isDirectory) {
error(
`Found subdirectory '${entry.name}' in islands/. The islands/ folder must not contain any subdirectories.`,
);
}
if (entry.isFile) {
const ext = extname(entry.name);
if (![".tsx", ".jsx", ".ts", ".js"].includes(ext)) continue;
const path = join(islandsDir, entry.name);
const file = toFileUrl(path).href.substring(islandsUrl.href.length);
islands.push(file);
}
}
} catch (err) {
if (err instanceof Deno.errors.NotFound) {
// Do nothing.
} else {
throw err;
}
}
islands.sort();
return { routes, islands };
}
export async function generate(directory: string, manifest: Manifest) {
const { routes, islands } = manifest;
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\`.
import config from "./deno.json" assert { type: "json" };
${
routes.map((file, i) => `import * as $${i} from "./routes${file}";`).join(
"\n",
)
}
${
islands.map((file, i) => `import * as $$${i} from "./islands${file}";`)
.join("\n")
}
const manifest = {
routes: {
${
routes.map((file, i) => `${JSON.stringify(`./routes${file}`)}: $${i},`)
.join("\n ")
}
},
islands: {
${
islands.map((file, i) => `${JSON.stringify(`./islands${file}`)}: $$${i},`)
.join("\n ")
}
},
baseUrl: import.meta.url,
config,
};
export default manifest;
`;
const proc = Deno.run({
cmd: [Deno.execPath(), "fmt", "-"],
stdin: "piped",
stdout: "piped",
stderr: "null",
});
const raw = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(output));
controller.close();
},
});
await raw.pipeTo(proc.stdin.writable);
const out = await proc.output();
await proc.status();
proc.close();
const manifestStr = new TextDecoder().decode(out);
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",
);
}
export async function dev(base: string, entrypoint: string) {
ensureMinDenoVersion();
entrypoint = new URL(entrypoint, base).href;
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);
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);
await import(entrypoint);
}
function arraysEqual<T>(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;
}

View File

@ -0,0 +1,140 @@
import { createContext } from "preact";
import { useContext } from "preact/hooks";
export const SELF = "'self'";
export const UNSAFE_INLINE = "'unsafe-inline'";
export const UNSAFE_EVAL = "'unsafe-eval'";
export const UNSAFE_HASHES = "'unsafe-hashes'";
export const NONE = "'none'";
export const STRICT_DYNAMIC = "'strict-dynamic'";
export function nonce(val: string) {
return `'nonce-${val}'`;
}
export interface ContentSecurityPolicy {
directives: ContentSecurityPolicyDirectives;
reportOnly: boolean;
}
export interface ContentSecurityPolicyDirectives {
// Fetch directives
/**
* Defines the valid sources for web workers and nested browsing contexts
* loaded using elements such as <frame> and <iframe>.
*/
childSrc?: string[];
/**
* Restricts the URLs which can be loaded using script interfaces.
*/
connectSrc?: string[];
/**
* Serves as a fallback for the other fetch directives.
*/
defaultSrc?: string[];
/**
* Specifies valid sources for fonts loaded using @font-face.
*/
fontSrc?: string[];
/**
* Specifies valid sources for nested browsing contexts loading using elements
* such as <frame> and <iframe>.
*/
frameSrc?: string[];
/**
* Specifies valid sources of images and favicons.
*/
imgSrc?: string[];
/**
* Specifies valid sources of application manifest files.
*/
manifestSrc?: string[];
/**
* Specifies valid sources for loading media using the <audio> , <video> and
* <track> elements.
*/
mediaSrc?: string[];
/**
* Specifies valid sources for the <object>, <embed>, and <applet> elements.
*/
objectSrc?: string[];
/**
* Specifies valid sources to be prefetched or prerendered.
*/
prefetchSrc?: string[];
/**
* Specifies valid sources for JavaScript.
*/
scriptSrc?: string[];
/**
* Specifies valid sources for JavaScript <script> elements.
*/
scriptSrcElem?: string[];
/**
* Specifies valid sources for JavaScript inline event handlers.
*/
scriptSrcAttr?: string[];
/**
* Specifies valid sources for stylesheets.
*/
styleSrc?: string[];
/**
* Specifies valid sources for stylesheets <style> elements and <link>
* elements with rel="stylesheet".
*/
styleSrcElem?: string[];
/**
* Specifies valid sources for inline styles applied to individual DOM
* elements.
*/
styleSrcAttr?: string[];
/**
* Specifies valid sources for Worker, SharedWorker, or ServiceWorker scripts.
*/
workerSrc?: string[];
// Document directives
/**
* Restricts the URLs which can be used in a document's <base> element.
*/
baseUri?: string[];
/**
* Enables a sandbox for the requested resource similar to the <iframe>
* sandbox attribute.
*/
sandbox?: string[];
// Navigation directives
/**
* Restricts the URLs which can be used as the target of a form submissions
* from a given context.
*/
formAction?: string[];
/**
* Specifies valid parents that may embed a page using <frame>, <iframe>,
* <object>, <embed>, or <applet>.
*/
frameAncestors?: string[];
/**
* Restricts the URLs to which a document can initiate navigation by any
* means, including <form> (if form-action is not specified), <a>,
* window.location, window.open, etc.
*/
navigateTo?: string[];
/**
* The URI to report CSP violations to.
*/
reportUri?: string;
}
export const CSP_CONTEXT = createContext<ContentSecurityPolicy | undefined>(
undefined,
);
export function useCSP(mutator: (csp: ContentSecurityPolicy) => void) {
const csp = useContext(CSP_CONTEXT);
if (csp) {
mutator(csp);
}
}

View File

@ -0,0 +1,22 @@
import { ComponentChildren, createContext } from "preact";
import { useContext } from "preact/hooks";
export interface HeadProps {
children: ComponentChildren;
}
export const HEAD_CONTEXT = createContext<ComponentChildren[]>([]);
export function Head(props: HeadProps) {
let context: ComponentChildren[];
try {
context = useContext(HEAD_CONTEXT);
} catch (err) {
throw new Error(
"<Head> component is not supported in the browser, or during suspense renders.",
{ cause: err },
);
}
context.push(props.children);
return null;
}

View File

@ -0,0 +1,71 @@
import { ComponentType, h, options, render } from "preact";
import { assetHashingHook } from "./utils.ts";
function createRootFragment(
parent: Element,
replaceNode: Node | Node[],
) {
replaceNode = ([] as Node[]).concat(replaceNode);
const s = replaceNode[replaceNode.length - 1].nextSibling;
function insert(c: Node, r: Node) {
parent.insertBefore(c, r || s);
}
// @ts-ignore this is fine
return parent.__k = {
nodeType: 1,
parentNode: parent,
firstChild: replaceNode[0],
childNodes: replaceNode,
insertBefore: insert,
appendChild: insert,
removeChild: function (c: Node) {
parent.removeChild(c);
},
};
}
// deno-lint-ignore no-explicit-any
export function revive(islands: Record<string, ComponentType>, props: any[]) {
function walk(node: Node | null) {
const tag = node!.nodeType === 8 &&
((node as Comment).data.match(/^\s*frsh-(.*)\s*$/) || [])[1];
let endNode: Node | null = null;
if (tag) {
const startNode = node!;
const children = [];
const parent = node!.parentNode;
// collect all children of the island
while ((node = node!.nextSibling) && node.nodeType !== 8) {
children.push(node);
}
startNode.parentNode!.removeChild(startNode); // remove start tag node
const [id, n] = tag.split(":");
render(
h(islands[id], props[Number(n)]),
createRootFragment(
parent! as HTMLElement,
children,
// deno-lint-ignore no-explicit-any
) as any as HTMLElement,
);
endNode = node;
}
const sib = node!.nextSibling;
const fc = node!.firstChild;
if (endNode) {
endNode.parentNode?.removeChild(endNode); // remove end tag node
}
if (sib) walk(sib);
if (fc) walk(fc);
}
walk(document.body);
}
const originalHook = options.vnode;
options.vnode = (vnode) => {
assetHashingHook(vnode);
if (originalHook) originalHook(vnode);
};

View File

@ -0,0 +1,2 @@
import "preact/debug";
export { revive } from "./main.ts";

View File

@ -0,0 +1,70 @@
import { VNode } from "preact";
export const INTERNAL_PREFIX = "/_frsh";
export const ASSET_CACHE_BUST_KEY = "__frsh_c";
export const IS_BROWSER = typeof document !== "undefined";
/**
* Create a "locked" asset path. This differs from a plain path in that it is
* specific to the current version of the application, and as such can be safely
* served with a very long cache lifetime (1 year).
*/
export function asset(path: string) {
if (!path.startsWith("/") || path.startsWith("//")) return path;
try {
const url = new URL(path, "https://freshassetcache.local");
if (
url.protocol !== "https:" || url.host !== "freshassetcache.local" ||
url.searchParams.has(ASSET_CACHE_BUST_KEY)
) {
return path;
}
url.searchParams.set(ASSET_CACHE_BUST_KEY, __FRSH_BUILD_ID);
return url.pathname + url.search + url.hash;
} catch (err) {
console.warn(
`Failed to create asset() URL, falling back to regular path ('${path}'):`,
err,
);
return path;
}
}
/** Apply the `asset` function to urls in a `srcset` attribute. */
export function assetSrcSet(srcset: string): string {
if (srcset.includes("(")) return srcset; // Bail if the srcset contains complicated syntax.
const parts = srcset.split(",");
const constructed = [];
for (const part of parts) {
const trimmed = part.trimStart();
const leadingWhitespace = part.length - trimmed.length;
if (trimmed === "") return srcset; // Bail if the srcset is malformed.
let urlEnd = trimmed.indexOf(" ");
if (urlEnd === -1) urlEnd = trimmed.length;
const leading = part.substring(0, leadingWhitespace);
const url = trimmed.substring(0, urlEnd);
const trailing = trimmed.substring(urlEnd);
constructed.push(leading + asset(url) + trailing);
}
return constructed.join(",");
}
export function assetHashingHook(
vnode: VNode<{
src?: string;
srcset?: string;
["data-fresh-disable-lock"]?: boolean;
}>,
) {
if (vnode.type === "img" || vnode.type === "source") {
const { props } = vnode;
if (props["data-fresh-disable-lock"]) return;
if (typeof props.src === "string") {
props.src = asset(props.src);
}
if (typeof props.srcset === "string") {
props.srcset = assetSrcSet(props.srcset);
}
}
}

View File

@ -0,0 +1,60 @@
import { assertEquals } from "../../tests/deps.ts";
import { asset, assetSrcSet } from "./utils.ts";
globalThis.__FRSH_BUILD_ID = "ID123";
Deno.test("asset", () => {
assertEquals(asset("/test.png"), "/test.png?__frsh_c=ID123");
assertEquals(asset("/test?f=1"), "/test?f=1&__frsh_c=ID123");
assertEquals(asset("/test#foo"), "/test?__frsh_c=ID123#foo");
assertEquals(asset("/test?f=1#foo"), "/test?f=1&__frsh_c=ID123#foo");
assertEquals(asset("./test.png"), "./test.png");
assertEquals(asset("//example.com/logo.png"), "//example.com/logo.png");
assertEquals(asset("/test.png?__frsh_c=1"), "/test.png?__frsh_c=1");
assertEquals(
asset("https://example.com/logo.png"),
"https://example.com/logo.png",
);
});
Deno.test("assetSrcSet", () => {
assertEquals(assetSrcSet("/img.png"), "/img.png?__frsh_c=ID123");
assertEquals(
assetSrcSet("/img.png, /img.png 2x"),
"/img.png?__frsh_c=ID123, /img.png?__frsh_c=ID123 2x",
);
assertEquals(assetSrcSet("/img.png 1x"), "/img.png?__frsh_c=ID123 1x");
assertEquals(
assetSrcSet("/img.png 1x, /img.png 2x"),
"/img.png?__frsh_c=ID123 1x, /img.png?__frsh_c=ID123 2x",
);
assertEquals(
assetSrcSet("/img.png 1.5x, /img.png 3x"),
"/img.png?__frsh_c=ID123 1.5x, /img.png?__frsh_c=ID123 3x",
);
//test with queries
assertEquals(
assetSrcSet("/img.png?w=140, /img.png?w=200 2x"),
"/img.png?w=140&__frsh_c=ID123, /img.png?w=200&__frsh_c=ID123 2x",
);
// test with extra spaces
assertEquals(
assetSrcSet("/img-s.png 300w, /img-l.png 600w , /img-xl.png 900w"),
"/img-s.png?__frsh_c=ID123 300w, /img-l.png?__frsh_c=ID123 600w , /img-xl.png?__frsh_c=ID123 900w",
);
// test with ( syntax
assertEquals(
assetSrcSet("/img.png ( 140,0w)"),
"/img.png ( 140,0w)",
);
// test with invalid parts
assertEquals(
assetSrcSet("/img.png,, /img-s.png 300w"),
"/img.png,, /img-s.png 300w",
);
});

View File

@ -0,0 +1,150 @@
import { BuildOptions } from "https://deno.land/x/esbuild@v0.14.51/mod.js";
import { BUILD_ID } from "./constants.ts";
import { denoPlugin, esbuild, toFileUrl } from "./deps.ts";
import { Island, Plugin } from "./types.ts";
export interface JSXConfig {
jsx: "react" | "react-jsx";
jsxImportSource?: string;
}
let esbuildInitialized: boolean | Promise<void> = false;
async function ensureEsbuildInitialized() {
if (esbuildInitialized === false) {
if (Deno.run === undefined) {
const wasmURL = new URL("./esbuild_v0.14.51.wasm", import.meta.url).href;
esbuildInitialized = fetch(wasmURL).then(async (r) => {
const resp = new Response(r.body, {
headers: { "Content-Type": "application/wasm" },
});
const wasmModule = await WebAssembly.compileStreaming(resp);
await esbuild.initialize({
wasmModule,
worker: false,
});
});
} else {
esbuild.initialize({});
}
await esbuildInitialized;
esbuildInitialized = true;
} else if (esbuildInitialized instanceof Promise) {
await esbuildInitialized;
}
}
const JSX_RUNTIME_MODE = {
"react": "transform",
"react-jsx": "automatic",
} as const;
export class Bundler {
#importMapURL: URL;
#jsxConfig: JSXConfig;
#islands: Island[];
#plugins: Plugin[];
#cache: Map<string, Uint8Array> | Promise<void> | undefined = undefined;
#dev: boolean;
constructor(
islands: Island[],
plugins: Plugin[],
importMapURL: URL,
jsxConfig: JSXConfig,
dev: boolean,
) {
this.#islands = islands;
this.#plugins = plugins;
this.#importMapURL = importMapURL;
this.#jsxConfig = jsxConfig;
this.#dev = dev;
}
async bundle() {
const entryPoints: Record<string, string> = {
main: this.#dev
? new URL("../../src/runtime/main_dev.ts", import.meta.url).href
: new URL("../../src/runtime/main.ts", import.meta.url).href,
};
for (const island of this.#islands) {
entryPoints[`island-${island.id}`] = island.url;
}
for (const plugin of this.#plugins) {
for (const [name, url] of Object.entries(plugin.entrypoints ?? {})) {
entryPoints[`plugin-${plugin.name}-${name}`] = url;
}
}
const absWorkingDir = Deno.cwd();
await ensureEsbuildInitialized();
// 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<BuildOptions> = this.#dev
? { minifyIdentifiers: false, minifySyntax: true, minifyWhitespace: true }
: { minify: true };
const bundle = await esbuild.build({
bundle: true,
define: { __FRSH_BUILD_ID: `"${BUILD_ID}"` },
entryPoints,
format: "esm",
metafile: true,
...minifyOptions,
outdir: ".",
// This is requried to ensure the format of the outputFiles path is the same
// between windows and linux
absWorkingDir,
outfile: "",
platform: "neutral",
plugins: [denoPlugin({ importMapURL: this.#importMapURL })],
sourcemap: this.#dev ? "linked" : false,
splitting: true,
target: ["chrome99", "firefox99", "safari15"],
treeShaking: true,
write: false,
jsx: JSX_RUNTIME_MODE[this.#jsxConfig.jsx],
jsxImportSource: this.#jsxConfig.jsxImportSource,
});
// const metafileOutputs = bundle.metafile!.outputs;
// for (const path in metafileOutputs) {
// const meta = metafileOutputs[path];
// const imports = meta.imports
// .filter(({ kind }) => kind === "import-statement")
// .map(({ path }) => `/${path}`);
// this.#preloads.set(`/${path}`, imports);
// }
const cache = new Map<string, Uint8Array>();
const absDirUrlLength = toFileUrl(absWorkingDir).href.length;
for (const file of bundle.outputFiles) {
cache.set(
toFileUrl(file.path).href.substring(absDirUrlLength),
file.contents,
);
}
this.#cache = cache;
return;
}
async cache(): Promise<Map<string, Uint8Array>> {
if (this.#cache === undefined) {
this.#cache = this.bundle();
}
if (this.#cache instanceof Promise) {
await this.#cache;
}
return this.#cache as Map<string, Uint8Array>;
}
async get(path: string): Promise<Uint8Array | null> {
const cache = await this.cache();
return cache.get(path) ?? null;
}
// getPreloads(path: string): string[] {
// return this.#preloads.get(path) ?? [];
// }
}

View File

@ -0,0 +1,23 @@
import { INTERNAL_PREFIX } from "../runtime/utils.ts";
export const REFRESH_JS_URL = `${INTERNAL_PREFIX}/refresh.js`;
export const ALIVE_URL = `${INTERNAL_PREFIX}/alive`;
export const BUILD_ID = Deno.env.get("DENO_DEPLOYMENT_ID") ||
crypto.randomUUID();
export const JS_PREFIX = `/js`;
export const DEBUG = !Deno.env.get("DENO_DEPLOYMENT_ID");
export function bundleAssetUrl(path: string) {
return `${INTERNAL_PREFIX}${JS_PREFIX}/${BUILD_ID}${path}`;
}
globalThis.__FRSH_BUILD_ID = BUILD_ID;
declare global {
interface Crypto {
randomUUID(): string;
}
// deno-lint-ignore no-var
var __FRSH_BUILD_ID: string;
}

View File

@ -0,0 +1,776 @@
import {
ConnInfo,
extname,
fromFileUrl,
RequestHandler,
rutt,
Status,
toFileUrl,
typeByExtension,
walk,
} from "./deps.ts";
import { h } from "preact";
import { Manifest } from "./mod.ts";
import { Bundler, JSXConfig } from "./bundle.ts";
import { ALIVE_URL, BUILD_ID, JS_PREFIX, REFRESH_JS_URL } from "./constants.ts";
import DefaultErrorHandler from "./default_error_page.ts";
import {
AppModule,
ErrorPage,
ErrorPageModule,
FreshOptions,
Handler,
Island,
Middleware,
MiddlewareModule,
MiddlewareRoute,
Plugin,
RenderFunction,
Route,
RouteModule,
UnknownPage,
UnknownPageModule,
} from "./types.ts";
import { render as internalRender } from "./render.ts";
import { ContentSecurityPolicyDirectives, SELF } from "../runtime/csp.ts";
import { ASSET_CACHE_BUST_KEY, INTERNAL_PREFIX } from "../runtime/utils.ts";
interface RouterState {
state: Record<string, unknown>;
}
interface StaticFile {
/** The URL to the static file on disk. */
localUrl: URL;
/** The path to the file as it would be in the incoming request. */
path: string;
/** The size of the file. */
size: number;
/** The content-type of the file. */
contentType: string;
/** Hash of the file contents. */
etag: string;
}
export class ServerContext {
#dev: boolean;
#routes: Route[];
#islands: Island[];
#staticFiles: StaticFile[];
#bundler: Bundler;
#renderFn: RenderFunction;
#middlewares: MiddlewareRoute[];
#app: AppModule;
#notFound: UnknownPage;
#error: ErrorPage;
#plugins: Plugin[];
constructor(
routes: Route[],
islands: Island[],
staticFiles: StaticFile[],
renderfn: RenderFunction,
middlewares: MiddlewareRoute[],
app: AppModule,
notFound: UnknownPage,
error: ErrorPage,
plugins: Plugin[],
importMapURL: URL,
jsxConfig: JSXConfig,
) {
this.#routes = routes;
this.#islands = islands;
this.#staticFiles = staticFiles;
this.#renderFn = renderfn;
this.#middlewares = middlewares;
this.#app = app;
this.#notFound = notFound;
this.#error = error;
this.#plugins = plugins;
this.#dev = typeof Deno.env.get("DENO_DEPLOYMENT_ID") !== "string"; // Env var is only set in prod (on Deploy).
this.#bundler = new Bundler(
this.#islands,
this.#plugins,
importMapURL,
jsxConfig,
this.#dev,
);
}
/**
* Process the manifest into individual components and pages.
*/
static async fromManifest(
manifest: Manifest,
opts: FreshOptions,
): Promise<ServerContext> {
// Get the manifest' base URL.
const baseUrl = new URL("./", manifest.baseUrl).href;
const config = manifest.config || { importMap: "./import_map.json" };
if (typeof config.importMap !== "string") {
throw new Error("deno.json must contain an 'importMap' property.");
}
const importMapURL = new URL(config.importMap, manifest.baseUrl);
config.compilerOptions ??= {};
let jsx: "react" | "react-jsx";
switch (config.compilerOptions.jsx) {
case "react":
case undefined:
jsx = "react";
break;
case "react-jsx":
jsx = "react-jsx";
break;
default:
throw new Error("Unknown jsx option: " + config.compilerOptions.jsx);
}
const jsxConfig: JSXConfig = {
jsx,
jsxImportSource: config.compilerOptions.jsxImportSource,
};
// Extract all routes, and prepare them into the `Page` structure.
const routes: Route[] = [];
const islands: Island[] = [];
const middlewares: MiddlewareRoute[] = [];
let app: AppModule = DEFAULT_APP;
let notFound: UnknownPage = DEFAULT_NOT_FOUND;
let error: ErrorPage = DEFAULT_ERROR;
for (const [self, module] of Object.entries(manifest.routes)) {
const url = new URL(self, baseUrl).href;
if (!url.startsWith(baseUrl + "routes")) {
throw new TypeError("Page is not a child of the basepath.");
}
const path = url.substring(baseUrl.length).substring("routes".length);
const baseRoute = path.substring(1, path.length - extname(path).length);
const name = baseRoute.replace("/", "-");
const isMiddleware = path.endsWith("/_middleware.tsx") ||
path.endsWith("/_middleware.ts") || path.endsWith("/_middleware.jsx") ||
path.endsWith("/_middleware.js");
if (!path.startsWith("/_") && !isMiddleware) {
const { default: component, config } = module as RouteModule;
let pattern = pathToPattern(baseRoute) + "{/}?";
if (config?.routeOverride) {
pattern = String(config.routeOverride);
}
let { handler } = module as RouteModule;
handler ??= {};
if (
component &&
typeof handler === "object" && handler.GET === undefined
) {
handler.GET = (_req, { render }) => render();
}
const route: Route = {
pattern,
url,
name,
component,
handler,
csp: Boolean(config?.csp ?? false),
};
routes.push(route);
} else if (isMiddleware) {
middlewares.push({
...middlewarePathToPattern(baseRoute),
...module as MiddlewareModule,
});
} else if (
path === "/_app.tsx" || path === "/_app.ts" ||
path === "/_app.jsx" || path === "/_app.js"
) {
app = module as AppModule;
} else if (
path === "/_404.tsx" || path === "/_404.ts" ||
path === "/_404.jsx" || path === "/_404.js"
) {
const { default: component, config } = module as UnknownPageModule;
let { handler } = module as UnknownPageModule;
if (component && handler === undefined) {
handler = (_req, { render }) => render();
}
notFound = {
pattern: pathToPattern(baseRoute),
url,
name,
component,
handler: handler ?? ((req) => rutt.defaultOtherHandler(req)),
csp: Boolean(config?.csp ?? false),
};
} else if (
path === "/_500.tsx" || path === "/_500.ts" ||
path === "/_500.jsx" || path === "/_500.js"
) {
const { default: component, config } = module as ErrorPageModule;
let { handler } = module as ErrorPageModule;
if (component && handler === undefined) {
handler = (_req, { render }) => render();
}
error = {
pattern: pathToPattern(baseRoute),
url,
name,
component,
handler: handler ??
((req, ctx) => rutt.defaultErrorHandler(req, ctx, ctx.error)),
csp: Boolean(config?.csp ?? false),
};
}
}
sortRoutes(routes);
sortRoutes(middlewares);
for (const [self, module] of Object.entries(manifest.islands)) {
const url = new URL(self, baseUrl).href;
if (!url.startsWith(baseUrl)) {
throw new TypeError("Island is not a child of the basepath.");
}
const path = url.substring(baseUrl.length).substring("islands".length);
const baseRoute = path.substring(1, path.length - extname(path).length);
const name = sanitizeIslandName(baseRoute);
const id = name.toLowerCase();
if (typeof module.default !== "function") {
throw new TypeError(
`Islands must default export a component ('${self}').`,
);
}
islands.push({ id, name, url, component: module.default });
}
const staticFiles: StaticFile[] = [];
try {
const staticFolder = new URL(
opts.staticDir ?? "./static",
manifest.baseUrl,
);
// TODO(lucacasonato): remove the extranious Deno.readDir when
// https://github.com/denoland/deno_std/issues/1310 is fixed.
for await (const _ of Deno.readDir(fromFileUrl(staticFolder))) {
// do nothing
}
const entires = walk(fromFileUrl(staticFolder), {
includeFiles: true,
includeDirs: false,
followSymlinks: false,
});
const encoder = new TextEncoder();
for await (const entry of entires) {
const localUrl = toFileUrl(entry.path);
const path = localUrl.href.substring(staticFolder.href.length);
const stat = await Deno.stat(localUrl);
const contentType = typeByExtension(extname(path)) ??
"application/octet-stream";
const etag = await crypto.subtle.digest(
"SHA-1",
encoder.encode(BUILD_ID + path),
).then((hash) =>
Array.from(new Uint8Array(hash))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
);
const staticFile: StaticFile = {
localUrl,
path,
size: stat.size,
contentType,
etag,
};
staticFiles.push(staticFile);
}
} catch (err) {
if (err instanceof Deno.errors.NotFound) {
// Do nothing.
} else {
throw err;
}
}
return new ServerContext(
routes,
islands,
staticFiles,
opts.render ?? DEFAULT_RENDER_FN,
middlewares,
app,
notFound,
error,
opts.plugins ?? [],
importMapURL,
jsxConfig,
);
}
/**
* This functions returns a request handler that handles all routes required
* by fresh, including static files.
*/
handler(): RequestHandler {
const inner = rutt.router<RouterState>(...this.#handlers());
const withMiddlewares = this.#composeMiddlewares(this.#middlewares);
return function handler(req: Request, connInfo: ConnInfo) {
// Redirect requests that end with a trailing slash
// to their non-trailing slash counterpart.
// Ex: /about/ -> /about
//const url = new URL(req.url);
//if (url.pathname.length > 1 && url.pathname.endsWith("/")) {
// url.pathname = url.pathname.slice(0, -1);
// return Response.redirect(url.href, Status.TemporaryRedirect);
//}
return withMiddlewares(req, connInfo, inner);
};
}
/**
* Identify which middlewares should be applied for a request,
* chain them and return a handler response
*/
#composeMiddlewares(middlewares: MiddlewareRoute[]) {
return (
req: Request,
connInfo: ConnInfo,
inner: rutt.Handler<RouterState>,
) => {
// identify middlewares to apply, if any.
// middlewares should be already sorted from deepest to shallow layer
const mws = selectMiddlewares(req.url, middlewares);
const handlers: (() => Response | Promise<Response>)[] = [];
const ctx = {
next() {
const handler = handlers.shift()!;
return Promise.resolve(handler());
},
...connInfo,
state: {},
};
for (const mw of mws) {
if (mw.handler instanceof Array) {
for (const handler of mw.handler) {
handlers.push(() => handler(req, ctx));
}
} else {
const handler = mw.handler;
handlers.push(() => handler(req, ctx));
}
}
handlers.push(() => inner(req, ctx));
const handler = handlers.shift()!;
return handler();
};
}
/**
* This function returns all routes required by fresh as an extended
* path-to-regex, to handler mapping.
*/
#handlers(): [
rutt.Routes<RouterState>,
rutt.Handler<RouterState>,
rutt.ErrorHandler<RouterState>,
] {
const routes: rutt.Routes<RouterState> = {};
routes[`${INTERNAL_PREFIX}${JS_PREFIX}/${BUILD_ID}/:path*`] = this
.#bundleAssetRoute();
if (this.#dev) {
routes[REFRESH_JS_URL] = () => {
const js =
`new EventSource("${ALIVE_URL}").addEventListener("message", function listener(e) { if (e.data !== "${BUILD_ID}") { this.removeEventListener('message', listener); location.reload(); } });`;
return new Response(js, {
headers: {
"content-type": "application/javascript; charset=utf-8",
},
});
};
routes[ALIVE_URL] = () => {
let timerId: number | undefined = undefined;
const body = new ReadableStream({
start(controller) {
controller.enqueue(`data: ${BUILD_ID}\nretry: 100\n\n`);
timerId = setInterval(() => {
controller.enqueue(`data: ${BUILD_ID}\n\n`);
}, 1000);
},
cancel() {
if (timerId !== undefined) {
clearInterval(timerId);
}
},
});
return new Response(body.pipeThrough(new TextEncoderStream()), {
headers: {
"content-type": "text/event-stream",
},
});
};
}
// Add the static file routes.
// each files has 2 static routes:
// - one serving the file at its location without a "cache bursting" mechanism
// - one containing the BUILD_ID in the path that can be cached
for (
const { localUrl, path, size, contentType, etag } of this.#staticFiles
) {
const route = sanitizePathToRegex(path);
routes[`GET@${route}`] = this.#staticFileHandler(
localUrl,
size,
contentType,
etag,
);
}
const genRender = <Data = undefined>(
route: Route<Data> | UnknownPage | ErrorPage,
status: number,
) => {
const imports: string[] = [];
if (this.#dev) {
imports.push(REFRESH_JS_URL);
}
return (
req: Request,
params: Record<string, string>,
error?: unknown,
) => {
return async (data?: Data) => {
if (route.component === undefined) {
throw new Error("This page does not have a component to render.");
}
if (
typeof route.component === "function" &&
route.component.constructor.name === "AsyncFunction"
) {
throw new Error(
"Async components are not supported. Fetch data inside of a route handler, as described in the docs: https://fresh.deno.dev/docs/getting-started/fetching-data",
);
}
const preloads: string[] = [];
const resp = await internalRender({
route,
islands: this.#islands,
plugins: this.#plugins,
app: this.#app,
imports,
preloads,
renderFn: this.#renderFn,
url: new URL(req.url),
params,
data,
error,
});
const headers: Record<string, string> = {
"content-type": "text/html; charset=utf-8",
};
const [body, csp] = resp;
if (csp) {
if (this.#dev) {
csp.directives.connectSrc = [
...(csp.directives.connectSrc ?? []),
SELF,
];
}
const directive = serializeCSPDirectives(csp.directives);
if (csp.reportOnly) {
headers["content-security-policy-report-only"] = directive;
} else {
headers["content-security-policy"] = directive;
}
}
return new Response(body, { status, headers });
};
};
};
const createUnknownRender = genRender(this.#notFound, Status.NotFound);
for (const route of this.#routes) {
const createRender = genRender(route, Status.OK);
if (typeof route.handler === "function") {
routes[route.pattern] = (req, ctx, params) =>
(route.handler as Handler)(req, {
...ctx,
params,
render: createRender(req, params),
renderNotFound: createUnknownRender(req, {}),
});
} else {
for (const [method, handler] of Object.entries(route.handler)) {
routes[`${method}@${route.pattern}`] = (req, ctx, params) =>
handler(req, {
...ctx,
params,
render: createRender(req, params),
renderNotFound: createUnknownRender(req, {}),
});
}
}
}
const unknownHandler: rutt.Handler<RouterState> = (
req,
ctx,
) =>
this.#notFound.handler(
req,
{
...ctx,
render: createUnknownRender(req, {}),
},
);
const errorHandlerRender = genRender(
this.#error,
Status.InternalServerError,
);
const errorHandler: rutt.ErrorHandler<RouterState> = (
req,
ctx,
error,
) => {
console.error(
"%cAn error occurred during route handling or page rendering.",
"color:red",
error,
);
return this.#error.handler(
req,
{
...ctx,
error,
render: errorHandlerRender(req, {}, error),
},
);
};
return [routes, unknownHandler, errorHandler];
}
#staticFileHandler(
localUrl: URL,
size: number,
contentType: string,
etag: string,
): rutt.MatchHandler {
return async (req: Request) => {
const url = new URL(req.url);
const key = url.searchParams.get(ASSET_CACHE_BUST_KEY);
if (key !== null && BUILD_ID !== key) {
url.searchParams.delete(ASSET_CACHE_BUST_KEY);
const location = url.pathname + url.search;
return new Response("", {
status: 307,
headers: {
"content-type": "text/plain",
location,
},
});
}
const headers = new Headers({
"content-type": contentType,
etag,
vary: "If-None-Match",
});
if (key !== null) {
headers.set("Cache-Control", "public, max-age=31536000, immutable");
}
const ifNoneMatch = req.headers.get("if-none-match");
if (ifNoneMatch === etag || ifNoneMatch === "W/" + etag) {
return new Response(null, { status: 304, headers });
} else {
const file = await Deno.open(localUrl);
headers.set("content-length", String(size));
return new Response(file.readable, { headers });
}
};
}
/**
* Returns a router that contains all fresh routes. Should be mounted at
* constants.INTERNAL_PREFIX
*/
#bundleAssetRoute = (): rutt.MatchHandler => {
return async (_req, _ctx, params) => {
const path = `/${params.path}`;
const file = await this.#bundler.get(path);
let res;
if (file) {
const headers = new Headers({
"Cache-Control": "public, max-age=604800, immutable",
});
const contentType = typeByExtension(extname(path));
if (contentType) {
headers.set("Content-Type", contentType);
}
res = new Response(file, {
status: 200,
headers,
});
}
return res ?? new Response(null, {
status: 404,
});
};
};
}
const DEFAULT_RENDER_FN: RenderFunction = (_ctx, render) => {
render();
};
const DEFAULT_APP: AppModule = {
default: ({ Component }) => h(Component, {}),
};
const DEFAULT_NOT_FOUND: UnknownPage = {
pattern: "",
url: "",
name: "_404",
handler: (req) => rutt.defaultOtherHandler(req),
csp: false,
};
const DEFAULT_ERROR: ErrorPage = {
pattern: "",
url: "",
name: "_500",
component: DefaultErrorHandler,
handler: (_req, ctx) => ctx.render(),
csp: false,
};
/**
* Return a list of middlewares that needs to be applied for request url
* @param url the request url
* @param middlewares Array of middlewares handlers and their routes as path-to-regexp style
*/
export function selectMiddlewares(url: string, middlewares: MiddlewareRoute[]) {
const selectedMws: Middleware[] = [];
const reqURL = new URL(url);
for (const { compiledPattern, handler } of middlewares) {
const res = compiledPattern.exec(reqURL);
if (res) {
selectedMws.push({ handler });
}
}
return selectedMws;
}
/**
* Sort pages by their relative routing priority, based on the parts in the
* route matcher
*/
function sortRoutes<T extends { pattern: string }>(routes: T[]) {
routes.sort((a, b) => {
const partsA = a.pattern.split("/");
const partsB = b.pattern.split("/");
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const partA = partsA[i];
const partB = partsB[i];
if (partA === undefined) return -1;
if (partB === undefined) return 1;
if (partA === partB) continue;
const priorityA = partA.startsWith(":") ? partA.endsWith("*") ? 0 : 1 : 2;
const priorityB = partB.startsWith(":") ? partB.endsWith("*") ? 0 : 1 : 2;
return Math.max(Math.min(priorityB - priorityA, 1), -1);
}
return 0;
});
}
/** Transform a filesystem URL path to a `path-to-regex` style matcher. */
function pathToPattern(path: string): string {
const parts = path.split("/");
if (parts[parts.length - 1] === "index") {
parts.pop();
}
const route = "/" + parts
.map((part) => {
if (part.startsWith("[...") && part.endsWith("]")) {
return `:${part.slice(4, part.length - 1)}**`;
}
if (part.startsWith("[") && part.endsWith("]")) {
return `:${part.slice(1, part.length - 1)}`;
}
return part;
})
.join("/");
return route;
}
// Normalize a path for use in a URL. Returns null if the path is unparsable.
export function normalizeURLPath(path: string): string | null {
try {
const pathUrl = new URL("file:///");
pathUrl.pathname = path;
return pathUrl.pathname;
} catch {
return null;
}
}
function sanitizePathToRegex(path: string): string {
return path
.replaceAll("\*", "\\*")
.replaceAll("\+", "\\+")
.replaceAll("\?", "\\?")
.replaceAll("\{", "\\{")
.replaceAll("\}", "\\}")
.replaceAll("\(", "\\(")
.replaceAll("\)", "\\)")
.replaceAll("\:", "\\:");
}
function toPascalCase(text: string): string {
return text.replace(
/(^\w|-\w)/g,
(substring) => substring.replace(/-/, "").toUpperCase(),
);
}
function sanitizeIslandName(name: string): string {
const fileName = name.replace("/", "");
return toPascalCase(fileName);
}
function serializeCSPDirectives(csp: ContentSecurityPolicyDirectives): string {
return Object.entries(csp)
.filter(([_key, value]) => value !== undefined)
.map(([k, v]: [string, string | string[]]) => {
// Turn camel case into snake case.
const key = k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
const value = Array.isArray(v) ? v.join(" ") : v;
return `${key} ${value}`;
})
.join("; ");
}
export function middlewarePathToPattern(baseRoute: string) {
baseRoute = baseRoute.slice(0, -"_middleware".length);
let pattern = pathToPattern(baseRoute);
if (pattern.endsWith("/")) {
pattern = pattern.slice(0, -1) + "{/*}?";
}
const compiledPattern = new URLPattern({ pathname: pattern });
return { pattern, compiledPattern };
}

View File

@ -0,0 +1,24 @@
import { assert } from "../../tests/deps.ts";
import { middlewarePathToPattern, selectMiddlewares } from "./context.ts";
import { MiddlewareRoute } from "./types.ts";
Deno.test("selectMiddlewares", () => {
const url = "https://fresh.deno.dev/api/abc/def";
const middlewaresPath = [
// should select
"_middleware",
"api/_middleware",
"api/[id]/_middleware",
"api/[id]/[path]/_middleware",
// should not select
"api/xyz/_middleware",
"api/[id]/xyz/_middleware",
"api/[id]/[path]/foo/_middleware",
];
const mwRoutes = middlewaresPath.map((path) =>
middlewarePathToPattern(path)
) as MiddlewareRoute[];
const mws = selectMiddlewares(url, mwRoutes);
assert(mws.length === 4);
});

View File

@ -0,0 +1,58 @@
import { h } from "preact";
import { DEBUG } from "./constants.ts";
import type { ErrorPageProps } from "./types.ts";
export default function DefaultErrorPage(props: ErrorPageProps) {
const { error } = props;
let message = undefined;
if (DEBUG) {
if (error instanceof Error) {
message = error.stack;
} else {
message = String(error);
}
}
return h(
"div",
{
style: {
display: "flex",
justifyContent: "center",
alignItems: "center",
},
},
h(
"div",
{
style: {
border: "#f3f4f6 2px solid",
borderTop: "red 4px solid",
background: "#f9fafb",
margin: 16,
minWidth: "300px",
width: "50%",
},
},
h("p", {
style: {
margin: 0,
fontSize: "12pt",
padding: 16,
fontFamily: "sans-serif",
},
}, "An error occurred during route handling or page rendering."),
message && h("pre", {
style: {
margin: 0,
fontSize: "12pt",
overflowY: "auto",
padding: 16,
paddingTop: 0,
fontFamily: "monospace",
},
}, message),
),
);
}

View File

@ -0,0 +1,31 @@
// -- std --
export {
extname,
fromFileUrl,
toFileUrl,
} from "https://deno.land/std@0.150.0/path/mod.ts";
export { walk } from "https://deno.land/std@0.150.0/fs/walk.ts";
export { serve } from "https://deno.land/std@0.150.0/http/server.ts";
export type {
ConnInfo,
Handler as RequestHandler,
ServeInit,
} from "https://deno.land/std@0.150.0/http/server.ts";
export { Status } from "https://deno.land/std@0.150.0/http/http_status.ts";
export {
typeByExtension,
} from "https://deno.land/std@0.150.0/media_types/mod.ts";
// -- rutt --
export * as rutt from "https://deno.land/x/rutt@0.0.14/mod.ts";
// -- esbuild --
// @deno-types="https://deno.land/x/esbuild@v0.14.51/mod.d.ts"
import * as esbuildWasm from "https://deno.land/x/esbuild@v0.14.51/wasm.js";
import * as esbuildNative from "https://deno.land/x/esbuild@v0.14.51/mod.js";
// @ts-ignore trust me
const esbuild: typeof esbuildWasm = Deno.run === undefined
? esbuildWasm
: esbuildNative;
export { esbuild, esbuildWasm as esbuildTypes };
export { denoPlugin } from "https://deno.land/x/esbuild_deno_loader@0.5.2/mod.ts";

Binary file not shown.

View File

@ -0,0 +1,15 @@
// This utility is based on https://github.com/zertosh/htmlescape
// License: https://github.com/zertosh/htmlescape/blob/0527ca7156a524d256101bb310a9f970f63078ad/LICENSE
const ESCAPE_LOOKUP: { [match: string]: string } = {
">": "\\u003e",
"<": "\\u003c",
"\u2028": "\\u2028",
"\u2029": "\\u2029",
};
const ESCAPE_REGEX = /[><\u2028\u2029]/g;
export function htmlEscapeJsonString(str: string): string {
return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]);
}

View File

@ -0,0 +1,18 @@
import { htmlEscapeJsonString } from "./htmlescape.ts";
import { assertEquals } from "../../tests/deps.ts";
Deno.test("with angle brackets should escape", () => {
const evilObj = { evil: "<script></script>" };
assertEquals(
htmlEscapeJsonString(JSON.stringify(evilObj)),
'{"evil":"\\u003cscript\\u003e\\u003c/script\\u003e"}',
);
});
Deno.test("with angle brackets should parse back", () => {
const evilObj = { evil: "<script></script>" };
assertEquals(
JSON.parse(htmlEscapeJsonString(JSON.stringify(evilObj))),
evilObj,
);
});

View File

@ -0,0 +1,72 @@
import { ServerContext } from "./context.ts";
import { serve } from "./deps.ts";
export { Status } from "./deps.ts";
import {
AppModule,
ErrorPageModule,
IslandModule,
MiddlewareModule,
RouteModule,
StartOptions,
UnknownPageModule,
} from "./types.ts";
export type {
AppProps,
ErrorHandler,
ErrorHandlerContext,
ErrorPageProps,
FreshOptions,
Handler,
HandlerContext,
Handlers,
MiddlewareHandler,
MiddlewareHandlerContext,
PageProps,
Plugin,
PluginRenderResult,
PluginRenderScripts,
PluginRenderStyleTag,
RenderFunction,
RouteConfig,
StartOptions,
UnknownHandler,
UnknownHandlerContext,
UnknownPageProps,
} from "./types.ts";
export { RenderContext } from "./render.ts";
export type { InnerRenderFunction } from "./render.ts";
export interface Manifest {
routes: Record<
string,
| RouteModule
| MiddlewareModule
| AppModule
| ErrorPageModule
| UnknownPageModule
>;
islands: Record<string, IslandModule>;
baseUrl: string;
config?: DenoConfig;
}
export interface DenoConfig {
importMap: string;
compilerOptions?: {
jsx?: string;
jsxImportSource?: string;
};
}
export { ServerContext };
export async function start(routes: Manifest, opts: StartOptions = {}) {
const ctx = await ServerContext.fromManifest(routes, opts);
opts.port ??= 8000;
if (opts.experimentalDenoServe === true) {
// @ts-ignore as `Deno.serve` is still unstable.
await Deno.serve(ctx.handler() as Deno.ServeHandler, opts);
} else {
await serve(ctx.handler(), opts);
}
}

View File

@ -0,0 +1,387 @@
import { renderToString } from "preact-render-to-string";
import { ComponentChildren, ComponentType, h, options } from "preact";
import {
AppModule,
ErrorPage,
Island,
Plugin,
PluginRenderFunctionResult,
PluginRenderResult,
PluginRenderStyleTag,
RenderFunction,
Route,
UnknownPage,
} from "./types.ts";
import { HEAD_CONTEXT } from "../runtime/head.ts";
import { CSP_CONTEXT, nonce, NONE, UNSAFE_INLINE } from "../runtime/csp.ts";
import { ContentSecurityPolicy } from "../runtime/csp.ts";
import { bundleAssetUrl } from "./constants.ts";
import { assetHashingHook } from "../runtime/utils.ts";
import { htmlEscapeJsonString } from "./htmlescape.ts";
export interface RenderOptions<Data> {
route: Route<Data> | UnknownPage | ErrorPage;
islands: Island[];
plugins: Plugin[];
app: AppModule;
imports: string[];
preloads: string[];
url: URL;
params: Record<string, string | string[]>;
renderFn: RenderFunction;
data?: Data;
error?: unknown;
lang?: string;
}
export type InnerRenderFunction = () => string;
export class RenderContext {
#id: string;
#state: Map<string, unknown> = new Map();
#styles: string[] = [];
#url: URL;
#route: string;
#lang: string;
constructor(id: string, url: URL, route: string, lang: string) {
this.#id = id;
this.#url = url;
this.#route = route;
this.#lang = lang;
}
/** A unique ID for this logical JIT render. */
get id(): string {
return this.#id;
}
/**
* State that is persisted between multiple renders with the same render
* context. This is useful because one logical JIT render could have multiple
* preact render passes due to suspense.
*/
get state(): Map<string, unknown> {
return this.#state;
}
/**
* All of the CSS style rules that should be inlined into the document.
* Adding to this list across multiple renders is supported (even across
* suspense!). The CSS rules will always be inserted on the client in the
* order specified here.
*/
get styles(): string[] {
return this.#styles;
}
/** The URL of the page being rendered. */
get url(): URL {
return this.#url;
}
/** The route matcher (e.g. /blog/:id) that the request matched for this page
* to be rendered. */
get route(): string {
return this.#route;
}
/** The language of the page being rendered. Defaults to "en". */
get lang(): string {
return this.#lang;
}
set lang(lang: string) {
this.#lang = lang;
}
}
function defaultCsp() {
return {
directives: { defaultSrc: [NONE], styleSrc: [UNSAFE_INLINE] },
reportOnly: false,
};
}
/**
* This function renders out a page. Rendering is synchronous and non streaming.
* Suspense boundaries are not supported.
*/
export async function render<Data>(
opts: RenderOptions<Data>,
): Promise<[string, ContentSecurityPolicy | undefined]> {
const props: Record<string, unknown> = {
params: opts.params,
url: opts.url,
route: opts.route.pattern,
data: opts.data,
};
if (opts.error) {
props.error = opts.error;
}
const csp: ContentSecurityPolicy | undefined = opts.route.csp
? defaultCsp()
: undefined;
const headComponents: ComponentChildren[] = [];
const vnode = h(CSP_CONTEXT.Provider, {
value: csp,
children: h(HEAD_CONTEXT.Provider, {
value: headComponents,
children: h(opts.app.default, {
Component() {
return h(opts.route.component! as ComponentType<unknown>, props);
},
}),
}),
});
const ctx = new RenderContext(
crypto.randomUUID(),
opts.url,
opts.route.pattern,
opts.lang ?? "en",
);
if (csp) {
// Clear the csp
const newCsp = defaultCsp();
csp.directives = newCsp.directives;
csp.reportOnly = newCsp.reportOnly;
}
// Clear the head components
headComponents.splice(0, headComponents.length);
// Setup the interesting VNode types
ISLANDS.splice(0, ISLANDS.length, ...opts.islands);
// Clear the encountered vnodes
ENCOUNTERED_ISLANDS.clear();
// Clear the island props
ISLAND_PROPS = [];
let bodyHtml: string | null = null;
function realRender(): string {
bodyHtml = renderToString(vnode);
return bodyHtml;
}
const plugins = opts.plugins.filter((p) => p.render !== null);
const renderResults: [Plugin, PluginRenderResult][] = [];
function render(): PluginRenderFunctionResult {
const plugin = plugins.shift();
if (plugin) {
const res = plugin.render!({ render });
if (res === undefined) {
throw new Error(
`${plugin?.name}'s render hook did not return a PluginRenderResult object.`,
);
}
renderResults.push([plugin, res]);
} else {
realRender();
}
if (bodyHtml === null) {
throw new Error(
`The 'render' function was not called by ${plugin?.name}'s render hook.`,
);
}
return {
htmlText: bodyHtml,
requiresHydration: ENCOUNTERED_ISLANDS.size > 0,
};
}
await opts.renderFn(ctx, () => render().htmlText);
if (bodyHtml === null) {
throw new Error("The `render` function was not called by the renderer.");
}
bodyHtml = bodyHtml as string;
const imports = opts.imports.map((url) => {
const randomNonce = crypto.randomUUID().replace(/-/g, "");
if (csp) {
csp.directives.scriptSrc = [
...csp.directives.scriptSrc ?? [],
nonce(randomNonce),
];
}
return [url, randomNonce] as const;
});
const state: [islands: unknown[], plugins: unknown[]] = [ISLAND_PROPS, []];
const styleTags: PluginRenderStyleTag[] = [];
let script =
`const STATE_COMPONENT = document.getElementById("__FRSH_STATE");const STATE = JSON.parse(STATE_COMPONENT?.textContent ?? "[[],[]]");`;
for (const [plugin, res] of renderResults) {
for (const hydrate of res.scripts ?? []) {
const i = state[1].push(hydrate.state) - 1;
const randomNonce = crypto.randomUUID().replace(/-/g, "");
if (csp) {
csp.directives.scriptSrc = [
...csp.directives.scriptSrc ?? [],
nonce(randomNonce),
];
}
const url = bundleAssetUrl(
`/plugin-${plugin.name}-${hydrate.entrypoint}.js`,
);
imports.push([url, randomNonce] as const);
script += `import p${i} from "${url}";p${i}(STATE[1][${i}]);`;
}
styleTags.splice(styleTags.length, 0, ...res.styles ?? []);
}
if (ENCOUNTERED_ISLANDS.size > 0) {
// Load the main.js script
{
const randomNonce = crypto.randomUUID().replace(/-/g, "");
if (csp) {
csp.directives.scriptSrc = [
...csp.directives.scriptSrc ?? [],
nonce(randomNonce),
];
}
const url = bundleAssetUrl("/main.js");
imports.push([url, randomNonce] as const);
}
script += `import { revive } from "${bundleAssetUrl("/main.js")}";`;
// Prepare the inline script that loads and revives the islands
let islandRegistry = "";
for (const island of ENCOUNTERED_ISLANDS) {
const randomNonce = crypto.randomUUID().replace(/-/g, "");
if (csp) {
csp.directives.scriptSrc = [
...csp.directives.scriptSrc ?? [],
nonce(randomNonce),
];
}
const url = bundleAssetUrl(`/island-${island.id}.js`);
imports.push([url, randomNonce] as const);
script += `import ${island.name} from "${url}";`;
islandRegistry += `${island.id}:${island.name},`;
}
script += `revive({${islandRegistry}}, STATE[0]);`;
}
if (state[0].length > 0 || state[1].length > 0) {
// Append state to the body
bodyHtml += `<script id="__FRSH_STATE" type="application/json">${
htmlEscapeJsonString(JSON.stringify(state))
}</script>`;
// Append the inline script to the body
const randomNonce = crypto.randomUUID().replace(/-/g, "");
if (csp) {
csp.directives.scriptSrc = [
...csp.directives.scriptSrc ?? [],
nonce(randomNonce),
];
}
bodyHtml +=
`<script type="module" nonce="${randomNonce}">${script}</script>`;
}
if (ctx.styles.length > 0) {
const node = h("style", {
id: "__FRSH_STYLE",
dangerouslySetInnerHTML: { __html: ctx.styles.join("\n") },
});
headComponents.splice(0, 0, node);
}
for (const style of styleTags) {
const node = h("style", {
id: style.id,
dangerouslySetInnerHTML: { __html: style.cssText },
media: style.media,
});
headComponents.splice(0, 0, node);
}
const html = template({
bodyHtml,
headComponents,
imports,
preloads: opts.preloads,
lang: ctx.lang,
});
return [html, csp];
}
export interface TemplateOptions {
bodyHtml: string;
headComponents: ComponentChildren[];
imports: (readonly [string, string])[];
preloads: string[];
lang: string;
}
export function template(opts: TemplateOptions): string {
const page = h(
"html",
{ lang: opts.lang },
h(
"head",
null,
h("meta", { charSet: "UTF-8" }),
h("meta", {
name: "viewport",
content: "width=device-width, initial-scale=1.0",
}),
opts.preloads.map((src) =>
h("link", { rel: "modulepreload", href: src })
),
opts.imports.map(([src, nonce]) =>
h("script", { src: src, nonce: nonce, type: "module" })
),
opts.headComponents,
),
h("body", { dangerouslySetInnerHTML: { __html: opts.bodyHtml } }),
);
return "<!DOCTYPE html>" + renderToString(page);
}
// Set up a preact option hook to track when vnode with custom functions are
// created.
const ISLANDS: Island[] = [];
const ENCOUNTERED_ISLANDS: Set<Island> = new Set([]);
let ISLAND_PROPS: unknown[] = [];
const originalHook = options.vnode;
let ignoreNext = false;
options.vnode = (vnode) => {
assetHashingHook(vnode);
const originalType = vnode.type as ComponentType<unknown>;
if (typeof vnode.type === "function") {
const island = ISLANDS.find((island) => island.component === originalType);
if (island) {
if (ignoreNext) {
ignoreNext = false;
return;
}
ENCOUNTERED_ISLANDS.add(island);
vnode.type = (props) => {
ignoreNext = true;
const child = h(originalType, props);
ISLAND_PROPS.push(props);
return h(
`!--frsh-${island.id}:${ISLAND_PROPS.length - 1}--`,
null,
child,
);
};
}
}
if (originalHook) originalHook(vnode);
};

View File

@ -0,0 +1,14 @@
import { template } from "./render.ts";
import { assertStringIncludes } from "../../tests/deps.ts";
Deno.test("check lang", () => {
const lang = "fr";
const body = template({
bodyHtml: "",
headComponents: [],
imports: [],
preloads: [],
lang,
});
assertStringIncludes(body, `<html lang="${lang}">`);
});

View File

@ -0,0 +1,306 @@
import { ComponentType } from "preact";
import { ConnInfo, rutt, ServeInit } from "./deps.ts";
import { InnerRenderFunction, RenderContext } from "./render.ts";
// --- APPLICATION CONFIGURATION ---
export type StartOptions = ServeInit & FreshOptions & {
/**
* UNSTABLE: use the `Deno.serve` API as the underlying HTTP server instead of
* the `std/http` API. Do not use this in production.
*
* This option is experimental and may be removed in a future Fresh release.
*/
experimentalDenoServe?: boolean;
};
export interface FreshOptions {
render?: RenderFunction;
plugins?: Plugin[];
staticDir?: string;
}
export type RenderFunction = (
ctx: RenderContext,
render: InnerRenderFunction,
) => void | Promise<void>;
/// --- ROUTES ---
// deno-lint-ignore no-explicit-any
export interface PageProps<T = any> {
/** The URL of the request that resulted in this page being rendered. */
url: URL;
/** The route matcher (e.g. /blog/:id) that the request matched for this page
* to be rendered. */
route: string;
/**
* The parameters that were matched from the route.
*
* For the `/foo/:bar` route with url `/foo/123`, `params` would be
* `{ bar: '123' }`. For a route with no matchers, `params` would be `{}`. For
* a wildcard route, like `/foo/:path*` with url `/foo/bar/baz`, `params` would
* be `{ path: 'bar/baz' }`.
*/
params: Record<string, string>;
/**
* Additional data passed into `HandlerContext.render`. Defaults to
* `undefined`.
*/
data: T;
}
export interface RouteConfig {
/**
* A route override for the page. This is useful for pages where the route
* can not be expressed through the filesystem routing capabilities.
*
* The route override must be a path-to-regexp compatible route matcher.
*/
routeOverride?: string;
/**
* If Content-Security-Policy should be enabled for this page. If 'true', a
* locked down policy will be used that allows only the scripts and styles
* that are generated by fresh. Additional scripts and styles can be added
* using the `useCSP` hook.
*/
csp?: boolean;
}
export interface HandlerContext<Data = unknown, State = Record<string, unknown>>
extends ConnInfo {
params: Record<string, string>;
render: (data?: Data) => Response | Promise<Response>;
renderNotFound: () => Response | Promise<Response>;
state: State;
}
// deno-lint-ignore no-explicit-any
export type Handler<T = any, State = Record<string, unknown>> = (
req: Request,
ctx: HandlerContext<T, State>,
) => Response | Promise<Response>;
// deno-lint-ignore no-explicit-any
export type Handlers<T = any, State = Record<string, unknown>> = {
[K in typeof rutt.METHODS[number]]?: Handler<T, State>;
};
export interface RouteModule {
default?: ComponentType<PageProps>;
// deno-lint-ignore no-explicit-any
handler?: Handler<any, any> | Handlers<any, any>;
config?: RouteConfig;
}
// deno-lint-ignore no-explicit-any
export interface Route<Data = any> {
pattern: string;
url: string;
name: string;
component?: ComponentType<PageProps<Data>>;
handler: Handler<Data> | Handlers<Data>;
csp: boolean;
}
// --- APP ---
export interface AppProps {
Component: ComponentType<Record<never, never>>;
}
export interface AppModule {
default: ComponentType<AppProps>;
}
// --- UNKNOWN PAGE ---
export interface UnknownPageProps {
/** The URL of the request that resulted in this page being rendered. */
url: URL;
/** The route matcher (e.g. /blog/:id) that the request matched for this page
* to be rendered. */
route: string;
}
export interface UnknownHandlerContext<State = Record<string, unknown>>
extends ConnInfo {
render: () => Response | Promise<Response>;
state: State;
}
export type UnknownHandler = (
req: Request,
ctx: UnknownHandlerContext,
) => Response | Promise<Response>;
export interface UnknownPageModule {
default?: ComponentType<UnknownPageProps>;
handler?: UnknownHandler;
config?: RouteConfig;
}
export interface UnknownPage {
pattern: string;
url: string;
name: string;
component?: ComponentType<UnknownPageProps>;
handler: UnknownHandler;
csp: boolean;
}
// --- ERROR PAGE ---
export interface ErrorPageProps {
/** The URL of the request that resulted in this page being rendered. */
url: URL;
/** The route matcher (e.g. /blog/:id) that the request matched for this page
* to be rendered. */
pattern: string;
/** The error that caused the error page to be loaded. */
error: unknown;
}
export interface ErrorHandlerContext<State = Record<string, unknown>>
extends ConnInfo {
error: unknown;
render: () => Response | Promise<Response>;
state: State;
}
export type ErrorHandler = (
req: Request,
ctx: ErrorHandlerContext,
) => Response | Promise<Response>;
export interface ErrorPageModule {
default?: ComponentType<ErrorPageProps>;
handler?: ErrorHandler;
config?: RouteConfig;
}
export interface ErrorPage {
pattern: string;
url: string;
name: string;
component?: ComponentType<ErrorPageProps>;
handler: ErrorHandler;
csp: boolean;
}
// --- MIDDLEWARES ---
export interface MiddlewareHandlerContext<State = Record<string, unknown>>
extends ConnInfo {
next: () => Promise<Response>;
state: State;
}
export interface MiddlewareRoute extends Middleware {
/**
* path-to-regexp style url path
*/
pattern: string;
/**
* URLPattern of the route
*/
compiledPattern: URLPattern;
}
export type MiddlewareHandler<State = Record<string, unknown>> = (
req: Request,
ctx: MiddlewareHandlerContext<State>,
) => Response | Promise<Response>;
// deno-lint-ignore no-explicit-any
export interface MiddlewareModule<State = any> {
handler: MiddlewareHandler<State> | MiddlewareHandler<State>[];
}
export interface Middleware<State = Record<string, unknown>> {
handler: MiddlewareHandler<State> | MiddlewareHandler<State>[];
}
// --- ISLANDS ---
export interface IslandModule {
// deno-lint-ignore no-explicit-any
default: ComponentType<any>;
}
export interface Island {
id: string;
name: string;
url: string;
component: ComponentType<unknown>;
}
// --- PLUGINS ---
export interface Plugin {
/** The name of the plugin. Must be snake-case. */
name: string;
/** A map of a snake-case names to a import specifiers. The entrypoints
* declared here can later be used in the "scripts" option of
* `PluginRenderResult` to load the entrypoint's code on the client.
*/
entrypoints?: Record<string, string>;
/** The render hook is called on the server every time some JSX needs to
* be turned into HTML. The render hook needs to call the `ctx.render`
* function exactly once.
*
* The hook can return a `PluginRenderResult` object that can do things like
* inject CSS into the page, or load additional JS files on the client.
*/
render?(ctx: PluginRenderContext): PluginRenderResult;
}
export interface PluginRenderContext {
render: PluginRenderFunction;
}
export interface PluginRenderResult {
/** CSS styles to be injected into the page. */
styles?: PluginRenderStyleTag[];
/** JS scripts to ship to the client. */
scripts?: PluginRenderScripts[];
}
export interface PluginRenderStyleTag {
cssText: string;
media?: string;
id?: string;
}
export interface PluginRenderScripts {
/** The "key" of the entrypoint (as specified in `Plugin.entrypoints`) for the
* script that should be loaded. The script must be an ES module that exports
* a default function.
*
* The default function is invoked with the `state` argument specified below.
*/
entrypoint: string;
/** The state argument that is passed to the default export invocation of the
* entrypoint's default export. The state must be JSON-serializable.
*/
state: unknown;
}
export type PluginRenderFunction = () => PluginRenderFunctionResult;
export interface PluginRenderFunctionResult {
/** The HTML text that was rendered. */
htmlText: string;
/** If the renderer encountered any islands that require hydration on the
* client.
*/
requiresHydration: boolean;
}

180
fresh-main/update.ts Normal file
View File

@ -0,0 +1,180 @@
import { join, Node, parse, Project, resolve } from "./src/dev/deps.ts";
import { error } from "./src/dev/error.ts";
import { freshImports, twindImports } from "./src/dev/imports.ts";
import { collect, ensureMinDenoVersion, generate } from "./src/dev/mod.ts";
ensureMinDenoVersion();
const help = `fresh-update
Update a Fresh project. This updates dependencies and optionally performs code
mods to update a project's source code to the latest recommended patterns.
To upgrade a projecct in the current directory, run:
fresh-update .
USAGE:
fresh-update <DIRECTORY>
`;
const flags = parse(Deno.args, {});
if (flags._.length !== 1) {
error(help);
}
const unresolvedDirectory = Deno.args[0];
const resolvedDirectory = resolve(unresolvedDirectory);
// Update dependencies in the import map.
const IMPORT_MAP_PATH = join(resolvedDirectory, "import_map.json");
let importMapText = await Deno.readTextFile(IMPORT_MAP_PATH);
const importMap = JSON.parse(importMapText);
freshImports(importMap.imports);
if (importMap.imports["twind"]) {
twindImports(importMap.imports);
}
importMapText = JSON.stringify(importMap, null, 2);
await Deno.writeTextFile(IMPORT_MAP_PATH, importMapText);
// Code mod for classic JSX -> automatic JSX.
const JSX_CODEMOD =
`This project is using the classic JSX transform. Would you like to update to the
automatic JSX transform? This will remove the /** @jsx h */ pragma from your
source code and add the jsx: "react-jsx" compiler option to your deno.json file.`;
const DENO_JSON_PATH = join(resolvedDirectory, "deno.json");
let denoJsonText = await Deno.readTextFile(DENO_JSON_PATH);
const denoJson = JSON.parse(denoJsonText);
if (denoJson.compilerOptions?.jsx !== "react-jsx" && confirm(JSX_CODEMOD)) {
console.log("Updating config file...");
denoJson.compilerOptions = denoJson.compilerOptions || {};
denoJson.compilerOptions.jsx = "react-jsx";
denoJson.compilerOptions.jsxImportSource = "preact";
denoJsonText = JSON.stringify(denoJson, null, 2);
await Deno.writeTextFile(DENO_JSON_PATH, denoJsonText);
const project = new Project();
const sfs = project.addSourceFilesAtPaths(
join(resolvedDirectory, "**", "*.{js,jsx,ts,tsx}"),
);
for (const sf of sfs) {
for (const d of sf.getImportDeclarations()) {
if (d.getModuleSpecifierValue() !== "preact") continue;
for (const n of d.getNamedImports()) {
const name = n.getName();
if (name === "h" || name === "Fragment") n.remove();
}
if (
d.getNamedImports().length === 0 &&
d.getNamespaceImport() === undefined &&
d.getDefaultImport() === undefined
) {
d.remove();
}
}
let text = sf.getFullText();
text = text.replaceAll("/** @jsx h */\n", "");
text = text.replaceAll("/** @jsxFrag Fragment */\n", "");
sf.replaceWithText(text);
await sf.save();
}
}
// Code mod for class={tw`border`} to class="border".
const TWIND_CODEMOD =
`This project is using an old version of the twind integration. Would you like to
update to the new twind plugin? This will remove the 'class={tw\`border\`}'
boilerplate from your source code replace it with the simpler 'class="border"'.`;
if (importMap.imports["@twind"] && confirm(TWIND_CODEMOD)) {
await Deno.remove(join(resolvedDirectory, importMap.imports["@twind"]));
delete importMap.imports["@twind"];
importMapText = JSON.stringify(importMap, null, 2);
await Deno.writeTextFile(IMPORT_MAP_PATH, importMapText);
const MAIN_TS = `/// <reference no-default-lib="true" />
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
/// <reference lib="dom.asynciterable" />
/// <reference lib="deno.ns" />
import { start } from "$fresh/server.ts";
import manifest from "./fresh.gen.ts";
import twindPlugin from "$fresh/plugins/twind.ts";
import twindConfig from "./twind.config.ts";
await start(manifest, { plugins: [twindPlugin(twindConfig)] });\n`;
const MAIN_TS_PATH = join(resolvedDirectory, "main.ts");
await Deno.writeTextFile(MAIN_TS_PATH, MAIN_TS);
const TWIND_CONFIG_TS = `import { Options } from "$fresh/plugins/twind.ts";
export default {
selfURL: import.meta.url,
} as Options;
`;
await Deno.writeTextFile(
join(resolvedDirectory, "twind.config.ts"),
TWIND_CONFIG_TS,
);
const project = new Project();
const sfs = project.addSourceFilesAtPaths(
join(resolvedDirectory, "**", "*.{js,jsx,ts,tsx}"),
);
for (const sf of sfs) {
const nodes = sf.forEachDescendantAsArray();
for (const n of nodes) {
if (!n.wasForgotten() && Node.isJsxAttribute(n)) {
const init = n.getInitializer();
const name = n.getName();
if (
Node.isJsxExpression(init) &&
(name === "class" || name === "className")
) {
const expr = init.getExpression();
if (Node.isTaggedTemplateExpression(expr)) {
const tag = expr.getTag();
if (Node.isIdentifier(tag) && tag.getText() === "tw") {
const template = expr.getTemplate();
if (Node.isNoSubstitutionTemplateLiteral(template)) {
n.setInitializer(`"${template.getLiteralValue()}"`);
}
}
} else if (expr?.getFullText() === `tw(props.class ?? "")`) {
n.setInitializer(`{props.class}`);
}
}
}
}
const text = sf.getFullText();
const removeTw = [...text.matchAll(/tw[,\s`(]/g)].length === 1;
for (const d of sf.getImportDeclarations()) {
if (d.getModuleSpecifierValue() !== "@twind") continue;
for (const n of d.getNamedImports()) {
const name = n.getName();
if (name === "tw" && removeTw) n.remove();
}
d.setModuleSpecifier("twind");
if (
d.getNamedImports().length === 0 &&
d.getNamespaceImport() === undefined &&
d.getDefaultImport() === undefined
) {
d.remove();
}
}
await sf.save();
}
}
const manifest = await collect(resolvedDirectory);
await generate(resolvedDirectory, manifest);

14
fresh-main/versions.json Normal file
View File

@ -0,0 +1,14 @@
[
"1.1.2",
"1.1.1",
"1.1.0",
"1.0.2",
"1.0.1",
"1.0.0",
"1.0.0-rc.6",
"1.0.0-rc.5",
"1.0.0-rc.4",
"1.0.0-rc.3",
"1.0.0-rc.2",
"1.0.0-rc.1"
]

View File

@ -5,29 +5,32 @@
import config from "./deno.json" assert { type: "json" };
import * as $0 from "./routes/_404.tsx";
import * as $1 from "./routes/_middleware.ts";
import * as $2 from "./routes/api/login.ts";
import * as $3 from "./routes/api/logout.ts";
import * as $4 from "./routes/dir/[...path].tsx";
import * as $5 from "./routes/doc/index.tsx";
import * as $6 from "./routes/index.tsx";
import * as $7 from "./routes/login.tsx";
import * as $2 from "./routes/api/logout.ts";
import * as $3 from "./routes/dir/[...path].tsx";
import * as $4 from "./routes/dir/_middleware.ts";
import * as $5 from "./routes/doc/_middleware.ts";
import * as $6 from "./routes/doc/index.tsx";
import * as $7 from "./routes/index.tsx";
import * as $8 from "./routes/login.tsx";
import * as $$0 from "./islands/ContentRenderer.tsx";
import * as $$1 from "./islands/Counter.tsx";
import * as $$2 from "./islands/DirList.tsx";
import * as $$3 from "./islands/DocSearch.tsx";
import * as $$4 from "./islands/FileViewer.tsx";
import * as $$5 from "./islands/UpList.tsx";
import * as $$5 from "./islands/Login.tsx";
import * as $$6 from "./islands/UpList.tsx";
const manifest = {
routes: {
"./routes/_404.tsx": $0,
"./routes/_middleware.ts": $1,
"./routes/api/login.ts": $2,
"./routes/api/logout.ts": $3,
"./routes/dir/[...path].tsx": $4,
"./routes/doc/index.tsx": $5,
"./routes/index.tsx": $6,
"./routes/login.tsx": $7,
"./routes/api/logout.ts": $2,
"./routes/dir/[...path].tsx": $3,
"./routes/dir/_middleware.ts": $4,
"./routes/doc/_middleware.ts": $5,
"./routes/doc/index.tsx": $6,
"./routes/index.tsx": $7,
"./routes/login.tsx": $8,
},
islands: {
"./islands/ContentRenderer.tsx": $$0,
@ -35,7 +38,8 @@ const manifest = {
"./islands/DirList.tsx": $$2,
"./islands/DocSearch.tsx": $$3,
"./islands/FileViewer.tsx": $$4,
"./islands/UpList.tsx": $$5,
"./islands/Login.tsx": $$5,
"./islands/UpList.tsx": $$6,
},
baseUrl: import.meta.url,
config,

View File

@ -1,6 +1,6 @@
{
"imports": {
"$fresh/": "https://deno.land/x/fresh@1.1.2/",
"$fresh/": "./fresh-main/",
"preact": "https://esm.sh/preact@10.11.0",
"preact/": "https://esm.sh/preact@10.11.0/",
"preact-render-to-string": "https://esm.sh/*preact-render-to-string@5.2.4",
@ -15,4 +15,4 @@
"sqlite": "https://deno.land/x/sqlite@v3.7.0/mod.ts",
"djwt": "https://deno.land/x/djwt@v2.8/mod.ts"
}
}
}

View File

@ -53,7 +53,7 @@ export function DirList(props: DirListProps) {
</li>
<ListItem
key=".."
href={`/dir/${encodePath(join(data.path, ".."))}?pretty`}
href={`/dir/${encodePath(join(data.path, ".."))}/?pretty`}
icon="/icon/back.svg"
>
...
@ -61,7 +61,7 @@ export function DirList(props: DirListProps) {
{files.map((file) => (
<ListItem
key={file.name}
href={`/dir/${encodePath(join(data.path, file.name))}?pretty`}
href={`/dir/${encodePath(join(data.path, file.name))}${(file.isDirectory ? "/" : "")}?pretty`}
icon={file.isDirectory
? "/icon/folder.svg"
: extToIcon(extname(file.name))}

52
islands/Login.tsx Normal file
View File

@ -0,0 +1,52 @@
export default function LoginForm({
redirect = "/",
failed = false,
}: { redirect?: string
failed?: boolean }
) {
return <div class="p-4 absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]
flex flex-col items-center border-gray-500 border-2 rounded-md
sm:max-w-screen-sm max-w-screen-md">
<img
src="/logo.svg"
class="w-32 h-32"
alt="the fresh logo: a sliced lemon dripping with juice"
/>
<h1 class="text-2xl font-bold">Login</h1>
{failed ? <p class="text-red-500">Login failed</p> : null}
<form
action={"/login?redirect=" + redirect}
method="POST"
class="flex flex-col gap-2 items-stretch"
>
<div class="flex gap-2 flex-wrap">
<div class="basis-40 flex items-center flex-1">
<label for="username" class="w-20">Username</label>
<input
type="text"
name="username"
id="username"
class="border-b-2 focus:border-green-500 transition-colors flex-1"
/>
</div>
<div class="flex items-center flex-1">
<label for="password" class="w-20">Password</label>
<input
type="password"
name="password"
id="password"
class="border-b-2 focus:border-green-500 transition-colors flex-1"
/>
</div>
</div>
<button
type="submit"
class="bg-gray-400 p-2 rounded
m-auto"
>
Login
</button>
</form>
</div>
}

View File

@ -5,7 +5,7 @@ import { encodePath } from "../util/util.ts";
function stairs(path: string) {
if (path === ".") return [];
const uplist = path.split("/");
const uplist = path.split("/").filter(x=> x.length > 0);
let current = ".";
const stairs = [];
for (const up of uplist) {
@ -34,7 +34,7 @@ export default function UpList(props: { path: string }) {
<span class="p-2">/</span>
<a
class="flex flex-wrap p-2 hover:bg-gray-400 rounded-sm"
href={`/dir/${encodePath(cur)}?pretty`}
href={`/dir/${encodePath(cur)}/?pretty`}
>
<img src={asset("/icon/folder.svg")} />
<span class="ml-1">{up}</span>

View File

@ -1,4 +1,4 @@
import { MiddlewareHandlerContext } from "$fresh/server.ts";
import { HandlerContext, MiddlewareHandlerContext, Status } from "$fresh/server.ts";
import { getCookies } from "http/cookie.ts";
import { verify } from "djwt";
import { prepareSecretKey } from "../util/secret.ts";
@ -7,15 +7,14 @@ export const handler = async (
req: Request,
ctx: MiddlewareHandlerContext<Record<string, unknown>>,
) => {
const secret_key = await prepareSecretKey();
const cookies = getCookies(req.headers);
const jwt = cookies["auth"];
try {
const payload = await verify(jwt, secret_key);
ctx.state["login"] = payload;
} catch (e) {
} catch (_) {
ctx.state["login"] = null;
}
return await ctx.next();
};
}

View File

@ -1,66 +0,0 @@
import { HandlerContext } from "$fresh/server.ts";
import { setCookie } from "http/cookie.ts";
import { Status } from "http/http_status.ts";
import { connectDB } from "../../src/user/db.ts";
import { getUser, verifyUser } from "../../src/user/user.ts";
import { create as createJWT } from "djwt";
import { prepareSecretKey } from "../../util/secret.ts";
async function POST(req: Request, _ctx: HandlerContext): Promise<Response> {
const url = new URL(req.url);
const form = await req.formData();
const username = form.get("username");
const password = form.get("password");
if (username && password) {
const DB = await connectDB();
const user = await getUser(DB, username.toString());
if (user) {
const SECRET_KEY = await prepareSecretKey();
if (await verifyUser(user, password.toString())) {
const headers = new Headers();
const jwt = await createJWT({ alg: "HS512", typ: "JWT" }, {
username: user.name,
}, SECRET_KEY);
setCookie(headers, {
name: "auth",
value: jwt,
httpOnly: true,
sameSite: "Strict",
maxAge: 60 * 60 * 24 * 7,
domain: url.hostname,
path: "/",
secure: url.protocol === "https:",
});
headers.set("Location", "/");
return new Response(null, {
status: Status.SeeOther, // See Other
headers: headers,
});
}
}
}
return new Response(
`<!DOCTYPE html><html>
<head> <title> Login Failed </title> </head>
<body>
<h1> Login Failed </h1>
<p> <a href="/"> Back to Home </a> </p>
<script>
document.location.href = "/login";
</script>
</body>
</html>`,
{
headers: {
"Content-Type": "text/html",
},
status: Status.Forbidden,
},
);
}
export const handler = {
POST,
};

View File

@ -10,6 +10,7 @@ import DirList, { EntryInfo } from "../../islands/DirList.tsx";
import FileViewer from "../../islands/FileViewer.tsx";
import RenderView from "../../islands/ContentRenderer.tsx";
import { serveFile } from "http/file_server.ts";
import { Status } from "http/http_status.ts";
type DirProps = {
type: "dir";
@ -23,11 +24,17 @@ type FileProps = {
stat: Deno.FileInfo;
};
type DirOrFileProps = DirProps | FileProps;
export type DirOrFileProps = DirProps | FileProps;
async function renderFile(req: Request, path: string) {
type RenderOption = {
fileInfo?: Deno.FileInfo;
}
async function renderFile(req: Request, path: string, { fileInfo }: RenderOption = {}) {
try {
const fileInfo = await Deno.stat(path);
if (!fileInfo) {
fileInfo = await Deno.stat(path);
}
if (fileInfo.isDirectory) {
// if index.html exists, serve it.
// otherwise, serve a directory listing.
@ -61,7 +68,6 @@ async function renderFile(req: Request, path: string) {
}
}
}
const res = await serveFile(req, path, {
fileInfo,
});
@ -76,11 +82,13 @@ async function renderFile(req: Request, path: string) {
}
}
async function renderPage(_req: Request, path: string, ctx: HandlerContext) {
async function renderPage(_req: Request, path: string, ctx: HandlerContext, { fileInfo }: RenderOption = {}) {
try {
const stat = await Deno.stat(path);
if (!fileInfo) {
fileInfo = await Deno.stat(path);
}
if (stat.isDirectory) {
if (fileInfo.isDirectory) {
const filesIter = await Deno.readDir(path);
const files: EntryInfo[] = [];
for await (const file of filesIter) {
@ -93,14 +101,14 @@ async function renderPage(_req: Request, path: string, ctx: HandlerContext) {
}
return await ctx.render({
type: "dir",
stat,
fileInfo,
files,
path,
});
} else {
return await ctx.render({
type: "file",
stat,
fileInfo,
path,
});
}
@ -113,31 +121,22 @@ async function renderPage(_req: Request, path: string, ctx: HandlerContext) {
}
async function GET(req: Request, ctx: HandlerContext): Promise<Response> {
const authRequired = Deno.env.get("AUTH_REQUIRED") === "true";
if (authRequired) {
const login = ctx.state["login"];
//console.log("login", login);
if (!login) {
return new Response(null, {
status: 302,
headers: {
"Location": "/login",
"content-type": "text/plain",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,HEAD,PUT,PATCH,POST,DELETE",
"Access-Control-Allow-Headers":
"Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With",
},
});
}
}
const url = new URL(req.url);
const path = removePrefixFromPathname(decodePath(url.pathname), "/dir");
const fileInfo = await Deno.stat(path);
if (fileInfo.isFile && url.pathname.endsWith("/")) {
url.pathname = url.pathname.slice(0, -1);
return Response.redirect(url, Status.TemporaryRedirect);
}
if ((!fileInfo.isFile) && (!url.pathname.endsWith("/"))) {
url.pathname += "/";
return Response.redirect(url, Status.TemporaryRedirect);
}
if (url.searchParams.has("pretty")) {
return await renderPage(req, path, ctx);
return await renderPage(req, path, ctx, { fileInfo });
} else {
return await renderFile(req, path);
return await renderFile(req, path, { fileInfo });
}
}
@ -234,4 +233,4 @@ export default function DirLists(props: PageProps<DirOrFileProps>) {
</div>
</>
);
}
}

View File

@ -0,0 +1 @@
export { handler } from "../../src/login_middleware.ts";

View File

@ -0,0 +1 @@
export { handler } from "../../src/login_middleware.ts";

View File

@ -1,26 +1,14 @@
import { Head } from "$fresh/runtime.ts";
import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts";
import { HandlerContext, Handlers, PageProps, Status } from "$fresh/server.ts";
import DocSearch from "../../islands/DocSearch.tsx";
import { Doc } from "../../src/collect.ts";
import { docCollector } from "../../src/store/doc.ts";
async function GET(req: Request, ctx: HandlerContext): Promise<Response> {
const authRequired = Deno.env.get("AUTH_REQUIRED") === "true";
if (authRequired) {
const login = ctx.state["login"];
if (!login) {
return new Response(null, {
status: 302,
headers: {
"Location": "/login",
"content-type": "text/plain",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,HEAD,PUT,PATCH,POST,DELETE",
"Access-Control-Allow-Headers":
"Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With",
},
});
}
const url = new URL(req.url);
if (url.pathname.endsWith("/")) {
url.pathname = url.pathname.slice(0, -1);
return Response.redirect(url, Status.TemporaryRedirect);
}
const docs = docCollector.getDocs();
return await ctx.render({ docs });

View File

@ -1,54 +1,91 @@
import { Head } from "$fresh/runtime.ts";
import { HandlerContext, PageProps } from "$fresh/server.ts";
import { setCookie } from "http/cookie.ts";
import { Status } from "http/http_status.ts";
import { connectDB } from "../src/user/db.ts";
import { getUser, verifyUser } from "../src/user/user.ts";
import { create as createJWT } from "djwt";
import { prepareSecretKey } from "../util/secret.ts";
import LoginForm from "../islands/Login.tsx";
export default function Login() {
async function GET(_req: Request, ctx: HandlerContext){
return await ctx.render();
}
async function POST(req: Request, _ctx: HandlerContext): Promise<Response> {
const url = new URL(req.url);
const form = await req.formData();
const username = form.get("username");
const password = form.get("password");
if (username && password) {
const DB = await connectDB();
const user = await getUser(DB, username.toString());
if (user) {
const SECRET_KEY = await prepareSecretKey();
if (await verifyUser(user, password.toString())) {
const headers = new Headers();
const jwt = await createJWT({ alg: "HS512", typ: "JWT" }, {
username: user.name,
}, SECRET_KEY);
setCookie(headers, {
name: "auth",
value: jwt,
httpOnly: true,
sameSite: "Strict",
maxAge: 60 * 60 * 24 * 7,
domain: url.hostname,
path: "/",
secure: url.protocol === "https:",
});
let redirect = "/";
if (url.searchParams.has("redirect")) {
redirect = url.searchParams.get("redirect")!;
}
headers.set("Location", redirect);
return new Response(null, {
status: Status.SeeOther, // See Other
headers: headers,
});
}
}
}
return new Response(
`<!DOCTYPE html><html>
<head> <title> Login Failed </title> </head>
<body>
<h1> Login Failed </h1>
<p> <a href="/"> Back to Home </a> </p>
<script>
document.location.href = "/login?failed=true&redirect=${url.searchParams.get("redirect")}";
</script>
</body>
</html>`,
{
headers: {
"Content-Type": "text/html",
},
status: Status.Unauthorized,
},
);
}
export const handler = {
GET,
POST,
};
export default function Login(props: PageProps) {
const redirect = props.url.searchParams.get("redirect");
const failed = props.url.searchParams.get("failed") === "true";
return (
<>
<Head>
<title>Simple file server - Login</title>
</Head>
<div class="">
<div class="p-4 absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]
flex flex-col items-center border-gray-500 border-2 rounded-md
sm:max-w-screen-sm max-w-screen-md">
<img
src="/logo.svg"
class="w-32 h-32"
alt="the fresh logo: a sliced lemon dripping with juice"
/>
<form
action="/api/login"
method="POST"
class="flex flex-col gap-2 items-stretch"
>
<div class="flex gap-2 flex-wrap">
<div class="basis-40 flex items-center flex-1">
<label for="username" class="w-20">Username</label>
<input
type="text"
name="username"
id="username"
class="border-b-2 focus:border-green-500 transition-colors flex-1"
/>
</div>
<div class="flex items-center flex-1">
<label for="password" class="w-20">Password</label>
<input
type="password"
name="password"
id="password"
class="border-b-2 focus:border-green-500 transition-colors flex-1"
/>
</div>
</div>
<button
type="submit"
class="bg-gray-400 p-2 rounded
m-auto"
>
Login
</button>
</form>
</div>
<LoginForm redirect={redirect ?? "/"} failed={failed}/>
</div>
</>
);

20
src/login_middleware.ts Normal file
View File

@ -0,0 +1,20 @@
import { MiddlewareHandlerContext, Status } from "$fresh/server.ts";
export const handler = async (
req: Request,
ctx: MiddlewareHandlerContext<Record<string, unknown>>,
) => {
const authRequired = Deno.env.get("AUTH_REQUIRED") === "true";
if (authRequired) {
const login = ctx.state["login"];
if (!login) {
return new Response(null, {
status: Status.Found,
headers: {
Location: `/login?redirect=${encodeURIComponent(req.url)}`,
}
});
}
}
return await ctx.next();
}