This commit is contained in:
Luca Casonato 2021-05-08 02:07:24 +02:00
parent f05c2e3a68
commit 6ecdf2ed73
No known key found for this signature in database
GPG Key ID: 789878CF6382A84F
17 changed files with 284 additions and 0 deletions

6
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"deno.enable": true,
"deno.lint": true,
"deno.unstable": false,
"deno.config": "./tsconfig.json"
}

27
cli.ts Normal file
View File

@ -0,0 +1,27 @@
import { join, toFileUrl } from "https://deno.land/std@0.95.0/path/mod.ts";
import { walk } from "https://deno.land/std@0.95.0/fs/walk.ts";
const files = [];
const pagesDir = join(Deno.cwd(), "./pages");
const pagesUrl = new URL(pagesDir, "file:///");
for await (
const entry of walk(pagesDir, {
includeDirs: false,
includeFiles: true,
exts: ["tsx", "jsx"],
})
) {
if (entry.isFile) {
const file = toFileUrl(entry.path).href.substring(pagesUrl.href.length);
files.push(file);
}
}
const output = `import { setup } from "../server.ts";
${files.map((file, i) => `import * as $${i} from "./pages${file}";`).join("\n")}
setup([${files.map((_, i) => `$${i}`).join(", ")}], import.meta.url);
`;
Deno.writeTextFile("./server.ts", output);

1
example/deps.ts Normal file
View File

@ -0,0 +1 @@
export * from "../runtime.ts";

16
example/pages/[name].tsx Normal file
View File

@ -0,0 +1,16 @@
import { h, PageProps, useState } from "../deps.ts";
export default function Home(props: PageProps) {
const [counter, setCounter] = useState(0);
return (
<div>
Hello {props.params.name}! {counter}{" "}
<button onClick={() => setCounter(counter + 1)}>+1</button>
</div>
);
}
export function self() {
return import.meta.url;
}

9
example/pages/index.tsx Normal file
View File

@ -0,0 +1,9 @@
import { h } from "../deps.ts";
export default function Home(props: {}) {
return <div>Hello World!</div>;
}
export function self() {
return import.meta.url;
}

6
example/server.ts Normal file
View File

@ -0,0 +1,6 @@
import { setup } from "../server.ts";
import * as $0 from "./pages/[name].tsx";
import * as $1 from "./pages/index.tsx";
setup([$0, $1], import.meta.url);

2
runtime.ts Normal file
View File

@ -0,0 +1,2 @@
export * from "./src/runtime/deps.ts";
export * from "./src/runtime/types.ts";

1
server.ts Normal file
View File

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

23
src/runtime/deps.ts Normal file
View File

@ -0,0 +1,23 @@
export * from "https://x.lcas.dev/preact@10.5.12/mod.js";
export type {
CreateHandle,
EffectCallback,
Inputs,
PropRef,
Reducer,
Ref,
StateUpdater,
} from "https://x.lcas.dev/preact@10.5.12/hooks.js";
export {
useCallback,
useContext,
useDebugValue,
useEffect,
useErrorBoundary,
useImperativeHandle,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
} from "https://x.lcas.dev/preact@10.5.12/hooks.js";

3
src/runtime/types.ts Normal file
View File

@ -0,0 +1,3 @@
export interface PageProps {
params: Record<string, string | string[]>;
}

45
src/server/bundle.ts Normal file
View File

@ -0,0 +1,45 @@
import { denoPlugin, esbuild } from "./deps.ts";
import { Page } from "./routes.ts";
let esbuildInitalized: boolean | Promise<void> = false;
async function ensureEsbuildInialized() {
if (esbuildInitalized === false) {
esbuildInitalized = await esbuild.initialize({
wasmURL: "https://unpkg.com/esbuild-wasm@0.11.19/esbuild.wasm",
worker: false,
});
await esbuildInitalized;
esbuildInitalized = true;
} else if (esbuildInitalized instanceof Promise) {
await esbuildInitalized;
}
}
export async function bundle(page: Page): Promise<string> {
const runtime = new URL("../../runtime.ts", import.meta.url);
const contents = `
import Page from "${page.url}";
import { h, render } from "${runtime.href}";
addEventListener("DOMContentLoaded", () => {
const props = JSON.parse(document.getElementById("__FRSH_PROPS").textContent);
render(h(Page, props), document.body);
});
`;
await ensureEsbuildInialized();
const bundle = await esbuild.build({
plugins: [denoPlugin({ loader: "portable" })],
write: false,
bundle: true,
minify: true,
platform: "neutral",
outfile: "",
jsxFactory: "h",
jsxFragment: "Fragment",
stdin: {
contents,
sourcefile: `fresh://entrypoint/${page.name}`,
},
});
return bundle.outputFiles[0].text;
}

13
src/server/deps.ts Normal file
View File

@ -0,0 +1,13 @@
// -- preact --
export { renderToString } from "https://x.lcas.dev/preact@10.5.12/ssr.js";
// -- std --
export { extname } from "https://deno.land/std@0.95.0/path/mod.ts";
// -- oak --
export * as oak from "https://deno.land/x/oak@v7.4.0/mod.ts";
// -- esbuild --
import * as esbuild from "https://cdn.skypack.dev/esbuild-wasm?dts";
export { esbuild };
export { denoPlugin } from "https://raw.githubusercontent.com/lucacasonato/esbuild_deno_loader/c5ee642f4552078324badbf1b541c7222c07d5ff/mod.ts";

13
src/server/mod.ts Normal file
View File

@ -0,0 +1,13 @@
import { createPages, PageModules } from "./routes.ts";
import { createServer } from "./server.ts";
export function setup(pageModules: PageModules[], selfUrl: string) {
const baseUrl = new URL("./", selfUrl).href;
const pages = createPages(pageModules, baseUrl);
const app = createServer(pages);
app.addEventListener("error", (err) => {
console.error(err.message);
});
addEventListener("fetch", app.fetchEventHandler());
}

11
src/server/render.ts Normal file
View File

@ -0,0 +1,11 @@
import { renderToString } from "./deps.ts";
import * as rt from "../runtime/deps.ts";
import { Page } from "./routes.ts";
export function render(page: Page, params: Record<string, string>): string {
const props = { params };
const body = renderToString(rt.h(page.component, props));
return `<!DOCTYPE html><html><head><script src="/_frsh/s/p/${page.name}.module.js" type="module"></script></head><body>${body}<script id="__FRSH_PROPS" type="application/json">${
JSON.stringify(props)
}</script></body></html>`;
}

72
src/server/routes.ts Normal file
View File

@ -0,0 +1,72 @@
import { extname } from "./deps.ts";
import * as rt from "../runtime/deps.ts";
import { PageProps } from "../runtime/types.ts";
export interface PageModules {
default: rt.ComponentType<PageProps>;
self: () => string;
}
export interface Page {
route: string;
url: string;
name: string;
component: rt.ComponentType<PageProps>;
}
export function createPages(pageModules: PageModules[], baseUrl: string): Page[] {
const pages: Page[] = [];
for (const pageModule of pageModules) {
const url = pageModule.self();
if (!url.startsWith(baseUrl)) {
throw new TypeError("Page is not a child of the basepath.");
}
const path = url.substring(baseUrl.length).substring("pages".length);
const name = path.substring(1, path.length - extname(path).length);
const route = pathToRoute(name);
const page: Page = {
route,
url,
name: name.replace("/", "-"),
component: pageModule.default,
};
pages.push(page);
}
pages.sort((a, b) => {
const partsA = a.route.split("/");
const partsB = b.route.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;
});
return pages;
}
function pathToRoute(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;
}

30
src/server/server.ts Normal file
View File

@ -0,0 +1,30 @@
import { oak } from "./deps.ts";
import { bundle } from "./bundle.ts";
import { render } from "./render.ts";
import { Page } from "./routes.ts";
export function createServer(pages: Page[]): oak.Application {
const router = new oak.Router();
for (const page of pages) {
router.get<Record<string, string>>(page.route, (ctx) => {
ctx.response.status = 200;
ctx.response.type = "html";
ctx.response.body = render(page, ctx.params);
});
router.get(`/_frsh/s/p/${page.name}.module.js`, async (ctx) => {
const js = await bundle(page);
ctx.response.status = 200;
ctx.response.type = "js";
ctx.response.body = js;
});
}
const app = new oak.Application();
app.use(router.routes());
app.use(router.allowedMethods());
return app;
}

6
tsconfig.json Normal file
View File

@ -0,0 +1,6 @@
{
"compilerOptions": {
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment"
}
}