From a60d57c762f29242eeb145efb2b1cae90192eb5b Mon Sep 17 00:00:00 2001 From: James Edmonds Date: Thu, 12 Oct 2023 01:21:34 +0000 Subject: [PATCH 01/14] wip --- .devcontainer/devcontainer.json | 2 +- app/app.tsx | 12 +++++++ app/client.tsx | 4 +++ app/deno.json | 13 ++++++++ app/server.tsx | 15 +++++++++ lib/react/renderer.ts | 59 +++++++++++++++++++++++++++++++++ 6 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 app/app.tsx create mode 100644 app/client.tsx create mode 100644 app/deno.json create mode 100644 app/server.tsx create mode 100644 lib/react/renderer.ts diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 97cde84d..04f5aa71 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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": { diff --git a/app/app.tsx b/app/app.tsx new file mode 100644 index 00000000..13e03328 --- /dev/null +++ b/app/app.tsx @@ -0,0 +1,12 @@ +export default function App() { + return ( + + + Testing + + +
Hello World
+ + + ); +} diff --git a/app/client.tsx b/app/client.tsx new file mode 100644 index 00000000..3538daa1 --- /dev/null +++ b/app/client.tsx @@ -0,0 +1,4 @@ +import { hydrateRoot } from "react-dom/client"; +import App from "./app.tsx"; + +hydrateRoot(document, ); diff --git a/app/deno.json b/app/deno.json new file mode 100644 index 00000000..2a5efa7d --- /dev/null +++ b/app/deno.json @@ -0,0 +1,13 @@ +{ + "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/": "../" + }, + "compilerOptions": { + "jsx": "react-jsxdev", + "jsxImportSource": "react" + } +} diff --git a/app/server.tsx b/app/server.tsx new file mode 100644 index 00000000..293dade2 --- /dev/null +++ b/app/server.tsx @@ -0,0 +1,15 @@ +import { createRenderer } from "ultra/lib/react/renderer.ts"; +import { renderToReadableStream } from "react-dom/server"; +import App from "./app.tsx"; + +const renderer = createRenderer({ + render(request) { + return renderToReadableStream(, { + bootstrapModules: [ + import.meta.resolve("./client.tsx"), + ], + }); + }, +}); + +Deno.serve(renderer.handleRequest); diff --git a/lib/react/renderer.ts b/lib/react/renderer.ts new file mode 100644 index 00000000..4118b86a --- /dev/null +++ b/lib/react/renderer.ts @@ -0,0 +1,59 @@ +import { toFileUrl } from "https://deno.land/std@0.203.0/path/to_file_url.ts"; + +interface Renderer { + handleRequest: (request: Request) => Promise; +} + +interface RendererOptions { + render: RenderFunction; +} + +type RenderResult = Promise | T | Promise | Response; + +type RenderFunction = ( + request: Request, + params?: Map, +) => RenderResult; + +export function createRenderer( + options: RendererOptions, +): Renderer { + const cwd = toFileUrl(Deno.cwd()); + + const handleRequest = async (request: Request): Promise => { + const result = await options.render(request); + + if (result instanceof ReadableStream) { + const transform = new TransformStream({ + transform: (chunk, controller) => { + const string = new TextDecoder().decode(chunk); + + // Find any urls in the string that match the cwd and replace them with the Ultra url + const regex = new RegExp(cwd.toString(), "g"); + const replaced = string.replace(regex, "/_ultra"); + + chunk = new TextEncoder().encode(replaced); + controller.enqueue(chunk); + }, + }); + + result.pipeThrough(transform); + + return new Response(transform.readable, { + headers: { + "content-type": "text/html", + }, + }); + } + + if (result instanceof Response) { + return result; + } + + return new Response(null, { status: 404 }); + }; + + return { + handleRequest, + }; +} From a672378f75a423176397ffc724ca869564e83593 Mon Sep 17 00:00:00 2001 From: James Edmonds Date: Thu, 12 Oct 2023 02:24:41 +0000 Subject: [PATCH 02/14] wip --- app/app.tsx | 21 +++++++++++++++++++- app/components/Test.tsx | 9 +++++++++ app/server.tsx | 28 ++++++++++++++++++++++++++- lib/compiler.ts | 36 ++++++++++++++++++++++++++++++++++ lib/react/renderer.ts | 43 ++++++++++++++++++++++++++++++++++++++--- 5 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 app/components/Test.tsx create mode 100644 lib/compiler.ts diff --git a/app/app.tsx b/app/app.tsx index 13e03328..867fbaed 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -1,11 +1,30 @@ +import { lazy, Suspense, useState } from "react"; +import { ErrorBoundary } from "https://esm.sh/*react-error-boundary@4.0.11"; + +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 ( Testing -
Hello World
+
Hello World {state}
+ Something went wrong} + onError={logError} + > + Loading...}> + + + + ); diff --git a/app/components/Test.tsx b/app/components/Test.tsx new file mode 100644 index 00000000..a7ac689f --- /dev/null +++ b/app/components/Test.tsx @@ -0,0 +1,9 @@ +import { useEffect } from "react"; + +export default function Test() { + useEffect(() => { + throw new Error("Boom!"); + }, []); + + return
Test
; +} diff --git a/app/server.tsx b/app/server.tsx index 293dade2..3eb09300 100644 --- a/app/server.tsx +++ b/app/server.tsx @@ -1,8 +1,18 @@ import { createRenderer } from "ultra/lib/react/renderer.ts"; import { renderToReadableStream } from "react-dom/server"; import App from "./app.tsx"; +import { createCompiler } from "ultra/lib/compiler.ts"; + +const importMap = { + imports: { + "react": "https://esm.sh/react@18", + "react/": "https://esm.sh/react@18/", + "react-dom/": "https://esm.sh/react-dom@18&external=react/", + }, +}; const renderer = createRenderer({ + importMap, render(request) { return renderToReadableStream(, { bootstrapModules: [ @@ -12,4 +22,20 @@ const renderer = createRenderer({ }, }); -Deno.serve(renderer.handleRequest); +const compiler = createCompiler({ + root: Deno.cwd(), +}); + +Deno.serve((request) => { + const url = new URL(request.url, "http://localhost"); + + if (url.pathname === "/favicon.ico") { + return new Response(null, { status: 404 }); + } + + if (url.pathname.startsWith("/_ultra/")) { + return compiler.handleRequest(request); + } + + return renderer.handleRequest(request); +}); diff --git a/lib/compiler.ts b/lib/compiler.ts new file mode 100644 index 00000000..5d04272f --- /dev/null +++ b/lib/compiler.ts @@ -0,0 +1,36 @@ +import { join } from "https://deno.land/std@0.203.0/url/mod.ts"; +import { compile } from "https://deno.land/x/mesozoic@v1.3.10/lib/compiler.ts"; + +type CompilerOptions = { + root: URL | string; +}; + +export function createCompiler(options: CompilerOptions) { + const root = new URL(options.root.toString(), import.meta.url); + + const handleRequest = async (request: Request): Promise => { + const { pathname } = new URL(request.url); + + if (!pathname.startsWith("/_ultra/")) { + return new Response("Not found", { status: 404 }); + } + + const filePath = pathname.replace(/^\/_ultra\//, ""); + const fileUrl = join(root, filePath); + const source = await Deno.readTextFile(fileUrl); + const result = await compile(fileUrl.toString(), source, { + jsxImportSource: "react", + development: true, + }); + + return new Response(result, { + headers: { + "Content-Type": "application/javascript", + }, + }); + }; + + return { + handleRequest, + }; +} diff --git a/lib/react/renderer.ts b/lib/react/renderer.ts index 4118b86a..db229bbe 100644 --- a/lib/react/renderer.ts +++ b/lib/react/renderer.ts @@ -5,9 +5,17 @@ interface Renderer { } interface RendererOptions { + importMap?: ImportMap; render: RenderFunction; } +type ImportMap = { + imports?: SpecifierMap; + scopes?: Record; +}; + +type SpecifierMap = Record; + type RenderResult = Promise | T | Promise | Response; type RenderFunction = ( @@ -20,19 +28,28 @@ export function createRenderer( ): Renderer { const cwd = toFileUrl(Deno.cwd()); + const importMapScript = options.importMap + ? `` + : null; + const handleRequest = async (request: Request): Promise => { const result = await options.render(request); if (result instanceof ReadableStream) { const transform = new TransformStream({ transform: (chunk, controller) => { - const string = new TextDecoder().decode(chunk); + let output = new TextDecoder().decode(chunk); + + // Inject an import map into the head, if there is a script tag in the head, place it before that + if (importMapScript) { + output = injectImportMapScript(importMapScript, output); + } // Find any urls in the string that match the cwd and replace them with the Ultra url const regex = new RegExp(cwd.toString(), "g"); - const replaced = string.replace(regex, "/_ultra"); + output = output.replace(regex, "/_ultra"); - chunk = new TextEncoder().encode(replaced); + chunk = new TextEncoder().encode(output); controller.enqueue(chunk); }, }); @@ -57,3 +74,23 @@ export function createRenderer( handleRequest, }; } + +function injectImportMapScript(importMapScript: string, output: string) { + const head = output.match(/(.*)<\/head>/s); + if (head) { + const headEnd = head[1].match(//s); + if (headEnd) { + output = output.replace( + headEnd[0], + `${importMapScript}${headEnd[0]}`, + ); + } else { + output = output.replace( + head[0], + `${head[0]}${importMapScript}`, + ); + } + } + + return output; +} From 8e4637296c3c4562471f616fd9dc49214a4edde6 Mon Sep 17 00:00:00 2001 From: James Edmonds Date: Thu, 12 Oct 2023 02:28:33 +0000 Subject: [PATCH 03/14] wip --- lib/importMap.ts | 6 ++++++ lib/react/renderer.ts | 14 ++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) create mode 100644 lib/importMap.ts diff --git a/lib/importMap.ts b/lib/importMap.ts new file mode 100644 index 00000000..b3e7d886 --- /dev/null +++ b/lib/importMap.ts @@ -0,0 +1,6 @@ +export type ImportMap = { + imports?: SpecifierMap; + scopes?: Record; +}; + +type SpecifierMap = Record; diff --git a/lib/react/renderer.ts b/lib/react/renderer.ts index db229bbe..5b3ec3a9 100644 --- a/lib/react/renderer.ts +++ b/lib/react/renderer.ts @@ -1,4 +1,5 @@ import { toFileUrl } from "https://deno.land/std@0.203.0/path/to_file_url.ts"; +import { ImportMap } from "../importMap.ts"; interface Renderer { handleRequest: (request: Request) => Promise; @@ -9,13 +10,6 @@ interface RendererOptions { render: RenderFunction; } -type ImportMap = { - imports?: SpecifierMap; - scopes?: Record; -}; - -type SpecifierMap = Record; - type RenderResult = Promise | T | Promise | Response; type RenderFunction = ( @@ -40,7 +34,6 @@ export function createRenderer( transform: (chunk, controller) => { let output = new TextDecoder().decode(chunk); - // Inject an import map into the head, if there is a script tag in the head, place it before that if (importMapScript) { output = injectImportMapScript(importMapScript, output); } @@ -75,6 +68,8 @@ export function createRenderer( }; } +// Inject an import map into the head, if there is a script tag in the head, place it before that + function injectImportMapScript(importMapScript: string, output: string) { const head = output.match(/(.*)<\/head>/s); if (head) { @@ -90,6 +85,9 @@ function injectImportMapScript(importMapScript: string, output: string) { `${head[0]}${importMapScript}`, ); } + } else { + // if there is no head tag, just inject it at the top of the output + output = `${importMapScript}${output}`; } return output; From 1ef4347ef12cb201127b3969e6f6c4379586bafe Mon Sep 17 00:00:00 2001 From: James Edmonds Date: Thu, 12 Oct 2023 03:44:38 +0000 Subject: [PATCH 04/14] wip --- app/components/Test.tsx | 6 -- app/server.tsx | 17 +++--- lib/handler.ts | 4 ++ lib/{ => react}/compiler.ts | 15 +++-- lib/react/renderer.ts | 114 +++++++++++++++++++++++++++--------- 5 files changed, 110 insertions(+), 46 deletions(-) create mode 100644 lib/handler.ts rename lib/{ => react}/compiler.ts (81%) diff --git a/app/components/Test.tsx b/app/components/Test.tsx index a7ac689f..bc65ca63 100644 --- a/app/components/Test.tsx +++ b/app/components/Test.tsx @@ -1,9 +1,3 @@ -import { useEffect } from "react"; - export default function Test() { - useEffect(() => { - throw new Error("Boom!"); - }, []); - return
Test
; } diff --git a/app/server.tsx b/app/server.tsx index 3eb09300..2c5c7976 100644 --- a/app/server.tsx +++ b/app/server.tsx @@ -1,17 +1,20 @@ -import { createRenderer } from "ultra/lib/react/renderer.ts"; import { renderToReadableStream } from "react-dom/server"; +import { createCompiler } from "ultra/lib/react/compiler.ts"; +import { createRenderer } from "ultra/lib/react/renderer.ts"; import App from "./app.tsx"; -import { createCompiler } from "ultra/lib/compiler.ts"; const importMap = { imports: { - "react": "https://esm.sh/react@18", - "react/": "https://esm.sh/react@18/", - "react-dom/": "https://esm.sh/react-dom@18&external=react/", + "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/", }, }; +const root = Deno.cwd(); + const renderer = createRenderer({ + root, importMap, render(request) { return renderToReadableStream(, { @@ -23,7 +26,7 @@ const renderer = createRenderer({ }); const compiler = createCompiler({ - root: Deno.cwd(), + root, }); Deno.serve((request) => { @@ -33,7 +36,7 @@ Deno.serve((request) => { return new Response(null, { status: 404 }); } - if (url.pathname.startsWith("/_ultra/")) { + if (compiler.supportsRequest(request)) { return compiler.handleRequest(request); } diff --git a/lib/handler.ts b/lib/handler.ts new file mode 100644 index 00000000..0082ba20 --- /dev/null +++ b/lib/handler.ts @@ -0,0 +1,4 @@ +export interface RequestHandler { + handleRequest: (request: Request) => Promise; + supportsRequest: (request: Request) => boolean; +} diff --git a/lib/compiler.ts b/lib/react/compiler.ts similarity index 81% rename from lib/compiler.ts rename to lib/react/compiler.ts index 5d04272f..c7ed315f 100644 --- a/lib/compiler.ts +++ b/lib/react/compiler.ts @@ -8,15 +8,15 @@ type CompilerOptions = { export function createCompiler(options: CompilerOptions) { const root = new URL(options.root.toString(), import.meta.url); + const pattern = new URLPattern({ + pathname: "/_ultra/:path*", + }); + const handleRequest = async (request: Request): Promise => { const { pathname } = new URL(request.url); - - if (!pathname.startsWith("/_ultra/")) { - return new Response("Not found", { status: 404 }); - } - const filePath = pathname.replace(/^\/_ultra\//, ""); const fileUrl = join(root, filePath); + const source = await Deno.readTextFile(fileUrl); const result = await compile(fileUrl.toString(), source, { jsxImportSource: "react", @@ -30,7 +30,12 @@ export function createCompiler(options: CompilerOptions) { }); }; + const supportsRequest = (request: Request): boolean => { + return pattern.test(request.url); + }; + return { + supportsRequest, handleRequest, }; } diff --git a/lib/react/renderer.ts b/lib/react/renderer.ts index 5b3ec3a9..a21dbafd 100644 --- a/lib/react/renderer.ts +++ b/lib/react/renderer.ts @@ -1,11 +1,13 @@ import { toFileUrl } from "https://deno.land/std@0.203.0/path/to_file_url.ts"; import { ImportMap } from "../importMap.ts"; -interface Renderer { +interface RequestHandler { handleRequest: (request: Request) => Promise; + supportsRequest: (request: Request) => boolean; } interface RendererOptions { + root: string | URL; importMap?: ImportMap; render: RenderFunction; } @@ -19,37 +21,28 @@ type RenderFunction = ( export function createRenderer( options: RendererOptions, -): Renderer { - const cwd = toFileUrl(Deno.cwd()); - - const importMapScript = options.importMap - ? `` - : null; +): RequestHandler { + const root = options.root instanceof URL + ? options.root + : toFileUrl(options.root); const handleRequest = async (request: Request): Promise => { const result = await options.render(request); if (result instanceof ReadableStream) { - const transform = new TransformStream({ - transform: (chunk, controller) => { - let output = new TextDecoder().decode(chunk); - - if (importMapScript) { - output = injectImportMapScript(importMapScript, output); - } + const transforms: TransformStream[] = []; + transforms.push(createUltraUrlTransformStream(root)); - // Find any urls in the string that match the cwd and replace them with the Ultra url - const regex = new RegExp(cwd.toString(), "g"); - output = output.replace(regex, "/_ultra"); - - chunk = new TextEncoder().encode(output); - controller.enqueue(chunk); - }, - }); + if (options.importMap) { + transforms.push(createImportMapTransformStream(options.importMap)); + } - result.pipeThrough(transform); + const stream = transforms.reduce( + (readable, transform) => readable.pipeThrough(transform), + result as ReadableStream, + ); - return new Response(transform.readable, { + return new Response(stream, { headers: { "content-type": "text/html", }, @@ -57,36 +50,101 @@ export function createRenderer( } if (result instanceof Response) { - return result; + const transforms: TransformStream[] = []; + + if (result.body) { + transforms.push(createUltraUrlTransformStream(root)); + if (options.importMap) { + transforms.push( + createImportMapTransformStream(options.importMap), + ); + } + } + + const stream = transforms.reduce( + (readable, transform) => readable.pipeThrough(transform), + result.body as ReadableStream, + ); + + return new Response(stream, { + headers: result.headers, + }); } return new Response(null, { status: 404 }); }; + const supportsRequest = (request: Request): boolean => { + // Check if the request accepts HTML + return true; + }; + return { handleRequest, + supportsRequest, }; } -// Inject an import map into the head, if there is a script tag in the head, place it before that +function createUltraUrlTransformStream(root: URL) { + const regex = new RegExp(root.toString(), "g"); + const transform = new TransformStream({ + transform: (chunk, controller) => { + const output = new TextDecoder().decode(chunk); + const newOutput = output.replace(regex, "/_ultra"); + chunk = new TextEncoder().encode(newOutput); + controller.enqueue(chunk); + }, + }); + + return transform; +} + +function createImportMapTransformStream( + importMap: ImportMap, +) { + const importMapScript = importMap + ? `` + : null; + let importMapInjected = false; + + const transform = new TransformStream({ + transform: (chunk, controller) => { + let output = new TextDecoder().decode(chunk); + + if (importMapScript && !importMapInjected) { + output = injectImportMapScript(importMapScript, output); + importMapInjected = true; + } + + chunk = new TextEncoder().encode(output); + controller.enqueue(chunk); + }, + }); + + return transform; +} function injectImportMapScript(importMapScript: string, output: string) { const head = output.match(/(.*)<\/head>/s); if (head) { const headEnd = head[1].match(//s); if (headEnd) { + console.debug("Injecting import map script before existing script tag"); output = output.replace( headEnd[0], `${importMapScript}${headEnd[0]}`, ); } else { + // We want to inject the importMapScript before the closing tag + console.debug("Injecting import map script before closing head tag"); output = output.replace( - head[0], - `${head[0]}${importMapScript}`, + /<\/head>/, + `${importMapScript}`, ); } } else { // if there is no head tag, just inject it at the top of the output + console.debug("Injecting import map script at the top of the output"); output = `${importMapScript}${output}`; } From 9d9c75483bd915b4f4afd50538c3fec1d66ad608 Mon Sep 17 00:00:00 2001 From: James Edmonds Date: Thu, 12 Oct 2023 05:18:26 +0000 Subject: [PATCH 05/14] wip --- app/client.tsx | 2 +- app/deno.json | 3 +- app/server.tsx | 36 ++-- lib/deps.ts | 2 +- lib/importMap.ts | 104 ++++++++++- lib/proxy.ts | 0 lib/react/client.js | 13 ++ lib/react/compiler.ts | 5 +- lib/react/renderer.ts | 116 +++--------- lib/react/server.js | 14 ++ lib/renderer.ts | 14 ++ lib/stream.ts | 405 ++++++++++-------------------------------- 12 files changed, 285 insertions(+), 429 deletions(-) create mode 100644 lib/proxy.ts create mode 100644 lib/react/client.js create mode 100644 lib/react/server.js create mode 100644 lib/renderer.ts diff --git a/app/client.tsx b/app/client.tsx index 3538daa1..3e852f50 100644 --- a/app/client.tsx +++ b/app/client.tsx @@ -1,4 +1,4 @@ import { hydrateRoot } from "react-dom/client"; -import App from "./app.tsx"; +import App from "/~/app.tsx"; hydrateRoot(document, ); diff --git a/app/deno.json b/app/deno.json index 2a5efa7d..3df6a00d 100644 --- a/app/deno.json +++ b/app/deno.json @@ -4,7 +4,8 @@ "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/": "../" + "ultra/": "../", + "/~/": "./" }, "compilerOptions": { "jsx": "react-jsxdev", diff --git a/app/server.tsx b/app/server.tsx index 2c5c7976..4f6e8e51 100644 --- a/app/server.tsx +++ b/app/server.tsx @@ -1,6 +1,8 @@ import { renderToReadableStream } from "react-dom/server"; -import { createCompiler } from "ultra/lib/react/compiler.ts"; -import { createRenderer } from "ultra/lib/react/renderer.ts"; +import { createImportMapProxy } from "ultra/lib/importMap.ts"; +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 "./app.tsx"; const importMap = { @@ -8,24 +10,32 @@ const importMap = { "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("./"), }, }; const root = Deno.cwd(); -const renderer = createRenderer({ +const proxiedImportMap = await createImportMapProxy(importMap, root); + +const renderer = createRenderHandler({ root, - importMap, + importMap: proxiedImportMap, render(request) { - return renderToReadableStream(, { - bootstrapModules: [ - import.meta.resolve("./client.tsx"), - ], - }); + return renderToReadableStream( + + + , + { + bootstrapModules: [ + import.meta.resolve("./client.tsx"), + ], + }, + ); }, }); -const compiler = createCompiler({ +const compiler = createCompilerHandler({ root, }); @@ -40,5 +50,9 @@ Deno.serve((request) => { return compiler.handleRequest(request); } - return renderer.handleRequest(request); + if (renderer.supportsRequest(request)) { + return renderer.handleRequest(request); + } + + return new Response("Not Found", { status: 404 }); }); diff --git a/lib/deps.ts b/lib/deps.ts index 24b51715..f67ea851 100644 --- a/lib/deps.ts +++ b/lib/deps.ts @@ -6,8 +6,8 @@ export { join, relative, resolve, - toFileUrl, } from "https://deno.land/std@0.176.0/path/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"; diff --git a/lib/importMap.ts b/lib/importMap.ts index b3e7d886..368c2222 100644 --- a/lib/importMap.ts +++ b/lib/importMap.ts @@ -1,6 +1,102 @@ -export type ImportMap = { - imports?: SpecifierMap; - scopes?: Record; +import { + type ImportMapJson, + parseFromJson, +} from "https://deno.land/x/import_map@v0.15.0/mod.ts"; +import { 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]; + }, + }); +} + +type ImportMapProxyOptions = { + root: string | URL; }; -type SpecifierMap = Record; +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; + }, + }); + } +} diff --git a/lib/proxy.ts b/lib/proxy.ts new file mode 100644 index 00000000..e69de29b diff --git a/lib/react/client.js b/lib/react/client.js new file mode 100644 index 00000000..f4c4a366 --- /dev/null +++ b/lib/react/client.js @@ -0,0 +1,13 @@ +import { createElement as h, Fragment } from "react"; + +/** + * @typedef {Object} UltraBrowserProps + * @property {import('react').ReactNode} [children] + */ + +/** + * @param {UltraBrowserProps} props + */ +export default function UltraBrowser({ children }) { + return h(Fragment, undefined, children); +} diff --git a/lib/react/compiler.ts b/lib/react/compiler.ts index c7ed315f..2af779bb 100644 --- a/lib/react/compiler.ts +++ b/lib/react/compiler.ts @@ -3,9 +3,11 @@ import { compile } from "https://deno.land/x/mesozoic@v1.3.10/lib/compiler.ts"; type CompilerOptions = { root: URL | string; + allowList?: string[]; + denyList?: string[]; }; -export function createCompiler(options: CompilerOptions) { +export function createCompilerHandler(options: CompilerOptions) { const root = new URL(options.root.toString(), import.meta.url); const pattern = new URLPattern({ @@ -15,6 +17,7 @@ export function createCompiler(options: CompilerOptions) { const handleRequest = async (request: Request): Promise => { const { pathname } = new URL(request.url); const filePath = pathname.replace(/^\/_ultra\//, ""); + console.debug("filePath", filePath); const fileUrl = join(root, filePath); const source = await Deno.readTextFile(fileUrl); diff --git a/lib/react/renderer.ts b/lib/react/renderer.ts index a21dbafd..60b449b8 100644 --- a/lib/react/renderer.ts +++ b/lib/react/renderer.ts @@ -1,42 +1,31 @@ -import { toFileUrl } from "https://deno.land/std@0.203.0/path/to_file_url.ts"; -import { ImportMap } from "../importMap.ts"; - -interface RequestHandler { - handleRequest: (request: Request) => Promise; - supportsRequest: (request: Request) => boolean; -} - -interface RendererOptions { - root: string | URL; - importMap?: ImportMap; - render: RenderFunction; -} - -type RenderResult = Promise | T | Promise | Response; - -type RenderFunction = ( - request: Request, - params?: Map, -) => RenderResult; - -export function createRenderer( +import { toFileUrl } from "../deps.ts"; +import { type RequestHandler } from "../handler.ts"; +import { type RendererOptions } from "../renderer.ts"; +import { + createImportMapTransformStream, + createUltraUrlTransformStream, +} from "../stream.ts"; + +export function createRenderHandler( options: RendererOptions, ): RequestHandler { const root = options.root instanceof URL ? options.root : toFileUrl(options.root); + const importMap = options.importMap; + const handleRequest = async (request: Request): Promise => { const result = await options.render(request); - if (result instanceof ReadableStream) { const transforms: TransformStream[] = []; - transforms.push(createUltraUrlTransformStream(root)); - if (options.importMap) { - transforms.push(createImportMapTransformStream(options.importMap)); + if (importMap) { + transforms.push(createImportMapTransformStream(importMap)); } + transforms.push(createUltraUrlTransformStream(root)); + const stream = transforms.reduce( (readable, transform) => readable.pipeThrough(transform), result as ReadableStream, @@ -54,9 +43,9 @@ export function createRenderer( if (result.body) { transforms.push(createUltraUrlTransformStream(root)); - if (options.importMap) { + if (importMap) { transforms.push( - createImportMapTransformStream(options.importMap), + createImportMapTransformStream(importMap), ); } } @@ -75,7 +64,10 @@ export function createRenderer( }; const supportsRequest = (request: Request): boolean => { - // Check if the request accepts HTML + const accept = request.headers.get("accept"); + if (!accept?.includes("text/html")) { + return false; + } return true; }; @@ -84,69 +76,3 @@ export function createRenderer( supportsRequest, }; } - -function createUltraUrlTransformStream(root: URL) { - const regex = new RegExp(root.toString(), "g"); - const transform = new TransformStream({ - transform: (chunk, controller) => { - const output = new TextDecoder().decode(chunk); - const newOutput = output.replace(regex, "/_ultra"); - chunk = new TextEncoder().encode(newOutput); - controller.enqueue(chunk); - }, - }); - - return transform; -} - -function createImportMapTransformStream( - importMap: ImportMap, -) { - const importMapScript = importMap - ? `` - : null; - let importMapInjected = false; - - const transform = new TransformStream({ - transform: (chunk, controller) => { - let output = new TextDecoder().decode(chunk); - - if (importMapScript && !importMapInjected) { - output = injectImportMapScript(importMapScript, output); - importMapInjected = true; - } - - chunk = new TextEncoder().encode(output); - controller.enqueue(chunk); - }, - }); - - return transform; -} - -function injectImportMapScript(importMapScript: string, output: string) { - const head = output.match(/(.*)<\/head>/s); - if (head) { - const headEnd = head[1].match(//s); - if (headEnd) { - console.debug("Injecting import map script before existing script tag"); - output = output.replace( - headEnd[0], - `${importMapScript}${headEnd[0]}`, - ); - } else { - // We want to inject the importMapScript before the closing tag - console.debug("Injecting import map script before closing head tag"); - output = output.replace( - /<\/head>/, - `${importMapScript}`, - ); - } - } else { - // if there is no head tag, just inject it at the top of the output - console.debug("Injecting import map script at the top of the output"); - output = `${importMapScript}${output}`; - } - - return output; -} diff --git a/lib/react/server.js b/lib/react/server.js new file mode 100644 index 00000000..a3c92138 --- /dev/null +++ b/lib/react/server.js @@ -0,0 +1,14 @@ +import { createElement as h, Fragment } from "react"; + +/** + * @typedef {Object} UltraServerProps + * @property {Request} [request] + * @property {import('react').ReactNode} [children] + */ + +/** + * @param {UltraServerProps} props + */ +export default function UltraServer({ request, children }) { + return h(Fragment, undefined, [children]); +} diff --git a/lib/renderer.ts b/lib/renderer.ts new file mode 100644 index 00000000..52633a87 --- /dev/null +++ b/lib/renderer.ts @@ -0,0 +1,14 @@ +import { type ImportMap } from "./importMap.ts"; + +export interface RendererOptions { + root: string | URL; + importMap?: ImportMap; + render: RenderFunction; +} + +type RenderResult = Promise | T | Promise | Response; + +type RenderFunction = ( + request: Request, + params?: Map, +) => RenderResult; diff --git a/lib/stream.ts b/lib/stream.ts index 59a94ede..13303720 100644 --- a/lib/stream.ts +++ b/lib/stream.ts @@ -1,349 +1,124 @@ -/** - * A lot of this code originally comes from https://github.com/vercel/next.js - * with some tweaking. - * - * node-web-stream-helpers.ts: https://github.com/vercel/next.js/blob/c79b67ccedda1ae6fd9d05cfccf1d2842b94f43f/packages/next/server/node-web-streams-helper.ts - */ -import type { ReactElement } from "react"; -import * as ReactDOMServer from "react-dom/server"; -import { ImportMap, Mode, RenderedReadableStream } from "./types.ts"; -import { nonNullable } from "./utils/non-nullable.ts"; -import { log } from "./logger.ts"; -import { readableStreamFromReader, StringReader } from "./deps.ts"; +import { type ImportMap } from "./importMap.ts"; -export function encodeText(input: string) { - return new TextEncoder().encode(input); -} +type RenderResponseTransformerOptions = { + root: URL; + importMap?: ImportMap; +}; -export function decodeText(input?: Uint8Array, textDecoder?: TextDecoder) { - return textDecoder - ? textDecoder.decode(input, { stream: true }) - : new TextDecoder().decode(input); -} +export function createRenderResponseTransformer( + options: RenderResponseTransformerOptions, +) { + const { root, importMap } = options; -export async function streamToString( - stream: ReadableStream, -): Promise { - const reader = stream.getReader(); - const textDecoder = new TextDecoder(); + const transformer = (result: Response | ReadableStream) => { + if (result instanceof ReadableStream) { + const transforms: TransformStream[] = []; + transforms.push(createUltraUrlTransformStream(root)); - let bufferedString = ""; + if (importMap) { + transforms.push(createImportMapTransformStream(importMap)); + } - while (true) { - const { done, value } = await reader.read(); + const stream = transforms.reduce( + (readable, transform) => readable.pipeThrough(transform), + result as ReadableStream, + ); - if (done) { - return bufferedString; + return new Response(stream, { + headers: { + "content-type": "text/html", + }, + }); } - bufferedString += decodeText(value, textDecoder); - } -} + if (result instanceof Response) { + const transforms: TransformStream[] = []; -export function createBufferedTransformStream(): TransformStream< - Uint8Array, - Uint8Array -> { - let bufferedString = ""; - let pendingFlush: Promise | null = null; + if (result.body) { + transforms.push(createUltraUrlTransformStream(root)); + if (importMap) { + transforms.push( + createImportMapTransformStream(importMap), + ); + } + } - const flushBuffer = (controller: TransformStreamDefaultController) => { - if (!pendingFlush) { - pendingFlush = new Promise((resolve) => { - setTimeout(() => { - controller.enqueue(encodeText(bufferedString)); - bufferedString = ""; - pendingFlush = null; - resolve(); - }, 0); + const stream = transforms.reduce( + (readable, transform) => readable.pipeThrough(transform), + result.body as ReadableStream, + ); + + return new Response(stream, { + headers: result.headers, }); } - return pendingFlush; }; - const textDecoder = new TextDecoder(); - - return new TransformStream({ - transform(chunk, controller) { - bufferedString += decodeText(chunk, textDecoder); - flushBuffer(controller); - }, - - flush() { - if (pendingFlush) { - textDecoder.decode(); - return pendingFlush; - } - }, - }); + return transformer; } -export function createTransformStream( - transform: (value: string) => string | Promise = (value) => value, -): TransformStream { - const textDecoder = new TextDecoder(); - - return new TransformStream({ - async transform(chunk, controller) { - const decoded = decodeText(chunk, textDecoder); - const transformed = await transform(decoded); - controller.enqueue(encodeText(transformed)); - }, - flush() { - textDecoder.decode(); - }, - }); -} - -export function createInsertedHTMLStream( - getServerInsertedHTML: () => Promise, -): TransformStream { - return new TransformStream({ - async transform(chunk, controller) { - const insertedHTMLChunk = encodeText(await getServerInsertedHTML()); - - controller.enqueue(insertedHTMLChunk); +export function createUltraUrlTransformStream(root: URL) { + const regex = new RegExp(root.toString(), "g"); + const transform = new TransformStream({ + transform: (chunk, controller) => { + const output = new TextDecoder().decode(chunk); + const newOutput = output.replace(regex, "/_ultra"); + chunk = new TextEncoder().encode(newOutput); controller.enqueue(chunk); }, }); -} -export function createHeadInsertionTransformStream( - insert: () => Promise, -): TransformStream { - let inserted = false; - let freezing = false; - - return new TransformStream({ - async transform(chunk, controller) { - // While react is flushing chunks, we don't apply insertions - if (freezing) { - controller.enqueue(chunk); - return; - } - - const insertion = await insert(); - - if (inserted) { - controller.enqueue(encodeText(insertion)); - controller.enqueue(chunk); - freezing = true; - } else { - const content = decodeText(chunk); - const index = content.indexOf(""); - if (index !== -1) { - const insertedHeadContent = content.slice(0, index) + insertion + - content.slice(index); - controller.enqueue(encodeText(insertedHeadContent)); - freezing = true; - inserted = true; - } - } - - if (!inserted) { - controller.enqueue(chunk); - } else { - setTimeout(() => { - freezing = false; - }); - } - }, - }); + return transform; } -export function createImportMapInjectionStream( +export function createImportMapTransformStream( importMap: ImportMap, - enableEsModuleShims?: boolean, - esModuleShimsPath?: string, - mode?: Mode, ) { - log.debug("Stream inject importMap"); - let injected = false; - - return createHeadInsertionTransformStream(() => { - if (injected) return Promise.resolve(""); - - if (mode === "development") { - importMap.imports = Object.fromEntries( - Object.entries(importMap.imports).map(([key, value]) => { - if (key.startsWith("@ultra/")) { - value = value.endsWith("/") ? value.slice(0, -1) : value; - value = `/_ultra/compiler/${encodeURIComponent(value)}/`; - } - - return [key, value]; - }), - ); - } - - const scripts = [ - ``, - ]; - - if (enableEsModuleShims && esModuleShimsPath) { - scripts.unshift( - ``, - ); - } - - injected = true; - return Promise.resolve(scripts.join("\n")); - }); -} + const importMapScript = importMap + ? `` + : null; + let importMapInjected = false; + + const transform = new TransformStream({ + transform: (chunk, controller) => { + let output = new TextDecoder().decode(chunk); + + if (importMapScript && !importMapInjected) { + output = injectImportMapScript(importMapScript, output); + importMapInjected = true; + } -export function createInlineDataStream( - dataStream: ReadableStream, -): TransformStream { - let dataStreamFinished: Promise | null = null; - return new TransformStream({ - transform(chunk, controller) { + chunk = new TextEncoder().encode(output); controller.enqueue(chunk); - - if (!dataStreamFinished) { - const dataStreamReader = dataStream.getReader(); - dataStreamFinished = new Promise((resolve) => { - setTimeout(async () => { - try { - while (true) { - const { done, value } = await dataStreamReader.read(); - if (done) { - return resolve(); - } - controller.enqueue(value); - } - } catch (error) { - controller.error(error); - } - resolve(); - }, 0); - }); - } - }, - flush() { - if (dataStreamFinished) { - return dataStreamFinished; - } }, }); -} -export function renderToInitialStream({ - element, - options, -}: { - element: ReactElement; - options?: ReactDOMServer.RenderToReadableStreamOptions; -}): Promise { - /** - * If the ReactDOM implementation doesn't support streams - * eg Preact, just use renderToString - */ - if (!ReactDOMServer["renderToReadableStream"]) { - const reactDomImpl = import.meta.resolve("react-dom/server"); - log.warning(`${reactDomImpl} doesn't support streams`); - - let html = ReactDOMServer.renderToString(element); - - if (options?.bootstrapModules) { - for (const bootstrapModule of options.bootstrapModules) { - html = - `${html}`; - } - } - - return Promise.resolve(readableStreamFromReader(new StringReader(html))); - } - - log.debug("Render to initial stream"); - return ReactDOMServer.renderToReadableStream(element, options); + return transform; } -type ContinueFromInitialStreamOptions = { - mode?: Mode; - generateStaticHTML: boolean; - disableHydration: boolean; - dataStream?: TransformStream; - importMap?: ImportMap; - enableEsModuleShims?: boolean; - esModuleShimsPath?: string; - getServerInsertedHTML?: () => Promise; - serverInsertedHTMLToHead: boolean; - flushDataStreamHandler?: () => void; -}; - -export async function continueFromInitialStream( - renderStream: RenderedReadableStream, - options: ContinueFromInitialStreamOptions, -): Promise> { - const { - mode, - importMap, - enableEsModuleShims, - esModuleShimsPath, - generateStaticHTML, - disableHydration, - dataStream, - getServerInsertedHTML, - flushDataStreamHandler, - serverInsertedHTMLToHead, - } = options; - - log.debug("Continue from initial stream"); - - /** - * @see https://reactjs.org/docs/react-dom-server.html#rendertoreadablestream - */ - if (generateStaticHTML && typeof renderStream.allReady !== undefined) { - log.debug( - "Waiting for stream to complete, generateStaticHTML was requested", - ); - await renderStream.allReady; +export function injectImportMapScript(importMapScript: string, output: string) { + const head = output.match(/(.*)<\/head>/s); + if (head) { + const headEnd = head[1].match(//s); + if (headEnd) { + console.debug("Injecting import map script before existing script tag"); + output = output.replace( + headEnd[0], + `${importMapScript}${headEnd[0]}`, + ); + } else { + // We want to inject the importMapScript before the closing tag + console.debug("Injecting import map script before closing head tag"); + output = output.replace( + /<\/head>/, + `${importMapScript}`, + ); + } + } else { + // if there is no head tag, just inject it at the top of the output + console.debug("Injecting import map script at the top of the output"); + output = `${importMapScript}${output}`; } - const transforms: Array> = [ - createBufferedTransformStream(), - /** - * Inject the provided importMap to the head, before any of the other - * transform streams below. - */ - importMap && !disableHydration - ? createImportMapInjectionStream( - importMap, - enableEsModuleShims, - esModuleShimsPath, - mode, - ) - : null, - /** - * Enqueue server injected html if serverInsertedHTMLToHead is false - */ - getServerInsertedHTML && !serverInsertedHTMLToHead - ? createInsertedHTMLStream(getServerInsertedHTML) - : null, - /** - * Handles useAsync calls - */ - dataStream ? createInlineDataStream(dataStream.readable) : null, - /** - * Insert server injected html to the head if serverInsertedHTMLToHead is true - */ - createHeadInsertionTransformStream(async () => { - log.debug("Stream Insert server side html", { serverInsertedHTMLToHead }); - // Insert server side html to end of head in app layout rendering, to avoid - // hydration errors. Remove this once it's ready to be handled by react itself. - const serverInsertedHTML = - getServerInsertedHTML && serverInsertedHTMLToHead - ? await getServerInsertedHTML() - : ""; - - return serverInsertedHTML; - }), - ].filter(nonNullable); - - flushDataStreamHandler && flushDataStreamHandler(); - - return transforms.reduce( - (readable, transform) => readable.pipeThrough(transform), - renderStream, - ); + return output; } From fc3a6f82bd5defed8c0cc5e770086b91d964fa2b Mon Sep 17 00:00:00 2001 From: James Edmonds Date: Thu, 12 Oct 2023 05:22:30 +0000 Subject: [PATCH 06/14] wip --- lib/deps.ts | 4 ++++ lib/importMap.ts | 8 ++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/deps.ts b/lib/deps.ts index f67ea851..b1c35f07 100644 --- a/lib/deps.ts +++ b/lib/deps.ts @@ -7,6 +7,10 @@ export { relative, resolve, } 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"; diff --git a/lib/importMap.ts b/lib/importMap.ts index 368c2222..a3502d7d 100644 --- a/lib/importMap.ts +++ b/lib/importMap.ts @@ -1,8 +1,4 @@ -import { - type ImportMapJson, - parseFromJson, -} from "https://deno.land/x/import_map@v0.15.0/mod.ts"; -import { toFileUrl } from "./deps.ts"; +import { type ImportMapJson, parseFromJson, toFileUrl } from "./deps.ts"; export type ImportMap = ImportMapJson; @@ -40,7 +36,7 @@ export async function createImportMapProxy( return importsProxy; } - return target[prop]; + return target[prop as keyof typeof target]; }, }); } From c9797036e5e361b5460d8f066ebc9ffbffef51bd Mon Sep 17 00:00:00 2001 From: James Edmonds Date: Thu, 12 Oct 2023 06:09:16 +0000 Subject: [PATCH 07/14] wip --- app/server.tsx | 13 +++++-------- lib/react/compiler.ts | 12 +++++++----- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/app/server.tsx b/app/server.tsx index 4f6e8e51..8226f6d9 100644 --- a/app/server.tsx +++ b/app/server.tsx @@ -1,26 +1,23 @@ import { renderToReadableStream } from "react-dom/server"; -import { createImportMapProxy } from "ultra/lib/importMap.ts"; 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 { createImportMapProxy } from "ultra/lib/importMap.ts"; import App from "./app.tsx"; -const importMap = { +const root = Deno.cwd(); +const importMap = await createImportMapProxy({ 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("./"), }, -}; - -const root = Deno.cwd(); - -const proxiedImportMap = await createImportMapProxy(importMap, root); +}, root); const renderer = createRenderHandler({ root, - importMap: proxiedImportMap, + importMap, render(request) { return renderToReadableStream( diff --git a/lib/react/compiler.ts b/lib/react/compiler.ts index 2af779bb..5f0d48ae 100644 --- a/lib/react/compiler.ts +++ b/lib/react/compiler.ts @@ -1,5 +1,6 @@ import { join } from "https://deno.land/std@0.203.0/url/mod.ts"; import { compile } from "https://deno.land/x/mesozoic@v1.3.10/lib/compiler.ts"; +import { type RequestHandler } from "../handler.ts"; type CompilerOptions = { root: URL | string; @@ -7,17 +8,18 @@ type CompilerOptions = { denyList?: string[]; }; -export function createCompilerHandler(options: CompilerOptions) { +export function createCompilerHandler( + options: CompilerOptions, +): RequestHandler { const root = new URL(options.root.toString(), import.meta.url); - + const prefix = "/_ultra/"; const pattern = new URLPattern({ - pathname: "/_ultra/:path*", + pathname: `${prefix}:path*`, }); const handleRequest = async (request: Request): Promise => { const { pathname } = new URL(request.url); - const filePath = pathname.replace(/^\/_ultra\//, ""); - console.debug("filePath", filePath); + const filePath = pathname.replace(prefix, "./"); const fileUrl = join(root, filePath); const source = await Deno.readTextFile(fileUrl); From a9334387ee307e62640357fac9f620a9297b6235 Mon Sep 17 00:00:00 2001 From: James Edmonds Date: Thu, 12 Oct 2023 06:09:36 +0000 Subject: [PATCH 08/14] wip --- app/server.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/server.tsx b/app/server.tsx index 8226f6d9..e3363080 100644 --- a/app/server.tsx +++ b/app/server.tsx @@ -2,18 +2,17 @@ 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 { createImportMapProxy } from "ultra/lib/importMap.ts"; import App from "./app.tsx"; const root = Deno.cwd(); -const importMap = await createImportMapProxy({ +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("./"), }, -}, root); +}; const renderer = createRenderHandler({ root, From 3656a3f0d5608bb91beef736da0b35640e36c4be Mon Sep 17 00:00:00 2001 From: James Edmonds Date: Thu, 12 Oct 2023 07:35:05 +0000 Subject: [PATCH 09/14] wip --- app/.gitignore | 1 + app/app.tsx | 3 ++ app/client.tsx | 9 ++++-- app/server.tsx | 4 +-- lib/react/client.js | 51 ++++++++++++++++++++++++++++--- lib/react/context.js | 7 +++++ lib/react/renderer.ts | 16 +--------- lib/react/server.js | 10 ++++-- lib/renderer.ts | 3 -- lib/stream.ts | 71 ++++++------------------------------------- 10 files changed, 86 insertions(+), 89 deletions(-) create mode 100644 app/.gitignore create mode 100644 lib/react/context.js diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 00000000..ac91fcd5 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/ultra \ No newline at end of file diff --git a/app/app.tsx b/app/app.tsx index 867fbaed..42d9417c 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -1,5 +1,6 @@ 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")); @@ -13,6 +14,8 @@ export default function App() { Testing + +
Hello World {state}
diff --git a/app/client.tsx b/app/client.tsx index 3e852f50..1b627a35 100644 --- a/app/client.tsx +++ b/app/client.tsx @@ -1,4 +1,9 @@ -import { hydrateRoot } from "react-dom/client"; +import UltraClient, { hydrate } from "ultra/lib/react/client.js"; import App from "/~/app.tsx"; -hydrateRoot(document, ); +hydrate( + document, + + + , +); diff --git a/app/server.tsx b/app/server.tsx index e3363080..48e48d49 100644 --- a/app/server.tsx +++ b/app/server.tsx @@ -11,15 +11,15 @@ const importMap = { "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 renderer = createRenderHandler({ root, - importMap, render(request) { return renderToReadableStream( - + , { diff --git a/lib/react/client.js b/lib/react/client.js index f4c4a366..b8865ffd 100644 --- a/lib/react/client.js +++ b/lib/react/client.js @@ -1,13 +1,56 @@ -import { createElement as h, Fragment } from "react"; +import { + createElement as h, + Fragment, + startTransition, + useContext, +} from "react"; +import { hydrateRoot } from "react-dom/client"; +import UltraServerContext from "./context.js"; /** - * @typedef {Object} UltraBrowserProps + * @typedef {Object} UltraClientProps * @property {import('react').ReactNode} [children] */ /** - * @param {UltraBrowserProps} props + * @param {UltraClientProps} props */ -export default function UltraBrowser({ children }) { +export default function UltraClient({ children }) { return h(Fragment, undefined, children); } + +/** + * @param {Element | Document} container + * @param {React.ReactNode} element + * @param {import('react-dom/client').HydrationOptions} [options] + */ +export function hydrate(container, element, options) { + const importMapScript = document.scripts.namedItem("importmap"); + const importMap = importMapScript + ? JSON.parse(importMapScript.innerHTML) + : {}; + + requestIdleCallback(() => { + startTransition(() => { + hydrateRoot( + container, + h(UltraServerContext.Provider, { + value: { importMap }, + children: element, + }), + options, + ); + }); + }); +} + +export function ImportMapScript() { + const { importMap } = useContext(UltraServerContext); + return h("script", { + name: "importmap", + type: "importmap", + dangerouslySetInnerHTML: { + __html: JSON.stringify(importMap), + }, + }); +} diff --git a/lib/react/context.js b/lib/react/context.js new file mode 100644 index 00000000..3a68d03e --- /dev/null +++ b/lib/react/context.js @@ -0,0 +1,7 @@ +import { createContext } from "react"; + +const UltraServerContext = createContext({ + importMap: {}, +}); + +export default UltraServerContext; diff --git a/lib/react/renderer.ts b/lib/react/renderer.ts index 60b449b8..f401b1f5 100644 --- a/lib/react/renderer.ts +++ b/lib/react/renderer.ts @@ -1,10 +1,7 @@ import { toFileUrl } from "../deps.ts"; import { type RequestHandler } from "../handler.ts"; import { type RendererOptions } from "../renderer.ts"; -import { - createImportMapTransformStream, - createUltraUrlTransformStream, -} from "../stream.ts"; +import { createUltraUrlTransformStream } from "../stream.ts"; export function createRenderHandler( options: RendererOptions, @@ -13,17 +10,11 @@ export function createRenderHandler( ? options.root : toFileUrl(options.root); - const importMap = options.importMap; - const handleRequest = async (request: Request): Promise => { const result = await options.render(request); if (result instanceof ReadableStream) { const transforms: TransformStream[] = []; - if (importMap) { - transforms.push(createImportMapTransformStream(importMap)); - } - transforms.push(createUltraUrlTransformStream(root)); const stream = transforms.reduce( @@ -43,11 +34,6 @@ export function createRenderHandler( if (result.body) { transforms.push(createUltraUrlTransformStream(root)); - if (importMap) { - transforms.push( - createImportMapTransformStream(importMap), - ); - } } const stream = transforms.reduce( diff --git a/lib/react/server.js b/lib/react/server.js index a3c92138..143d59e2 100644 --- a/lib/react/server.js +++ b/lib/react/server.js @@ -1,14 +1,20 @@ import { createElement as h, Fragment } from "react"; +import UltraServerContext from "./context.js"; /** * @typedef {Object} UltraServerProps * @property {Request} [request] + * @property {import('../importMap.ts').ImportMap} [importMap] * @property {import('react').ReactNode} [children] */ /** * @param {UltraServerProps} props */ -export default function UltraServer({ request, children }) { - return h(Fragment, undefined, [children]); +export default function UltraServer({ request, importMap, children }) { + return h( + UltraServerContext.Provider, + { value: { importMap } }, + h(Fragment, undefined, children), + ); } diff --git a/lib/renderer.ts b/lib/renderer.ts index 52633a87..db7a68ed 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -1,8 +1,5 @@ -import { type ImportMap } from "./importMap.ts"; - export interface RendererOptions { root: string | URL; - importMap?: ImportMap; render: RenderFunction; } diff --git a/lib/stream.ts b/lib/stream.ts index 13303720..1467e4f0 100644 --- a/lib/stream.ts +++ b/lib/stream.ts @@ -1,70 +1,19 @@ -import { type ImportMap } from "./importMap.ts"; - -type RenderResponseTransformerOptions = { - root: URL; - importMap?: ImportMap; -}; - -export function createRenderResponseTransformer( - options: RenderResponseTransformerOptions, -) { - const { root, importMap } = options; - - const transformer = (result: Response | ReadableStream) => { - if (result instanceof ReadableStream) { - const transforms: TransformStream[] = []; - transforms.push(createUltraUrlTransformStream(root)); - - if (importMap) { - transforms.push(createImportMapTransformStream(importMap)); - } - - const stream = transforms.reduce( - (readable, transform) => readable.pipeThrough(transform), - result as ReadableStream, - ); - - return new Response(stream, { - headers: { - "content-type": "text/html", - }, - }); - } - - if (result instanceof Response) { - const transforms: TransformStream[] = []; - - if (result.body) { - transforms.push(createUltraUrlTransformStream(root)); - if (importMap) { - transforms.push( - createImportMapTransformStream(importMap), - ); - } - } - - const stream = transforms.reduce( - (readable, transform) => readable.pipeThrough(transform), - result.body as ReadableStream, - ); - - return new Response(stream, { - headers: result.headers, - }); - } - }; - - return transformer; -} +import { ImportMap } from "./importMap.ts"; export function createUltraUrlTransformStream(root: URL) { const regex = new RegExp(root.toString(), "g"); + let buffer = ""; + const transform = new TransformStream({ - transform: (chunk, controller) => { + transform: (chunk) => { const output = new TextDecoder().decode(chunk); - const newOutput = output.replace(regex, "/_ultra"); - chunk = new TextEncoder().encode(newOutput); + buffer += output; + }, + flush: (controller) => { + const newOutput = buffer.replace(regex, "/_ultra"); + const chunk = new TextEncoder().encode(newOutput); controller.enqueue(chunk); + controller.terminate(); }, }); From fa57b29bd91dbf18114b0b5fa393f7062bd7fa6b Mon Sep 17 00:00:00 2001 From: James Edmonds Date: Thu, 12 Oct 2023 07:38:50 +0000 Subject: [PATCH 10/14] wip --- app/server.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/server.tsx b/app/server.tsx index 48e48d49..0a1b8e55 100644 --- a/app/server.tsx +++ b/app/server.tsx @@ -5,6 +5,14 @@ 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", From 1cef019e1561e2f2ec9ae1f8b12240e991751069 Mon Sep 17 00:00:00 2001 From: Daniel Mizerski Date: Sat, 14 Oct 2023 02:21:43 +0200 Subject: [PATCH 11/14] tech: help with typings for refactor server handler --- lib/react/renderer.ts | 3 ++- lib/renderer.ts | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/react/renderer.ts b/lib/react/renderer.ts index f401b1f5..333f2112 100644 --- a/lib/react/renderer.ts +++ b/lib/react/renderer.ts @@ -1,10 +1,11 @@ +import { type ReactDOMServerReadableStream } from "react-dom/server"; import { toFileUrl } from "../deps.ts"; import { type RequestHandler } from "../handler.ts"; import { type RendererOptions } from "../renderer.ts"; import { createUltraUrlTransformStream } from "../stream.ts"; export function createRenderHandler( - options: RendererOptions, + options: RendererOptions, ): RequestHandler { const root = options.root instanceof URL ? options.root diff --git a/lib/renderer.ts b/lib/renderer.ts index db7a68ed..5fb8e97d 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -1,11 +1,14 @@ -export interface RendererOptions { +// See: +// https://react.dev/reference/react-dom/server/renderToReadableStream#usage +// https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream +export interface RendererOptions { root: string | URL; render: RenderFunction; } type RenderResult = Promise | T | Promise | Response; -type RenderFunction = ( +type RenderFunction = ( request: Request, params?: Map, ) => RenderResult; From 0b7d5efe6dd44be2604ab4c018f7b5cfd189c4d0 Mon Sep 17 00:00:00 2001 From: Daniel Mizerski Date: Sat, 14 Oct 2023 03:53:58 +0200 Subject: [PATCH 12/14] tech: revive example basic on refactor branch --- .vscode/settings.json | 3 ++ examples/basic/server.tsx | 61 +++++++++++++++++++++++++++------------ lib/handler.ts | 20 +++++++++++++ lib/static/handler.ts | 49 +++++++++++++++++++++++++++++++ lib/utils/import-map.ts | 8 ++++- 5 files changed, 122 insertions(+), 19 deletions(-) create mode 100644 lib/static/handler.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index b1dc05c5..6f562563 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,6 +6,9 @@ "[css]": { "editor.defaultFormatter": "vscode.css-language-features" }, + "[typescriptreact]": { + "editor.defaultFormatter": "denoland.vscode-deno", + }, "deno.enablePaths": [ "./examples/ultra-website", "./examples/basic", diff --git a/examples/basic/server.tsx b/examples/basic/server.tsx index 785b968e..52006aa0 100644 --- a/examples/basic/server.tsx +++ b/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( + + + , + { + bootstrapModules: [ + import.meta.resolve("./client.tsx"), + ], + }, + ); + }, }); -server.get("*", async (context) => { - /** - * Render the request - */ - const result = await server.render(); +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 }); +}); diff --git a/lib/handler.ts b/lib/handler.ts index 0082ba20..7ba878db 100644 --- a/lib/handler.ts +++ b/lib/handler.ts @@ -2,3 +2,23 @@ export interface RequestHandler { handleRequest: (request: Request) => Promise; 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; + } +} diff --git a/lib/static/handler.ts b/lib/static/handler.ts new file mode 100644 index 00000000..6d380968 --- /dev/null +++ b/lib/static/handler.ts @@ -0,0 +1,49 @@ +import { join } from "https://deno.land/std@0.203.0/url/mod.ts"; +import { type RequestHandler } from "../handler.ts"; + +type StaticHandlerOptions = { + pathToRoot: URL | string; + prefix?: string; +}; + +export function createStaticHandler( + options: StaticHandlerOptions, +): RequestHandler { + const { + pathToRoot, + prefix = "/" + } = options; + + const root = new URL(pathToRoot.toString(), import.meta.url); + + const handleRequest = async (request: Request): Promise => { + const { pathname } = new URL(request.url); + const filePath = pathname.replace(prefix, "./"); + const fileUrl = join(root, filePath); + + // See https://docs.deno.com/runtime/tutorials/file_server#example + let file; + try { + file = await Deno.open(fileUrl, { read: true }); + } catch { + // If the file cannot be opened, return a "404 Not Found" response + throw "Can't handle this request"; + } + + // Build a readable stream so the file doesn't have to be fully loaded into + // memory while we send it + const readableStream = file.readable; + + // Build and send the response + return new Response(readableStream); + }; + + const supportsRequest = (request: Request): boolean => { + return true; // use it as a fallback for now + }; + + return { + supportsRequest, + handleRequest, + }; +} diff --git a/lib/utils/import-map.ts b/lib/utils/import-map.ts index bf365bda..10f22418 100644 --- a/lib/utils/import-map.ts +++ b/lib/utils/import-map.ts @@ -1,4 +1,4 @@ -import { resolve, toFileUrl } from "../deps.ts"; +import { type ImportMapJson, resolve, toFileUrl } from "../deps.ts"; import { Mode } from "../types.ts"; export function resolveImportMapPath(mode: Mode, root: string, path: string) { @@ -8,3 +8,9 @@ export function resolveImportMapPath(mode: Mode, root: string, path: string) { return toFileUrl(resolve(root, "importMap.browser.json")).href; } + +export const readImportMap = async (path: string) => { + const importMap = await Deno.readTextFile(path); + // TODO: zod-check the import map + return JSON.parse(importMap) as ImportMapJson; +} From 793cd159d374bcf36cfcd204975706ff671284fe Mon Sep 17 00:00:00 2001 From: Omar Mashaal Date: Sun, 15 Oct 2023 15:38:15 +1100 Subject: [PATCH 13/14] dev: add dev cmd to app --- app/deno.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/deno.json b/app/deno.json index 3df6a00d..fd6e03b9 100644 --- a/app/deno.json +++ b/app/deno.json @@ -7,6 +7,9 @@ "ultra/": "../", "/~/": "./" }, + "tasks": { + "dev": "deno run -A server.tsx" + }, "compilerOptions": { "jsx": "react-jsxdev", "jsxImportSource": "react" From f31148f2bf18eee734deb22dc6c7670ce4566c00 Mon Sep 17 00:00:00 2001 From: James Edmonds Date: Tue, 17 Oct 2023 22:03:43 +0000 Subject: [PATCH 14/14] response like --- app/server.tsx | 17 ++++------------- lib/react/mod.ts | 41 +++++++++++++++++++++++++++++++++++++++++ lib/renderer.ts | 7 ++++--- 3 files changed, 49 insertions(+), 16 deletions(-) create mode 100644 lib/react/mod.ts diff --git a/app/server.tsx b/app/server.tsx index 0a1b8e55..eda0a662 100644 --- a/app/server.tsx +++ b/app/server.tsx @@ -1,6 +1,5 @@ import { renderToReadableStream } from "react-dom/server"; -import { createCompilerHandler } from "ultra/lib/react/compiler.ts"; -import { createRenderHandler } from "ultra/lib/react/renderer.ts"; +import { createReactHandler } from "ultra/lib/react/mod.ts"; import UltraServer from "ultra/lib/react/server.js"; import App from "./app.tsx"; @@ -23,7 +22,7 @@ const importMap = { }, }; -const renderer = createRenderHandler({ +const handler = createReactHandler({ root, render(request) { return renderToReadableStream( @@ -39,10 +38,6 @@ const renderer = createRenderHandler({ }, }); -const compiler = createCompilerHandler({ - root, -}); - Deno.serve((request) => { const url = new URL(request.url, "http://localhost"); @@ -50,12 +45,8 @@ Deno.serve((request) => { return new Response(null, { status: 404 }); } - if (compiler.supportsRequest(request)) { - return compiler.handleRequest(request); - } - - if (renderer.supportsRequest(request)) { - return renderer.handleRequest(request); + if (handler.supportsRequest(request)) { + return handler.handleRequest(request); } return new Response("Not Found", { status: 404 }); diff --git a/lib/react/mod.ts b/lib/react/mod.ts new file mode 100644 index 00000000..41dbd0e9 --- /dev/null +++ b/lib/react/mod.ts @@ -0,0 +1,41 @@ +import { RenderFunction } from "../renderer.ts"; +import { createCompilerHandler } from "./compiler.ts"; +import { createRenderHandler } from "./renderer.ts"; + +type CreateReactHandlerOptions = { + root: string | URL; + render: RenderFunction; +}; + +export function createReactHandler(options: CreateReactHandlerOptions) { + const renderer = createRenderHandler({ + root: options.root, + render: options.render, + }); + + const compiler = createCompilerHandler({ + root: options.root, + }); + + const handleRequest = (request: Request) => { + if (compiler.supportsRequest(request)) { + return compiler.handleRequest(request); + } + + if (renderer.supportsRequest(request)) { + return renderer.handleRequest(request); + } + + return new Response("Not Found", { status: 404 }); + }; + + const supportsRequest = (request: Request) => { + return compiler.supportsRequest(request) || + renderer.supportsRequest(request); + }; + + return { + handleRequest, + supportsRequest, + }; +} diff --git a/lib/renderer.ts b/lib/renderer.ts index 5fb8e97d..d6aa16f7 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -1,14 +1,15 @@ // See: // https://react.dev/reference/react-dom/server/renderToReadableStream#usage // https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream -export interface RendererOptions { +export interface RendererOptions { root: string | URL; render: RenderFunction; } -type RenderResult = Promise | T | Promise | Response; +type RenderResult = Promise | T; +type ResponseLike = Response | ReadableStream | null; -type RenderFunction = ( +export type RenderFunction = ( request: Request, params?: Map, ) => RenderResult;