-
Notifications
You must be signed in to change notification settings - Fork 665
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
base: main
Are you sure you want to change the base?
Changes from 14 commits
27536cf
c64c707
32badfe
65b71cc
c537253
0580fb3
4a0f0d0
5359f83
1d879a8
d4cf635
85ec271
9209fb9
be64b68
f8faf02
3d49e4a
590c5e8
224a053
c1985c6
ddf1dc9
f57da17
e241320
ac03cd6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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", | ||
], | ||
}, | ||
], | ||
}; | ||
} | ||
``` |
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"], | ||
}, | ||
{ | ||
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> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -182,6 +182,35 @@ Deno.test({ | |
sanitizeResources: false, | ||
}); | ||
|
||
Deno.test({ | ||
name: "plugin supports multiple islands", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a bit pedantic, but plugins already support multiple islands, because There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.