Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 16 additions & 2 deletions packages/fastify-renderer/src/client/react/matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,34 @@ import { MatcherFn } from 'wouter'

type ParamData = Record<string, string | string[]>

const normalizePattern = (pattern: string): string => {
return (
pattern
// path-to-regexp <8 legacy wildcard params (e.g. "/:splat*")
// path-to-regexp 8 wildcard params (e.g. "/*splat")
.replace(/:([A-Za-z0-9_]+)\*/g, '*$1')
// find-my-way wildcard routes use unnamed "*" so give them a stable name
.replace(/(^|\/)\*(?=\/|$)/g, '$1*splat')
)
}

/*
* This function creates a matcher function for a given path pattern.
*
* @param {string} path — a path like "/:foo/:bar"
* @return {MatchFunction<ParamData>} — a function that matches paths and extracts params
*/
const createMatcher = (path: string): MatchFunction<ParamData> => {
return match(path, { decode: decodeURIComponent })
return match(normalizePattern(path), { decode: decodeURIComponent })
}

const cache: Record<string, MatchFunction<ParamData>> = {}

// obtains a cached matcher function for the pattern
const getMatcher = (pattern: string) => cache[pattern] || (cache[pattern] = createMatcher(pattern))
const getMatcher = (pattern: string) => {
const normalizedPattern = normalizePattern(pattern)
return cache[normalizedPattern] || (cache[normalizedPattern] = createMatcher(normalizedPattern))
}

