Skip to content

Commit

Permalink
RSC cache control (#11126)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tobbe authored Jul 31, 2024
1 parent 7e6c7bc commit c59c8e3
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 16 deletions.
2 changes: 1 addition & 1 deletion packages/router/src/rsc/ClientRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const LocationAwareRouter = ({
)
}

// @TODO(RSC): Our types dont fully handle async components
// TODO (RSC): Our types dont fully handle async components
return rscFetch('__rwjs__Routes', {
location: { pathname, search },
}) as unknown as ReactNode
Expand Down
120 changes: 120 additions & 0 deletions packages/router/src/rsc/RscCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* This cache is used for RSC fetches, so that we don't re-fetch the same
* component (i.e. page) multiple times and get stuck in a loop.
*
* `key`: A stringified location-like object.
* `value`: A Promise that resolves to a React element.
*/
export class RscCache {
private cache = new Map<string, Thenable<React.ReactElement>>()
private socket: WebSocket
private sendRetries = 0
private isEnabled = true

constructor() {
this.socket = new WebSocket('ws://localhost:18998')

// Event listener for WebSocket connection open
this.socket.addEventListener('open', () => {
console.log('Connected to WebSocket server.')
})

// Event listener for incoming messages
this.socket.addEventListener('message', (event) => {
console.log('Incomming message', event)
if (event.data.startsWith('{')) {
const data = JSON.parse(event.data)

console.log('Incomming message id', data.id)
console.log('Incomming message key', data.key)

if (data.id === 'rsc-cache-delete') {
if (!this.cache.has(data.key)) {
console.error('')
console.error(
'RscCache::message::rsc-cache-delete key not found in cache',
)
console.error('')
}
this.cache.delete(data.key)

this.sendToWebSocket('update', {
fullCache: Object.fromEntries(
Array.from(this.cache.entries()).map(
([location, elementThenable]) => [
location,
// @ts-expect-error hack to get the value of a Thenable
elementThenable.value,
],
),
),
})
} else if (data.id === 'rsc-cache-clear') {
this.cache.clear()
this.sendToWebSocket('update', { fullCache: {} })
} else if (data.id === 'rsc-cache-enable') {
console.log('RscCache::message::rsc-cache-enable')
this.isEnabled = true
} else if (data.id === 'rsc-cache-disable') {
console.log('RscCache::message::rsc-cache-disable')
this.isEnabled = false
}
}
})
}

get(key: string): Thenable<React.ReactElement> | undefined {
const value = this.cache.get(key)
console.log('RscCache.get', key, value)
return value
}

set(key: string, value: Thenable<React.ReactElement>) {
console.log('RscCache.set', key, value)

if (!this.isEnabled) {
// Always clear the cache if the cache is disabled
this.cache.clear()
}

this.cache.set(key, value)

// There's no point in sending a Promise over the WebSocket, so we wait for
// it to resolve before sending the value.
value.then((resolvedValue) => {
console.log('RscCache.set key:', key)
console.log('RscCache.set resolved value:', resolvedValue)
this.sendToWebSocket('set', {
updatedKey: key,
fullCache: Object.fromEntries(
Array.from(this.cache.entries()).map(
// @ts-expect-error hack to get the value of a Thenable
([location, elementThenable]) => [location, elementThenable.value],
),
),
})
})
}

private sendToWebSocket(action: string, payload: Record<string, any>) {
console.log('RscCache::sendToWebSocket action', action, 'payload', payload)

if (this.socket.readyState === WebSocket?.OPEN) {
this.sendRetries = 0
this.socket.send(JSON.stringify({ id: 'rsc-cache-' + action, payload }))
} else if (
this.socket.readyState === WebSocket?.CONNECTING &&
this.sendRetries < 10
) {
const backoff = 300 + this.sendRetries * 100
setTimeout(() => {
this.sendRetries++
this.sendToWebSocket(action, payload)
}, backoff)
} else if (this.sendRetries >= 10) {
console.error('Exhausted retries to send message to WebSocket server.')
} else {
console.error('WebSocket connection is closed.')
}
}
}
5 changes: 4 additions & 1 deletion packages/router/src/rsc/rscFetchForClientRouter.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { Options } from 'react-server-dom-webpack/client'
import { createFromFetch, encodeReply } from 'react-server-dom-webpack/client'

import { RscCache } from './RscCache.js'

const BASE_PATH = '/rw-rsc/'

const rscCache = new Map<string, Thenable<React.ReactElement>>()
const rscCache = new RscCache()

export interface RscFetchProps extends Record<string, unknown> {
location: {
Expand Down Expand Up @@ -67,6 +69,7 @@ export function rscFetch(rscId: string, props: RscFetchProps) {
response,
options,
)

rscCache.set(serializedProps, componentPromise)

return componentPromise
Expand Down
2 changes: 2 additions & 0 deletions packages/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
"vite": "5.3.5",
"vite-plugin-cjs-interop": "2.1.1",
"vite-plugin-node-polyfills": "0.22.0",
"ws": "8.18.0",
"yargs-parser": "21.1.1"
},
"devDependencies": {
Expand All @@ -103,6 +104,7 @@
"@types/express": "4",
"@types/fs-extra": "11.0.4",
"@types/react": "^18.2.55",
"@types/ws": "^8",
"@types/yargs-parser": "21.0.3",
"concurrently": "8.2.2",
"glob": "11.0.0",
Expand Down
29 changes: 29 additions & 0 deletions packages/vite/src/runFeServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import express from 'express'
import type { HTTPMethod } from 'find-my-way'
import { createProxyMiddleware } from 'http-proxy-middleware'
import type { Manifest as ViteBuildManifest } from 'vite'
import WebSocket, { WebSocketServer } from 'ws'

import { getConfig, getPaths } from '@redwoodjs/project-config'
import { getRscStylesheetLinkGenerator } from '@redwoodjs/router/rscCss'
Expand Down Expand Up @@ -62,6 +63,8 @@ export async function runFeServer() {
if (rscEnabled) {
const { setClientEntries } = await import('./rsc/rscWorkerCommunication.js')

createWebSocketServer()

try {
// This will fail if we're not running in RSC mode (i.e. for Streaming SSR)
await setClientEntries()
Expand Down Expand Up @@ -206,4 +209,30 @@ export async function runFeServer() {
}
}

function createWebSocketServer() {
const wsServer = new WebSocketServer({ port: 18998 })

wsServer.on('connection', (ws) => {
console.log('A new client connected.')

// Event listener for incoming messages. The `data` is a Buffer
ws.on('message', (data) => {
const message = data.toString()
console.log('Received message:', message)

// Broadcast the message to all connected clients
wsServer.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message)
}
})
})

// Event listener for client disconnection
ws.on('close', () => {
console.log('A client disconnected.')
})
})
}

runFeServer()
30 changes: 16 additions & 14 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8692,6 +8692,7 @@ __metadata:
"@types/express": "npm:4"
"@types/fs-extra": "npm:11.0.4"
"@types/react": "npm:^18.2.55"
"@types/ws": "npm:^8"
"@types/yargs-parser": "npm:21.0.3"
"@vitejs/plugin-react": "npm:4.3.1"
"@whatwg-node/fetch": "npm:0.9.19"
Expand Down Expand Up @@ -8722,6 +8723,7 @@ __metadata:
vite-plugin-cjs-interop: "npm:2.1.1"
vite-plugin-node-polyfills: "npm:0.22.0"
vitest: "npm:2.0.4"
ws: "npm:8.18.0"
yargs-parser: "npm:21.1.1"
bin:
rw-dev-fe: ./dist/devFeServer.js
Expand Down Expand Up @@ -11260,12 +11262,12 @@ __metadata:
languageName: node
linkType: hard

"@types/ws@npm:^8.0.0, @types/ws@npm:^8.5.10":
version: 8.5.10
resolution: "@types/ws@npm:8.5.10"
"@types/ws@npm:^8, @types/ws@npm:^8.0.0, @types/ws@npm:^8.5.10":
version: 8.5.11
resolution: "@types/ws@npm:8.5.11"
dependencies:
"@types/node": "npm:*"
checksum: 10c0/e9af279b984c4a04ab53295a40aa95c3e9685f04888df5c6920860d1dd073fcc57c7bd33578a04b285b2c655a0b52258d34bee0a20569dca8defb8393e1e5d29
checksum: 10c0/50bd2e1a12659fa024a97d7e8c267fbf2a2c2251f1edf2057aa7dfc99682f5c025a188df9e27414675c78d3b189346a3567e1e4c218ad79a9d2b0f1f2b860c3a
languageName: node
linkType: hard

Expand Down Expand Up @@ -30046,16 +30048,7 @@ __metadata:
languageName: node
linkType: hard

"ws@npm:^6.1.0":
version: 6.2.3
resolution: "ws@npm:6.2.3"
dependencies:
async-limiter: "npm:~1.0.0"
checksum: 10c0/56a35b9799993cea7ce2260197e7879f21bbbb194a967f31acbbda6f7f46ecda4365951966fb062044c95197e19fb2f053be6f65c172435455186835f494de41
languageName: node
linkType: hard

"ws@npm:^8.11.0, ws@npm:^8.12.0, ws@npm:^8.14.2, ws@npm:^8.18.0, ws@npm:^8.2.3, ws@npm:^8.4.2":
"ws@npm:8.18.0, ws@npm:^8.11.0, ws@npm:^8.12.0, ws@npm:^8.14.2, ws@npm:^8.18.0, ws@npm:^8.2.3, ws@npm:^8.4.2":
version: 8.18.0
resolution: "ws@npm:8.18.0"
peerDependencies:
Expand All @@ -30070,6 +30063,15 @@ __metadata:
languageName: node
linkType: hard

"ws@npm:^6.1.0":
version: 6.2.3
resolution: "ws@npm:6.2.3"
dependencies:
async-limiter: "npm:~1.0.0"
checksum: 10c0/56a35b9799993cea7ce2260197e7879f21bbbb194a967f31acbbda6f7f46ecda4365951966fb062044c95197e19fb2f053be6f65c172435455186835f494de41
languageName: node
linkType: hard

"xdg-basedir@npm:^3.0.0":
version: 3.0.0
resolution: "xdg-basedir@npm:3.0.0"
Expand Down

0 comments on commit c59c8e3

Please sign in to comment.