Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: server handlers #282

Merged
merged 16 commits into from
Oct 17, 2023
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/ultra
34 changes: 34 additions & 0 deletions app/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { lazy, Suspense, useState } from "react";
import { ErrorBoundary } from "https://esm.sh/*[email protected]";
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Test() {
return <div>Test</div>;
}
17 changes: 17 additions & 0 deletions app/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"imports": {
"react": "https://esm.sh/stable/[email protected]?dev",
"react/": "https://esm.sh/stable/[email protected]&dev/",
"react-dom": "https://esm.sh/[email protected]?external=react&dev",
"react-dom/": "https://esm.sh/[email protected]&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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ export {
join,
relative,
resolve,
toFileUrl,
} from "https://deno.land/[email protected]/path/mod.ts";
export {
type ImportMapJson,
parseFromJson,
} from "https://deno.land/x/[email protected]/mod.ts";
export { toFileUrl } from "https://deno.land/[email protected]/path/to_file_url.ts";
export { load as dotenv } from "https://deno.land/[email protected]/dotenv/mod.ts";
export { default as outdent } from "https://deno.land/x/[email protected]/mod.ts";
export { gte } from "https://deno.land/[email protected]/semver/mod.ts";
Expand Down
24 changes: 24 additions & 0 deletions lib/handler.ts
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading