Skip to content

Commit 300e27c

Browse files
refactor: server handlers (#282)
1 parent 18e51de commit 300e27c

23 files changed

+649
-349
lines changed

.devcontainer/devcontainer.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"image": "mcr.microsoft.com/devcontainers/base:jammy",
77
"features": {
88
"ghcr.io/devcontainers-contrib/features/deno:1": {
9-
"version": "1.36.4"
9+
"version": "1.37.1"
1010
}
1111
},
1212
"customizations": {

.vscode/settings.json

+3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
"[css]": {
77
"editor.defaultFormatter": "vscode.css-language-features"
88
},
9+
"[typescriptreact]": {
10+
"editor.defaultFormatter": "denoland.vscode-deno",
11+
},
912
"deno.enablePaths": [
1013
"./examples/ultra-website",
1114
"./examples/basic",

app/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/ultra

app/app.tsx

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { lazy, Suspense, useState } from "react";
2+
import { ErrorBoundary } from "https://esm.sh/*react-error-boundary@4.0.11";
3+
import { ImportMapScript } from "ultra/lib/react/client.js";
4+
5+
const LazyComponent = lazy(() => import("./components/Test.tsx"));
6+
7+
const logError = (error: Error, info: { componentStack: string }) => {
8+
console.log(error, info);
9+
};
10+
11+
export default function App() {
12+
const [state, setState] = useState(0);
13+
return (
14+
<html>
15+
<head>
16+
<title>Testing</title>
17+
<link rel="stylesheet" href="/style.css" />
18+
<ImportMapScript />
19+
</head>
20+
<body>
21+
<main>Hello World {state}</main>
22+
<ErrorBoundary
23+
fallback={<div>Something went wrong</div>}
24+
onError={logError}
25+
>
26+
<Suspense fallback={<div>Loading...</div>}>
27+
<LazyComponent />
28+
</Suspense>
29+
</ErrorBoundary>
30+
<button onClick={() => setState(state + 1)}>Click Me</button>
31+
</body>
32+
</html>
33+
);
34+
}

app/client.tsx

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import UltraClient, { hydrate } from "ultra/lib/react/client.js";
2+
import App from "/~/app.tsx";
3+
4+
hydrate(
5+
document,
6+
<UltraClient>
7+
<App />
8+
</UltraClient>,
9+
);

app/components/Test.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Test() {
2+
return <div>Test</div>;
3+
}

app/deno.json

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"imports": {
3+
"react": "https://esm.sh/stable/react@18.2.0?dev",
4+
"react/": "https://esm.sh/stable/react@18.2.0&dev/",
5+
"react-dom": "https://esm.sh/react-dom@18.2.0?external=react&dev",
6+
"react-dom/": "https://esm.sh/react-dom@18.2.0&external=react&dev/",
7+
"ultra/": "../",
8+
"/~/": "./"
9+
},
10+
"tasks": {
11+
"dev": "deno run -A server.tsx"
12+
},
13+
"compilerOptions": {
14+
"jsx": "react-jsxdev",
15+
"jsxImportSource": "react"
16+
}
17+
}

app/server.tsx

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { renderToReadableStream } from "react-dom/server";
2+
import { createReactHandler } from "ultra/lib/react/mod.ts";
3+
import UltraServer from "ultra/lib/react/server.js";
4+
import App from "./app.tsx";
5+
6+
const root = Deno.cwd();
7+
8+
// create symlink to ultra for development
9+
try {
10+
await Deno.symlink("../", "./ultra", { type: "dir" });
11+
} catch (error) {
12+
// ignore
13+
}
14+
15+
const importMap = {
16+
imports: {
17+
"react": "https://esm.sh/react@18?dev",
18+
"react/": "https://esm.sh/react@18&dev/",
19+
"react-dom/": "https://esm.sh/react-dom@18&dev&external=react/",
20+
"/~/": import.meta.resolve("./"),
21+
"ultra/": import.meta.resolve("./ultra/"),
22+
},
23+
};
24+
25+
const handler = createReactHandler({
26+
root,
27+
render(request) {
28+
return renderToReadableStream(
29+
<UltraServer request={request} importMap={importMap}>
30+
<App />
31+
</UltraServer>,
32+
{
33+
bootstrapModules: [
34+
import.meta.resolve("./client.tsx"),
35+
],
36+
},
37+
);
38+
},
39+
});
40+
41+
Deno.serve((request) => {
42+
const url = new URL(request.url, "http://localhost");
43+
44+
if (url.pathname === "/favicon.ico") {
45+
return new Response(null, { status: 404 });
46+
}
47+
48+
if (handler.supportsRequest(request)) {
49+
return handler.handleRequest(request);
50+
}
51+
52+
return new Response("Not Found", { status: 404 });
53+
});

examples/basic/server.tsx

+43-18
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,51 @@
1-
import { createServer } from "ultra/server.ts";
1+
import { renderToReadableStream } from "react-dom/server";
2+
import { createCompilerHandler } from "ultra/lib/react/compiler.ts";
3+
import { createRenderHandler } from "ultra/lib/react/renderer.ts";
4+
import UltraServer from "ultra/lib/react/server.js";
25
import App from "./src/app.tsx";
6+
import { readImportMap } from "ultra/lib/utils/import-map.ts";
7+
import { createStaticHandler } from "ultra/lib/static/handler.ts";
8+
import { composeHandlers } from "ultra/lib/handler.ts";
39

4-
const server = await createServer({
5-
importMapPath: Deno.env.get("ULTRA_MODE") === "development"
6-
? import.meta.resolve("./importMap.dev.json")
7-
: import.meta.resolve("./importMap.json"),
8-
browserEntrypoint: import.meta.resolve("./client.tsx"),
10+
const root = Deno.cwd();
11+
12+
const importMap = Deno.env.get("ULTRA_MODE") === "development"
13+
? await readImportMap("./importMap.dev.json")
14+
: await readImportMap("./importMap.json");
15+
16+
const renderer = createRenderHandler({
17+
root,
18+
render(request) {
19+
return renderToReadableStream(
20+
<UltraServer request={request} importMap={importMap}>
21+
<App />
22+
</UltraServer>,
23+
{
24+
bootstrapModules: [
25+
import.meta.resolve("./client.tsx"),
26+
],
27+
},
28+
);
29+
},
930
});
1031

11-
server.get("*", async (context) => {
12-
/**
13-
* Render the request
14-
*/
15-
const result = await server.render(<App />);
32+
const compiler = createCompilerHandler({
33+
root,
34+
});
1635

17-
return context.body(result, 200, {
18-
"content-type": "text/html; charset=utf-8",
19-
});
36+
const staticHandler = createStaticHandler({
37+
pathToRoot: import.meta.resolve("./public"),
2038
});
2139

22-
if (import.meta.main) {
23-
Deno.serve(server.fetch);
24-
}
40+
const executeHandlers = composeHandlers(
41+
compiler,
42+
renderer,
43+
staticHandler
44+
);
2545

26-
export default server;
46+
Deno.serve((request) => {
47+
const response = executeHandlers(request);
48+
if (response) return response;
49+
50+
return new Response("Not Found", { status: 404 });
51+
});

lib/deps.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ export {
66
join,
77
relative,
88
resolve,
9-
toFileUrl,
109
} from "https://deno.land/std@0.176.0/path/mod.ts";
10+
export {
11+
type ImportMapJson,
12+
parseFromJson,
13+
} from "https://deno.land/x/import_map@v0.15.0/mod.ts";
14+
export { toFileUrl } from "https://deno.land/std@0.203.0/path/to_file_url.ts";
1115
export { load as dotenv } from "https://deno.land/std@0.176.0/dotenv/mod.ts";
1216
export { default as outdent } from "https://deno.land/x/outdent@v0.8.0/mod.ts";
1317
export { gte } from "https://deno.land/std@0.176.0/semver/mod.ts";

lib/handler.ts

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export interface RequestHandler {
2+
handleRequest: (request: Request) => Promise<Response>;
3+
supportsRequest: (request: Request) => boolean;
4+
}
5+
6+
export function executeHandler (request: Request, handler: RequestHandler) {
7+
try {
8+
if (handler.supportsRequest(request)) {
9+
return handler.handleRequest(request);
10+
}
11+
} catch (_) {
12+
return null;
13+
}
14+
}
15+
16+
export function composeHandlers (...handlers: RequestHandler[]) {
17+
return function executeHandlerArray (request: Request) {
18+
for (const handler of handlers) {
19+
const response = executeHandler(request, handler);
20+
if (response) return response;
21+
}
22+
return null;
23+
}
24+
}

lib/importMap.ts

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { type ImportMapJson, parseFromJson, toFileUrl } from "./deps.ts";
2+
3+
export type ImportMap = ImportMapJson;
4+
5+
export async function createImportMapProxy(
6+
target: ImportMapJson,
7+
root: string | URL,
8+
) {
9+
const base = root instanceof URL ? root : toFileUrl(root);
10+
const importMap = await parseFromJson(base, target);
11+
12+
const importsProxy = new Proxy(target.imports, {
13+
get: (target, prop) => {
14+
if (typeof prop === "symbol") {
15+
throw new TypeError("Symbol properties are not supported.");
16+
}
17+
18+
const value = target[prop];
19+
const resolved = !value ? importMap.resolve(prop, base) : value;
20+
21+
return resolved;
22+
},
23+
});
24+
25+
return new Proxy(target, {
26+
get: (target, prop) => {
27+
if (typeof prop === "symbol") {
28+
throw new TypeError("Symbol properties are not supported.");
29+
}
30+
31+
if (prop === "toJSON") {
32+
return () => target;
33+
}
34+
35+
if (prop === "imports") {
36+
return importsProxy;
37+
}
38+
39+
return target[prop as keyof typeof target];
40+
},
41+
});
42+
}
43+
44+
type ImportMapProxyOptions = {
45+
root: string | URL;
46+
};
47+
48+
export class ImportMapProxy {
49+
imports: object;
50+
scopes: object;
51+
52+
constructor(target: ImportMapJson, options: ImportMapProxyOptions) {
53+
const root = options.root instanceof URL
54+
? options.root
55+
: toFileUrl(options.root);
56+
57+
this.imports = new Proxy(target.imports ?? {}, {
58+
get: (target, prop) => {
59+
if (typeof prop === "symbol") {
60+
throw new TypeError("Symbol properties are not supported.");
61+
}
62+
63+
const specifier = target[prop];
64+
65+
if (specifier) {
66+
return new URL(specifier, root).href;
67+
}
68+
69+
return undefined;
70+
},
71+
});
72+
73+
this.scopes = new Proxy(target.scopes ?? {}, {
74+
get: (target, prop) => {
75+
if (typeof prop === "symbol") {
76+
throw new TypeError("Symbol properties are not supported.");
77+
}
78+
79+
const scope = target[prop];
80+
if (scope) {
81+
return new Proxy(scope, {
82+
get: (target, prop) => {
83+
if (typeof prop === "symbol") {
84+
throw new TypeError("Symbol properties are not supported.");
85+
}
86+
const specifier = target[prop];
87+
if (specifier) {
88+
return new URL(specifier, root).href;
89+
}
90+
return undefined;
91+
},
92+
});
93+
}
94+
return undefined;
95+
},
96+
});
97+
}
98+
}

lib/proxy.ts

Whitespace-only changes.

0 commit comments

Comments
 (0)