diff --git a/src/runtime/main.ts b/src/runtime/main.ts index 7639e2722d3..6f50338d516 100644 --- a/src/runtime/main.ts +++ b/src/runtime/main.ts @@ -24,13 +24,8 @@ function createRootFragment( }; } -const ISLAND_PROPS_COMPONENT = document.getElementById("__FRSH_ISLAND_PROPS"); // deno-lint-ignore no-explicit-any -const ISLAND_PROPS: any[] = JSON.parse( - ISLAND_PROPS_COMPONENT?.textContent ?? "[]", -); - -export function revive(islands: Record) { +export function revive(islands: Record, props: any[]) { function walk(node: Node | null) { const tag = node!.nodeType === 8 && ((node as Comment).data.match(/^\s*frsh-(.*)\s*$/) || [])[1]; @@ -47,7 +42,7 @@ export function revive(islands: Record) { const [id, n] = tag.split(":"); render( - h(islands[id], ISLAND_PROPS[Number(n)]), + h(islands[id], props[Number(n)]), createRootFragment( parent! as HTMLElement, children, diff --git a/src/server/bundle.ts b/src/server/bundle.ts index 5356ac74a2c..e63d7fb0b9f 100644 --- a/src/server/bundle.ts +++ b/src/server/bundle.ts @@ -1,6 +1,6 @@ import { BUILD_ID } from "./constants.ts"; import { denoPlugin, esbuild, toFileUrl } from "./deps.ts"; -import { Island } from "./types.ts"; +import { Island, Plugin } from "./types.ts"; let esbuildInitialized: boolean | Promise = false; async function ensureEsbuildInitialized() { @@ -23,10 +23,12 @@ async function ensureEsbuildInitialized() { export class Bundler { #importMapURL: URL; #islands: Island[]; + #plugins: Plugin[]; #cache: Map | Promise | undefined = undefined; - constructor(islands: Island[], importMapURL: URL) { + constructor(islands: Island[], plugins: Plugin[], importMapURL: URL) { this.#islands = islands; + this.#plugins = plugins; this.#importMapURL = importMapURL; } @@ -39,6 +41,12 @@ export class Bundler { entryPoints[`island-${island.id}`] = island.url; } + for (const plugin of this.#plugins) { + for (const [name, url] of Object.entries(plugin.entrypoints ?? {})) { + entryPoints[`plugin-${plugin.name}-${name}`] = url; + } + } + const absWorkingDir = Deno.cwd(); await ensureEsbuildInitialized(); const bundle = await esbuild.build({ diff --git a/src/server/context.ts b/src/server/context.ts index 8b3f8255d9a..f7f068934a6 100644 --- a/src/server/context.ts +++ b/src/server/context.ts @@ -24,6 +24,7 @@ import { Middleware, MiddlewareModule, MiddlewareRoute, + Plugin, RenderFunction, Route, RouteModule, @@ -61,6 +62,7 @@ export class ServerContext { #app: AppModule; #notFound: UnknownPage; #error: ErrorPage; + #plugins: Plugin[]; constructor( routes: Route[], @@ -71,6 +73,7 @@ export class ServerContext { app: AppModule, notFound: UnknownPage, error: ErrorPage, + plugins: Plugin[], importMapURL: URL, ) { this.#routes = routes; @@ -81,7 +84,8 @@ export class ServerContext { this.#app = app; this.#notFound = notFound; this.#error = error; - this.#bundler = new Bundler(this.#islands, importMapURL); + this.#plugins = plugins; + this.#bundler = new Bundler(this.#islands, this.#plugins, importMapURL); this.#dev = typeof Deno.env.get("DENO_DEPLOYMENT_ID") !== "string"; // Env var is only set in prod (on Deploy). } @@ -260,6 +264,7 @@ export class ServerContext { app, notFound, error, + opts.plugins ?? [], importMapURL, ); } @@ -414,6 +419,7 @@ export class ServerContext { const resp = await internalRender({ route, islands: this.#islands, + plugins: this.#plugins, app: this.#app, imports, preloads, diff --git a/src/server/mod.ts b/src/server/mod.ts index bd9b24e2601..897f242c844 100644 --- a/src/server/mod.ts +++ b/src/server/mod.ts @@ -21,6 +21,10 @@ export type { Handlers, MiddlewareHandlerContext, PageProps, + Plugin, + PluginRenderResult, + PluginRenderScripts, + PluginRenderStyleTag, RenderFunction, RouteConfig, StartOptions, diff --git a/src/server/render.tsx b/src/server/render.tsx index de2216684e4..6b57cc6125a 100644 --- a/src/server/render.tsx +++ b/src/server/render.tsx @@ -5,6 +5,10 @@ import { AppModule, ErrorPage, Island, + Plugin, + PluginRenderFunctionResult, + PluginRenderResult, + PluginRenderStyleTag, RenderFunction, Route, UnknownPage, @@ -14,10 +18,12 @@ import { CSP_CONTEXT, nonce, NONE, UNSAFE_INLINE } from "../runtime/csp.ts"; import { ContentSecurityPolicy } from "../runtime/csp.ts"; import { bundleAssetUrl } from "./constants.ts"; import { assetHashingHook } from "../runtime/utils.ts"; +import { f } from "https://dev.jspm.io/npm:@jspm/core@1.1.1/nodelibs/chunk-0c2d1322.js"; export interface RenderOptions { route: Route | UnknownPage | ErrorPage; islands: Island[]; + plugins: Plugin[]; app: AppModule; imports: string[]; preloads: string[]; @@ -176,17 +182,46 @@ export async function render( let bodyHtml: string | null = null; - function render() { + function realRender(): string { bodyHtml = renderToString(vnode); return bodyHtml; } - await opts.renderFn(ctx, render as InnerRenderFunction); + const plugins = opts.plugins.filter((p) => p.render !== null); + const renderResults: [Plugin, PluginRenderResult][] = []; + + function render(): PluginRenderFunctionResult { + const plugin = plugins.shift(); + if (plugin) { + const res = plugin.render!({ render }); + if (res === undefined) { + throw new Error( + `${plugin?.name}'s render hook did not return a PluginRenderResult object.`, + ); + } + renderResults.push([plugin, res]); + } else { + realRender(); + } + if (bodyHtml === null) { + throw new Error( + `The 'render' function was not called by ${plugin?.name}'s render hook.`, + ); + } + return { + htmlText: bodyHtml, + requiresHydration: ENCOUNTERED_ISLANDS.size > 0, + }; + } + + await opts.renderFn(ctx, () => render().htmlText); if (bodyHtml === null) { throw new Error("The `render` function was not called by the renderer."); } + bodyHtml = bodyHtml as string; + const imports = opts.imports.map((url) => { const randomNonce = crypto.randomUUID().replace(/-/g, ""); if (csp) { @@ -198,6 +233,32 @@ export async function render( return [url, randomNonce] as const; }); + const state: [islands: unknown[], plugins: unknown[]] = [ISLAND_PROPS, []]; + const styleTags: PluginRenderStyleTag[] = []; + + let script = + `const STATE_COMPONENT = document.getElementById("__FRSH_STATE");const STATE = JSON.parse(STATE_COMPONENT?.textContent ?? "[[],[]]");`; + + for (const [plugin, res] of renderResults) { + for (const hydrate of res.scripts ?? []) { + const i = state[1].push(hydrate.state) - 1; + const randomNonce = crypto.randomUUID().replace(/-/g, ""); + if (csp) { + csp.directives.scriptSrc = [ + ...csp.directives.scriptSrc ?? [], + nonce(randomNonce), + ]; + } + const url = bundleAssetUrl( + `/plugin-${plugin.name}-${hydrate.entrypoint}.js`, + ); + imports.push([url, randomNonce] as const); + + script += `import p${i} from "${url}";p${i}(STATE[1][${i}]);`; + } + styleTags.splice(styleTags.length, 0, ...res.styles ?? []); + } + if (ENCOUNTERED_ISLANDS.size > 0) { // Load the main.js script { @@ -212,8 +273,9 @@ export async function render( imports.push([url, randomNonce] as const); } + script += `import { revive } from "${bundleAssetUrl("/main.js")}";`; + // Prepare the inline script that loads and revives the islands - let islandImports = ""; let islandRegistry = ""; for (const island of ENCOUNTERED_ISLANDS) { const randomNonce = crypto.randomUUID().replace(/-/g, ""); @@ -225,12 +287,17 @@ export async function render( } const url = bundleAssetUrl(`/island-${island.id}.js`); imports.push([url, randomNonce] as const); - islandImports += `\nimport ${island.name} from "${url}";`; - islandRegistry += `\n ${island.id}: ${island.name},`; + script += `import ${island.name} from "${url}";`; + islandRegistry += `${island.id}:${island.name},`; } - const initCode = `import { revive } from "${ - bundleAssetUrl("/main.js") - }";${islandImports}\nrevive({${islandRegistry}\n});`; + script += `revive({${islandRegistry}}, STATE[0]);`; + } + + if (state[0].length > 0 || state[1].length > 0) { + // Append state to the body + bodyHtml += ``; // Append the inline script to the body const randomNonce = crypto.randomUUID().replace(/-/g, ""); @@ -240,10 +307,25 @@ export async function render( nonce(randomNonce), ]; } - (bodyHtml as string) += - ``; + bodyHtml += + ``; + } + + if (ctx.styles.length > 0) { + const node = h("style", { + id: "__FRSH_STYLE", + dangerouslySetInnerHTML: { __html: ctx.styles.join("\n") }, + }); + headComponents.splice(0, 0, node); + } + + for (const style of styleTags) { + const node = h("style", { + id: style.id, + dangerouslySetInnerHTML: { __html: style.cssText }, + media: style.media, + }); + headComponents.splice(0, 0, node); } const html = template({ @@ -251,7 +333,6 @@ export async function render( headComponents, imports, preloads: opts.preloads, - styles: ctx.styles, lang: ctx.lang, }); @@ -262,7 +343,6 @@ export interface TemplateOptions { bodyHtml: string; headComponents: ComponentChildren[]; imports: (readonly [string, string])[]; - styles: string[]; preloads: string[]; lang: string; } @@ -277,10 +357,6 @@ export function template(opts: TemplateOptions): string { {opts.imports.map(([src, nonce]) => ( ))} - '); + assert(!body.includes(`>[[],[]]`)); + assert(!body.includes(`import`)); +}); + +Deno.test("/with-island prerender", async () => { + const resp = await router(new Request("https://fresh.deno.dev/with-island")); + assert(resp); + assertEquals(resp.status, Status.OK); + const body = await resp.text(); + assertStringIncludes( + body, + '', + ); + assertStringIncludes(body, `>[[{}],["JS injected!"]]`); + assertStringIncludes(body, `/plugin-js-inject-main.js"`); +}); + +Deno.test({ + name: "/with-island hydration", + async fn(t) { + // Preparation + const serverProcess = Deno.run({ + cmd: ["deno", "run", "-A", "./tests/fixture_plugin/main.ts"], + stdout: "piped", + stderr: "inherit", + }); + + const decoder = new TextDecoderStream(); + const lines = serverProcess.stdout.readable + .pipeThrough(decoder) + .pipeThrough(new TextLineStream()); + + let started = false; + for await (const line of lines) { + if (line.includes("Listening on http://")) { + started = true; + break; + } + } + if (!started) { + throw new Error("Server didn't start up"); + } + + await delay(100); + + const browser = await puppeteer.launch({ args: ["--no-sandbox"] }); + const page = await browser.newPage(); + + await page.goto("http://localhost:8000/with-island", { + waitUntil: "networkidle2", + }); + + await t.step("island is revived", async () => { + await page.waitForSelector("#csr"); + }); + + await t.step("title was updated", async () => { + const title = await page.title(); + assertEquals(title, "JS injected!"); + }); + + await browser.close(); + + await lines.cancel(); + serverProcess.kill("SIGTERM"); + serverProcess.close(); + }, + sanitizeOps: false, + sanitizeResources: false, +});