export const matcher: MatcherFn = (pattern, path) => {
const matchFn = getMatcher(String(pattern || ''))
Expand Down
6 changes: 6 additions & 0 deletions packages/fastify-renderer/src/node/Plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ import { RenderableRegistration, Renderer } from './renderers/Renderer'
import './types' // necessary to make sure that the fastify types are augmented
import { FastifyRendererHook, ServerEntrypointManifest, ViteClientManifest } from './types'

export type ProcessEnvMatcher = string | RegExp
export type PreserveProcessEnvOptions = boolean | { include: ProcessEnvMatcher[] }

export interface FastifyRendererOptions {
renderer?: ReactRendererOptions
vite?: InlineConfig
preserveProcessEnv?: PreserveProcessEnvOptions
base?: string
layout?: string
document?: Template
Expand All @@ -27,6 +31,7 @@ export type ImperativeRenderable = symbol
export class FastifyRendererPlugin {
renderer: Renderer
devMode: boolean
preserveProcessEnv: PreserveProcessEnvOptions
vite: InlineConfig
viteBase: string
clientOutDir: string
Expand All @@ -40,6 +45,7 @@ export class FastifyRendererPlugin {

constructor(incomingOptions: FastifyRendererOptions) {
this.devMode = incomingOptions.devMode ?? process.env.NODE_ENV != 'production'
this.preserveProcessEnv = incomingOptions.preserveProcessEnv ?? false

this.vite = incomingOptions.vite || {}
this.vite.base ??= '/.vite/'
Expand Down
39 changes: 37 additions & 2 deletions packages/fastify-renderer/src/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { FastifyInstance, FastifyPluginAsync, FastifyReply } from 'fastify'
import fp from 'fastify-plugin'
import { promises as fs } from 'fs'
import path from 'path'
import { resolveConfig, build as viteBuild, createServer, ResolvedConfig, ViteDevServer } from 'vite'
import { resolveConfig, build as viteBuild, createServer, Plugin as VitePlugin, ResolvedConfig, ViteDevServer } from 'vite'
import { DefaultDocumentTemplate } from './DocumentTemplate'
import { FastifyRendererOptions, FastifyRendererPlugin, ImperativeRenderable } from './Plugin'
import { PartialRenderOptions, Render, RenderableRegistration } from './renderers/Renderer'
Expand All @@ -16,6 +16,25 @@ import './types' // necessary to make sure that the fastify types are augmented
import { ServerRenderer } from './types'
import { mapFilepathToEntrypointName } from './utils'

const processEnvDotAccessPattern = /\(\{\}\)\.([A-Za-z_$][\w$]*)/g

const moduleMatchesAnyPattern = (id: string, includePatterns: (string | RegExp)[]) =>
includePatterns.some((pattern) => (typeof pattern === 'string' ? id.includes(pattern) : pattern.test(id)))

const preserveProcessEnvForMatchingModules = (includePatterns: (string | RegExp)[]): VitePlugin => ({
name: 'fastify-renderer:preserve-process-env',
enforce: 'post',
transform(code, id) {
if (!moduleMatchesAnyPattern(id, includePatterns)) return null

const transformed = code
.replace(processEnvDotAccessPattern, (_match, key: string) => `process.env.${key}`)

if (transformed === code) return null
return { code: transformed, map: null }
},
})

const plugin: FastifyPluginAsync<FastifyRendererOptions> = async (fastify, incomingOptions) => {
const plugin = new FastifyRendererPlugin(incomingOptions)
let vite: ViteDevServer
Expand Down Expand Up @@ -148,10 +167,26 @@ const plugin: FastifyPluginAsync<FastifyRendererOptions> = async (fastify, incom

// register vite once all the routes have been defined
fastify.addHook('onReady', async () => {
const define = { ...(plugin.vite?.define || {}) }
const extraVitePlugins: VitePlugin[] = []

// Opt out of Vite's default production replacement of `process.env` with `{}` for client bundles.
// This keeps `process.env.SOME_VAR` intact when callers explicitly request it.
if (plugin.preserveProcessEnv === true && !('process.env' in define)) {
define['process.env'] = 'process.env'
} else if (
plugin.preserveProcessEnv &&
plugin.preserveProcessEnv !== true &&
plugin.preserveProcessEnv.include.length > 0
) {
extraVitePlugins.push(preserveProcessEnvForMatchingModules(plugin.preserveProcessEnv.include))
}

fastify[kRendererViteOptions] = {
clearScreen: false,
...plugin.vite,
plugins: [...(plugin.vite?.plugins || []), ...plugin.renderer.vitePlugins()],
define,
plugins: [...(plugin.vite?.plugins || []), ...extraVitePlugins, ...plugin.renderer.vitePlugins()],
server: {
middlewareMode: true,
...plugin.vite?.server,
Expand Down
126 changes: 125 additions & 1 deletion packages/fastify-renderer/test/FastifyRenderer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import path from 'path'
import * as Vite from 'vite'
import FastifyRenderer, { build } from '../src/node'
import { FastifyRendererPlugin } from '../src/node/Plugin'
import { kRenderOptions } from '../src/node/symbols'
import { kRendererViteOptions, kRenderOptions } from '../src/node/symbols'
import { newFastify } from './helpers'

const testComponent = require.resolve(path.join(__dirname, 'fixtures', 'test-module.tsx'))
Expand Down Expand Up @@ -60,6 +60,130 @@ describe('FastifyRenderer', () => {
expect(server.printRoutes()).toMatch('.vite')
})

test('should not preserve process.env references by default', async () => {
await server.ready()
expect(server[kRendererViteOptions].define?.['process.env']).toBeUndefined()
})

test('should preserve process.env references when configured', async () => {
server = await newFastify()
await server.register(FastifyRenderer, { ...options, preserveProcessEnv: true })

await server.ready()
expect(server[kRendererViteOptions].define?.['process.env']).toEqual('process.env')
})

test('should not override an explicit vite.define["process.env"] when preserveProcessEnv is true', async () => {
server = await newFastify()
await server.register(FastifyRenderer, {
...options,
preserveProcessEnv: true,
vite: {
...options.vite,
define: {
'process.env': JSON.stringify({ KEEP: 'EXPLICIT' }),
},
},
})

await server.ready()
expect(server[kRendererViteOptions].define?.['process.env']).toEqual(JSON.stringify({ KEEP: 'EXPLICIT' }))
})

test('should preserve process.env references for matching module ids when configured with include matchers', async () => {
server = await newFastify()
await server.register(FastifyRenderer, {
...options,
preserveProcessEnv: {
include: ['/packages/docs/', /\/docs\//],
},
})

await server.ready()

// scoped mode should avoid setting a global Vite define, and instead rely on a transform plugin
expect(server[kRendererViteOptions].define?.['process.env']).toBeUndefined()

const preservePlugin = server[kRendererViteOptions].plugins.find(
(plugin: any) => plugin.name === 'fastify-renderer:preserve-process-env'
)
expect(preservePlugin).toBeDefined()

const originalCode = 'const appUrl = ({}).PUBLIC_APP_URL;'
const docsModuleId = '/workspace/packages/docs/pages/reference/example.mdx'
const webModuleId = '/workspace/packages/web/src/components/auth/LoginPage.tsx'

const transformedDocsCode = preservePlugin.transform(originalCode, docsModuleId)
expect(transformedDocsCode.code).toContain('process.env.PUBLIC_APP_URL')

const transformedWebCode = preservePlugin.transform(originalCode, webModuleId)
expect(transformedWebCode).toBeNull()
})

test('should not rewrite bracket-form object access in scoped preserve mode', async () => {
server = await newFastify()
await server.register(FastifyRenderer, {
...options,
preserveProcessEnv: {
include: ['/packages/docs/'],
},
})

await server.ready()

const preservePlugin = server[kRendererViteOptions].plugins.find(
(plugin: any) => plugin.name === 'fastify-renderer:preserve-process-env'
)
expect(preservePlugin).toBeDefined()

const bracketFormCode = 'const appUrl = ({})["PUBLIC_APP_URL"];'
const docsModuleId = '/workspace/packages/docs/pages/reference/example.mdx'

const transformed = preservePlugin.transform(bracketFormCode, docsModuleId)
expect(transformed).toBeNull()
})

test('should leave already-correct process.env code unchanged in scoped preserve mode', async () => {
server = await newFastify()
await server.register(FastifyRenderer, {
...options,
preserveProcessEnv: {
include: ['/packages/docs/'],
},
})

await server.ready()

const preservePlugin = server[kRendererViteOptions].plugins.find(
(plugin: any) => plugin.name === 'fastify-renderer:preserve-process-env'
)
expect(preservePlugin).toBeDefined()

const alreadyCorrectCode = 'const appUrl = process.env.PUBLIC_APP_URL;'
const docsModuleId = '/workspace/packages/docs/pages/reference/example.mdx'

const transformed = preservePlugin.transform(alreadyCorrectCode, docsModuleId)
expect(transformed).toBeNull()
})

test('should not add scoped preserve plugin when include matchers are empty', async () => {
server = await newFastify()
await server.register(FastifyRenderer, {
...options,
preserveProcessEnv: {
include: [],
},
})

await server.ready()

expect(server[kRendererViteOptions].define?.['process.env']).toBeUndefined()
const preservePlugins = server[kRendererViteOptions].plugins.filter(
(plugin: any) => plugin.name === 'fastify-renderer:preserve-process-env'
)
expect(preservePlugins).toHaveLength(0)
})

test('should close vite devServer when fastify server is closing in dev mode', async () => {
const devServer = await Vite.createServer()
const closeSpy = jest.spyOn(devServer, 'close')
Expand Down
89 changes: 89 additions & 0 deletions packages/fastify-renderer/test/matcher.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { matcher } from '../src/client/react/matcher'

describe('matcher', () => {
test('supports legacy wildcard params from path-to-regexp <8', () => {
const [matched, params] = matcher(
'/app/:tenant/:env/schema/:namespace/:splat*',
'/app/acme/prod/schema/core/getting-started/intro'
)

expect(matched).toBe(true)
expect(params).toEqual({
tenant: 'acme',
env: 'prod',
namespace: 'core',
splat: ['getting-started', 'intro'],
})
})

test('matches catch-all route', () => {
const [matched, params] = matcher('/*', '/totally/unknown/path')

expect(matched).toBe(true)
expect(params).toEqual({
splat: ['totally', 'unknown', 'path'],
})
})

test('matches route with named segment and wildcard tail', () => {
const [matched, params] = matcher('/app/:tenant/:env/schema/:namespace/*', '/app/my-app/development/schema/catalog/orders')

expect(matched).toBe(true)
expect(params).toEqual({
tenant: 'my-app',
env: 'development',
namespace: 'catalog',
splat: ['orders'],
})
})

test('matches route with fixed number of named segments', () => {
const [matched, params] = matcher('/app/:tenant/:env/schema/:resource', '/app/my-app/development/schema/order')

expect(matched).toBe(true)
expect(params).toEqual({
tenant: 'my-app',
env: 'development',
resource: 'order',
})
})

test('query and hash do not affect matching', () => {
const [matched, params] = matcher('/app/:tenant/:env/schema/:resource', '/app/my-app/development/schema/order?tab=fields#actions')

expect(matched).toBe(true)
expect(params).toEqual({
tenant: 'my-app',
env: 'development',
resource: 'order',
})
})

test('does not overmatch non-wildcard route', () => {
const [matched, params] = matcher('/app/:tenant/:env/schema/:resource', '/app/my-app/development/schema/order/extra')

expect(matched).toBe(false)
expect(params).toBeNull()
})

test('matches base route with two params', () => {
const [matched, params] = matcher('/app/:tenant/:env', '/app/my-app/development')

expect(matched).toBe(true)
expect(params).toEqual({
tenant: 'my-app',
env: 'development',
})
})

test('markdown path still matches route pattern', () => {
const [matched, params] = matcher('/app/:tenant/:env/schema/:resource', '/app/my-app/development/schema/order.md')

expect(matched).toBe(true)
expect(params).toEqual({
tenant: 'my-app',
env: 'development',
resource: 'order.md',
})
})
})
Loading