Skip to content

Commit d25eeee

Browse files
authored
Merge pull request #331 from gadget-inc/feature/imperative-rendering
Add support for imperative rendering
2 parents fbbde20 + 2ef1446 commit d25eeee

20 files changed

+640
-133
lines changed

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,45 @@ export const DefaultDocumentTemplate: Template = (data: TemplateData<any>) => te
133133
`
134134
```
135135

136+
## Imperatively rendering a component
137+
138+
Imperative rendering allows routes to dynamically render a component based on specific conditions, instead of always rendering the same component. To do so, we still require that the component is registered to allow Vite to bundle it.
139+
140+
Note that the route which renders the component is a normal route that doesn't need any special route options configuration.
141+
142+
To register a component, you can do the following:
143+
144+
```js
145+
// The return value needs to be passed down to the reply.render() function
146+
const Renderable = server.registerRenderable(require.resolve('./ImperativelyRenderablePage'))
147+
```
148+
149+
And then you can render it imperatively in your routes:
150+
151+
```js
152+
server.get('/imperative', async (request, reply) => {
153+
return reply.render(Renderable, {
154+
hostname: os.hostname(),
155+
requestIP: request.ip,
156+
})
157+
})
158+
```
159+
160+
A big reason why you might want to imperatively render routes is for conditional rendering, where you only want to render if the user has permission or if some header is correctly passed. Imperative rendering works fine for routes that only sometimes use `reply.render`, and otherwise do normal `reply.send`s:
161+
162+
```js
163+
server.get('/imperative/:bool', async (request: FastifyRequest<{ Params: { bool: string } }>, reply) => {
164+
if (request.params.bool == 'true') {
165+
return reply.render(Renderable, {
166+
hostname: os.hostname(),
167+
requestIP: request.ip,
168+
})
169+
} else {
170+
return reply.redirect('/not-found')
171+
}
172+
})
173+
```
174+
136175
## How it works
137176

138177
- mounts a `vite` server as a fastify plugin that knows how to transform code to be run server side.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"scripts": {
1010
"watch": "yarn workspace fastify-renderer watch",
1111
"build": "yarn workspaces run build",
12+
"typecheck": "yarn workspaces run typecheck",
1213
"lint": "eslint \"packages/**/*.{js,ts,tsx}\"",
1314
"lint:fix": "prettier --loglevel warn --write \"packages/**/*.{ts,tsx}\" && eslint \"packages/**/*.{ts,tsx}\" --quiet --fix",
1415
"release": "yarn workspace fastify-renderer publish",
@@ -47,6 +48,7 @@
4748
"devDependencies": {
4849
"cross-env": "^7.0.3",
4950
"fs-extra": "^10.1.0",
50-
"playwright-chromium": "^1.21.0"
51+
"playwright-chromium": "^1.21.0",
52+
"wds": "^0.12.0"
5153
}
5254
}

