Skip to content

Commit

Permalink
feat(rsc-auth): Implement serverStore to hold and pass req info to RSC (
Browse files Browse the repository at this point in the history
redwoodjs#10585)

First pass at implementing a per-request store that allows:

- access to headers and cookies from requests in server components
- access to serverAuthState from server components
- maps serverAuthState updated from middleware to the the per request
store

This PR also implements execution of middleware in the RSC handler. Note
that this is done in a "good enough" way currently, because the RSC
handler doesn't use Fetch requests (but everything else does)
 
Important things to note:
- the store is initialised _again_ in the RSC worker, with the same
values on each invocation of renderRsc
- we have _not_ tested or tried in Dev because `rw dev` does not work in
RSC yet
- we have _not_ tested behaviour on initial SSR - because this is not
implemented yet in RSC
  • Loading branch information
dac09 authored May 17, 2024
1 parent 697bc8e commit 288bcab
Show file tree
Hide file tree
Showing 13 changed files with 325 additions and 62 deletions.
15 changes: 15 additions & 0 deletions .changesets/10585.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
- feat(rsc-auth): Implement serverStore to hold and pass req info to RSC (#10585) by @dac09

First pass at implementing a per-request store that allows:

- access to headers and cookies from requests in server components
- access to serverAuthState from server components
- maps serverAuthState updated from middleware to the the per request store

This PR also implements execution of middleware in the RSC handler. Note that this is done in a "good enough" way currently, because the RSC handler doesn't use Fetch requests (but everything else does)

Important things to note:
- the store is initialised _again_ in the RSC worker, with the same values on each invocation of renderRsc
- we have _not_ tested or tried in Dev because `rw dev` does not work in RSC yet
- we have _not_ tested behaviour on initial SSR - because this is not implemented yet in RSC

4 changes: 4 additions & 0 deletions packages/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
"./middleware": {
"types": "./dist/middleware/index.d.ts",
"default": "./dist/middleware/index.js"
},
"./serverStore": {
"types": "./dist/serverStore.d.ts",
"default": "./dist/serverStore.js"
}
},
"bin": {
Expand Down
24 changes: 22 additions & 2 deletions packages/vite/src/devFeServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ import { createMiddlewareRouter } from './middleware/register.js'
import type { Middleware } from './middleware/types.js'
import { rscRoutesAutoLoader } from './plugins/vite-plugin-rsc-routes-auto-loader.js'
import { createRscRequestHandler } from './rsc/rscRequestHandler.js'
import { createPerRequestMap, createServerStorage } from './serverStore.js'
import { collectCssPaths, componentsModules } from './streaming/collectCss.js'
import { createReactStreamingHandler } from './streaming/createReactStreamingHandler.js'
import { ensureProcessDirWeb } from './utils.js'
import { convertExpressHeaders, ensureProcessDirWeb } from './utils.js'

// TODO (STREAMING) Just so it doesn't error out. Not sure how to handle this.
globalThis.__REDWOOD__PRERENDER_PAGES = {}
Expand Down Expand Up @@ -69,6 +70,8 @@ async function createServer() {
appType: 'custom',
})

const serverStorage = createServerStorage()

// create a handler that will invoke middleware with or without a route
// The DEV one will create a new middleware router on each request
const handleWithMiddleware = (route?: RouteSpec) => {
Expand Down Expand Up @@ -96,8 +99,25 @@ async function createServer() {
// use vite's connect instance as middleware
app.use(vite.middlewares)

app.use('*', (req, _res, next) => {
// Convert express headers to fetch headers
const perReqStore = createPerRequestMap({
headers: convertExpressHeaders(req.headersDistinct),
})

// By wrapping next, we ensure that all of the other handlers will use this same perReqStore
// But note that the serverStorage is RE-initialised for the RSC worker
serverStorage.run(perReqStore, next)
})

// Mounting middleware at /rw-rsc will strip /rw-rsc from req.url
app.use('/rw-rsc', createRscRequestHandler())
app.use(
'/rw-rsc',
createRscRequestHandler({
getMiddlewareRouter: async () => createMiddlewareRouter(vite),
viteDevServer: vite,
}),
)

const routes = getProjectRoutes()

Expand Down
6 changes: 6 additions & 0 deletions packages/vite/src/middleware/invokeMiddleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,17 @@ import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'

import { middlewareDefaultAuthProviderState } from '@redwoodjs/auth'

import { createServerStorage } from '../serverStore'

import { invoke } from './invokeMiddleware'
import type { MiddlewareRequest } from './MiddlewareRequest'
import { MiddlewareResponse } from './MiddlewareResponse'

describe('Invoke middleware', () => {
beforeAll(() => {
createServerStorage()
})

test('returns a MiddlewareResponse, even if no middleware defined', async () => {
const [mwRes, authState] = await invoke(new Request('https://example.com'))
expect(mwRes).toBeInstanceOf(MiddlewareResponse)
Expand Down
13 changes: 13 additions & 0 deletions packages/vite/src/middleware/invokeMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
type ServerAuthState,
} from '@redwoodjs/auth'

import { setServerAuthState } from '../serverStore.js'

import { MiddlewareRequest } from './MiddlewareRequest.js'
import { MiddlewareResponse } from './MiddlewareResponse.js'
import type { Middleware, MiddlewareInvokeOptions } from './types.js'
Expand All @@ -21,6 +23,8 @@ export const invoke = async (
options?: MiddlewareInvokeOptions,
): Promise<[MiddlewareResponse, ServerAuthState]> => {
if (typeof middleware !== 'function') {
setupServerStore(req, middlewareDefaultAuthProviderState)

return [MiddlewareResponse.next(), middlewareDefaultAuthProviderState]
}

Expand Down Expand Up @@ -49,7 +53,16 @@ export const invoke = async (
console.error('~'.repeat(80))
console.error(e)
console.error('~'.repeat(80))
} finally {
// This one is for the server. The worker serverStore is initialized in the worker itself!
setupServerStore(req, mwReq.serverAuthContext.get())
}

return [mwRes, mwReq.serverAuthContext.get()]
}

const setupServerStore = (_req: Request, serverAuthState: ServerAuthState) => {
// Init happens in app.use('*')

setServerAuthState(serverAuthState)
}
3 changes: 2 additions & 1 deletion packages/vite/src/rsc/rscBuildForServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ export async function rscBuildForServer(
noExternal: true,
// Can't inline prisma client (db calls fail at runtime) or react-dom
// (css pre-init failure)
external: ['@prisma/client', 'react-dom'],
// Server store has to be externalized, because it's a singleton (shared between FW and App)
external: ['@prisma/client', 'react-dom', '@redwoodjs/vite/serverStore'],
resolve: {
// These conditions are used in the plugin pipeline, and only affect non-externalized
// dependencies during the SSR build. Which because of `noExternal: true` means all
Expand Down
80 changes: 76 additions & 4 deletions packages/vite/src/rsc/rscRequestHandler.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,85 @@
import * as DefaultFetchAPI from '@whatwg-node/fetch'
import { normalizeNodeRequest } from '@whatwg-node/server'
import busboy from 'busboy'
import type { Request, Response } from 'express'
import type {
Request as ExpressRequest,
Response as ExpressResponse,
} from 'express'
import type Router from 'find-my-way'
import type { HTTPMethod } from 'find-my-way'
import type { ViteDevServer } from 'vite'

import {
decodeReply,
decodeReplyFromBusboy,
} from '../bundled/react-server-dom-webpack.server'
import { hasStatusCode } from '../lib/StatusError.js'
import type { Middleware } from '../middleware'
import { invoke } from '../middleware/invokeMiddleware'
import { getAuthState, getRequestHeaders } from '../serverStore'

import { sendRscFlightToStudio } from './rscStudioHandlers.js'
import { renderRsc } from './rscWorkerCommunication.js'

export function createRscRequestHandler() {
interface CreateRscRequestHandlerOptions {
getMiddlewareRouter: () => Promise<Router.Instance<any>>
viteDevServer?: ViteDevServer
}

export function createRscRequestHandler(
options: CreateRscRequestHandlerOptions,
) {
// This is mounted at /rw-rsc, so will have /rw-rsc stripped from req.url
return async (req: Request, res: Response, next: () => void) => {

// above this line is for ALL users ☝️, not a per request basis
// -------------
return async (
req: ExpressRequest,
res: ExpressResponse,
next: () => void,
) => {
const basePath = '/rw-rsc/'

console.log('basePath', basePath)
console.log('req.originalUrl', req.originalUrl, 'req.url', req.url)
console.log('req.headers.host', req.headers.host)
console.log("req.headers['rw-rsc']", req.headers['rw-rsc'])

const mwRouter = await options.getMiddlewareRouter()

if (mwRouter) {
// @MARK: Temporarily create Fetch Request here.
// Ideally we'll have converted this whole handler to be Fetch Req and Response
const webReq = normalizeNodeRequest(req, DefaultFetchAPI.Request)
const matchedMw = mwRouter.find(webReq.method as HTTPMethod, webReq.url)

const [mwResponse] = await invoke(
webReq,
matchedMw?.handler as Middleware | undefined,
{
params: matchedMw?.params,
viteDevServer: options.viteDevServer,
},
)

const webRes = mwResponse.toResponse()

// @MARK: Grab the headers from MWResponse and set them on the Express Response
// @TODO This is a temporary solution until we can convert this entire handler to use Fetch API
// This WILL not handle multiple Set-Cookie headers correctly. Proper Fetch-Response support will resolve this.
webRes.headers.forEach((value, key) => {
res.setHeader(key, value)
})

if (mwResponse.isRedirect() || mwResponse.body) {
// We also don't know what the Router will do if this RSC handler fails at any point
// Whatever that behavior is, this should match.
throw new Error(
'Not Implemented: What should happen if this RSC handler fails? And which part - Client side router?',
)
}
}

// https://www.rfc-editor.org/rfc/rfc6648
// "SHOULD NOT prefix their parameter names with "X-" or similar constructs."
if (req.headers['rw-rsc'] !== '1') {
Expand Down Expand Up @@ -124,7 +185,18 @@ export function createRscRequestHandler() {
}

try {
const pipeable = await renderRsc({ rscId, props, rsfId, args })
const pipeable = await renderRsc({
rscId,
props,
rsfId,
args,
// Pass the serverState from server to the worker
// Inside the worker, we'll use this to re-initalize the server state (because workers are stateless)
serverState: {
headersInit: Object.fromEntries(getRequestHeaders().entries()),
serverAuthState: getAuthState(),
},
})

await sendRscFlightToStudio({
rscId,
Expand Down
16 changes: 14 additions & 2 deletions packages/vite/src/rsc/rscStudioHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { Request } from 'express'

import { getRawConfig, getConfig } from '@redwoodjs/project-config'

import { getAuthState, getRequestHeaders } from '../serverStore.js'

import { renderRsc } from './rscWorkerCommunication.js'
import type { RenderInput } from './rscWorkerCommunication.js'

Expand Down Expand Up @@ -115,7 +117,7 @@ const createStudioFlightHandler = (
}
}

interface StudioRenderInput extends RenderInput {
interface StudioRenderInput extends Omit<RenderInput, 'serverState'> {
basePath: string
req: Request
handleError: (e: Error) => void
Expand All @@ -132,7 +134,17 @@ export const sendRscFlightToStudio = async (input: StudioRenderInput) => {
// surround renderRsc with performance metrics
const startedAt = Date.now()
const start = performance.now()
const pipeable = await renderRsc({ rscId, props, rsfId, args })

const pipeable = await renderRsc({
rscId,
props,
rsfId,
args,
serverState: {
headersInit: Object.fromEntries(getRequestHeaders().entries()),
serverAuthState: getAuthState(),
},
})
const endedAt = Date.now()
const end = performance.now()
const duration = end - start
Expand Down
Loading

0 comments on commit 288bcab

Please sign in to comment.