fly-node icon indicating copy to clipboard operation
fly-node copied to clipboard

Remix replayable

Open kentcdodds opened this issue 4 years ago • 6 comments


function getReplayResponse(request: Request) {
  if (isPrimaryRegion) return null
  const pathname = new URL(request.url).pathname
  const logInfo = {
    pathname,
    method: request.method,
    PRIMARY_REGION,
    FLY_REGION,
  }
  console.info(`Replaying:`, logInfo)
  return redirect(pathname, {
    status: 302,
    headers: {'fly-replay': `region=${PRIMARY_REGION}`},
  })
}

function getReplayResponseForError(request: Request, errorMessage?: string) {
  // depending on how the error is serialized, there may be quotes and escape
  // characters in the error message, so we'll use a regex instead of a regular includes.
  const isReadOnlyError = /SqlState\(.*?25006.*?\)/.test(errorMessage ?? '')
  return isReadOnlyError ? getReplayResponse(request) : null
}

async function getDocumentReplayResponse(
  request: Request,
  remixContext: EntryContext,
) {
  return getReplayResponseForError(
    request,
    remixContext.componentDidCatchEmulator.error?.message,
  )
}

async function getDataReplayResponse(request: Request, response: Response) {
  if (response.status > 199 && response.status < 300) return null

  const textClone = response.clone()
  const text = await textClone.text().catch(() => null)
  return getReplayResponseForError(request, text ?? '')
}

kentcdodds avatar Sep 13 '21 17:09 kentcdodds

Patch on remix to make this work:

diff --git a/node_modules/@remix-run/server-runtime/server.js b/node_modules/@remix-run/server-runtime/server.js
index 7289bcc..76ba6ca 100644
--- a/node_modules/@remix-run/server-runtime/server.js
+++ b/node_modules/@remix-run/server-runtime/server.js
@@ -66,7 +66,7 @@ async function handleDataRequest(request, loadContext, build, platform, routes)
     var _platform$formatServe;
 
     let formattedError = (await ((_platform$formatServe = platform.formatServerError) === null || _platform$formatServe === void 0 ? void 0 : _platform$formatServe.call(platform, error))) || error;