packages/fastify-renderer/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
"main": "node/index.js",
1616
"types": "node/index.d.ts",
1717
"scripts": {
18+
"typecheck": "run-p typecheck:*",
19+
"typecheck:client": "tsc --noEmit --incremental --project src/client",
20+
"typecheck:node": "tsc --noEmit --incremental --project src/node",
1821
"watch": "run-p watch:*",
1922
"watch:client-es": "tsc --watch --incremental --project src/client",
2023
"watch:node-cjs": "tsc --watch --incremental --project src/node",
@@ -99,7 +102,6 @@
99102
"react-dom": "0.0.0-experimental-4ead6b530",
100103
"rimraf": "^3.0.2",
101104
"ts-jest": "^27.1.4",
102-
"ts-node": "^9.1.1",
103105
"typescript": "^4.6.3"
104106
},
105107
"files": [

packages/fastify-renderer/src/node/Plugin.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { InlineConfig } from 'vite'
66
import { Template } from './DocumentTemplate'
77
import { RenderBus } from './RenderBus'
88
import { ReactRenderer, ReactRendererOptions } from './renderers/react/ReactRenderer'
9-
import { RenderableRoute, Renderer } from './renderers/Renderer'
9+
import { RenderableRegistration, Renderer } from './renderers/Renderer'
1010
import './types' // necessary to make sure that the fastify types are augmented
1111
import { FastifyRendererHook, ServerEntrypointManifest, ViteClientManifest } from './types'
1212

@@ -22,6 +22,8 @@ export interface FastifyRendererOptions {
2222
hooks?: (FastifyRendererHook | (() => FastifyRendererHook))[]
2323
}
2424

25+
export type ImperativeRenderable = symbol
26+
2527
export class FastifyRendererPlugin {
2628
renderer: Renderer
2729
devMode: boolean
@@ -33,7 +35,8 @@ export class FastifyRendererPlugin {
3335
hooks: (FastifyRendererHook | (() => FastifyRendererHook))[]
3436
clientManifest?: ViteClientManifest
3537
serverEntrypointManifest?: ServerEntrypointManifest
36-
routes: RenderableRoute[] = []
38+
renderables: RenderableRegistration[] = []
39+
registeredComponents: Record<ImperativeRenderable, RenderableRegistration> = {}
3740

3841
constructor(incomingOptions: FastifyRendererOptions) {
3942
this.devMode = incomingOptions.devMode ?? process.env.NODE_ENV != 'production'
@@ -105,7 +108,13 @@ export class FastifyRendererPlugin {
105108
}
106109
}
107110

108-
registerRoute(options: RenderableRoute) {
109-
this.routes.push(options)
111+
register(options: RenderableRegistration): ImperativeRenderable {
112+
// If the component is not already registered, we register it and return a unique symbol for it
113+
const symbol = Symbol(options.renderable)
114+
this.registeredComponents[symbol] = options
115+
116+
this.renderables.push(options)
117+
118+
return symbol
110119
}
111120
}

packages/fastify-renderer/src/node/index.ts

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable @typescript-eslint/require-await */
2-
import { FastifyInstance } from 'fastify'
2+
import { FastifyInstance, FastifyReply } from 'fastify'
33
import 'fastify-accepts'
44
import fp from 'fastify-plugin'
55
import fastifyStatic from 'fastify-static'
@@ -16,8 +16,8 @@ import {
1616
ViteDevServer,
1717
} from 'vite'
1818
import { DefaultDocumentTemplate } from './DocumentTemplate'
19-
import { FastifyRendererOptions, FastifyRendererPlugin } from './Plugin'
20-
import { PartialRenderOptions, Render, RenderableRoute, RenderOptions } from './renderers/Renderer'
19+
import { FastifyRendererOptions, FastifyRendererPlugin, ImperativeRenderable } from './Plugin'
20+
import { PartialRenderOptions, Render, RenderableRegistration, RenderOptions } from './renderers/Renderer'
2121
import { kRendererPlugin, kRendererViteOptions, kRenderOptions } from './symbols'
2222
import { wrap } from './tracing'
2323
import './types' // necessary to make sure that the fastify types are augmented
@@ -75,35 +75,63 @@ const FastifyRenderer = fp<FastifyRendererOptions>(
7575
document: incomingOptions.document || DefaultDocumentTemplate,
7676
})
7777

78+
fastify.decorate('registerRenderable', function (this: FastifyInstance, renderable: string) {
79+
const renderableRoute: RenderableRegistration = { ...this[kRenderOptions], renderable }
80+
return plugin.register(renderableRoute)
81+
})
82+
83+
const render = async (reply: FastifyReply, renderableRoute: RenderableRegistration, props: any) => {
84+
if (reply.sent) return
85+
86+
void reply.header('Vary', 'Accept')
87+
switch (reply.request.accepts().type(['html', 'json'])) {
88+
case 'json':
89+
await reply.type('application/json').send({ props })
90+
break
91+
case 'html':
92+
void reply.type('text/html')
93+
const render: Render<typeof props> = { ...renderableRoute, request: reply.request, reply, props }
94+
await plugin.renderer.render(render)
95+
break
96+
default:
97+
await reply.type('text/plain').send('Content type not supported')
98+
break
99+
}
100+
}
101+
102+
fastify.decorateReply('render', async function (this: FastifyReply, token: ImperativeRenderable, props: any) {
103+
if (!plugin.registeredComponents[token]) {
104+
throw new Error(`No registered renderable was found for the provided token= ${token.toString()}`)
105+
}
106+
const request = this.request
107+
const renderableRoute: RenderableRegistration = {
108+
...this.server[kRenderOptions],
109+
pathPattern: request.url,
110+
renderable: plugin.registeredComponents[token].renderable,
111+
isImperative: true,
112+
}
113+
114+
await render(this, renderableRoute, props)
115+
})
116+
78117
// Wrap routes that have the `render` option set to invoke the rendererer with the result of the route handler as the props
79118
fastify.addHook('onRoute', function (this: FastifyInstance, routeOptions) {
80119
if (routeOptions.render) {
81120
const oldHandler = wrap('fastify-renderer.getProps', routeOptions.handler as ServerRenderer<any>)
82121
const renderable = routeOptions.render
83122
const plugin = this[kRendererPlugin]
84-
const renderableRoute: RenderableRoute = { ...this[kRenderOptions], url: routeOptions.url, renderable }
123+
const renderableRoute: RenderableRegistration = {
124+
...this[kRenderOptions],
125+
pathPattern: routeOptions.url,
126+
renderable,
127+
}
85128

86-
plugin.registerRoute(renderableRoute)
129+
plugin.register(renderableRoute)
87130

88131
routeOptions.handler = wrap('fastify-renderer.handler', async function (this: FastifyInstance, request, reply) {
89132
const props = await oldHandler.call(this, request, reply)
90133

91-
if (!reply.sent) {
92-
void reply.header('Vary', 'Accept')
93-
switch (request.accepts().type(['html', 'json'])) {
94-
case 'json':
95-
await reply.type('application/json').send({ props })
96-
break
97-
case 'html':
98-
void reply.type('text/html')
99-
const render: Render<typeof props> = { ...renderableRoute, request, reply, props, renderable }
100-
await plugin.renderer.render(render)
101-
break
102-
default:
103-
await reply.type('text/plain').send('Content type not supported')
104-
break
105-
}
106-
}
134+
await render(reply, renderableRoute, props)
107135
})
108136
}
109137
})
@@ -162,7 +190,7 @@ const FastifyRenderer = fp<FastifyRendererOptions>(
162190
config = await resolveConfig(fastify[kRendererViteOptions], 'serve')
163191
}
164192

165-
await plugin.renderer.prepare(plugin.routes, config, devServer)
193+
await plugin.renderer.prepare(plugin.renderables, config, devServer)
166194
})
167195

