Skip to content

Commit

Permalink
feat: add initial plugin support (#512)
Browse files Browse the repository at this point in the history
This commit adds initial support for fresh "plugins". These are bundles
of functionality that can be added to a fresh plugin through a simple
addition in the `main.ts` file.

Right now plugins are only able to hook into the render steps. This
means that they can inject styling and scripts. They can not yet hook
into routing.

Things that I think should be possible now:
- styling integrations for twind, unocss, and probably a few other tools

Things that are not possible yet:
- google analytics middleware
- database connectors
- CMS connectors

The routing additions to plugins can be added in a follow-up though.
  • Loading branch information
lucacasonato authored Aug 9, 2022
1 parent 49092ed commit fd4e2bb
Show file tree
Hide file tree
Showing 20 changed files with 410 additions and 31 deletions.
9 changes: 2 additions & 7 deletions src/runtime/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ComponentType>) {
export function revive(islands: Record<string, ComponentType>, props: any[]) {
function walk(node: Node | null) {
const tag = node!.nodeType === 8 &&
((node as Comment).data.match(/^\s*frsh-(.*)\s*$/) || [])[1];
Expand All @@ -47,7 +42,7 @@ export function revive(islands: Record<string, ComponentType>) {

const [id, n] = tag.split(":");
render(
h(islands[id], ISLAND_PROPS[Number(n)]),
h(islands[id], props[Number(n)]),
createRootFragment(
parent! as HTMLElement,
children,
Expand Down
12 changes: 10 additions & 2 deletions src/server/bundle.ts
Original file line number Diff line number Diff line change
@@ -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<void> = false;
async function ensureEsbuildInitialized() {
Expand All @@ -23,10 +23,12 @@ async function ensureEsbuildInitialized() {
export class Bundler {
#importMapURL: URL;
#islands: Island[];
#plugins: Plugin[];
#cache: Map<string, Uint8Array> | Promise<void> | undefined = undefined;

constructor(islands: Island[], importMapURL: URL) {
constructor(islands: Island[], plugins: Plugin[], importMapURL: URL) {
this.#islands = islands;
this.#plugins = plugins;
this.#importMapURL = importMapURL;
}

Expand All @@ -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({
Expand Down
8 changes: 7 additions & 1 deletion src/server/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
Middleware,
MiddlewareModule,
MiddlewareRoute,
Plugin,
RenderFunction,
Route,
RouteModule,
Expand Down Expand Up @@ -61,6 +62,7 @@ export class ServerContext {
#app: AppModule;
#notFound: UnknownPage;
#error: ErrorPage;
#plugins: Plugin[];

constructor(
routes: Route[],
Expand All @@ -71,6 +73,7 @@ export class ServerContext {
app: AppModule,
notFound: UnknownPage,
error: ErrorPage,
plugins: Plugin[],
importMapURL: URL,
) {
this.#routes = routes;
Expand All @@ -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).
}

Expand Down Expand Up @@ -260,6 +264,7 @@ export class ServerContext {
app,
notFound,
error,
opts.plugins ?? [],
importMapURL,
);
}
Expand Down Expand Up @@ -414,6 +419,7 @@ export class ServerContext {
const resp = await internalRender({
route,
islands: this.#islands,
plugins: this.#plugins,
app: this.#app,
imports,
preloads,
Expand Down
4 changes: 4 additions & 0 deletions src/server/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export type {
Handlers,
MiddlewareHandlerContext,
PageProps,
Plugin,
PluginRenderResult,
PluginRenderScripts,
PluginRenderStyleTag,
RenderFunction,
RouteConfig,
StartOptions,
Expand Down
112 changes: 94 additions & 18 deletions src/server/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import {
AppModule,
ErrorPage,
Island,
Plugin,
PluginRenderFunctionResult,
PluginRenderResult,
PluginRenderStyleTag,
RenderFunction,
Route,
UnknownPage,
Expand All @@ -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<Data> {
route: Route<Data> | UnknownPage | ErrorPage;
islands: Island[];
plugins: Plugin[];
app: AppModule;
imports: string[];
preloads: string[];
Expand Down Expand Up @@ -176,17 +182,46 @@ export async function render<Data>(

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) {
Expand All @@ -198,6 +233,32 @@ export async function render<Data>(
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
{
Expand All @@ -212,8 +273,9 @@ export async function render<Data>(
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, "");
Expand All @@ -225,12 +287,17 @@ export async function render<Data>(
}
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 += `<script id="__FRSH_STATE" type="application/json">${
JSON.stringify(state)
}</script>`;

// Append the inline script to the body
const randomNonce = crypto.randomUUID().replace(/-/g, "");
Expand All @@ -240,18 +307,32 @@ export async function render<Data>(
nonce(randomNonce),
];
}
(bodyHtml as string) +=
`<script id="__FRSH_ISLAND_PROPS" type="application/json">${
JSON.stringify(ISLAND_PROPS)
}</script><script type="module" nonce="${randomNonce}">${initCode}</script>`;
bodyHtml +=
`<script type="module" nonce="${randomNonce}">${script}</script>`;
}

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({
bodyHtml,
headComponents,
imports,
preloads: opts.preloads,
styles: ctx.styles,
lang: ctx.lang,
});

Expand All @@ -262,7 +343,6 @@ export interface TemplateOptions {
bodyHtml: string;
headComponents: ComponentChildren[];
imports: (readonly [string, string])[];
styles: string[];
preloads: string[];
lang: string;
}
Expand All @@ -277,10 +357,6 @@ export function template(opts: TemplateOptions): string {
{opts.imports.map(([src, nonce]) => (
<script src={src} nonce={nonce} type="module"></script>
))}
<style
id="__FRSH_STYLE"
dangerouslySetInnerHTML={{ __html: opts.styles.join("\n") }}
/>
{opts.headComponents}
</head>
<body dangerouslySetInnerHTML={{ __html: opts.bodyHtml }} />
Expand Down
4 changes: 2 additions & 2 deletions src/server/render_test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { template } from "./render.tsx";
import { assertStringIncludes } from "../../tests/deps.ts";

Deno.test("check lang", () => {
const lang = "fr";
const body = template({
bodyHtml: "",
headComponents: [],
imports: [],
preloads: [],
styles: [],
lang: lang,
lang,
});
assertStringIncludes(body, `<html lang="${lang}">`);
});
Loading

0 comments on commit fd4e2bb

Please sign in to comment.