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

Multi plugin islands configs #2275

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions docs/canary/concepts/plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
---
description: Plugins can add new functionality to Fresh without requiring significant complexity.
---

Plugins can dynamically add new functionality to Fresh without exposing
significant complexity to the user. Users can add plugins by importing and
initializing them in their `main.ts` file:

```ts main.ts
import { start } from "$fresh/server.ts";
import manifest from "./fresh.gen.ts";

import twindPlugin from "$fresh/plugins/twind.ts";
import twindConfig from "./twind.config.js";

await start(manifest, {
plugins: [
// This line configures Fresh to use the first-party twind plugin.
twindPlugin(twindConfig),
],
});
```

Currently, the only available first-party plugin is the Twind plugin.
Third-party plugins are also supported - they can be imported from any HTTP
server, like any other Deno module.

Plugin hooks are executed in the order that the plugins are defined in the
`plugins` array. This means that the first plugin in the array will be executed
first, and the last plugin in the array will be executed last. For many plugins,
this does not matter, but for some plugins it may.

## Creating a plugin

Fresh plugins are in essence a collection of hooks that allow the plugin to hook
into various systems inside of Fresh. Currently only a `render` hook is
available (explained below).

A Fresh plugin is just a JavaScript object that conforms to the
[Plugin](https://deno.land/x/fresh/server.ts?s=Plugin) interface. The only
required property of a plugin is its name. Names must only contain the
characters `a`-`z`, and `_`.

```ts
import { Plugin } from "$fresh/server.ts";

const plugin: Plugin = {
name: "my_plugin",
};
```

A plugin containing only a name is technically valid, but not very useful. To be
able to do anything with a plugin, it must register some hooks, middlewares, or
routes.

### Hook: `render`

The render hook allows plugins to:

- Control timing of the synchronous render of a page.
- Inject additional CSS and JS into the rendered page.

This is commonly used to set thread local variables for the duration of the
render (for example preact global context, preact option hooks, or for style
libraries like Twind). After render is complete, the plugin can inject inline
CSS and JS modules (with attached state) into the page.

The render hook is called with the
[`PluginRenderContext`](https://deno.land/x/fresh/server.ts?s=PluginRenderContext)
object, which contains a `render()` method. This method must be invoked during
the render hook to actually render the page. It is a terminal error to not call
the `render()` method during the render hook.

The `render()` method returns a
[`PluginRenderFunctionResult`](https://deno.land/x/fresh/server.ts?s=PluginRenderFunctionResult)
object which contains the HTML text of the rendered page, as well as a boolean
indicating whether the page contains any islands that will be hydrated on the
client.

The `render` hook needs to synchronously return a
[`PluginRenderResult`](https://deno.land/x/fresh/server.ts?s=PluginRenderResult)
object. Additional CSS and JS modules can be added to be injected into the page
by adding them to `styles`, `links` and `scripts` arrays in this object. The
plugin can also replace the the HTML in side the `<body>`-element of the page by
including a `htmlText` string in this object.

`styles` are injected into the `<head>` of the page as inline CSS. Each entry
can define the CSS text to inject, as well as an optional `id` for the style
tag, and an optional `media` attribute for the style tag.

`links` are injected into the `<head>` of the page as `<link>` tags. A link tag
is created for each entry, with attributes from the entry's properties.

`scripts` define JavaScript/TypeScript modules to be injected into the page. The
possibly loaded modules need to be defined up front in the `Plugin#entrypoints`
property. Each defined module must be a JavaScript/TypeScript module that has a
default export of a function that takes one (arbitrary) argument, and returns
nothing (or a promise resolving to nothing). Fresh will call this function with
the state defined in the `scripts` entry. The state can be any arbitrary JSON
serializable JavaScript value.

For an example of a plugin that uses the `render` hook, see the first-party
[Twind plugin](https://github.com/denoland/fresh/blob/main/plugins/twind.ts).

### Hook: `renderAsync`

This hook is largely the same as the `render` hook, with a couple of key
differences to make asynchronous style and script generation possible. It must
asynchronously return its
[`PluginRenderResult`](https://deno.land/x/fresh/server.ts?s=PluginRenderResult),
either from an `async/await` function or wrapped within a promise.

The render hook is called with the
[`PluginAsyncRenderContext`](https://deno.land/x/fresh/server.ts?s=PluginAsyncRenderContext)
object, which contains a `renderAsync()` method. This method must be invoked
during the render hook to actually render the page. It is a terminal error to
not call the `renderAsync()` method during the render hook.

This is useful for when plugins are generating styles and scripts with
asynchronous dependencies based on the `htmlText`. Unlike the synchronous render
hook, async render hooks for multiple pages can be running at the same time.
This means that unlike the synchronous render hook, you can not use global
variables to propagate state between the render hook and the renderer.

The `renderAsync` hooks start before any page rendering occurs, and finish after
all rendering is complete -- they wrap around the underlying JSX->string
rendering, plugin `render` hooks, and the
[`RenderFunction`](https://deno.land/x/fresh/server.ts?s=RenderFunction) that
may be provided to Fresh's `start` entrypoint in the `main.ts` file.

### Hook: `buildStart`

This hook is run at the start of the Fresh
[ahead-of-time build task](/docs/concepts/ahead-of-time-builds). It may be
synchronous or asynchronous.

The build start hook is called with the
[`ResolvedFreshConfig`](https://deno.land/x/fresh/src/server/types.ts?s=ResolvedFreshConfig)
object, which contains the full Fresh configuration.

This hook may be used to generate precompiled static assets. Any files saved to
the `static` subfolder of `config.build.outDir` (typically `_fresh`) will be
served the same as other [static files](/docs/concepts/static-files).

### Hook: `buildEnd`

This hook is run at the end of the Fresh
[ahead-of-time build task](/docs/concepts/ahead-of-time-builds). It may be
synchronous or asynchronous.

### Routes and Middlewares

You can create routes and middlewares that get loaded and rendered like the
normal [routes](/docs/concepts/routes) and
[middlewares](/docs/concepts/middleware).

The plugin routes and middlewares need a defined path in the format of a file
name without a filetype inside the routes directory(E.g. `blog/index`,
`blog/[slug]`).

For more examples see the [Concepts: Routing](/docs/concepts/routing) page.

To create a middleware you need to create a `MiddlewareHandler` function.

And to create a route you can create both a Handler and/or component.

A very basic example can be found
[here](https://github.com/denoland/fresh/blob/main/tests/fixture_plugin/utils/route-plugin.ts).

### Islands

Islands from plugins can be loaded by specifying a list of file paths in your
plugin. Those files will be treated by Fresh as if they had been placed inside
the `islands/` directory. They will be processed and bundled for the browser in
the same way.

```tsx my-island-plugin.ts
import { Plugin } from "$fresh/server.ts";

export default function myIslandPlugin(): Plugin {
return {
name: "my-island-plugin",
islands: {
baseLocation: import.meta.url,
paths: ["./plugin/MyPluginIsland.tsx", "./plugin/OtherPluginIsland.tsx"],
},
};
}
```

In addition, you can also specify an array of islands in your plugin
configuration, enabling inclusion of islands from shared libraries.

```tsx my-island-plugin.ts
import { Plugin } from "$fresh/server.ts";
import { loadSharedIslands } from "some-other-library";

export default function myIslandPlugin(): Plugin {
return {
name: "my-island-plugin",
islands: [
...loadSharedIslands(),
{
baseLocation: import.meta.url,
paths: [
"./plugin/MyPluginIsland.tsx",
"./plugin/OtherPluginIsland.tsx",
],
},
],
};
}
```
2 changes: 1 addition & 1 deletion docs/toc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const toc: RawTableOfContents = {
["data-fetching", "Data fetching", "link:latest"],
["ahead-of-time-builds", "Ahead-of-time Builds", "link:latest"],
["deployment", "Deployment", "link:latest"],
["plugins", "Plugins", "link:latest"],
["plugins", "Plugins", "link:canary"],
["updating", "Updating Fresh", "link:latest"],
["server-configuration", "Server configuration", "link:latest"],
],
Expand Down
26 changes: 16 additions & 10 deletions src/server/fs_extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,17 +242,23 @@ export async function extractRoutes(

for (const plugin of config.plugins || []) {
if (!plugin.islands) continue;
const base = dirname(plugin.islands.baseLocation);
else if (!Array.isArray(plugin.islands)) {
plugin.islands = [plugin.islands];
}

for (const specifier of plugin.islands.paths) {
const full = join(base, specifier);
const module = await import(full);
const name = sanitizeIslandName(basename(full, extname(full)));
processedIslands.push({
name,
path: full,
module,
});
for (const pluginIslands of plugin.islands) {
const base = dirname(pluginIslands.baseLocation);

for (const specifier of pluginIslands.paths) {
const full = join(base, specifier);
const module = await import(full);
const name = sanitizeIslandName(basename(full, extname(full)));
processedIslands.push({
name,
path: full,
module,
});
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@ export interface Plugin<State = Record<string, unknown>> {

middlewares?: PluginMiddleware<State>[];

islands?: PluginIslands;
islands?: PluginIslands | PluginIslands[];
}

export interface PluginRenderContext {
Expand Down
2 changes: 2 additions & 0 deletions tests/fixture_plugin/fresh.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import jsInjectPlugin from "./utils/js-inject-plugin.ts";
import cssInjectPluginAsync from "./utils/css-inject-plugin-async.ts";
import linkInjectPlugin from "./utils/link-inject-plugin.ts";
import routePlugin from "./utils/route-plugin.ts";
import routePluginMultipleIslands from "./utils/route-plugin-multiple-islands.ts";
import secondMiddlewarePlugin from "$fresh/tests/fixture_plugin/utils/second-middleware-plugin.ts";

export default {
Expand All @@ -13,6 +14,7 @@ export default {
cssInjectPluginAsync,
linkInjectPlugin,
routePlugin({ title: "Title Set From Plugin Config", async: false }),
routePluginMultipleIslands(),
secondMiddlewarePlugin(),
],
} as FreshConfig;
29 changes: 29 additions & 0 deletions tests/fixture_plugin/utils/route-plugin-multiple-islands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Plugin } from "$fresh/server.ts";
import IslandsPluginComponent from "./sample_routes/PluginRouteWithIslands.tsx";
import { PluginMiddlewareState } from "$fresh/tests/fixture_plugin/utils/route-plugin.ts";

export default function routePluginMultipleIslands(): Plugin<
PluginMiddlewareState
> {
return {
name: "routePluginMultipleIslands",
routes: [
{
path: "pluginroutewithislands",
component: IslandsPluginComponent,
},
],
islands: [
{
baseLocation: import.meta.url,
paths: ["./sample_islands/IslandFromPlugin.tsx"],
},
Comment on lines +17 to +20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is interesting because it essentially duplicates the island registered from route-plugin.ts. As I was stepping through the code, I saw what was about to happen and kept thinking "no problem, it'll get cleared out later". But it doesn't! But it also doesn't cause problems... 🤔

I guess it's not a terrible thing to have it like this, because sooner or later someone is bound to end up in a similar situation. I've made note of this in #1568 (comment) that we need to detect island conflicts like this. Although this is probably a special case in that the "conflict" isn't an issue, because everything is the same.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any changes to make for this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing to do. I just wanted to highlight that this is happening and isn't causing issues, even though it's unexpected.

{
baseLocation: import.meta.resolve(
"./sample_islands/sub/Island2FromPlugin.tsx",
),
paths: ["./Island2FromPlugin.tsx"],
},
],
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { IS_BROWSER } from "../../../../../src/runtime/utils.ts";

export default function Island2FromPlugin() {
const id = IS_BROWSER ? "csr_alt_folder2" : "ssr_alt_folder2";
return (
<div>
<p id={id}>{id}</p>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Island from "../../islands/Island.tsx";
import IslandFromPlugin from "../sample_islands/IslandFromPlugin.tsx";
import Island2FromPlugin from "../sample_islands/sub/Island2FromPlugin.tsx";

export default function IslandsPluginComponent() {
return (
<div>
<Island />
<IslandFromPlugin />
<Island2FromPlugin />
</div>
);
}
29 changes: 29 additions & 0 deletions tests/plugin_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,35 @@ Deno.test({
sanitizeResources: false,
});

Deno.test({
name: "plugin supports multiple islands",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit pedantic, but plugins already support multiple islands, because PluginIslands.paths is a string[]. What if you rename the test to "plugins support multiple island configs" or something like that. I think it would then be more clear what the test is asserting.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

async fn(t) {
await withPageName(
"./tests/fixture_plugin/main.ts",
async (page, address) => {
async function idTest(id: string) {
const elem = await page.waitForSelector(`#${id}`);

const value = await elem?.evaluate((el) => el.textContent);
assert(value === `${id}`, `value ${value} not equal to id ${id}`);
}

await page.goto(`${address}/pluginroutewithislands`, {
waitUntil: "networkidle2",
});

await t.step("verify tags", async () => {
await idTest("csr");
await idTest("csr_alt_folder");
await idTest("csr_alt_folder2");
});
},
);
},
sanitizeOps: false,
sanitizeResources: false,
});

Deno.test({
name: "/with-island hydration",
async fn(t) {
Expand Down
Loading