168196
fastify.addHook('onClose', async () => {
@@ -178,7 +206,6 @@ const FastifyRenderer = fp<FastifyRendererOptions>(
178206

179207
module.exports = exports = FastifyRenderer
180208
export default FastifyRenderer
181-
182209
export const build = async (fastify: FastifyInstance) => {
183210
const plugin = fastify[kRendererPlugin]
184211
if (!plugin) {
@@ -189,7 +216,7 @@ export const build = async (fastify: FastifyInstance) => {
189216

190217
const clientEntrypoints: Record<string, string> = {}
191218
const serverEntrypoints: Record<string, string> = {}
192-
for (const renderableRoute of plugin.routes) {
219+
for (const renderableRoute of plugin.renderables) {
193220
const entrypointName = mapFilepathToEntrypointName(renderableRoute.renderable, renderableRoute.base)
194221
clientEntrypoints[entrypointName] = plugin.renderer.buildVirtualClientEntrypointModuleID(renderableRoute)
195222
serverEntrypoints[entrypointName] = plugin.renderer.buildVirtualServerEntrypointModuleID(renderableRoute)
@@ -239,3 +266,5 @@ export const build = async (fastify: FastifyInstance) => {
239266
JSON.stringify(virtualModulesToRenderedEntrypoints, null, 2)
240267
)
241268
}
269+
270+
Object.assign(FastifyRenderer, { build })

packages/fastify-renderer/src/node/renderers/Renderer.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,28 @@ export type PartialRenderOptions =
1616
| { base: string }
1717
| { document: Template }
1818

19-
/** One renderable route */
20-
export interface RenderableRoute extends RenderOptions {
21-
url: string
19+
/** One renderable component registered in the system */
20+
export interface RenderableRegistration extends RenderOptions {
21+
/** The URL path pattern this renderable will be mounted at */
22+
pathPattern?: string
23+
/** The path on disk to the renderable component for SSR */
2224
renderable: string
25+
/** If this renderable was registered for imperative rendering */
26+
isImperative?: true
2327
}
2428

2529
/** A unit of renderable work */
26-
export interface Render<Props = any> extends RenderableRoute {
30+
export interface Render<Props = any> extends RenderableRegistration {
2731
request: FastifyRequest
2832
reply: FastifyReply
2933
props: Props
3034
}
3135

3236
/** An object that knows how to render */
3337
export interface Renderer {
34-
prepare(routes: RenderableRoute[], viteOptions: ResolvedConfig, devServer?: ViteDevServer): Promise<void>
38+
prepare(renderable: RenderableRegistration[], viteOptions: ResolvedConfig, devServer?: ViteDevServer): Promise<void>
3539
render<Props>(render: Render<Props>): Promise<void>
36-
buildVirtualClientEntrypointModuleID(route: RenderableRoute): string
37-
buildVirtualServerEntrypointModuleID(route: RenderableRoute): string
40+
buildVirtualClientEntrypointModuleID(renderable: RenderableRegistration): string
41+
buildVirtualServerEntrypointModuleID(renderable: RenderableRegistration): string
3842
vitePlugins(): Plugin[]
3943
}

0 commit comments

Comments
 (0)