Skip to content

Commit

Permalink
refactor: server handlers (#282)
Browse files Browse the repository at this point in the history
  • Loading branch information
deckchairlabs committed Oct 17, 2023
1 parent 18e51de commit 300e27c
Show file tree
Hide file tree
Showing 23 changed files with 649 additions and 349 deletions.
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Expand Up @@ -6,7 +6,7 @@
"image": "mcr.microsoft.com/devcontainers/base:jammy",
"features": {
"ghcr.io/devcontainers-contrib/features/deno:1": {
"version": "1.36.4"
"version": "1.37.1"
}
},
"customizations": {
Expand Down
3 changes: 3 additions & 0 deletions .vscode/settings.json
Expand Up @@ -6,6 +6,9 @@
"[css]": {
"editor.defaultFormatter": "vscode.css-language-features"
},
"[typescriptreact]": {
"editor.defaultFormatter": "denoland.vscode-deno",
},
"deno.enablePaths": [
"./examples/ultra-website",
"./examples/basic",
Expand Down
1 change: 1 addition & 0 deletions app/.gitignore
@@ -0,0 +1 @@
/ultra
34 changes: 34 additions & 0 deletions app/app.tsx
@@ -0,0 +1,34 @@
import { lazy, Suspense, useState } from "react";
import { ErrorBoundary } from "https://esm.sh/*react-error-boundary@4.0.11";
import { ImportMapScript } from "ultra/lib/react/client.js";

const LazyComponent = lazy(() => import("./components/Test.tsx"));

const logError = (error: Error, info: { componentStack: string }) => {
console.log(error, info);
};

export default function App() {
const [state, setState] = useState(0);
return (
<html>
<head>
<title>Testing</title>
<link rel="stylesheet" href="/style.css" />
<ImportMapScript />
</head>
<body>
<main>Hello World {state}</main>
<ErrorBoundary
fallback={<div>Something went wrong</div>}
onError={logError}
>
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
</ErrorBoundary>
<button onClick={() => setState(state + 1)}>Click Me</button>
</body>
</html>
);
}
9 changes: 9 additions & 0 deletions app/client.tsx
@@ -0,0 +1,9 @@
import UltraClient, { hydrate } from "ultra/lib/react/client.js";
import App from "/~/app.tsx";

hydrate(
document,
<UltraClient>
<App />
</UltraClient>,
);
3 changes: 3 additions & 0 deletions app/components/Test.tsx
@@ -0,0 +1,3 @@
export default function Test() {
return <div>Test</div>;
}
17 changes: 17 additions & 0 deletions app/deno.json
@@ -0,0 +1,17 @@
{
"imports": {
"react": "https://esm.sh/stable/react@18.2.0?dev",
"react/": "https://esm.sh/stable/react@18.2.0&dev/",
"react-dom": "https://esm.sh/react-dom@18.2.0?external=react&dev",
"react-dom/": "https://esm.sh/react-dom@18.2.0&external=react&dev/",
"ultra/": "../",
"/~/": "./"
},
"tasks": {
"dev": "deno run -A server.tsx"
},
"compilerOptions": {
"jsx": "react-jsxdev",
"jsxImportSource": "react"
}
}
53 changes: 53 additions & 0 deletions app/server.tsx
@@ -0,0 +1,53 @@
import { renderToReadableStream } from "react-dom/server";
import { createReactHandler } from "ultra/lib/react/mod.ts";
import UltraServer from "ultra/lib/react/server.js";
import App from "./app.tsx";

const root = Deno.cwd();

// create symlink to ultra for development
try {
await Deno.symlink("../", "./ultra", { type: "dir" });
} catch (error) {
// ignore
}

const importMap = {
imports: {
"react": "https://esm.sh/react@18?dev",
"react/": "https://esm.sh/react@18&dev/",
"react-dom/": "https://esm.sh/react-dom@18&dev&external=react/",
"/~/": import.meta.resolve("./"),
"ultra/": import.meta.resolve("./ultra/"),
},
};

const handler = createReactHandler({
root,
render(request) {
return renderToReadableStream(
<UltraServer request={request} importMap={importMap}>
<App />
</UltraServer>,
{
bootstrapModules: [
import.meta.resolve("./client.tsx"),
],
},
);
},
});

Deno.serve((request) => {
const url = new URL(request.url, "http://localhost");

if (url.pathname === "/favicon.ico") {
return new Response(null, { status: 404 });
}

if (handler.supportsRequest(request)) {
return handler.handleRequest(request);
}

return new Response("Not Found", { status: 404 });
});
61 changes: 43 additions & 18 deletions examples/basic/server.tsx
@@ -1,26 +1,51 @@
import { createServer } from "ultra/server.ts";
import { renderToReadableStream } from "react-dom/server";
import { createCompilerHandler } from "ultra/lib/react/compiler.ts";
import { createRenderHandler } from "ultra/lib/react/renderer.ts";
import UltraServer from "ultra/lib/react/server.js";
import App from "./src/app.tsx";
import { readImportMap } from "ultra/lib/utils/import-map.ts";
import { createStaticHandler } from "ultra/lib/static/handler.ts";
import { composeHandlers } from "ultra/lib/handler.ts";

const server = await createServer({
importMapPath: Deno.env.get("ULTRA_MODE") === "development"
? import.meta.resolve("./importMap.dev.json")
: import.meta.resolve("./importMap.json"),
browserEntrypoint: import.meta.resolve("./client.tsx"),
const root = Deno.cwd();

const importMap = Deno.env.get("ULTRA_MODE") === "development"
? await readImportMap("./importMap.dev.json")
: await readImportMap("./importMap.json");

const renderer = createRenderHandler({
root,
render(request) {
return renderToReadableStream(
<UltraServer request={request} importMap={importMap}>
<App />
</UltraServer>,
{
bootstrapModules: [
import.meta.resolve("./client.tsx"),
],
},
);
},
});

server.get("*", async (context) => {
/**
* Render the request
*/
const result = await server.render(<App />);
const compiler = createCompilerHandler({
root,
});

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

if (import.meta.main) {
Deno.serve(server.fetch);
}
const executeHandlers = composeHandlers(
compiler,
renderer,
staticHandler
);

export default server;
Deno.serve((request) => {
const response = executeHandlers(request);
if (response) return response;

return new Response("Not Found", { status: 404 });
});
6 changes: 5 additions & 1 deletion lib/deps.ts
Expand Up @@ -6,8 +6,12 @@ export {
join,
relative,
resolve,
toFileUrl,
} from "https://deno.land/std@0.176.0/path/mod.ts";
export {
type ImportMapJson,
parseFromJson,
} from "https://deno.land/x/import_map@v0.15.0/mod.ts";
export { toFileUrl } from "https://deno.land/std@0.203.0/path/to_file_url.ts";
export { load as dotenv } from "https://deno.land/std@0.176.0/dotenv/mod.ts";
export { default as outdent } from "https://deno.land/x/outdent@v0.8.0/mod.ts";
export { gte } from "https://deno.land/std@0.176.0/semver/mod.ts";
Expand Down
24 changes: 24 additions & 0 deletions lib/handler.ts
@@ -0,0 +1,24 @@
export interface RequestHandler {
handleRequest: (request: Request) => Promise<Response>;
supportsRequest: (request: Request) => boolean;
}

export function executeHandler (request: Request, handler: RequestHandler) {
try {
if (handler.supportsRequest(request)) {
return handler.handleRequest(request);
}
} catch (_) {
return null;
}
}

export function composeHandlers (...handlers: RequestHandler[]) {
return function executeHandlerArray (request: Request) {
for (const handler of handlers) {
const response = executeHandler(request, handler);
if (response) return response;
}
return null;
}
}
98 changes: 98 additions & 0 deletions lib/importMap.ts
@@ -0,0 +1,98 @@
import { type ImportMapJson, parseFromJson, toFileUrl } from "./deps.ts";

export type ImportMap = ImportMapJson;

export async function createImportMapProxy(
target: ImportMapJson,
root: string | URL,
) {
const base = root instanceof URL ? root : toFileUrl(root);
const importMap = await parseFromJson(base, target);

const importsProxy = new Proxy(target.imports, {
get: (target, prop) => {
if (typeof prop === "symbol") {
throw new TypeError("Symbol properties are not supported.");
}

const value = target[prop];
const resolved = !value ? importMap.resolve(prop, base) : value;

return resolved;
},
});

return new Proxy(target, {
get: (target, prop) => {
if (typeof prop === "symbol") {
throw new TypeError("Symbol properties are not supported.");
}

if (prop === "toJSON") {
return () => target;
}

if (prop === "imports") {
return importsProxy;
}

return target[prop as keyof typeof target];
},
});
}

type ImportMapProxyOptions = {
root: string | URL;
};

export class ImportMapProxy {
imports: object;
scopes: object;

constructor(target: ImportMapJson, options: ImportMapProxyOptions) {
const root = options.root instanceof URL
? options.root
: toFileUrl(options.root);

this.imports = new Proxy(target.imports ?? {}, {
get: (target, prop) => {
if (typeof prop === "symbol") {
throw new TypeError("Symbol properties are not supported.");
}

const specifier = target[prop];

if (specifier) {
return new URL(specifier, root).href;
}

return undefined;
},
});

this.scopes = new Proxy(target.scopes ?? {}, {
get: (target, prop) => {
if (typeof prop === "symbol") {
throw new TypeError("Symbol properties are not supported.");
}

const scope = target[prop];
if (scope) {
return new Proxy(scope, {
get: (target, prop) => {
if (typeof prop === "symbol") {
throw new TypeError("Symbol properties are not supported.");
}
const specifier = target[prop];
if (specifier) {
return new URL(specifier, root).href;
}
return undefined;
},
});
}
return undefined;
},
});
}
}
Empty file added lib/proxy.ts
Empty file.

0 comments on commit 300e27c

Please sign in to comment.