Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
4c66757
multithreading WIP
vezaynk Apr 27, 2023
a49fdff
More WIP
vezaynk Apr 27, 2023
b956569
more WIP
vezaynk Apr 27, 2023
e1e88ab
apply fix
vezaynk Apr 28, 2023
fce6f6e
Dev and Prod working
vezaynk Apr 28, 2023
a8fb7a8
Fix up tests
vezaynk Apr 28, 2023
aef4297
Remove Worker.js
vezaynk Apr 29, 2023
8892002
Streaming works
vezaynk May 4, 2023
34e6535
Setup worker pool
vezaynk May 5, 2023
d305a74
Setup preloading
vezaynk May 5, 2023
c9b919b
Add transform support
vezaynk May 5, 2023
19002c7
Cleanup 1
vezaynk May 5, 2023
20df079
One more ignore
vezaynk May 5, 2023
9598aa8
bump dev dependencies
vezaynk May 8, 2023
d1f4dd1
Use `satisfies` on postMessage
vezaynk May 8, 2023
108bafc
Building works
vezaynk May 16, 2023
4a6f97b
Forward more types into pass-through
vezaynk May 16, 2023
f9b6aa5
Try-catch load ts-node
vezaynk May 16, 2023
8e8065e
Add comment
vezaynk May 16, 2023
0c95835
Set up transform hook test
vezaynk May 16, 2023
a82434f
Merge branch 'main' of https://github.com/gadget-inc/fastify-renderer
vezaynk Nov 2, 2023
2180e04
Things are cooking, but not working
vezaynk Nov 3, 2023
879f579
It's alive!
vezaynk Nov 3, 2023
a5fab24
lint
vezaynk Nov 3, 2023
94a23bc
Merge branch 'work-on-working'
vezaynk Nov 3, 2023
7d0e154
some cleanup
vezaynk Nov 3, 2023
4f67ad1
Some test cleanup
vezaynk Nov 3, 2023
ff8043e
Improving semantics
vezaynk Nov 3, 2023
0a99ad3
nonce WIP
vezaynk Nov 4, 2023
eee3c2b
Nonces work
vezaynk Nov 4, 2023
4951bba
Trying to fix tests
vezaynk Nov 4, 2023
fd33bae
Disable a bunch of tests
vezaynk Nov 4, 2023
0118f1b
version bumpings
vezaynk Nov 4, 2023
978cf5a
Fixing ts issues
vezaynk Nov 4, 2023
271765d
mo' version bumps, mo' problems
vezaynk Nov 4, 2023
0b26823
Merge branch 'main' of github.com:gadget-inc/fastify-renderer
vezaynk Nov 4, 2023
94bf9dc
More cleanup
vezaynk Nov 4, 2023
535c89b
remove settings
vezaynk Nov 4, 2023
866dd60
partially disable tests
vezaynk Nov 4, 2023
50af9e1
lint
vezaynk Nov 4, 2023
44b191c
pass root tests
vezaynk Nov 18, 2023
7a6a0ba
handle error
vezaynk Nov 18, 2023
7d9bc42
add some error handling
vezaynk Nov 18, 2023
e38fcc7
Upgrade to React 18
vezaynk Nov 18, 2023
e705dc0
fix deprecation warning
vezaynk Nov 18, 2023
aec6e19
add error stream
vezaynk Nov 18, 2023
e57a48e
fix useTransition
vezaynk Nov 18, 2023
858b98f
Demo React 18 Upgrade Errors
vezaynk Nov 19, 2023
3dd81d5
Jest to Vitest migration
vezaynk Nov 19, 2023
8de744d
Merge branch 'upgrade-react'
vezaynk Nov 19, 2023
1eb6484
All tests go
vezaynk Nov 19, 2023
8970f50
unskip test
vezaynk Nov 19, 2023
d61fc28
Add a couple tests
vezaynk Nov 19, 2023
c7a9585
improve consistency between dev and prod
vezaynk Nov 19, 2023
d89ac3b
Add defineRenderHook
vezaynk Nov 19, 2023
a20e882
remove error hook from server
vezaynk Nov 19, 2023
9eca2bc
Write test error hook
vezaynk Nov 19, 2023
6367fc7
fix comment
vezaynk Nov 19, 2023
7b160ee
remove excessive itmeout
vezaynk Nov 19, 2023
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,4 @@
"playwright-chromium": "^1.25.0",
"wds": "^0.12.0"
}
}
}
6 changes: 4 additions & 2 deletions packages/fastify-renderer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,10 @@
"path-to-regexp": "^6.2.1",
"sanitize-filename": "^1.6.3",
"stream-template": "^0.0.10",
"ts-node": "^10.9.1",
"vite": "^2.9.15",
"wouter": "^2.7.5"
"wouter": "^2.7.5",
"resource-pooler": "^0.1.0"
},
"peerDependencies": {
"fastify": "^3.13.0",
Expand Down Expand Up @@ -113,4 +115,4 @@
"README.md",
"LICENSE"
]
}
}
12 changes: 9 additions & 3 deletions packages/fastify-renderer/src/client/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
"compilerOptions": {
"outDir": "../../client",
"module": "esnext",
"types": ["react/experimental", "react-dom/experimental"],
"lib": ["ESNext", "DOM"]
"types": [
"react/experimental",
"react-dom/experimental"
],
"lib": [
"ESNext",
"DOM"
]
}
}
}
7 changes: 6 additions & 1 deletion packages/fastify-renderer/src/node/Plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,12 @@ export class FastifyRendererPlugin {
* Implements the backend integration logic for vite -- pulls out the chain of imported modules from the vite manifest and generates <script/> or <link/> tags to source the assets in the browser.
**/
pushImportTagsFromManifest = (bus: RenderBus, entryName: string, root = true) => {
const manifestEntry = this.clientManifest![entryName]
let manifestEntry = this.clientManifest![entryName]
if (!manifestEntry) {
// TODO: Refactor this away
const closestName = Object.keys(this.clientManifest!).find((k) => entryName.startsWith(k))
if (closestName) manifestEntry = this.clientManifest![closestName]
}
if (!manifestEntry) {
throw new Error(
`Module id ${entryName} was not found in the built assets manifest. Was it included in the build?`
Expand Down
188 changes: 133 additions & 55 deletions packages/fastify-renderer/src/node/renderers/react/ReactRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import reactRefresh from '@vitejs/plugin-react-refresh'
import path from 'path'
import querystring from 'querystring'
import type { ReactElement } from 'react'
import { createPool, ResourcePooler } from 'resource-pooler'
import { PassThrough } from 'stream'
import { URL } from 'url'
import { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
import { normalizePath } from 'vite/dist/node'
import { Worker } from 'worker_threads'
import { FastifyRendererPlugin } from '../../Plugin'
import { RenderBus } from '../../RenderBus'
import { wrap } from '../../tracing'
import { FastifyRendererHook } from '../../types'
import { mapFilepathToEntrypointName, unthunk } from '../../utils'
import { Render, RenderableRegistration, Renderer, scriptTag } from '../Renderer'
import { staticRender, streamingRender } from './ssr'

const CLIENT_ENTRYPOINT_PREFIX = '/@fstr!entrypoint:'
const SERVER_ENTRYPOINT_PREFIX = '/@fstr!server-entrypoint:'
Expand All @@ -20,22 +23,6 @@ export interface ReactRendererOptions {
mode: 'sync' | 'streaming'
}

const staticLocationHook = (path = '/', { record = false } = {}) => {
// eslint-disable-next-line prefer-const
let hook
const navigate = (to, { replace }: { replace?: boolean } = {}) => {
if (record) {
if (replace) {
hook.history.pop()
}
hook.history.push(to)
}
}
hook = () => [path, navigate]
hook.history = [path]
return hook
}

export class ReactRenderer implements Renderer {
static ROUTE_TABLE_ID = '/@fstr!route-table.js'

Expand All @@ -44,6 +31,8 @@ export class ReactRenderer implements Renderer {
renderables!: RenderableRegistration[]
tmpdir!: string
clientModulePath: string
workerPool: ResourcePooler<Worker, Worker> | null = null
transformHooks: string[] = []

constructor(readonly plugin: FastifyRendererPlugin, readonly options: ReactRendererOptions) {
this.clientModulePath = require.resolve('../../../client/react')
Expand All @@ -69,14 +58,111 @@ export class ReactRenderer implements Renderer {
for (const renderable of renderables) {
await this.loadModule(this.entrypointRequirePathForServer(renderable))
}

const mode = this.options.mode
this.transformHooks = this.plugin.hooks
.map(unthunk)
.map((hook) => hook.transform?.absolutePath)
.flatMap((hook) => (hook ? [hook] : []))

const modulePaths = await this.getPreloadPaths()
const paths = [...modulePaths, ...this.transformHooks]

this.workerPool = await createPool({
create() {
const workerData = {
paths,
}

let worker: Worker
switch (mode) {
case 'streaming':
worker = new Worker(require.resolve('./StreamingWorker.import.js'), {
workerData,
})
case 'sync':
worker = new Worker(require.resolve('./StaticWorker.import.js'), {
workerData,
})
}

return worker
},
async dispose(worker) {
await worker.terminate()
},
})
}
}

/** The purpose of adding this function is to allow us to spy on this method, otherwise it isn't available in the class prototype */
async render<Props>(render: Render<Props>): Promise<void> {
return this.wrappedRender(render)
}
private workerStreamRender<Props>(render: Render<Props>): NodeJS.ReadableStream {
const requirePath = this.entrypointRequirePathForServer(render)

const destination = this.stripBasePath(render.request.url, render.base)
if (!this.workerPool) throw new Error('WorkerPool not setup')
const passthrough = new PassThrough()

// Do not `await` or else it will not return
// until the whole stream is completed
void this.workerPool.use(
(worker) =>
new Promise<void>((resolve) => {
worker.postMessage({
modulePath: path.join(this.plugin.serverOutDir, mapFilepathToEntrypointName(requirePath)),
renderBase: render.base,
bootProps: render.props,
destination,
mode: this.options.mode,
hooks: this.transformHooks,
})
worker.on('message', (content) => {
if (content) {
passthrough.write(content)
} else {
passthrough.end()
resolve()
}
})
worker.on('error', (error) => passthrough.destroy(error))
})
)

return passthrough
}
private async getPreloadPaths() {
return this.renderables.map((renderable) =>
path.join(this.plugin.serverOutDir, mapFilepathToEntrypointName(this.entrypointRequirePathForServer(renderable)))
)
}
private async workerRender<Props>(render: Render<Props>) {
const requirePath = this.entrypointRequirePathForServer(render)

const destination = this.stripBasePath(render.request.url, render.base)

if (!this.workerPool) throw new Error('WorkerPool is not setup')

return this.workerPool.use((worker) => {
worker.postMessage({
modulePath: path.join(this.plugin.serverOutDir, mapFilepathToEntrypointName(requirePath)),
renderBase: render.base,
bootProps: render.props,
destination,
mode: this.options.mode,
hooks: this.transformHooks,
})
return new Promise<string>((resolve, reject) => {
worker
.once('message', (content) => {
resolve(content as string)
})
.once('error', (error) => reject(error))
})
})
}
/** Renders a given request and sends the resulting HTML document out with the `reply`. */
private wrappedRender = wrap('fastify-renderer.render', async <Props,>(render: Render<Props>): Promise<void> => {
const bus = this.startRenderBus(render)
Expand All @@ -85,44 +171,39 @@ export class ReactRenderer implements Renderer {
try {
const requirePath = this.entrypointRequirePathForServer(render)

// we load all the context needed for this render from one `loadModule` call, which is really important for keeping the same copy of React around in all of the different bits that touch it.
const { React, ReactDOMServer, Router, RenderBusContext, Layout, Entrypoint } = (
await this.loadModule(requirePath)
).default

const destination = this.stripBasePath(render.request.url, render.base)

let app: ReactElement = React.createElement(
RenderBusContext.Provider,
null,
React.createElement(
Router,
{
base: render.base,
hook: staticLocationHook(destination),
},
React.createElement(
Layout,
{
isNavigating: false,
navigationDestination: destination,
switch (this.options.mode) {
case 'streaming': {
const devRender = async () =>
streamingRender({
module: (await this.devServer!.ssrLoadModule(requirePath)).default,
renderBase: render.base,
bootProps: render.props,
},
React.createElement(Entrypoint, render.props)
)
)
)
destination,
hooks: this.transformHooks,
})

const prodRender = () => this.workerStreamRender(render)
const stream = await (this.plugin.devMode ? devRender() : prodRender())

for (const hook of hooks) {
if (hook.transform) {
app = hook.transform(app)
await render.reply.send(this.renderStreamingTemplate(stream, bus, render, hooks))
break
}
}
case 'sync': {
const devRender = async () =>
staticRender({
module: (await this.devServer!.ssrLoadModule(requirePath)).default,
renderBase: render.base,
bootProps: render.props,
destination,
hooks: this.transformHooks,
})

if (this.options.mode == 'streaming') {
await render.reply.send(this.renderStreamingTemplate(app, bus, ReactDOMServer, render, hooks))
} else {
await render.reply.send(this.renderSynchronousTemplate(app, bus, ReactDOMServer, render, hooks))
const prodRender = () => this.workerRender(render)
const content = await (this.plugin.devMode ? devRender() : prodRender())
await render.reply.send(this.renderSynchronousTemplate(content, bus, render, hooks))
}
}
} catch (error: unknown) {
this.devServer?.ssrFixStacktrace(error as Error)
Expand Down Expand Up @@ -223,17 +304,16 @@ export class ReactRenderer implements Renderer {
}

private renderStreamingTemplate<Props>(
app: JSX.Element,
contentStream: NodeJS.ReadableStream,
bus: RenderBus,
ReactDOMServer: any,
render: Render<Props>,
hooks: FastifyRendererHook[]
) {
this.runHeadHooks(bus, hooks)
// There are not postRenderHead hooks for streaming templates
// so let's end the head stack
bus.push('head', null)
const contentStream = ReactDOMServer.renderToNodeStream(app)

contentStream.on('end', () => {
this.runTailHooks(bus, hooks)
})
Expand All @@ -249,14 +329,12 @@ export class ReactRenderer implements Renderer {
}

private renderSynchronousTemplate<Props>(
app: JSX.Element,
content: string,
bus: RenderBus,
ReactDOMServer: any,
render: Render<Props>,
hooks: FastifyRendererHook[]
) {
this.runHeadHooks(bus, hooks)
const content = ReactDOMServer.renderToString(app)
this.runPostRenderHeadHooks(bus, hooks)
this.runTailHooks(bus, hooks)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Context: https://github.com/TypeStrong/ts-node/issues/676#issuecomment-470898116
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('ts-node').register({
// Disable type-checking
transpileOnly: true,
})
require(require.resolve('./StaticWorker.ts'))
12 changes: 12 additions & 0 deletions packages/fastify-renderer/src/node/renderers/react/StaticWorker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { parentPort } from 'worker_threads'
import { WorkerRenderInput } from '../../types'
import { staticRender } from './ssr'

if (!parentPort) throw new Error('Missing parentPort')
const port = parentPort

port.on('message', (args: WorkerRenderInput) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const content = staticRender({ ...args, module: require(args.modulePath).default })
port.postMessage(content)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Context: https://github.com/TypeStrong/ts-node/issues/676#issuecomment-470898116
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('ts-node').register({
// Disable type-checking
transpileOnly: true,
})
require(require.resolve('./StreamingWorker.ts'))
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { parentPort } from 'worker_threads'
import { WorkerRenderInput } from '../../types'
import { streamingRender } from './ssr'

if (!parentPort) throw new Error('Missing parentPort')
const port = parentPort

port.on('message', (args: WorkerRenderInput) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const stream = streamingRender({ ...args, module: require(args.modulePath).default })

stream.on('data', (content) => {
port.postMessage(content)
})

stream.on('end', () => port.postMessage(null))
})
Loading