-    return responses.json(await errors.serializeError(formattedError), {
+    response = responses.json(await errors.serializeError(formattedError), {
       status: 500,
       headers: {
         "X-Remix-Error": "unfortunately, yes"
@@ -74,6 +74,19 @@ async function handleDataRequest(request, loadContext, build, platform, routes)
     });
   }
 
+  if (build.entry.module.handleDataRequest) {
+    try {
+      response = await build.entry.module.handleDataRequest(clonedRequest, response, loadContext, routeMatch.params);
+    } catch (error) {
+      return responses.json(errors.serializeError(error), {
+        status: 500,
+        headers: {
+          "X-Data-Request-Handler-Error": "Handle your errors, yo."
+        }
+      });
+    }
+  }
+
   if (isRedirectResponse(response)) {
     // We don't have any way to prevent a fetch request from following
     // redirects. So we use the `X-Remix-Redirect` header to indicate the

kentcdodds avatar Sep 13 '21 17:09 kentcdodds

This is entry.server.ts:

// This isn't supported yet. We're using patch-package to make this work:
// https://github.com/remix-run/remix/issues/217
export async function handleDataRequest(
  request: Request,
  dataResponse: Response,
) {
  const replayResponse = await getDataReplayResponse(request, dataResponse)
  if (replayResponse) {
    return replayResponse
  }
  // TODO: remove this when we go to production
  dataResponse.headers.set('X-Robots-Tag', 'none')

  dataResponse.headers.set('X-Powered-By', 'Kody the Koala')
  dataResponse.headers.set('X-Fly-Region', process.env.FLY_REGION ?? 'unknown')
  return dataResponse
}

kentcdodds avatar Sep 13 '21 17:09 kentcdodds

https://npm.im/patch-package

kentcdodds avatar Sep 13 '21 17:09 kentcdodds

This is my redis client stuff:

import redis from 'redis'
import {getRequiredServerEnvVar} from './misc'

declare global {
  // This prevents us from making multiple connections to the db when the
  // require cache is cleared.
  // eslint-disable-next-line
  var replicaClient: redis.RedisClient | undefined,
    primaryClient: redis.RedisClient | undefined
}

const REDIS_URL = getRequiredServerEnvVar('REDIS_URL')
const replica = new URL(REDIS_URL)
const isLocalHost = replica.hostname === 'localhost'
const isInternal = replica.hostname.includes('.internal')

const isMultiRegion = !isLocalHost && isInternal

const PRIMARY_REGION = isMultiRegion
  ? getRequiredServerEnvVar('PRIMARY_REGION')
  : null
const FLY_REGION = isMultiRegion ? getRequiredServerEnvVar('FLY_REGION') : null

if (FLY_REGION) {
  replica.host = `${FLY_REGION}.${replica.host}`
}

const replicaClient = createClient('replicaClient', {
  url: replica.toString(),
  family: isInternal ? 'IPv6' : 'IPv4',
})

let primaryClient: redis.RedisClient | null = null
if (FLY_REGION !== PRIMARY_REGION) {
  const primary = new URL(REDIS_URL)
  if (!isLocalHost) {
    primary.host = `${PRIMARY_REGION}.${primary.host}`
  }
  primaryClient = createClient('primaryClient', {
    url: primary.toString(),
    family: isInternal ? 'IPv6' : 'IPv4',
  })
}

function createClient(
  name: 'replicaClient' | 'primaryClient',
  options: redis.ClientOpts,
): redis.RedisClient {
  let client = global[name]
  if (!client) {
    const url = new URL(options.url ?? 'http://no-redis-url.example.com?weird')
    // eslint-disable-next-line no-multi-assign
    client = global[name] = redis.createClient(options)

    client.on('error', (error: string) => {
      console.error(`REDIS ${name} (${url.host}) ERROR:`, error)
    })
  }
  return client
}

// NOTE: Caching should never crash the app, so instead of rejecting all these
// promises, we'll just resolve things with null and log the error.

function get<Value = unknown>(key: string): Promise<Value | null> {
  return new Promise(resolve => {
    replicaClient.get(key, (err: Error | null, result: string | null) => {
      if (err) {
        console.error(
          `REDIS replicaClient (${FLY_REGION}) ERROR with .get:`,
          err,
        )
      }
      resolve(result ? (JSON.parse(result) as Value) : null)
    })
  })
}

function set<Value>(key: string, value: Value): Promise<'OK'> {
  return new Promise(resolve => {
    replicaClient.set(
      key,
      JSON.stringify(value),
      (err: Error | null, reply: 'OK') => {
        if (err)
          console.error(
            `REDIS replicaClient (${FLY_REGION}) ERROR with .set:`,
            err,
          )
        resolve(reply)
      },
    )
  })
}

function del(key: string): Promise<string> {
  return new Promise(resolve => {
    // fire and forget on primary, we only care about replica
    primaryClient?.del(key, (err: Error | null) => {
      if (err) {
        console.error('Primary delete error', err)
      }
    })
    replicaClient.del(key, (err: Error | null, result: number | null) => {
      if (err) {
        console.error(
          `REDIS replicaClient (${FLY_REGION}) ERROR with .del:`,
          err,
        )
        resolve('error')
      } else {
        resolve(`${key} deleted: ${result}`)
      }
    })
  })
}

const redisCache = {get, set, del}
export {get, set, del, redisCache}

kentcdodds avatar Sep 13 '21 17:09 kentcdodds

All of it is here: https://github.com/kentcdodds/remix-kentcdodds

kentcdodds avatar Sep 13 '21 17:09 kentcdodds

Here's what I have at the top of my prisma file that might be useful:

import {PrismaClient} from '@prisma/client'
import type {EntryContext} from 'remix'
import {redirect} from 'remix'
import chalk from 'chalk'
import type {User, Session} from '~/types'
import {encrypt, decrypt} from './encryption.server'
import {getRequiredServerEnvVar} from './misc'

declare global {
  // This prevents us from making multiple connections to the db when the
  // require cache is cleared.
  // eslint-disable-next-line
  var prisma: ReturnType<typeof getClient> | undefined
}

const DATABASE_URL = getRequiredServerEnvVar('DATABASE_URL')
const regionalDB = new URL(DATABASE_URL)
const isLocalHost = regionalDB.hostname === 'localhost'
const PRIMARY_REGION = isLocalHost
  ? null
  : getRequiredServerEnvVar('PRIMARY_REGION')

const FLY_REGION = isLocalHost ? null : getRequiredServerEnvVar('FLY_REGION')
const isPrimaryRegion = PRIMARY_REGION === FLY_REGION
if (!isLocalHost) {
  regionalDB.host = `${FLY_REGION}.${regionalDB.host}`
  if (!isPrimaryRegion) {
    // 5433 is the read-replica port
    regionalDB.port = '5433'
  }
}

const logThreshold = 15

const prisma = getClient(() => {
  // NOTE: during development if you change anything in this function, remember
  // that this only runs once per server restart and won't automatically be
  // re-run per request like everything else is.
  console.log(`Connecting to ${regionalDB.host}`)
  const client = new PrismaClient({
    log: [
      {level: 'query', emit: 'event'},
      {level: 'error', emit: 'stdout'},
      {level: 'info', emit: 'stdout'},
      {level: 'warn', emit: 'stdout'},
    ],
    datasources: {
      db: {
        url: regionalDB.toString(),
      },
    },
  })
  client.$on('query', e => {
    if (e.duration < logThreshold) return

    const color =
      e.duration < 15
        ? 'green'
        : e.duration < 20
        ? 'blue'
        : e.duration < 35
        ? 'yellow'
        : e.duration < 50
        ? 'redBright'
        : 'red'
    const dur = chalk[color](`${e.duration}ms`)
    console.log(`prisma:query - ${dur} - ${e.query}`)
  })
  return client
})

function getClient(createClient: () => PrismaClient): PrismaClient {
  let client = global.prisma
  if (!client) {
    // eslint-disable-next-line no-multi-assign
    client = global.prisma = createClient()
  }
  return client
}

const isProd = process.env.NODE_ENV === 'production'

if (!isProd && DATABASE_URL && !DATABASE_URL.includes('localhost')) {
  // if we're connected to a non-localhost db, let's make
  // sure we know it.
  const domain = new URL(DATABASE_URL)
  if (domain.password) {
    domain.password = '**************'
  }
  console.warn(
    `
⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️
Connected to non-localhost DB in dev mode:
  ${domain.toString()}
⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️
    `.trim(),
  )
}

kentcdodds avatar Sep 13 '21 17:09 kentcdodds