diff --git a/.env.example b/.env.example index 7f7293e75..77468fc51 100644 --- a/.env.example +++ b/.env.example @@ -31,11 +31,15 @@ TITO_API_SECRET=secret_live_some_long_thing SESSION_SECRET=anything_works_here MAGIC_LINK_SECRET=whatever_stuff -# If you're running the postgres db from docker-compose then this is the URL you should use -DATABASE_URL="postgresql://kody:the_koala@localhost:5432/kentcdodds_db?schema=public" +# Feature: basically everything +# Mocked: No, must run sqlite locally +DATABASE_URL="file:./data.db?connection_limit=1" +CACHE_DATABASE_PATH="other/cache.db" -# If you're running the redis db from docker-compose then this is the URL you should use -REDIS_URL="redis://:alex_rocks@localhost:6379" +# Feature: Fly regions +# Mocked: No +FLY_REGION="den" +FLY_INSTANCE="123456" # Feature: Call Kent podcast # Mocked: yes diff --git a/.eslintrc.js b/.eslintrc.js index 4c8ad6fc2..82b404471 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,3 +1,4 @@ +/** @type {import('@types/eslint').Linter.BaseConfig} */ module.exports = { extends: [ 'eslint-config-kentcdodds', @@ -13,6 +14,7 @@ module.exports = { 'no-console': 'off', // meh... + '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/sort-type-union-intersection-members': 'off', 'jsx-a11y/media-has-caption': 'off', diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 151ec39a3..ce90c803d 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -3,188 +3,198 @@ on: push: branches: - main + - dev pull_request: {} jobs: - changes: - name: ๐Ÿ”Ž Determine deployable changes - runs-on: ubuntu-latest - outputs: - DEPLOYABLE: ${{steps.changes.outputs.DEPLOYABLE}} - steps: - - name: โฌ‡๏ธ Checkout repo - uses: actions/checkout@v2 - with: - fetch-depth: '50' - - - name: โŽ” Setup node - uses: actions/setup-node@v2 - with: - node-version: 16 - - - name: ๐Ÿ”Ž Determine deployable changes - id: changes - run: >- - echo ::set-output name=DEPLOYABLE::$(node ./other/is-deployable.js ${{ - github.sha }}) - - - name: โ“ Deployable - run: >- - echo "DEPLOYABLE: ${{steps.changes.outputs.DEPLOYABLE}}" - - lint: - name: โฌฃ ESLint - needs: [changes] - if: needs.changes.outputs.DEPLOYABLE == 'true' - runs-on: ubuntu-latest - steps: - - name: ๐Ÿ›‘ Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.1 - - - name: โฌ‡๏ธ Checkout repo - uses: actions/checkout@v2 - - - name: โŽ” Setup node - uses: actions/setup-node@v2 - with: - node-version: 16 - - - name: ๐Ÿ“ฅ Download deps - uses: bahmutov/npm-install@v1 - - - name: ๐Ÿ”ฌ Lint - run: npm run lint - - typecheck: - name: สฆ TypeScript - needs: [changes] - if: needs.changes.outputs.DEPLOYABLE == 'true' - runs-on: ubuntu-latest - steps: - - name: ๐Ÿ›‘ Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.1 - - - name: โฌ‡๏ธ Checkout repo - uses: actions/checkout@v2 - - - name: โŽ” Setup node - uses: actions/setup-node@v2 - with: - node-version: 16 - - - name: ๐Ÿ“ฅ Download deps - uses: bahmutov/npm-install@v1 - - - name: ๐Ÿ”Ž Type check - run: npm run typecheck - - jest: - name: ๐Ÿƒ Jest - needs: [changes] - if: needs.changes.outputs.DEPLOYABLE == 'true' - runs-on: ubuntu-latest - steps: - - name: ๐Ÿ›‘ Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.1 - - - name: โฌ‡๏ธ Checkout repo - uses: actions/checkout@v2 - - - name: โŽ” Setup node - uses: actions/setup-node@v2 - with: - node-version: 16 - - - name: ๐Ÿ“ฅ Download deps - uses: bahmutov/npm-install@v1 - - - name: ๐Ÿƒ Run jest - run: npm run test -- --coverage - - cypress: - name: โšซ๏ธ Cypress - needs: [changes] - if: needs.changes.outputs.DEPLOYABLE == 'true' - runs-on: ubuntu-latest - strategy: - # when one test fails, DO NOT cancel the other - # containers, because this will kill Cypress processes - # leaving the Dashboard hanging ... - # https://github.com/cypress-io/github-action/issues/48 - fail-fast: false - matrix: - # run 3 copies of the current job in parallel - containers: ['๐Ÿจ Kody', '๐Ÿ’ฏ Hannah', '๐Ÿฆ‰ Olivia'] - steps: - - name: ๐Ÿ›‘ Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.1 - - - name: โฌ‡๏ธ Checkout repo - uses: actions/checkout@v2 - - - name: ๐Ÿ„ Copy test env vars - run: cp .env.example .env - - - name: โŽ” Setup node - uses: actions/setup-node@v2 - with: - node-version: 16 - - - name: ๐Ÿ“ฅ Download deps - uses: bahmutov/npm-install@v1 - - - name: ๐ŸŽ› Installing ffmpeg - uses: FedericoCarboni/setup-ffmpeg@v1 - - - name: โš™๏ธ Build - run: npm run build - env: - ENABLE_TEST_ROUTES: 'true' - COMMIT_SHA: ${{ github.sha }} - - - name: ๐Ÿณ Docker compose - run: docker-compose up -d && sleep 3 && npx prisma migrate reset --force - env: - DATABASE_URL: 'postgresql://kody:the_koala@localhost:5432/kentcdodds_db?schema=public' - - - name: ๐ŸŒณ Cypress run - uses: cypress-io/github-action@v4 - continue-on-error: true - with: - start: npm run start:mocks - wait-on: 'http://localhost:8811' - record: true - parallel: true - group: 'KCD Cypress' - env: - PORT: '8811' - RUNNING_E2E: 'true' - DATABASE_URL: 'postgresql://kody:the_koala@localhost:5432/kentcdodds_db?schema=public' - REDIS_URL: ${{ secrets.REDIS_CI_TEST_URL }} - CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} - DISABLE_TELEMETRY: 'true' + # changes: + # name: ๐Ÿ”Ž Determine deployable changes + # runs-on: ubuntu-latest + # outputs: + # DEPLOYABLE: ${{steps.changes.outputs.DEPLOYABLE}} + # steps: + # - name: โฌ‡๏ธ Checkout repo + # uses: actions/checkout@v2 + # with: + # fetch-depth: '50' + + # - name: โŽ” Setup node + # uses: actions/setup-node@v3 + # with: + # node-version: 16 + + # - name: ๐Ÿ”Ž Determine deployable changes + # id: changes + # run: >- + # echo ::set-output name=DEPLOYABLE::$(node ./other/is-deployable.js ${{ + # github.sha }}) + + # - name: โ“ Deployable + # run: >- + # echo "DEPLOYABLE: ${{steps.changes.outputs.DEPLOYABLE}}" + + # lint: + # name: โฌฃ ESLint + # needs: [changes] + # if: needs.changes.outputs.DEPLOYABLE == 'true' + # runs-on: ubuntu-latest + # steps: + # - name: ๐Ÿ›‘ Cancel Previous Runs + # uses: styfle/cancel-workflow-action@0.11.0 + + # - name: โฌ‡๏ธ Checkout repo + # uses: actions/checkout@v2 + + # - name: โŽ” Setup node + # uses: actions/setup-node@v3 + # with: + # node-version: 16 + + # - name: ๐Ÿ“ฅ Download deps + # uses: bahmutov/npm-install@v1 + + # - name: ๐Ÿ”ฌ Lint + # run: npm run lint + + # typecheck: + # name: สฆ TypeScript + # needs: [changes] + # if: needs.changes.outputs.DEPLOYABLE == 'true' + # runs-on: ubuntu-latest + # steps: + # - name: ๐Ÿ›‘ Cancel Previous Runs + # uses: styfle/cancel-workflow-action@0.11.0 + + # - name: โฌ‡๏ธ Checkout repo + # uses: actions/checkout@v2 + + # - name: โŽ” Setup node + # uses: actions/setup-node@v3 + # with: + # node-version: 16 + + # - name: ๐Ÿ“ฅ Download deps + # uses: bahmutov/npm-install@v1 + + # - name: ๐Ÿ”Ž Type check + # run: npm run typecheck + + # jest: + # name: ๐Ÿƒ Jest + # needs: [changes] + # if: needs.changes.outputs.DEPLOYABLE == 'true' + # runs-on: ubuntu-latest + # steps: + # - name: ๐Ÿ›‘ Cancel Previous Runs + # uses: styfle/cancel-workflow-action@0.11.0 + + # - name: โฌ‡๏ธ Checkout repo + # uses: actions/checkout@v2 + + # - name: โŽ” Setup node + # uses: actions/setup-node@v3 + # with: + # node-version: 16 + + # - name: ๐Ÿ“ฅ Download deps + # uses: bahmutov/npm-install@v1 + + # - name: ๐Ÿƒ Run jest + # run: npm run test -- --coverage + + # TODO: fix playwright + # playwright: + # name: ๐ŸŽญ Playwright + # runs-on: ubuntu-latest + # timeout-minutes: 60 + # steps: + # - name: ๐Ÿ›‘ Cancel Previous Runs + # uses: styfle/cancel-workflow-action@0.10.0 + + # - name: โฌ‡๏ธ Checkout repo + # uses: actions/checkout@v3 + + # - name: ๐Ÿ„ Copy test env vars + # run: cp .env.example .env + + # - name: โŽ” Setup node + # uses: actions/setup-node@v3 + # with: + # node-version: 18 + + # - name: ๐ŸŽฌ Setup ffmpeg + # uses: FedericoCarboni/setup-ffmpeg@v1 + + # - name: ๐Ÿ“ฅ Download deps + # uses: bahmutov/npm-install@v1 + # with: + # useLockFile: false + + # - name: ๐ŸŒ Install Playwright Browsers + # run: npx playwright install chromium --with-deps + + # - name: ๐Ÿ›  Setup Database + # run: npx prisma migrate reset --force + + # - name: ๐Ÿ— Build + # run: npm run build + + # - name: ๐Ÿฆ Prime Site Cache + # id: site-cache + # uses: actions/cache@v3 + # with: + # path: other/cache.db + # key: site-cache + + # - name: ๐Ÿ˜… Generate Site Cache + # if: steps.site-cache.outputs.cache-hit != 'true' + # run: npm run prime-cache:mocks + + # - name: ๐ŸŽญ Playwright tests + # run: npx playwright test + # env: + # PORT: 8811 + + # - name: ๐Ÿ“Š Upload report + # uses: actions/upload-artifact@v2 + # if: always() + # with: + # name: playwright-report + # path: playwright-report/ + # retention-days: 30 build: name: ๐Ÿณ Build - needs: [changes] + # needs: [changes] if: - ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' && - needs.changes.outputs.DEPLOYABLE == 'true' }} + ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && + github.event_name == 'push' }} + # if: + # ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && + # github.event_name == 'push' && needs.changes.outputs.DEPLOYABLE == 'true' + # }} runs-on: ubuntu-latest # only build/deploy main branch on pushes steps: - name: ๐Ÿ›‘ Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.1 + uses: styfle/cancel-workflow-action@0.11.0 - name: โฌ‡๏ธ Checkout repo uses: actions/checkout@v2 + - name: ๐Ÿ‘€ Read app name + uses: SebRollen/toml-action@v1.0.0 + id: app_name + with: + file: 'fly.toml' + field: 'app' + - name: ๐Ÿณ Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 # Setup cache - name: โšก๏ธ Cache Docker layers - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} @@ -192,18 +202,20 @@ jobs: ${{ runner.os }}-buildx- - name: ๐Ÿ”‘ Fly Registry Auth - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: registry: registry.fly.io username: x password: ${{ secrets.FLY_API_TOKEN }} - name: ๐Ÿณ Docker build - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: context: . push: true - tags: registry.fly.io/withered-frost-3196:${{ github.sha }} + tags: + registry.fly.io/${{ steps.app_name.outputs.value }}:${{ + github.ref_name }}-${{ github.sha }} build-args: | COMMIT_SHA=${{ github.sha }} cache-from: type=local,src=/tmp/.buildx-cache @@ -222,24 +234,48 @@ jobs: deploy: name: ๐Ÿš€ Deploy runs-on: ubuntu-latest - needs: [changes, lint, typecheck, jest, build] + needs: [build] + # needs: [changes, lint, typecheck, jest, build] # only build/deploy main branch on pushes if: - ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' && - needs.changes.outputs.DEPLOYABLE == 'true' }} + ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && + github.event_name == 'push' }} + # if: + # ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && + # github.event_name == 'push' && needs.changes.outputs.DEPLOYABLE == 'true' + # }} steps: - name: ๐Ÿ›‘ Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.1 + uses: styfle/cancel-workflow-action@0.11.0 - name: โฌ‡๏ธ Checkout repo uses: actions/checkout@v2 - - name: ๐Ÿš€ Deploy - uses: superfly/flyctl-actions@1.1 + - name: ๐Ÿ‘€ Read app name + uses: SebRollen/toml-action@v1.0.0 + id: app_name + with: + file: 'fly.toml' + field: 'app' + + - name: ๐Ÿš€ Deploy Staging + if: ${{ github.ref == 'refs/heads/dev' }} + uses: superfly/flyctl-actions@1.3 + with: + args: + 'deploy --app ${{ steps.app_name.outputs.value }}-staging --image + registry.fly.io/${{ steps.app_name.outputs.value }}:${{ + github.ref_name }}-${{ github.sha }}' + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + + - name: ๐Ÿš€ Deploy Production + if: ${{ github.ref == 'refs/heads/main' }} + uses: superfly/flyctl-actions@1.3 with: args: - 'deploy -i registry.fly.io/withered-frost-3196:${{ github.sha }} - --strategy rolling' + 'deploy --image registry.fly.io/${{ steps.app_name.outputs.value + }}:${{ github.ref_name }}-${{ github.sha }}' env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/.github/workflows/refresh-content.yml b/.github/workflows/refresh-content.yml index c08b273fa..7e4fe99cb 100644 --- a/.github/workflows/refresh-content.yml +++ b/.github/workflows/refresh-content.yml @@ -3,6 +3,7 @@ on: push: branches: - main + - dev jobs: refresh: diff --git a/.gitignore b/.gitignore index 0759cc16b..fd18f665a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,11 +13,12 @@ tsconfig.tsbuildinfo /app/styles/**/*.css other/postcss.ignored -/cypress/videos -/cypress/screenshots *.local.* .env .env.production .envrc - +/test-results/ +/playwright-report/ +/playwright/.cache/ +/other/cache.db \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index 85e70a68e..3803de9e7 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,3 +9,6 @@ node_modules/ /coverage app/styles +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e91764e66..f4d785e5c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -121,30 +121,29 @@ endpoints are mocked out via [`MSW`](https://mswjs.io/). ## Caching Because the mdx files are built on-demand and that can take some time, we -heavily cache them via redis (which is configured in the `docker-compose.yml` -file). This means that if you need to work on content, you'll need a way to -clear the cache. Luckily, when running the dev script, we have a file watcher -that auto-updates the cache as you save the file. It should happen so fast you -don't even notice what's going on, but I thought I'd mention it here just so you -know if it doesn't work. +heavily cache them in sqlite. This means that if you need to work on content, +you'll need a way to clear the cache. Luckily, when running the dev script, we +have a file watcher that auto-updates the cache as you save the file. It should +happen so fast you don't even notice what's going on, but I thought I'd mention +it here just so you know if it doesn't work. ## Running automated tests We have two kinds of tests, unit and component tests with Jest and E2E tests -with Cypress. +with Playwright. ```sh # run the unit and component tests with jest via: npm run test -# run the Cypress tests in dev mode: +# run the Playwright tests in dev mode: npm run test:e2e:dev -# run the Cypress tests in headless mode: +# run the Playwright tests in headless mode: npm run test:e2e:run ``` -Jest runs on changed files as part of the husky git commit hook. Cypress runs +Jest runs on changed files as part of the husky git commit hook. Playwright runs only on CI. ## Running static tests (Formatting/Linting/Typing) diff --git a/Dockerfile b/Dockerfile index e92e5a948..9e9046c63 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,23 @@ +# Fetch the LiteFS binary using a multi-stage build. +FROM flyio/litefs:sha-33a80a3 AS litefs + # base node image -FROM node:16-bullseye-slim as base +FROM node:18-bullseye-slim as base -# install open ssl for prisma and ffmpeg for the call kent functionality -RUN apt-get update && apt-get install -y openssl ffmpeg +# install open ssl and sqlite3 for prisma +# ffmpeg for the call kent functionality +# ca-certificates and fuse for litefs +RUN apt-get update && apt-get install -y fuse openssl ffmpeg sqlite3 ca-certificates # install all node_modules, including dev FROM base as deps -ENV CYPRESS_INSTALL_BINARY=0 -ENV HUSKY_SKIP_INSTALL=1 - RUN mkdir /app/ WORKDIR /app/ ADD package.json .npmrc package-lock.json ./ ADD other/patches ./other/patches -RUN npm install --production=false +RUN npm install # setup production node_modules FROM base as production-deps @@ -25,7 +27,7 @@ WORKDIR /app/ COPY --from=deps /app/node_modules /app/node_modules ADD package.json .npmrc package-lock.json /app/ -RUN npm prune --production +RUN npm prune --omit=dev # build app FROM base as build @@ -38,9 +40,14 @@ WORKDIR /app/ COPY --from=deps /app/node_modules /app/node_modules +ADD other/runfile.js /app/other/runfile.js + # schema doesn't change much so these will stay cached -ADD prisma . +ADD prisma /app/prisma +ADD prisma-postgres /app/prisma-postgres + RUN npx prisma generate +RUN npx prisma generate --schema ./prisma-postgres/schema.prisma # app code changes all the time ADD . . @@ -49,19 +56,36 @@ RUN npm run build # build smaller image for running FROM base -ENV DATABASE_URL=file:/data/sqlite.db +ENV FLY="true" +ENV FLY_LITEFS_DIR="/litefs/data" +ENV DATABASE_URL="file:$FLY_LITEFS_DIR/sqlite.db" +ENV PORT="8080" +ENV NODE_ENV="production" +ENV CACHE_DATABASE_PATH=/data/cache.db ENV NODE_ENV=production # Make SQLite CLI accessible RUN echo "#!/bin/sh\nset -x\nsqlite3 \$DATABASE_URL" > /usr/local/bin/database-cli && chmod +x /usr/local/bin/database-cli +RUN echo "#!/bin/sh\nset -x\nsqlite3 \$CACHE_DATABASE_PATH" > /usr/local/bin/cache-database-cli && chmod +x /usr/local/bin/cache-database-cli RUN mkdir /app/ WORKDIR /app/ COPY --from=production-deps /app/node_modules /app/node_modules COPY --from=build /app/node_modules/.prisma /app/node_modules/.prisma +COPY --from=build /app/node_modules/@prisma/client-postgres /app/node_modules/@prisma/client-postgres COPY --from=build /app/build /app/build COPY --from=build /app/public /app/public COPY --from=build /app/server-build /app/server-build +COPY --from=build /app/other/runfile.js /app/other/runfile.js +COPY --from=build /app/other/start.js /app/other/start.js +COPY --from=build /app/prisma /app/prisma +COPY --from=build /app/prisma-postgres /app/prisma-postgres + ADD . . -CMD ["npm", "run", "start"] +# prepare for litefs +COPY --from=litefs /usr/local/bin/litefs /usr/local/bin/litefs +ADD other/litefs.yml /etc/litefs.yml +RUN mkdir -p /data ${FLY_LITEFS_DIR} + +CMD ["litefs", "mount", "--", "node", "./other/start.js"] diff --git a/app/__test_routes__/login.tsx b/app/__test_routes__/login.tsx deleted file mode 100644 index 928f8ff2e..000000000 --- a/app/__test_routes__/login.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type {ActionFunction} from '@remix-run/node' -import {redirect} from '@remix-run/node' -import {getMagicLink, prismaWrite, prismaRead} from '~/utils/prisma.server' -import {getDomainUrl} from '~/utils/misc' - -export const action: ActionFunction = async ({request}) => { - const form = await request.json() - const email = form.email - const firstName = form.firstName - const team = form.team - const role = form.role ?? 'MEMBER' - if (typeof email !== 'string') { - throw new Error('email required for login page') - } - if (!email.endsWith('example.com')) { - throw new Error('All test emails must end in example.com') - } - - const user = await prismaRead.user.findUnique({where: {email}}) - if (user) { - if (typeof firstName === 'string') { - await prismaWrite.user.update({where: {id: user.id}, data: {firstName}}) - } - } else { - if (typeof firstName !== 'string') { - throw new Error('firstName required when creating a new user') - } - if (team !== 'BLUE' && team !== 'YELLOW' && team !== 'RED') { - throw new Error('a valid team is required') - } - - await prismaWrite.user.create({data: {email, team, firstName, role}}) - } - return redirect( - getMagicLink({ - emailAddress: email, - validateSessionMagicLink: false, - domainUrl: getDomainUrl(request), - }), - ) -} - -export default () => null diff --git a/app/components/form-elements.tsx b/app/components/form-elements.tsx index 0f5f86b30..f437c82da 100644 --- a/app/components/form-elements.tsx +++ b/app/components/form-elements.tsx @@ -97,11 +97,11 @@ const Field = React.forwardRef< diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 212286815..631f06dab 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -1,10 +1,14 @@ import * as React from 'react' import ReactDOMServer from 'react-dom/server' -import type {EntryContext} from '@remix-run/node' +import cookie from 'cookie' +import type {EntryContext, HandleDataRequestFunction} from '@remix-run/node' import {RemixServer as Remix} from '@remix-run/react' import {getEnv} from './utils/env.server' import {routes as otherRoutes} from './other-routes.server' -import {getRequiredServerEnvVar} from './utils/misc' +import {getFlyReplayResponse, getInstanceInfo} from './utils/fly.server' +import invariant from 'tiny-invariant' +import fs from 'fs' +import path from 'path' if (process.env.NODE_ENV === 'development') { try { @@ -25,12 +29,9 @@ export default async function handleRequest( if (responseStatusCode >= 500) { // maybe we're just in trouble in this region... if we're not in the primary // region, then replay and hopefully it works next time. - const FLY_REGION = getRequiredServerEnvVar('FLY_REGION') - if (FLY_REGION !== ENV.PRIMARY_REGION) { - return new Response('Fly Replay', { - status: 409, - headers: {'fly-replay': `region=${ENV.PRIMARY_REGION}`}, - }) + const {currentIsPrimary, primaryInstance} = await getInstanceInfo() + if (!currentIsPrimary) { + return getFlyReplayResponse(primaryInstance) } } @@ -52,6 +53,7 @@ export default async function handleRequest( responseHeaders.set('Content-Type', 'text/html') responseHeaders.set('Content-Length', String(Buffer.byteLength(html))) + responseHeaders.append( 'Link', '; rel="preconnect"', @@ -63,17 +65,46 @@ export default async function handleRequest( }) } -export function handleDataRequest(response: Response) { +export async function handleDataRequest( + response: Response, + {request}: Parameters[1], +) { + const {currentIsPrimary, primaryInstance} = await getInstanceInfo() if (response.status >= 500) { - // maybe we're just in trouble in this region... if we're not in the primary - // region, then replay and hopefully it works next time. - const FLY_REGION = getRequiredServerEnvVar('FLY_REGION') - if (FLY_REGION !== ENV.PRIMARY_REGION) { - return new Response('Fly Replay', { - status: 409, - headers: {'fly-replay': `region=${ENV.PRIMARY_REGION}`}, - }) + // maybe we're just in trouble in this instance... if we're not in the primary + // instance, then replay and hopefully it works next time. + if (!currentIsPrimary) { + return getFlyReplayResponse(primaryInstance) + } + } + + if (request.method === 'POST') { + if (currentIsPrimary) { + const txnum = getTXNumber() + if (txnum) { + response.headers.append( + 'Set-Cookie', + cookie.serialize('txnum', txnum.toString(), { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: true, + }), + ) + } } } return response } + +function getTXNumber() { + const {FLY_LITEFS_DIR} = process.env + invariant(FLY_LITEFS_DIR, 'FLY_LITEFS_DIR is not defined') + let dbPos = '0' + try { + dbPos = fs.readFileSync(path.join(FLY_LITEFS_DIR, `sqlite.db-pos`), 'utf-8') + } catch { + // ignore + } + return parseInt(dbPos.trim().split('/')[0] ?? '0', 16) +} diff --git a/app/root.tsx b/app/root.tsx index 674bf6402..72147021b 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -48,8 +48,6 @@ import { import {getEnv} from './utils/env.server' import {getUserInfo} from './utils/user-info.server' import {getClientSession} from './utils/client.server' -import type {Timings} from './utils/metrics.server' -import {time, getServerTimeHeader} from './utils/metrics.server' import {Navbar} from './components/navbar' import {Spacer} from './components/spacer' import {Footer} from './components/footer' @@ -159,18 +157,12 @@ export const loader: LoaderFunction = async ({request}) => { return new Response() } - const timings: Timings = {} const session = await getSession(request) const themeSession = await getThemeSession(request) const clientSession = await getClientSession(request) const loginInfoSession = await getLoginInfoSession(request) - const user = await time({ - name: 'getUser in root loader', - type: 'postgres read', - timings, - fn: () => session.getUser(), - }) + const user = await session.getUser() const randomFooterImageKeys = Object.keys(illustrationImages) const randomFooterImageKey = randomFooterImageKeys[ @@ -179,14 +171,7 @@ export const loader: LoaderFunction = async ({request}) => { const data: LoaderData = { user, - userInfo: user - ? await time({ - name: 'getUserInfo in root loader', - type: 'convertkit and discord read', - timings, - fn: () => getUserInfo(user, {request, timings}), - }) - : null, + userInfo: user ? await getUserInfo(user, {request}) : null, ENV: getEnv(), randomFooterImageKey, requestInfo: { @@ -201,7 +186,6 @@ export const loader: LoaderFunction = async ({request}) => { } const headers: HeadersInit = new Headers() - headers.append('Server-Timing', getServerTimeHeader(timings)) // this can lead to race conditions if a child route is also trying to commit // the cookie as well. This is a bug in remix that will hopefully be fixed. // we reduce the likelihood of a problem by only committing if the value is diff --git a/app/routes/$slug.tsx b/app/routes/$slug.tsx index 3b50c4d7e..ce20c6b31 100644 --- a/app/routes/$slug.tsx +++ b/app/routes/$slug.tsx @@ -1,8 +1,8 @@ import * as React from 'react' -import type {HeadersFunction, LoaderFunction} from '@remix-run/node' +import type {HeadersFunction, DataFunctionArgs} from '@remix-run/node' import {json} from '@remix-run/node' import {useCatch, useLoaderData} from '@remix-run/react' -import type {MdxPage, MdxListItem, KCDHandle} from '~/types' +import type {KCDHandle} from '~/types' import { getMdxPage, getMdxPagesInDirectory, @@ -21,11 +21,6 @@ import {getImageBuilder, getImgProps} from '~/images' import {reuseUsefulLoaderHeaders} from '~/utils/misc' import {BlurrableImage} from '~/components/blurrable-image' -type LoaderData = { - page: MdxPage - blogRecommendations: Array -} - export const handle: KCDHandle = { getSitemapEntries: async request => { const pages = await getMdxPagesInDirectory('pages', {request}) @@ -37,32 +32,30 @@ export const handle: KCDHandle = { }, } -export const loader: LoaderFunction = async ({params, request}) => { +export async function loader({params, request}: DataFunctionArgs) { if (!params.slug) { throw new Error('params.slug is not defined') } // because this is our catch-all thing, we'll do an early return for anything // that has a other route setup. The response will be handled there. if (pathedRoutes[new URL(request.url).pathname]) { - return new Response() + throw new Response('Use other route', {status: 404}) } - const [page, blogRecommendations] = await Promise.all([ - getMdxPage({contentDir: 'pages', slug: params.slug}, {request}).catch( - () => null, - ), - getBlogRecommendations(request), - ]) + const page = await getMdxPage( + {contentDir: 'pages', slug: params.slug}, + {request}, + ).catch(() => null) const headers = { 'Cache-Control': 'private, max-age=3600', Vary: 'Cookie', } if (!page) { + const blogRecommendations = await getBlogRecommendations(request) throw json({blogRecommendations}, {status: 404, headers}) } - const data: LoaderData = {page, blogRecommendations} - return json(data, {status: 200, headers}) + return json({page}, {status: 200, headers}) } export const headers: HeadersFunction = reuseUsefulLoaderHeaders @@ -70,7 +63,7 @@ export const headers: HeadersFunction = reuseUsefulLoaderHeaders export const meta = mdxPageMeta export default function MdxScreen() { - const data = useLoaderData() + const data = useLoaderData() const {code, frontmatter} = data.page const isDraft = Boolean(frontmatter.draft) const Component = useMdxComponent(code) diff --git a/app/routes/action/refresh-cache.tsx b/app/routes/action/refresh-cache.tsx index ed376d052..4ff9112a4 100644 --- a/app/routes/action/refresh-cache.tsx +++ b/app/routes/action/refresh-cache.tsx @@ -1,9 +1,8 @@ import path from 'path' -import * as React from 'react' import type {ActionFunction} from '@remix-run/node' import {json, redirect} from '@remix-run/node' import {getRequiredServerEnvVar} from '~/utils/misc' -import {redisCache} from '~/utils/redis.server' +import {cache} from '~/utils/cache.server' import {getBlogMdxListItems, getMdxDirList, getMdxPage} from '~/utils/mdx' import {getTalksAndTags} from '~/utils/talks.server' import {getTestimonials} from '~/utils/testimonials.server' @@ -14,6 +13,22 @@ type Body = | {keys: Array; commitSha?: string} | {contentPaths: Array; commitSha?: string} +export type RefreshShaInfo = { + sha: string + date: string +} + +export function isRefreshShaInfo(value: any): value is RefreshShaInfo { + return ( + typeof value === 'object' && + value !== null && + 'sha' in value && + typeof value.sha === 'string' && + 'date' in value && + typeof value.date === 'string' + ) +} + export const commitShaKey = 'meta:last-refresh-commit-sha' export const action: ActionFunction = async ({request}) => { @@ -29,19 +44,28 @@ export const action: ActionFunction = async ({request}) => { const body = (await request.json()) as Body - function setShaInRedis() { - if (body.commitSha) { - void redisCache.set(commitShaKey, {sha: body.commitSha, date: new Date()}) + function setShaInCache() { + const {commitSha: sha} = body + if (sha) { + const value: RefreshShaInfo = {sha, date: new Date().toISOString()} + cache.set(commitShaKey, { + value, + metadata: { + createdTime: new Date().getTime(), + swr: Number.MAX_SAFE_INTEGER, + ttl: Number.MAX_SAFE_INTEGER, + }, + }) } } if ('keys' in body && Array.isArray(body.keys)) { for (const key of body.keys) { - void redisCache.del(key) + void cache.delete(key) } - setShaInRedis() + setShaInCache() return json({ - message: 'Deleting redis cache keys', + message: 'Deleting cache keys', keys: body.keys, commitSha: body.commitSha, }) @@ -89,7 +113,7 @@ export const action: ActionFunction = async ({request}) => { void getMdxDirList('pages', {forceFresh: true}) } - setShaInRedis() + setShaInCache() return json({ message: 'Refreshing cache for content paths', contentPaths: refreshingContentPaths, @@ -100,7 +124,3 @@ export const action: ActionFunction = async ({request}) => { } export const loader = () => redirect('/', {status: 404}) - -export default function MarkRead() { - return
Oops... You should not see this.
-} diff --git a/app/routes/blog.$slug.tsx b/app/routes/blog.$slug.tsx index a190fb9e9..d40e6877b 100644 --- a/app/routes/blog.$slug.tsx +++ b/app/routes/blog.$slug.tsx @@ -1,5 +1,9 @@ import * as React from 'react' -import type {HeadersFunction, ActionFunction, LoaderArgs} from '@remix-run/node' +import type { + HeadersFunction, + ActionFunction, + DataFunctionArgs, +} from '@remix-run/node' import {json} from '@remix-run/node' import { Link, @@ -33,8 +37,6 @@ import { } from '~/utils/blog.server' import {FourOhFour, ServerError} from '~/components/errors' import {TeamStats} from '~/components/team-stats' -import type {Timings} from '~/utils/metrics.server' -import {getServerTimeHeader} from '~/utils/metrics.server' import {formatDate, formatNumber, reuseUsefulLoaderHeaders} from '~/utils/misc' import {BlurrableImage} from '~/components/blurrable-image' import {getSession} from '~/utils/session.server' @@ -145,18 +147,14 @@ type CatchData = { leadingTeam: Team | null } -export async function loader({request, params}: LoaderArgs) { +export async function loader({request, params}: DataFunctionArgs) { if (!params.slug) { throw new Error('params.slug is not defined') } - const timings: Timings = {} const page = await getMdxPage( - { - contentDir: 'blog', - slug: params.slug, - }, - {request, timings}, + {contentDir: 'blog', slug: params.slug}, + {request}, ) const [recommendations, readRankings, totalReads, workshops, workshopEvents] = @@ -171,7 +169,7 @@ export async function loader({request, params}: LoaderArgs) { }), getBlogReadRankings({request, slug: params.slug}), getTotalPostReads(request, params.slug), - getWorkshops({request, timings}), + getWorkshops({request}), getScheduledEvents({request}), ]) @@ -184,7 +182,6 @@ export async function loader({request, params}: LoaderArgs) { const headers = { 'Cache-Control': 'private, max-age=3600', Vary: 'Cookie', - 'Server-Timing': getServerTimeHeader(timings), } if (!page) { throw json(catchData, {status: 404, headers}) diff --git a/app/routes/blog.tsx b/app/routes/blog.tsx index b9dfe6d45..f21156585 100644 --- a/app/routes/blog.tsx +++ b/app/routes/blog.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import type { HeadersFunction, - LoaderArgs, + DataFunctionArgs, MetaFunction, SerializeFrom, } from '@remix-run/node' @@ -29,8 +29,6 @@ import {filterPosts, getRankingLeader} from '~/utils/blog' import {HeroSection} from '~/components/sections/hero-section' import {PlusIcon} from '~/components/icons/plus-icon' import {Button} from '~/components/button' -import type {Timings} from '~/utils/metrics.server' -import {getServerTimeHeader} from '~/utils/metrics.server' import {ServerError} from '~/components/errors' import { formatAbbreviatedNumber, @@ -63,9 +61,7 @@ export const handle: KCDHandle = { getSitemapEntries: () => [{route: `/blog`, priority: 0.7}], } -export async function loader({request}: LoaderArgs) { - const timings: Timings = {} - +export async function loader({request}: DataFunctionArgs) { const [ posts, [recommended], @@ -75,7 +71,7 @@ export async function loader({request}: LoaderArgs) { allPostReadRankings, userReads, ] = await Promise.all([ - getBlogMdxListItems({request, timings}).then(allPosts => + getBlogMdxListItems({request}).then(allPosts => allPosts.filter(p => !p.frontmatter.draft), ), getBlogRecommendations(request, {limit: 1}), @@ -109,7 +105,6 @@ export async function loader({request}: LoaderArgs) { headers: { 'Cache-Control': 'private, max-age=3600', Vary: 'Cookie', - 'Server-Timing': getServerTimeHeader(timings), }, }) } diff --git a/app/routes/cache.admin.tsx b/app/routes/cache.admin.tsx new file mode 100644 index 000000000..f8aa41fe6 --- /dev/null +++ b/app/routes/cache.admin.tsx @@ -0,0 +1,157 @@ +import * as React from 'react' +import type {DataFunctionArgs} from '@remix-run/node' +import {json} from '@remix-run/node' +import { + Form, + useFetcher, + useLoaderData, + useSearchParams, +} from '@remix-run/react' +import {H2} from '~/components/typography' +import {cache, getAllCacheKeys, searchCacheKeys} from '~/utils/cache.server' +import {requireAdminUser} from '~/utils/session.server' +import {Spacer} from '~/components/spacer' +import invariant from 'tiny-invariant' +import {Button} from '~/components/button' +import {useDoubleCheck} from '~/utils/misc' +import {Field} from '~/components/form-elements' +import {SearchIcon} from '~/components/icons/search-icon' + +export async function loader({request}: DataFunctionArgs) { + await requireAdminUser(request) + const searchParams = new URL(request.url).searchParams + const query = searchParams.get('query') + const limit = Number(searchParams.get('limit') ?? 100) + const region = searchParams.get('region') ?? process.env.FLY_REGION + if (process.env.FLY && region !== process.env.FLY_REGION) { + throw new Response('Fly Replay', { + status: 409, + headers: { + 'fly-replay': `region=${region}`, + }, + }) + } + + let cacheKeys: Array + if (typeof query === 'string') { + cacheKeys = await searchCacheKeys(query, limit) + } else { + cacheKeys = await getAllCacheKeys(limit) + } + return json({cacheKeys, region}) +} + +export async function action({request}: DataFunctionArgs) { + await requireAdminUser(request) + const formData = await request.formData() + const key = formData.get('cacheKey') + const region = formData.get('region') ?? process.env.FLY_REGION + if (process.env.FLY && region !== process.env.FLY_REGION) { + throw new Response('Fly Replay', { + status: 409, + headers: { + 'fly-replay': `region=${region}`, + }, + }) + } + + invariant(typeof key === 'string', 'cacheKey must be a string') + await cache.delete(key) + return json({success: true}) +} + +export default function CacheAdminRoute() { + const data = useLoaderData() + const [searchParams] = useSearchParams() + const query = searchParams.get('query') ?? '' + const limit = searchParams.get('limit') ?? '100' + const region = searchParams.get('region') ?? data.region + + return ( +
+

Cache Admin

+ +
+
+
+
+ + +
+ {data.cacheKeys.length} +
+
+
+ + +
+
+ +
+ {data.cacheKeys.map(key => ( + + ))} +
+
+ ) +} + +function CacheKeyRow({cacheKey, region}: {cacheKey: string; region?: string}) { + const fetcher = useFetcher() + const dc = useDoubleCheck() + return ( +
+ + + + + + + {cacheKey} + +
+ ) +} + +export function ErrorBoundary({error}: {error: Error}) { + console.error(error) + + return
An unexpected error occurred: {error.message}
+} diff --git a/app/routes/calls.admin.tsx b/app/routes/calls.admin.tsx index 6f69244db..babffa3bc 100644 --- a/app/routes/calls.admin.tsx +++ b/app/routes/calls.admin.tsx @@ -4,7 +4,7 @@ import {json, redirect} from '@remix-run/node' import {Link, Outlet, useLoaderData} from '@remix-run/react' import type {Await, KCDHandle} from '~/types' import {requireAdminUser} from '~/utils/session.server' -import {prismaRead, prismaWrite} from '~/utils/prisma.server' +import {prisma} from '~/utils/prisma.server' import {getAvatarForUser} from '~/utils/misc' import {useRootData} from '~/utils/use-root-data' @@ -23,7 +23,7 @@ export const action: ActionFunction = async ({request}) => { console.warn(`No callId provided to call delete action.`) return redirect(new URL(request.url).pathname) } - const call = await prismaRead.call.findFirst({ + const call = await prisma.call.findFirst({ // NOTE: since we require an admin user, we don't need to check // whether this user is the creator of the call where: {id: callId}, @@ -33,12 +33,12 @@ export const action: ActionFunction = async ({request}) => { console.warn(`Failed to get a call to delete by callId: ${callId}`) return redirect(new URL(request.url).pathname) } - await prismaWrite.call.delete({where: {id: callId}}) + await prisma.call.delete({where: {id: callId}}) return redirect(new URL(request.url).pathname) } async function getAllCalls() { - const calls = await prismaRead.call.findMany({ + const calls = await prisma.call.findMany({ select: { id: true, title: true, diff --git a/app/routes/calls.admin/$callId.tsx b/app/routes/calls.admin/$callId.tsx index dea95ed78..28289abc7 100644 --- a/app/routes/calls.admin/$callId.tsx +++ b/app/routes/calls.admin/$callId.tsx @@ -7,7 +7,7 @@ import {format} from 'date-fns' import {useRootData, useUser} from '~/utils/use-root-data' import {CallRecorder} from '~/components/calls/recorder' import {requireAdminUser} from '~/utils/session.server' -import {prismaWrite, prismaRead} from '~/utils/prisma.server' +import {prisma} from '~/utils/prisma.server' import { getAvatarForUser, getErrorMessage, @@ -45,10 +45,10 @@ export const action: ActionFunction = async ({request, params}) => { await requireAdminUser(request) if (request.method === 'DELETE') { - await prismaWrite.call.delete({where: {id: params.callId}}) + await prisma.call.delete({where: {id: params.callId}}) return redirect('/calls/admin') } - const call = await prismaRead.call.findFirst({ + const call = await prisma.call.findFirst({ where: {id: params.callId}, include: {user: true}, }) @@ -138,7 +138,7 @@ Thanks for your call. Kent just replied and the episode has been published to th } } - await prismaWrite.call.delete({ + await prisma.call.delete({ where: {id: call.id}, }) @@ -154,7 +154,7 @@ type LoaderData = { } async function getCallInfo({callId}: {callId: string}) { - const call = await prismaRead.call.findFirst({ + const call = await prisma.call.findFirst({ where: {id: callId}, select: { base64: true, diff --git a/app/routes/calls.record.tsx b/app/routes/calls.record.tsx index ee5bab823..18ade21a8 100644 --- a/app/routes/calls.record.tsx +++ b/app/routes/calls.record.tsx @@ -5,7 +5,7 @@ import {Link, Outlet, useLoaderData, useLocation} from '@remix-run/react' import type {Await} from '~/types' import {AnimatePresence, motion} from 'framer-motion' import {getUser} from '~/utils/session.server' -import {prismaRead} from '~/utils/prisma.server' +import {prisma} from '~/utils/prisma.server' import {Grid} from '~/components/grid' import {H2, Paragraph} from '~/components/typography' import {BackLink} from '~/components/arrow-button' @@ -14,7 +14,7 @@ import {ButtonLink} from '~/components/button' import {useRootData} from '~/utils/use-root-data' function getCalls(userId: string) { - return prismaRead.call.findMany({ + return prisma.call.findMany({ where: {userId}, select: {id: true, title: true}, }) @@ -84,7 +84,7 @@ function Record({
{title} diff --git a/app/routes/calls.record/$callId.tsx b/app/routes/calls.record/$callId.tsx index 0aa66dbaf..efe75bacc 100644 --- a/app/routes/calls.record/$callId.tsx +++ b/app/routes/calls.record/$callId.tsx @@ -8,7 +8,7 @@ import {json, redirect} from '@remix-run/node' import {Form, useLoaderData} from '@remix-run/react' import type {Call, KCDHandle} from '~/types' import {requireUser} from '~/utils/session.server' -import {prismaRead, prismaWrite} from '~/utils/prisma.server' +import {prisma} from '~/utils/prisma.server' import {Paragraph} from '~/components/typography' import {reuseUsefulLoaderHeaders, useDoubleCheck} from '~/utils/misc' import {Button} from '~/components/button' @@ -26,7 +26,7 @@ export const action: ActionFunction = async ({params, request}) => { throw new Error('params.callId is not defined') } const user = await requireUser(request) - const call = await prismaRead.call.findFirst({ + const call = await prisma.call.findFirst({ // NOTE: this is how we ensure the user is the owner of the call // and is therefore authorized to delete it. where: {userId: user.id, id: params.callId}, @@ -38,7 +38,7 @@ export const action: ActionFunction = async ({params, request}) => { ) return redirect('/calls/record') } - await prismaWrite.call.delete({where: {id: params.callId}}) + await prisma.call.delete({where: {id: params.callId}}) return redirect('/calls/record') } @@ -50,7 +50,7 @@ export const loader: LoaderFunction = async ({params, request}) => { throw new Error('params.callId is not defined') } const user = await requireUser(request) - const call = await prismaRead.call.findFirst({ + const call = await prisma.call.findFirst({ // NOTE: this is how we ensure the user is the owner of the call // and is therefore authorized to delete it. where: {userId: user.id, id: params.callId}, diff --git a/app/routes/calls.record/new.tsx b/app/routes/calls.record/new.tsx index 417303ce1..a9cf79f86 100644 --- a/app/routes/calls.record/new.tsx +++ b/app/routes/calls.record/new.tsx @@ -8,7 +8,7 @@ import {CallRecorder} from '~/components/calls/recorder' import type {RecordingFormData} from '~/components/calls/submit-recording-form' import {RecordingForm} from '~/components/calls/submit-recording-form' import {requireUser} from '~/utils/session.server' -import {prismaWrite} from '~/utils/prisma.server' +import {prisma} from '~/utils/prisma.server' import { getDomainUrl, getErrorMessage, @@ -72,7 +72,7 @@ export const action: ActionFunction = async ({request}) => { userId: user.id, base64: audio, } - const createdCall = await prismaWrite.call.create({data: call}) + const createdCall = await prisma.call.create({data: call}) try { const channelId = getRequiredServerEnvVar('DISCORD_PRIVATE_BOT_CHANNEL') diff --git a/app/routes/discord/callback.tsx b/app/routes/discord/callback.ts similarity index 81% rename from app/routes/discord/callback.tsx rename to app/routes/discord/callback.ts index cbc5f0bc5..29ab3b187 100644 --- a/app/routes/discord/callback.tsx +++ b/app/routes/discord/callback.ts @@ -1,7 +1,6 @@ -import type {LoaderFunction} from '@remix-run/node' +import type {DataFunctionArgs} from '@remix-run/node' import {redirect} from '@remix-run/node' import type {KCDHandle} from '~/types' -import * as React from 'react' import {requireUser} from '~/utils/session.server' import {getDomainUrl, getErrorMessage} from '~/utils/misc' import {connectDiscord} from '~/utils/discord.server' @@ -12,7 +11,7 @@ export const handle: KCDHandle = { getSitemapEntries: () => null, } -export const loader: LoaderFunction = async ({request}) => { +export async function loader({request}: DataFunctionArgs) { const user = await requireUser(request) const domainUrl = getDomainUrl(request) const code = new URL(request.url).searchParams.get('code') @@ -53,11 +52,3 @@ export const loader: LoaderFunction = async ({request}) => { return redirect(url.toString()) } } - -export default function DiscordCallback() { - return ( -
- {`Congrats! You're seeing something you shouldn't ever be able to see because you should have been redirected. Good job!`} -
- ) -} diff --git a/app/routes/healthcheck.tsx b/app/routes/healthcheck.tsx index ee4db762e..29e5f38f1 100644 --- a/app/routes/healthcheck.tsx +++ b/app/routes/healthcheck.tsx @@ -1,14 +1,14 @@ -import type {LoaderFunction} from '@remix-run/node' -import {prismaRead} from '~/utils/prisma.server' +import type {DataFunctionArgs} from '@remix-run/node' +import {prisma} from '~/utils/prisma.server' import {getBlogReadRankings} from '~/utils/blog.server' -export const loader: LoaderFunction = async ({request}) => { +export async function loader({request}: DataFunctionArgs) { const host = request.headers.get('X-Forwarded-Host') ?? request.headers.get('host') try { await Promise.all([ - prismaRead.user.count(), + prisma.user.count(), getBlogReadRankings({request}), fetch(`http://${host}`, {method: 'HEAD'}).then(r => { if (!r.ok) return Promise.reject(r) @@ -16,7 +16,7 @@ export const loader: LoaderFunction = async ({request}) => { ]) return new Response('OK') } catch (error: unknown) { - console.log('healthcheck โŒ', {error}) + console.log(request.url, 'healthcheck โŒ', {error}) return new Response('ERROR', {status: 500}) } } diff --git a/app/routes/magic.tsx b/app/routes/magic.tsx index f7c08e09e..f1f2514ae 100644 --- a/app/routes/magic.tsx +++ b/app/routes/magic.tsx @@ -1,4 +1,4 @@ -import type {LoaderFunction} from '@remix-run/node' +import type {DataFunctionArgs} from '@remix-run/node' import {redirect} from '@remix-run/node' import type {KCDHandle} from '~/types' import * as React from 'react' @@ -9,7 +9,7 @@ export const handle: KCDHandle = { getSitemapEntries: () => null, } -export const loader: LoaderFunction = async ({request}) => { +export async function loader({request}: DataFunctionArgs) { const loginInfoSession = await getLoginInfoSession(request) try { const session = await getUserSessionFromMagicLink(request) diff --git a/app/routes/me.admin.tsx b/app/routes/me.admin.tsx index 95a9f7698..369df921c 100644 --- a/app/routes/me.admin.tsx +++ b/app/routes/me.admin.tsx @@ -12,7 +12,7 @@ import type {Column} from 'react-table' import {Grid} from '~/components/grid' import {H1} from '~/components/typography' import type {Await, KCDHandle} from '~/types' -import {prismaRead, prismaWrite} from '~/utils/prisma.server' +import {prisma} from '~/utils/prisma.server' import {requireAdminUser} from '~/utils/session.server' import { formatDate, @@ -69,7 +69,7 @@ async function getLoaderData({request}: {request: Request}) { if (isOrderField(spOrderField)) orderField = spOrderField const limit = Number(searchParams.get('limit') ?? DEFAULT_LIMIT) - const users = await prismaRead.user.findMany({ + const users = await prisma.user.findMany({ where: query ? { OR: [ @@ -108,9 +108,9 @@ export const action: ActionFunction = async ({request}) => { if (!id) return json({error: 'id is required'}, {status: 400}) if (request.method === 'DELETE') { - await prismaWrite.user.delete({where: {id}}) + await prisma.user.delete({where: {id}}) } else { - await prismaWrite.user.update({ + await prisma.user.update({ where: {id}, data: values, }) diff --git a/app/routes/me.tsx b/app/routes/me.tsx index 3d396a7f6..59e31f048 100644 --- a/app/routes/me.tsx +++ b/app/routes/me.tsx @@ -8,7 +8,7 @@ import type { import {json, redirect} from '@remix-run/node' import {Form, useActionData, useLoaderData} from '@remix-run/react' import clsx from 'clsx' -import Dialog from '@reach/dialog' +import {Dialog} from '@reach/dialog' import type {KCDHandle} from '~/types' import {useRootData} from '~/utils/use-root-data' import {getQrCodeDataURL} from '~/utils/qrcode.server' @@ -25,7 +25,7 @@ import { deleteConvertKitCache, deleteDiscordCache, } from '~/utils/user-info.server' -import {prismaRead, prismaWrite, getMagicLink} from '~/utils/prisma.server' +import {prisma, getMagicLink} from '~/utils/prisma.server' import { deleteOtherSessions, getSession, @@ -73,7 +73,7 @@ type LoaderData = {qrLoginCode: string; sessionCount: number} export const loader: LoaderFunction = async ({request}) => { const user = await requireUser(request) - const sessionCount = await prismaRead.session.count({ + const sessionCount = await prisma.session.count({ where: {userId: user.id}, }) const qrLoginCode = await getQrCodeDataURL( @@ -125,7 +125,7 @@ export const action: ActionFunction = async ({request}) => { try { if (actionId === actionIds.logout) { const session = await getSession(request) - session.signOut() + await session.signOut() const searchParams = new URLSearchParams({ message: `๐Ÿ‘‹ See you again soon!`, }) @@ -135,7 +135,7 @@ export const action: ActionFunction = async ({request}) => { } if (actionId === actionIds.deleteDiscordConnection && user.discordId) { await deleteDiscordCache(user.discordId) - await prismaWrite.user.update({ + await prisma.user.update({ where: {id: user.id}, data: {discordId: null}, }) @@ -150,7 +150,7 @@ export const action: ActionFunction = async ({request}) => { validators: {firstName: getFirstNameError}, handleFormValues: async ({firstName}) => { if (firstName && user.firstName !== firstName) { - await prismaWrite.user.update({ + await prisma.user.update({ where: {id: user.id}, data: {firstName}, }) @@ -171,11 +171,11 @@ export const action: ActionFunction = async ({request}) => { } if (actionId === actionIds.deleteAccount) { const session = await getSession(request) - session.signOut() + await session.signOut() if (user.discordId) await deleteDiscordCache(user.discordId) if (user.convertKitId) await deleteConvertKitCache(user.convertKitId) - await prismaWrite.user.delete({where: {id: user.id}}) + await prisma.user.delete({where: {id: user.id}}) const searchParams = new URLSearchParams({ message: `โœ… Your KCD account and all associated data has been completely deleted from the KCD database.`, }) @@ -346,7 +346,7 @@ function YouScreen() { readOnly /> -
+
diff --git a/app/routes/prisma-studio.$.tsx b/app/routes/prisma-studio.$.tsx new file mode 100644 index 000000000..b78be5be0 --- /dev/null +++ b/app/routes/prisma-studio.$.tsx @@ -0,0 +1,22 @@ +import type {DataFunctionArgs} from '@remix-run/node' +import {requireAdminUser} from '~/utils/session.server' + +export async function loader({request}: DataFunctionArgs) { + await requireAdminUser(request) + const {pathname} = new URL(request.url) + const url = `http://localhost:5555${pathname.replace('/prisma-studio', '')}` + return fetch(url, { + headers: request.headers, + }) +} + +export async function action({request}: DataFunctionArgs) { + await requireAdminUser(request) + const {pathname} = new URL(request.url) + const url = `http://localhost:5555${pathname.replace('/prisma-studio', '')}` + return fetch(url, { + method: request.method, + body: request.body, + headers: request.headers, + }) +} diff --git a/app/routes/prisma-studio.tsx b/app/routes/prisma-studio.tsx new file mode 100644 index 000000000..7ea0fb0d6 --- /dev/null +++ b/app/routes/prisma-studio.tsx @@ -0,0 +1,39 @@ +import {spawn} from 'child_process' +import type {DataFunctionArgs} from '@remix-run/node' +import {requireAdminUser} from '~/utils/session.server' + +async function ensurePrismaStudioIsRunning() { + try { + await fetch('http://localhost:5555', {method: 'HEAD'}) + // eslint-disable-next-line @typescript-eslint/no-implicit-any-catch, @typescript-eslint/no-explicit-any + } catch (error: any) { + if ('code' in error) { + if (error.code !== 'ECONNREFUSED') throw error + } + + spawn('npx', ['prisma', 'studio'], { + stdio: 'inherit', + shell: true, + detached: true, + }) + // give it a second to start up + await new Promise(resolve => setTimeout(resolve, 1000)) + } +} + +export async function loader({request}: DataFunctionArgs) { + await requireAdminUser(request) + await ensurePrismaStudioIsRunning() + const response = await fetch('http://localhost:5555', request) + const studioHtml = await response.text() + const relativeStudioHtml = studioHtml.replace( + /"\.\/(.*)"/g, + '"/prisma-studio/$1"', + ) + return new Response(relativeStudioHtml, { + headers: { + 'Content-Type': 'text/html', + 'Content-Length': String(Buffer.byteLength(relativeStudioHtml)), + }, + }) +} diff --git a/app/routes/refresh-commit-sha[.]json.tsx b/app/routes/refresh-commit-sha[.]json.tsx index ec521f5cb..134f8b28e 100644 --- a/app/routes/refresh-commit-sha[.]json.tsx +++ b/app/routes/refresh-commit-sha[.]json.tsx @@ -1,14 +1,28 @@ -import type {LoaderFunction} from '@remix-run/node' -import {redisCache} from '~/utils/redis.server' -import {commitShaKey as refreshCacheCommitShaKey} from './action/refresh-cache' +import {json} from '@remix-run/node' +import {cache} from '~/utils/cache.server' +import type {RefreshShaInfo} from './action/refresh-cache' +import { + commitShaKey as refreshCacheCommitShaKey, + isRefreshShaInfo, +} from './action/refresh-cache' -export const loader: LoaderFunction = async () => { - const shaInfo = await redisCache.get(refreshCacheCommitShaKey) - const data = JSON.stringify(shaInfo) - return new Response(data, { - headers: { - 'Content-Type': 'application/json', - 'Content-Length': String(Buffer.byteLength(data)), - }, - }) +export async function loader() { + const result = await cache.get(refreshCacheCommitShaKey) + if (!result) { + return json(null) + } + + let value: RefreshShaInfo + try { + value = JSON.parse(result.value as any) + if (!isRefreshShaInfo(value)) { + throw new Error(`Invalid value: ${result.value}`) + } + } catch (error: unknown) { + console.error(`Error parsing commit sha from cache: ${error}`) + cache.delete(refreshCacheCommitShaKey) + return json(null) + } + + return json(value) } diff --git a/app/routes/resources/cache.$cacheKey.ts b/app/routes/resources/cache.$cacheKey.ts new file mode 100644 index 000000000..549b9a528 --- /dev/null +++ b/app/routes/resources/cache.$cacheKey.ts @@ -0,0 +1,12 @@ +import type {DataFunctionArgs} from '@remix-run/node' +import {json} from '@remix-run/node' +import invariant from 'tiny-invariant' +import {cache} from '~/utils/cache.server' +import {requireAdminUser} from '~/utils/session.server' + +export async function loader({request, params}: DataFunctionArgs) { + await requireAdminUser(request) + const {cacheKey} = params + invariant(cacheKey, 'cacheKey is required') + return json({cacheKey, value: await cache.get(cacheKey)}) +} diff --git a/app/routes/resources/search.ts b/app/routes/resources/search.ts index 06fbf0f84..a8baddfbd 100644 --- a/app/routes/resources/search.ts +++ b/app/routes/resources/search.ts @@ -1,9 +1,9 @@ -import type {LoaderArgs} from '@remix-run/node' +import type {DataFunctionArgs} from '@remix-run/node' import {json} from '@remix-run/node' import {getDomainUrl} from '~/utils/misc' import {searchKCD} from '~/utils/search.server' -export async function loader({request}: LoaderArgs) { +export async function loader({request}: DataFunctionArgs) { const query = new URL(request.url).searchParams.get('query') const domainUrl = getDomainUrl(request) if (typeof query !== 'string' || !query) { diff --git a/app/routes/s.$query.tsx b/app/routes/s.$query.tsx index 4db60421e..2f0cce463 100644 --- a/app/routes/s.$query.tsx +++ b/app/routes/s.$query.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import type {LoaderArgs} from '@remix-run/node' +import type {DataFunctionArgs} from '@remix-run/node' import {json, redirect} from '@remix-run/node' import {Link, useLoaderData, useParams} from '@remix-run/react' import {images} from '~/images' @@ -43,7 +43,7 @@ function itemsToSegmentedItems(items: NormalizedItemGroup['items']) { }, init) } -export async function loader({request, params}: LoaderArgs) { +export async function loader({request, params}: DataFunctionArgs) { const query = params.query if (typeof query !== 'string' || !query) return redirect('/') diff --git a/app/routes/signup.tsx b/app/routes/signup.tsx index 1f32f60d6..d2a81e40e 100644 --- a/app/routes/signup.tsx +++ b/app/routes/signup.tsx @@ -8,7 +8,7 @@ import type {KCDHandle, Team} from '~/types' import {useTeam} from '~/utils/team-provider' import {getSession, getUser} from '~/utils/session.server' import {getLoginInfoSession} from '~/utils/login.server' -import {prismaWrite, prismaRead, validateMagicLink} from '~/utils/prisma.server' +import {prisma, validateMagicLink} from '~/utils/prisma.server' import {getErrorStack, isTeam, teams} from '~/utils/misc' import {tagKCDSiteSubscriber} from '../convertkit/convertkit.server' import {Grid} from '~/components/grid' @@ -104,7 +104,7 @@ export const action: ActionFunction = async ({request}) => { const {firstName, team} = formData try { - const user = await prismaWrite.user.create({ + const user = await prisma.user.create({ data: {email, firstName, team}, }) @@ -114,7 +114,7 @@ export const action: ActionFunction = async ({request}) => { firstName, fields: {kcd_team: team, kcd_site_id: user.id}, }) - await prismaWrite.user.update({ + await prisma.user.update({ data: {convertKitId: String(sub.id)}, where: {id: user.id}, }) @@ -155,7 +155,7 @@ export const loader: LoaderFunction = async ({request}) => { }) } - const userForMagicLink = await prismaRead.user.findFirst({ + const userForMagicLink = await prisma.user.findFirst({ where: {email}, select: {id: true}, }) diff --git a/app/routes/workshops.tsx b/app/routes/workshops.tsx index 72495a75f..b493d8002 100644 --- a/app/routes/workshops.tsx +++ b/app/routes/workshops.tsx @@ -4,8 +4,6 @@ import {json} from '@remix-run/node' import {Outlet} from '@remix-run/react' import type {KCDHandle, Workshop} from '~/types' import {getWorkshops} from '~/utils/workshops.server' -import type {Timings} from '~/utils/metrics.server' -import {getServerTimeHeader} from '~/utils/metrics.server' import type {WorkshopEvent} from '~/utils/workshop-tickets.server' import {getScheduledEvents} from '~/utils/workshop-tickets.server' import {reuseUsefulLoaderHeaders} from '~/utils/misc' @@ -22,9 +20,8 @@ type LoaderData = { } export const loader: LoaderFunction = async ({request}) => { - const timings: Timings = {} const [workshops, workshopEvents] = await Promise.all([ - getWorkshops({request, timings}), + getWorkshops({request}), getScheduledEvents({request}), ]) @@ -43,7 +40,6 @@ export const loader: LoaderFunction = async ({request}) => { const headers = { 'Cache-Control': 'public, max-age=3600', Vary: 'Cookie', - 'Server-Timing': getServerTimeHeader(timings), } return json(data, {headers}) } diff --git a/app/routes/workshops/$slug.tsx b/app/routes/workshops/$slug.tsx index 068f4f99f..a43f2a05f 100644 --- a/app/routes/workshops/$slug.tsx +++ b/app/routes/workshops/$slug.tsx @@ -17,8 +17,6 @@ import {Spacer} from '~/components/spacer' import {TestimonialSection} from '~/components/sections/testimonial-section' import {FourOhFour} from '~/components/errors' import {getBlogRecommendations} from '~/utils/blog.server' -import type {Timings} from '~/utils/metrics.server' -import {getServerTimeHeader} from '~/utils/metrics.server' import {getWorkshops} from '~/utils/workshops.server' import {useWorkshopsData} from '../workshops' import {ConvertKitForm} from '../../convertkit/form' @@ -61,16 +59,14 @@ export const loader: LoaderFunction = async ({params, request}) => { if (!params.slug) { throw new Error('params.slug is not defined') } - const timings: Timings = {} const [workshops, blogRecommendations] = await Promise.all([ - getWorkshops({request, timings}), + getWorkshops({request}), getBlogRecommendations(request), ]) const workshop = workshops.find(w => w.slug === params.slug) const headers = { 'Cache-Control': 'private, max-age=3600', Vary: 'Cookie', - 'Server-Timing': getServerTimeHeader(timings), } if (!workshop) { diff --git a/app/utils/blog.server.ts b/app/utils/blog.server.ts index f9521f601..e601af3d7 100644 --- a/app/utils/blog.server.ts +++ b/app/utils/blog.server.ts @@ -1,8 +1,9 @@ import type {Team, MdxListItem, Await, User} from '~/types' import {subYears, subMonths} from 'date-fns' +import {cachified} from 'cachified' import {shuffle} from 'lodash' import {getBlogMdxListItems} from './mdx' -import {prismaRead} from './prisma.server' +import {prisma} from './prisma.server' import { getDomainUrl, getOptionalTeam, @@ -13,8 +14,7 @@ import { import {getSession, getUser} from './session.server' import {filterPosts} from './blog' import {getClientSession} from './client.server' -import {cachified, lruCache} from './cache.server' -import {redisCache} from './redis.server' +import {cache, lruCache, shouldForceFresh} from './cache.server' import {sendMessageFromDiscordBot} from './discord.server' import {teamEmoji} from './team-provider' @@ -51,7 +51,7 @@ async function getBlogRecommendations( const where = user ? {user: {id: user.id}, postSlug: {notIn: exclude.filter(Boolean)}} : {clientId, postSlug: {notIn: exclude.filter(Boolean)}} - const readPosts = await prismaRead.postRead.groupBy({ + const readPosts = await prisma.postRead.groupBy({ by: ['postSlug'], where, }) @@ -125,7 +125,7 @@ async function getMostPopularPostSlugs({ if (exclude.length) return getFreshValue() async function getFreshValue() { - const result = await prismaRead.postRead.groupBy({ + const result = await prisma.postRead.groupBy({ by: ['postSlug'], _count: true, orderBy: { @@ -144,7 +144,8 @@ async function getMostPopularPostSlugs({ return cachified({ key: `${limit}-most-popular-post-slugs`, - maxAge: 1000 * 60, + ttl: 1000 * 60, + staleWhileRevalidate: 1000 * 60 * 60 * 24, cache: lruCache, getFreshValue, checkValue: (value: unknown) => @@ -153,30 +154,34 @@ async function getMostPopularPostSlugs({ } async function getTotalPostReads(request: Request, slug?: string) { + const key = `total-post-reads:${slug ?? '__all-posts__'}` return cachified({ - key: `total-post-reads:${slug ?? '__all-posts__'}`, + key, cache: lruCache, - maxAge: 1000 * 60, - request, + ttl: 1000 * 60, + staleWhileRevalidate: 1000 * 60 * 60 * 24, + forceFresh: await shouldForceFresh({request, key}), checkValue: (value: unknown) => typeof value === 'number', getFreshValue: () => - prismaRead.postRead.count(slug ? {where: {postSlug: slug}} : undefined), + prisma.postRead.count(slug ? {where: {postSlug: slug}} : undefined), }) } async function getReaderCount(request: Request) { + const key = 'total-reader-count' return cachified({ - key: 'total-reader-count', + key, cache: lruCache, - maxAge: 1000 * 60 * 5, - request, + ttl: 1000 * 60 * 5, + staleWhileRevalidate: 1000 * 60 * 60 * 24, + forceFresh: await shouldForceFresh({request, key}), checkValue: (value: unknown) => typeof value === 'number', getFreshValue: async () => { // couldn't figure out how to do this in one query with out $queryRaw ๐Ÿคทโ€โ™‚๏ธ type CountResult = [{count: BigInt}] const [userIdCount, clientIdCount] = await Promise.all([ - prismaRead.$queryRaw`SELECT COUNT(DISTINCT "public"."PostRead"."userId") FROM "public"."PostRead" WHERE ("public"."PostRead"."userId") IS NOT NULL` as Promise, - prismaRead.$queryRaw`SELECT COUNT(DISTINCT "public"."PostRead"."clientId") FROM "public"."PostRead" WHERE ("public"."PostRead"."clientId") IS NOT NULL` as Promise, + prisma.$queryRaw`SELECT COUNT(DISTINCT "public"."PostRead"."userId") FROM "public"."PostRead" WHERE ("public"."PostRead"."userId") IS NOT NULL` as Promise, + prisma.$queryRaw`SELECT COUNT(DISTINCT "public"."PostRead"."clientId") FROM "public"."PostRead" WHERE ("public"."PostRead"."clientId") IS NOT NULL` as Promise, ]).catch(() => [[{count: BigInt(0)}], [{count: BigInt(0)}]]) return Number(userIdCount[0].count) + Number(clientIdCount[0].count) }, @@ -197,10 +202,10 @@ async function getBlogReadRankings({ const key = slug ? `blog:${slug}:rankings` : `blog:rankings` const rankingObjs = await cachified({ key, - cache: redisCache, - maxAge: slug ? 1000 * 60 * 60 * 24 * 7 : 1000 * 60 * 60, - request, - forceFresh, + cache, + ttl: slug ? 1000 * 60 * 60 * 24 * 7 : 1000 * 60 * 60, + staleWhileRevalidate: 1000 * 60 * 60 * 24, + forceFresh: await shouldForceFresh({forceFresh, request, key}), checkValue: (value: unknown) => Array.isArray(value) && value.every(v => typeof v === 'object' && 'team' in v), @@ -209,7 +214,7 @@ async function getBlogReadRankings({ teams.map(async function getRankingsForTeam( team, ): Promise<{team: Team; totalReads: number; ranking: number}> { - const totalReads = await prismaRead.postRead.count({ + const totalReads = await prisma.postRead.count({ where: { postSlug: slug, user: {team}, @@ -263,20 +268,21 @@ async function getAllBlogPostReadRankings({ request?: Request forceFresh?: boolean }) { + const key = 'all-blog-post-read-rankings' return cachified({ - key: 'all-blog-post-read-rankings', - cache: redisCache, - forceFresh, - request, - maxAge: 1000 * 60 * 5, // the underlying caching should be able to handle this every 5 minues + key, + cache, + forceFresh: await shouldForceFresh({forceFresh, request, key}), + ttl: 1000 * 60 * 5, // the underlying caching should be able to handle this every 5 minues + staleWhileRevalidate: 1000 * 60 * 60 * 24, getFreshValue: async () => { const posts = await getBlogMdxListItems({request}) const {default: pLimit} = await import('p-limit') // each of the getBlogReadRankings calls results in 9 postgres queries // and we don't want to hit the limit of connections so we limit this - // to 2 at a time. Though most of the data should be cached in redis - // anyway. This is good to just be certain. + // to 2 at a time. Though most of the data should be cached anyway. + // This is good to just be certain. const limit = pLimit(2) const allPostReadRankings: Record = {} await Promise.all( @@ -297,7 +303,7 @@ async function getAllBlogPostReadRankings({ async function getRecentReads(slug: string | undefined, team: Team) { const withinTheLastSixMonths = subMonths(new Date(), 6) - const count = await prismaRead.postRead.count({ + const count = await prisma.postRead.count({ where: { postSlug: slug, createdAt: {gt: withinTheLastSixMonths}, @@ -310,7 +316,7 @@ async function getRecentReads(slug: string | undefined, team: Team) { async function getActiveMembers(team: Team) { const withinTheLastYear = subYears(new Date(), 1) - const count = await prismaRead.user.count({ + const count = await prisma.user.count({ where: { team, postReads: { @@ -327,7 +333,7 @@ async function getActiveMembers(team: Team) { async function getSlugReadsByUser(request: Request) { const user = await getUser(request) if (!user) return [] - const reads = await prismaRead.postRead.findMany({ + const reads = await prisma.postRead.findMany({ where: {userId: user.id}, select: {postSlug: true}, }) diff --git a/app/utils/cache.server.ts b/app/utils/cache.server.ts index 3a0ced5ad..baac80590 100644 --- a/app/utils/cache.server.ts +++ b/app/utils/cache.server.ts @@ -1,237 +1,97 @@ import LRU from 'lru-cache' -import {formatDuration, intervalToDuration} from 'date-fns' -import type {Timings} from './metrics.server' -import {time} from './metrics.server' +import type {Cache as CachifiedCache, CacheEntry} from 'cachified' +import {lruCacheAdapter} from 'cachified' +import Database from 'better-sqlite3' import {getUser} from './session.server' +import {getRequiredServerEnvVar} from './misc' -function niceFormatDuration(milliseconds: number) { - const duration = intervalToDuration({start: 0, end: milliseconds}) - const formatted = formatDuration(duration, {delimiter: ', '}) - const ms = milliseconds % 1000 - return [formatted, ms ? `${ms.toFixed(3)}ms` : null] - .filter(Boolean) - .join(', ') -} +const CACHE_DATABASE_PATH = getRequiredServerEnvVar('CACHE_DATABASE_PATH') declare global { // This preserves the LRU cache during development // eslint-disable-next-line - var lruCache: - | (LRU & {name: string}) - | undefined + var __lruCache: LRU> | undefined, + __cacheDb: ReturnType | undefined } -const lruCache = (global.lruCache = global.lruCache - ? global.lruCache - : createLruCache()) - -function createLruCache() { - // doing anything other than "any" here was a big pain - const newCache = new LRU({ - max: 1000, - ttl: 1000 * 60 * 60, // 1 hour - }) - Object.assign(newCache, {name: 'LRU'}) - return newCache as typeof newCache & {name: 'LRU'} +const cacheDb = (global.__cacheDb = global.__cacheDb + ? global.__cacheDb + : createDatabase()) + +function createDatabase() { + const db = new Database(CACHE_DATABASE_PATH) + // create cache table with metadata JSON column and value JSON column if it does not exist already + db.exec(` + CREATE TABLE IF NOT EXISTS cache ( + key TEXT PRIMARY KEY, + metadata TEXT, + value TEXT + ) + `) + return db } -type CacheMetadata = { - createdTime: number - maxAge: number | null +const lru = (global.__lruCache = global.__lruCache + ? global.__lruCache + : new LRU>({max: 1000})) + +export const lruCache = lruCacheAdapter(lru) + +export const cache: CachifiedCache = { + name: 'SQLite cache', + get(key) { + const result = cacheDb + .prepare('SELECT value, metadata FROM cache WHERE key = ?') + .get(key) + if (!result) return null + return { + metadata: JSON.parse(result.metadata), + value: JSON.parse(result.value), + } + }, + set(key, {value, metadata}) { + cacheDb + .prepare( + 'INSERT OR REPLACE INTO cache (key, value, metadata) VALUES (@key, @value, @metadata)', + ) + .run({ + key, + value: JSON.stringify(value), + metadata: JSON.stringify(metadata), + }) + }, + async delete(key) { + cacheDb.prepare('DELETE FROM cache WHERE key = ?').run(key) + }, } -function shouldRefresh(metadata: CacheMetadata) { - if (metadata.maxAge) { - return Date.now() > metadata.createdTime + metadata.maxAge - } - return false +export async function getAllCacheKeys(limit: number) { + return cacheDb + .prepare('SELECT key FROM cache LIMIT ?') + .all(limit) + .map(row => row.key) } -type VNUP = Value | null | undefined | Promise - -const keysRefreshing = new Set() +export async function searchCacheKeys(search: string, limit: number) { + return cacheDb + .prepare('SELECT key FROM cache WHERE key LIKE ? LIMIT ?') + .all(`%${search}%`, limit) + .map(row => row.key) +} -async function cachified< - Value, - Cache extends { - name: string - get: (key: string) => VNUP<{ - metadata: CacheMetadata - value: Value - }> - set: ( - key: string, - value: { - metadata: CacheMetadata - value: Value - }, - ) => unknown | Promise - del: (key: string) => unknown | Promise - }, ->(options: { - key: string - cache: Cache - getFreshValue: () => Promise - checkValue?: (value: Value) => boolean | string +export async function shouldForceFresh({ + forceFresh, + request, + key, +}: { forceFresh?: boolean | string request?: Request - fallbackToCache?: boolean - timings?: Timings - timingType?: string - maxAge?: number -}): Promise { - const { - key, - cache, - getFreshValue, - request, - checkValue = value => Boolean(value), - fallbackToCache = true, - timings, - timingType = 'getting fresh value', - maxAge, - } = options - - // if forceFresh is a string, we'll only force fresh if the key is in the - // comma separated list. Otherwise we'll go with it's value and fallback - // to the shouldForceFresh function on the request if the request is provided - // otherwise it's false. - const forceFresh = - typeof options.forceFresh === 'string' - ? options.forceFresh.split(',').includes(key) - : options.forceFresh ?? - (request ? await shouldForceFresh(request, key) : false) - - function assertCacheEntry(entry: unknown): asserts entry is { - metadata: CacheMetadata - value: Value - } { - if (typeof entry !== 'object' || entry === null) { - throw new Error( - `Cache entry for ${key} is not a cache entry object, it's a ${typeof entry}`, - ) - } - if (!('metadata' in entry)) { - throw new Error( - `Cache entry for ${key} does not have a metadata property`, - ) - } - if (!('value' in entry)) { - throw new Error(`Cache entry for ${key} does not have a value property`) - } - } - - if (!forceFresh) { - try { - const cached = await time({ - name: `cache.get(${key})`, - type: 'cache read', - fn: () => cache.get(key), - timings, - }) - if (cached) { - assertCacheEntry(cached) - - if (shouldRefresh(cached.metadata)) { - // time to refresh the value. Fire and forget so we don't slow down - // this request - // we use setTimeout here to make sure this happens on the next tick - // of the event loop so we don't end up slowing this request down in the - // event the cache is synchronous (unlikely now, but if the code is changed - // then it's quite possible this could happen and it would be easy to - // forget to check). - // In practice we have had a handful of situations where multiple - // requests triggered a refresh of the same resource, so that's what - // the keysRefreshing thing is for to ensure we don't refresh a - // value if it's already in the process of being refreshed. - if (!keysRefreshing.has(key)) { - keysRefreshing.add(key) - setTimeout(() => { - // eslint-disable-next-line prefer-object-spread - void cachified(Object.assign({}, options, {forceFresh: true})) - .catch(() => {}) - .finally(() => { - keysRefreshing.delete(key) - }) - }, 200) - } - } - const valueCheck = checkValue(cached.value) - if (valueCheck === true) { - return cached.value - } else { - const reason = typeof valueCheck === 'string' ? valueCheck : 'unknown' - console.warn( - `check failed for cached value of ${key}\nReason: ${reason}.\nDeleting the cache key and trying to get a fresh value.`, - cached, - ) - await cache.del(key) - } - } - } catch (error: unknown) { - console.error( - `error with cache at ${key}. Deleting the cache key and trying to get a fresh value.`, - error, - ) - await cache.del(key) - } - } - - const start = performance.now() - const value = await time({ - name: `getFreshValue for ${key}`, - type: timingType, - fn: getFreshValue, - timings, - }).catch((error: unknown) => { - console.error( - `getting a fresh value for ${key} failed`, - {fallbackToCache, forceFresh}, - error, - ) - // If we got this far without forceFresh then we know there's nothing - // in the cache so no need to bother trying again without a forceFresh. - // So we need both the option to fallback and the ability to fallback. - if (fallbackToCache && forceFresh) { - return cachified({...options, forceFresh: false}) - } else { - throw error - } - }) - const totalTime = performance.now() - start - - const valueCheck = checkValue(value) - if (valueCheck === true) { - const metadata: CacheMetadata = { - maxAge: maxAge ?? null, - createdTime: Date.now(), - } - try { - console.log( - `Updating the cache value for ${key}.`, - `Getting a fresh value for this took ${niceFormatDuration(totalTime)}.`, - `Caching for a minimum of ${ - typeof maxAge === 'number' - ? `${niceFormatDuration(maxAge)}` - : 'forever' - } in ${cache.name}.`, - ) - await cache.set(key, {metadata, value}) - } catch (error: unknown) { - console.error(`error setting cache: ${key}`, error) - } - } else { - const reason = typeof valueCheck === 'string' ? valueCheck : 'unknown' - console.error( - `check failed for cached value of ${key}\nReason: ${reason}.\nDeleting the cache key and trying to get a fresh value.`, - value, - ) - throw new Error(`check failed for fresh value of ${key}`) - } - return value -} + key: string +}) { + if (typeof forceFresh === 'boolean') return forceFresh + if (typeof forceFresh === 'string') return forceFresh.split(',').includes(key) -async function shouldForceFresh(request: Request, key: string) { + if (!request) return false const fresh = new URL(request.url).searchParams.get('fresh') if (typeof fresh !== 'string') return false if ((await getUser(request))?.role !== 'ADMIN') return false @@ -240,8 +100,6 @@ async function shouldForceFresh(request: Request, key: string) { return fresh.split(',').includes(key) } -export {cachified, lruCache} - /* eslint max-depth: "off", diff --git a/app/utils/compile-mdx.server.ts b/app/utils/compile-mdx.server.ts index c8e1f4dd5..653a3abad 100644 --- a/app/utils/compile-mdx.server.ts +++ b/app/utils/compile-mdx.server.ts @@ -12,7 +12,7 @@ import type {GitHubFile} from '~/types' import * as twitter from './twitter.server' function handleEmbedderError({url}: {url: string}) { - return `

Error embedding ${url}.` + return `

Error embedding ${url}

.` } type GottenHTML = string | null @@ -250,7 +250,11 @@ async function getQueue() { const {default: PQueue} = await import('p-queue') if (_queue) return _queue - _queue = new PQueue({concurrency: 1}) + _queue = new PQueue({ + concurrency: 1, + throwOnTimeout: true, + timeout: 1000 * 30, + }) return _queue } diff --git a/app/utils/credits.server.ts b/app/utils/credits.server.ts index bbd38b1ba..a0b97c423 100644 --- a/app/utils/credits.server.ts +++ b/app/utils/credits.server.ts @@ -1,8 +1,8 @@ import * as YAML from 'yaml' import {downloadFile} from './github.server' import {getErrorMessage, typedBoolean} from './misc' -import {redisCache} from './redis.server' -import {cachified} from './cache.server' +import {cachified} from 'cachified' +import {cache, shouldForceFresh} from './cache.server' export type Person = { name: string @@ -120,12 +120,13 @@ async function getPeople({ request?: Request forceFresh?: boolean }) { + const key = 'content:data:credits.yml' const allPeople = await cachified({ - cache: redisCache, - key: 'content:data:credits.yml', - request, - forceFresh, - maxAge: 1000 * 60 * 60 * 24 * 30, + cache, + key, + forceFresh: await shouldForceFresh({forceFresh, request, key}), + ttl: 1000 * 60 * 60 * 24 * 30, + staleWhileRevalidate: 1000 * 60 * 60 * 24, getFreshValue: async () => { const creditsString = await downloadFile('content/data/credits.yml') const rawCredits = YAML.parse(creditsString) diff --git a/app/utils/discord.server.ts b/app/utils/discord.server.ts index 21741f8d6..158028bfe 100644 --- a/app/utils/discord.server.ts +++ b/app/utils/discord.server.ts @@ -1,6 +1,7 @@ import type {User, Team} from '~/types' -import {prismaWrite} from './prisma.server' +import {prisma} from './prisma.server' import {getRequiredServerEnvVar, getTeam} from './misc' +import {ensurePrimary} from './fly.server' const DISCORD_CLIENT_ID = getRequiredServerEnvVar('DISCORD_CLIENT_ID') const DISCORD_CLIENT_SECRET = getRequiredServerEnvVar('DISCORD_CLIENT_SECRET') @@ -123,7 +124,7 @@ async function updateDiscordRolesForUser( discordMember: DiscordMember, user: User, ) { - await prismaWrite.user.update({ + await prisma.user.update({ where: {id: user.id}, data: {discordId: discordMember.user.id}, }) @@ -179,6 +180,7 @@ async function connectDiscord({ code: string domainUrl: string }) { + await ensurePrimary() const {discordUser, discordToken} = await getUserToken({code, domainUrl}) await addUserToDiscordServer(discordUser, discordToken) diff --git a/app/utils/env.server.ts b/app/utils/env.server.ts index 09a0eb7a8..04d751c3c 100644 --- a/app/utils/env.server.ts +++ b/app/utils/env.server.ts @@ -3,7 +3,6 @@ function getEnv() { FLY: process.env.FLY, NODE_ENV: process.env.NODE_ENV, DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID, - PRIMARY_REGION: process.env.PRIMARY_REGION, } } diff --git a/app/utils/fly.server.ts b/app/utils/fly.server.ts new file mode 100644 index 000000000..e8aa990f2 --- /dev/null +++ b/app/utils/fly.server.ts @@ -0,0 +1,51 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' +import invariant from 'tiny-invariant' + +export async function ensurePrimary() { + const {currentIsPrimary, currentInstance, primaryInstance} = + await getInstanceInfo() + + if (!currentIsPrimary) { + console.log( + `Instance (${currentInstance}) in ${process.env.FLY_REGION} is not primary (primary is: ${primaryInstance}), sending fly replay response`, + ) + throw new Response('Fly Replay', { + status: 409, + headers: {'fly-replay': `instance=${primaryInstance}`}, + }) + } +} + +export async function getInstanceInfo() { + const currentInstance = os.hostname() + let primaryInstance + try { + const {FLY_LITEFS_DIR} = process.env + invariant(FLY_LITEFS_DIR, 'FLY_LITEFS_DIR is not defined') + primaryInstance = await fs.promises.readFile( + path.join(FLY_LITEFS_DIR, '.primary'), + 'utf8', + ) + primaryInstance = primaryInstance.trim() + } catch (error: unknown) { + primaryInstance = currentInstance + } + return { + primaryInstance, + currentInstance, + currentIsPrimary: currentInstance === primaryInstance, + } +} + +export async function getFlyReplayResponse(instance?: string) { + return new Response('Fly Replay', { + status: 409, + headers: { + 'fly-replay': `instance=${ + instance ?? (await getInstanceInfo()).primaryInstance + }`, + }, + }) +} diff --git a/app/utils/github.server.ts b/app/utils/github.server.ts index f79f118e9..8cab08635 100644 --- a/app/utils/github.server.ts +++ b/app/utils/github.server.ts @@ -3,6 +3,8 @@ import {Octokit as createOctokit} from '@octokit/rest' import {throttling} from '@octokit/plugin-throttling' import type {GitHubFile} from '~/types' +const ref = process.env.GITHUB_REF ?? 'main' + const Octokit = createOctokit.plugin(throttling) type ThrottleOptions = { @@ -144,6 +146,7 @@ async function downloadFile(path: string) { owner: 'kentcdodds', repo: 'kentcdodds.com', path, + ref, }, ) @@ -169,6 +172,7 @@ async function downloadDirList(path: string) { owner: 'kentcdodds', repo: 'kentcdodds.com', path, + ref, }) const data = resp.data diff --git a/app/utils/markdown.server.ts b/app/utils/markdown.server.ts index bae25b4d5..32c3bc7c8 100644 --- a/app/utils/markdown.server.ts +++ b/app/utils/markdown.server.ts @@ -32,7 +32,6 @@ async function markdownToHtmlDocument(markdownString: string) { .use(rehypeStringify) .process(markdownString) - console.log(result) return result.value.toString() } @@ -51,20 +50,3 @@ export { markdownToHtmlDocument, stripHtml, } - -// async function go() { -// console.log( -// await markdownToHtml( -// ` -// # helo - -// this is stuff - -//
-//

this is stuff

-//
-// `.trim(), -// ), -// ) -// } -// go() diff --git a/app/utils/mdx.tsx b/app/utils/mdx.tsx index 2913d4ee5..2ce8e4b15 100644 --- a/app/utils/mdx.tsx +++ b/app/utils/mdx.tsx @@ -3,15 +3,14 @@ import {buildImageUrl} from 'cloudinary-build-url' import type {LoaderData as RootLoaderData} from '../root' import type {GitHubFile, MdxListItem, MdxPage} from '~/types' import * as mdxBundler from 'mdx-bundler/client' +import {cachified, verboseReporter} from 'cachified' import {compileMdx} from '~/utils/compile-mdx.server' import { downloadDirList, downloadMdxFileOrDirectory, } from '~/utils/github.server' import {AnchorOrLink, getDisplayUrl, getUrl, typedBoolean} from '~/utils/misc' -import {redisCache} from './redis.server' -import type {Timings} from './metrics.server' -import {cachified} from './cache.server' +import {cache, shouldForceFresh} from './cache.server' import {getSocialMetas} from './seo' import { getImageBuilder, @@ -25,15 +24,12 @@ import {ConvertKitForm} from '~/convertkit/form' type CachifiedOptions = { forceFresh?: boolean | string request?: Request - timings?: Timings - maxAge?: number - expires?: Date + ttl?: number } -const defaultMaxAge = 1000 * 60 * 60 * 24 * 30 +const defaultTTL = 1000 * 60 * 60 * 24 +const defaultStaleWhileRevalidate = 1000 * 60 * 60 * 24 * 30 -const getCompiledKey = (contentDir: string, slug: string) => - `${contentDir}:${slug}:compiled` const checkCompiledValue = (value: unknown) => typeof value === 'object' && (value === null || ('code' in value && 'frontmatter' in value)) @@ -48,11 +44,13 @@ async function getMdxPage( }, options: CachifiedOptions, ): Promise { - const key = getCompiledKey(contentDir, slug) + const {forceFresh, ttl = defaultTTL, request} = options + const key = `mdx-page:${contentDir}:${slug}:compiled` const page = await cachified({ - cache: redisCache, - maxAge: defaultMaxAge, - ...options, + cache, + ttl, + staleWhileRevalidate: defaultStaleWhileRevalidate, + forceFresh: await shouldForceFresh({forceFresh, request, key}), // reusing the same key as compiledMdxCached because we just return that // exact same value. Cachifying this allows us to skip getting the cached files key, @@ -76,7 +74,7 @@ async function getMdxPage( }) if (!page) { // if there's no page, let's remove it from the cache - void redisCache.del(key) + void cache.delete(key) } return page } @@ -108,11 +106,14 @@ async function getMdxPagesInDirectory( const getDirListKey = (contentDir: string) => `${contentDir}:dir-list` async function getMdxDirList(contentDir: string, options?: CachifiedOptions) { + const {forceFresh, ttl = defaultTTL, request} = options ?? {} + const key = getDirListKey(contentDir) return cachified({ - cache: redisCache, - maxAge: defaultMaxAge, - ...options, - key: getDirListKey(contentDir), + cache, + ttl, + staleWhileRevalidate: defaultStaleWhileRevalidate, + forceFresh: await shouldForceFresh({forceFresh, request, key}), + key, checkValue: (value: unknown) => Array.isArray(value), getFreshValue: async () => { const fullContentDirPath = `content/${contentDir}` @@ -129,19 +130,18 @@ async function getMdxDirList(contentDir: string, options?: CachifiedOptions) { }) } -const getDownloadKey = (contentDir: string, slug: string) => - `${contentDir}:${slug}:downloaded` - -async function downloadMdxFilesCached( +export async function downloadMdxFilesCached( contentDir: string, slug: string, options: CachifiedOptions, ) { - const key = getDownloadKey(contentDir, slug) + const {forceFresh, ttl = defaultTTL, request} = options + const key = `${contentDir}:${slug}:downloaded` const downloaded = await cachified({ - cache: redisCache, - maxAge: defaultMaxAge, - ...options, + cache, + ttl, + staleWhileRevalidate: defaultStaleWhileRevalidate, + forceFresh: await shouldForceFresh({forceFresh, request, key}), key, checkValue: (value: unknown) => { if (typeof value !== 'object') { @@ -166,7 +166,7 @@ async function downloadMdxFilesCached( }) // if there aren't any files, remove it from the cache if (!downloaded.files.length) { - void redisCache.del(key) + void cache.delete(key) } return downloaded } @@ -184,11 +184,18 @@ async function compileMdxCached({ files: Array options: CachifiedOptions }) { - const key = getCompiledKey(contentDir, slug) + const key = `${contentDir}:${slug}:compiled` const page = await cachified({ - cache: redisCache, - maxAge: defaultMaxAge, + cache, + ttl: defaultTTL, + staleWhileRevalidate: defaultStaleWhileRevalidate, + reporter: verboseReporter(), ...options, + forceFresh: await shouldForceFresh({ + forceFresh: options.forceFresh, + request: options.request, + key, + }), key, checkValue: checkCompiledValue, getFreshValue: async () => { @@ -236,7 +243,7 @@ async function compileMdxCached({ }) // if there's no page, remove it from the cache if (!page) { - void redisCache.del(key) + void cache.delete(key) } return page } @@ -283,11 +290,14 @@ async function getDataUrlForImage(imageUrl: string) { } async function getBlogMdxListItems(options: CachifiedOptions) { + const {request, forceFresh, ttl = defaultTTL} = options + const key = 'blog:mdx-list-items' return cachified({ - cache: redisCache, - maxAge: defaultMaxAge, - ...options, - key: 'blog:mdx-list-items', + cache, + ttl, + staleWhileRevalidate: defaultStaleWhileRevalidate, + forceFresh: await shouldForceFresh({forceFresh, request, key}), + key, getFreshValue: async () => { let pages = await getMdxPagesInDirectory('blog', options).then(allPosts => allPosts.filter(p => !p.frontmatter.draft), diff --git a/app/utils/metrics.server.ts b/app/utils/metrics.server.ts deleted file mode 100644 index 66c182395..000000000 --- a/app/utils/metrics.server.ts +++ /dev/null @@ -1,42 +0,0 @@ -type Timings = Record> - -async function time({ - name, - type, - fn, - timings, -}: { - name: string - type: string - fn: () => ReturnType | Promise - timings?: Timings -}): Promise { - if (!timings) return fn() - - const start = performance.now() - const result = await fn() - type = type.replaceAll(' ', '_') - let timingType = timings[type] - if (!timingType) { - // eslint-disable-next-line no-multi-assign - timingType = timings[type] = [] - } - - timingType.push({name, type, time: performance.now() - start}) - return result -} - -function getServerTimeHeader(timings: Timings) { - return Object.entries(timings) - .map(([key, timingInfos]) => { - const dur = timingInfos - .reduce((acc, timingInfo) => acc + timingInfo.time, 0) - .toFixed(1) - const desc = timingInfos.map(t => t.name).join(' & ') - return `${key};dur=${dur};desc="${desc}"` - }) - .join(',') -} - -export {time, getServerTimeHeader} -export type {Timings} diff --git a/app/utils/misc.tsx b/app/utils/misc.tsx index 5813a671a..5f399dbef 100644 --- a/app/utils/misc.tsx +++ b/app/utils/misc.tsx @@ -237,6 +237,9 @@ function getDiscordAuthorizeURL(domainUrl: string) { return url.toString() } +/** + * @returns domain URL (without a ending slash) + */ function getDomainUrl(request: Request) { const host = request.headers.get('X-Forwarded-Host') ?? request.headers.get('host') diff --git a/app/utils/prisma.server.ts b/app/utils/prisma.server.ts index d74620ec6..66a8cdedd 100644 --- a/app/utils/prisma.server.ts +++ b/app/utils/prisma.server.ts @@ -1,21 +1,18 @@ import {PrismaClient} from '@prisma/client' -import chalk from 'chalk' import type {Session} from '~/types' import {encrypt, decrypt} from './encryption.server' +import {ensurePrimary} from './fly.server' declare global { // This prevents us from making multiple connections to the db when the // require cache is cleared. // eslint-disable-next-line - var prismaRead: ReturnType | undefined - // eslint-disable-next-line - var prismaWrite: ReturnType | undefined + var __prisma: ReturnType | undefined } const logThreshold = 50 -const prismaRead = global.prismaRead ?? (global.prismaRead = getClient()) -const prismaWrite = prismaRead +const prisma = global.__prisma ?? (global.__prisma = getClient()) function getClient(): PrismaClient { // NOTE: during development if you change anything in this function, remember @@ -31,6 +28,7 @@ function getClient(): PrismaClient { }) client.$on('query', async e => { if (e.duration < logThreshold) return + const {default: chalk} = await import('chalk') const color = e.duration < 30 @@ -144,7 +142,8 @@ async function validateMagicLink(link: string, sessionMagicLink?: string) { async function createSession( sessionData: Omit, ) { - return prismaWrite.session.create({ + await ensurePrimary() + return prisma.session.create({ data: { ...sessionData, expirationDate: new Date(Date.now() + sessionExpirationTime), @@ -153,7 +152,7 @@ async function createSession( } async function getUserFromSessionId(sessionId: string) { - const session = await prismaRead.session.findUnique({ + const session = await prisma.session.findUnique({ where: {id: sessionId}, include: {user: true}, }) @@ -162,15 +161,17 @@ async function getUserFromSessionId(sessionId: string) { } if (Date.now() > session.expirationDate.getTime()) { - await prismaWrite.session.delete({where: {id: sessionId}}) + await ensurePrimary() + await prisma.session.delete({where: {id: sessionId}}) throw new Error('Session expired. Please request a new magic link.') } // if there's less than ~six months left, extend the session const twoWeeks = 1000 * 60 * 60 * 24 * 30 * 6 if (Date.now() + twoWeeks > session.expirationDate.getTime()) { + await ensurePrimary() const newExpirationDate = new Date(Date.now() + sessionExpirationTime) - await prismaWrite.session.update({ + await prisma.session.update({ data: {expirationDate: newExpirationDate}, where: {id: sessionId}, }) @@ -182,10 +183,10 @@ async function getUserFromSessionId(sessionId: string) { async function getAllUserData(userId: string) { const {default: pProps} = await import('p-props') return pProps({ - user: prismaRead.user.findUnique({where: {id: userId}}), - calls: prismaRead.call.findMany({where: {userId}}), - postReads: prismaRead.postRead.findMany({where: {userId}}), - sessions: prismaRead.session.findMany({where: {userId}}), + user: prisma.user.findUnique({where: {id: userId}}), + calls: prisma.call.findMany({where: {userId}}), + postReads: prisma.postRead.findMany({where: {userId}}), + sessions: prisma.session.findMany({where: {userId}}), }) } @@ -198,7 +199,7 @@ async function addPostRead({ | {userId?: undefined; clientId: string} )) { const id = userId ? {userId} : {clientId} - const readInLastWeek = await prismaRead.postRead.findFirst({ + const readInLastWeek = await prisma.postRead.findFirst({ select: {id: true}, where: { ...id, @@ -209,7 +210,8 @@ async function addPostRead({ if (readInLastWeek) { return null } else { - const postRead = await prismaWrite.postRead.create({ + await ensurePrimary() + const postRead = await prisma.postRead.create({ data: {postSlug: slug, ...id}, select: {id: true}, }) @@ -218,8 +220,7 @@ async function addPostRead({ } export { - prismaRead, - prismaWrite, + prisma, getMagicLink, validateMagicLink, linkExpirationTime, diff --git a/app/utils/redis.server.ts b/app/utils/redis.server.ts deleted file mode 100644 index deab1c29c..000000000 --- a/app/utils/redis.server.ts +++ /dev/null @@ -1,121 +0,0 @@ -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') - console.log(`Setting up redis client to ${url.host} for ${name}`) - // 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(key: string): Promise { - 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(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 { - 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, name: 'redis'} -export {get, set, del, redisCache} diff --git a/app/utils/session.server.ts b/app/utils/session.server.ts index 0f0598ca2..05a83621e 100644 --- a/app/utils/session.server.ts +++ b/app/utils/session.server.ts @@ -2,16 +2,16 @@ import {createCookieSessionStorage, redirect} from '@remix-run/node' import type {User} from '@prisma/client' import {sendMagicLinkEmail} from './send-email.server' import { - prismaRead, + prisma, getMagicLink, getUserFromSessionId, - prismaWrite, validateMagicLink, createSession, sessionExpirationTime, } from './prisma.server' import {getRequiredServerEnvVar} from './misc' import {getLoginInfoSession} from './login.server' +import {ensurePrimary} from './fly.server' const sessionIdKey = '__session_id__' @@ -40,7 +40,7 @@ async function sendToken({ domainUrl, }) - const user = await prismaRead.user + const user = await prisma.user .findUnique({where: {email: emailAddress}}) .catch(() => { /* ignore... */ @@ -84,11 +84,12 @@ async function getSession(request: Request) { const userSession = await createSession({userId: user.id}) session.set(sessionIdKey, userSession.id) }, - signOut: () => { + signOut: async () => { const sessionId = getSessionId() if (sessionId) { + await ensurePrimary() unsetSessionId() - prismaWrite.session + prisma.session .delete({where: {id: sessionId}}) .catch((error: unknown) => { console.error(`Failure deleting user session: `, error) @@ -127,7 +128,8 @@ async function deleteOtherSessions(request: Request) { return } const user = await getUserFromSessionId(token) - await prismaWrite.session.deleteMany({ + await ensurePrimary() + await prisma.session.deleteMany({ where: {userId: user.id, NOT: {id: token}}, }) } @@ -151,7 +153,7 @@ async function getUserSessionFromMagicLink(request: Request) { loginInfoSession.getMagicLink(), ) - const user = await prismaRead.user.findUnique({where: {email}}) + const user = await prisma.user.findUnique({where: {email}}) if (!user) return null const session = await getSession(request) @@ -163,7 +165,7 @@ async function requireAdminUser(request: Request): Promise { const user = await getUser(request) if (!user) { const session = await getSession(request) - session.signOut() + await session.signOut() throw redirect('/login', {headers: await session.getHeaders()}) } if (user.role !== 'ADMIN') { @@ -176,7 +178,7 @@ async function requireUser(request: Request): Promise { const user = await getUser(request) if (!user) { const session = await getSession(request) - session.signOut() + await session.signOut() throw redirect('/login', {headers: await session.getHeaders()}) } return user diff --git a/app/utils/simplecast.server.ts b/app/utils/simplecast.server.ts index 9935bc5a7..b509799f7 100644 --- a/app/utils/simplecast.server.ts +++ b/app/utils/simplecast.server.ts @@ -13,8 +13,8 @@ import type * as M from 'mdast' import type * as H from 'hast' import {getRequiredServerEnvVar, typedBoolean} from './misc' import {markdownToHtml, stripHtml} from './markdown.server' -import {redisCache} from './redis.server' -import {cachified} from './cache.server' +import {cache, shouldForceFresh} from './cache.server' +import {cachified} from 'cachified' const SIMPLECAST_KEY = getRequiredServerEnvVar('SIMPLECAST_KEY') const CHATS_WITH_KENT_PODCAST_ID = getRequiredServerEnvVar( @@ -43,12 +43,16 @@ const getCachedSeasons = async ({ forceFresh?: boolean }) => cachified({ - cache: redisCache, + cache, key: seasonsCacheKey, - maxAge: 1000 * 60 * 60 * 24 * 7, + ttl: 1000 * 60 * 60 * 24 * 7, + staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, getFreshValue: () => getSeasons({request, forceFresh}), - request, - forceFresh, + forceFresh: await shouldForceFresh({ + forceFresh, + request, + key: seasonsCacheKey, + }), checkValue: (value: unknown) => Array.isArray(value) && value.length > 0 && @@ -57,7 +61,7 @@ const getCachedSeasons = async ({ ), }) -const getCachedEpisode = async ( +async function getCachedEpisode( episodeId: string, { request, @@ -66,17 +70,19 @@ const getCachedEpisode = async ( request: Request forceFresh?: boolean }, -) => - cachified({ - cache: redisCache, - key: `simplecast:episode:${episodeId}`, - maxAge: 1000 * 60 * 60 * 24 * 7, +) { + const key = `simplecast:episode:${episodeId}` + return cachified({ + cache, + key, + ttl: 1000 * 60 * 60 * 24 * 7, + staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, getFreshValue: () => getEpisode(episodeId), - request, - forceFresh, + forceFresh: await shouldForceFresh({forceFresh, request, key}), checkValue: (value: unknown) => typeof value === 'object' && value !== null && 'title' in value, }) +} async function getSeasons({ request, diff --git a/app/utils/talks.server.ts b/app/utils/talks.server.ts index 784e9bac3..ebc87abd7 100644 --- a/app/utils/talks.server.ts +++ b/app/utils/talks.server.ts @@ -4,8 +4,8 @@ import type {Await} from '~/types' import {typedBoolean} from '~/utils/misc' import {markdownToHtml, stripHtml} from '~/utils/markdown.server' import {downloadFile} from '~/utils/github.server' -import {cachified} from '~/utils/cache.server' -import {redisCache} from '~/utils/redis.server' +import {cachified} from 'cachified' +import {cache, shouldForceFresh} from '~/utils/cache.server' type RawTalk = { title?: string @@ -114,12 +114,13 @@ async function getTalksAndTags({ const slugify = await getSlugify() slugify.reset() + const key = 'content:data:talks.yml' const talks = await cachified({ - cache: redisCache, - key: 'content:data:talks.yml', - maxAge: 1000 * 60 * 60 * 24 * 14, - request, - forceFresh, + cache, + key, + ttl: 1000 * 60 * 60 * 24 * 14, + staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, + forceFresh: await shouldForceFresh({forceFresh, request, key}), getFreshValue: async () => { const talksString = await downloadFile('content/data/talks.yml') const rawTalks = YAML.parse(talksString) as Array diff --git a/app/utils/testimonials.server.ts b/app/utils/testimonials.server.ts index 2a32d9124..cc32f4ec2 100644 --- a/app/utils/testimonials.server.ts +++ b/app/utils/testimonials.server.ts @@ -1,9 +1,9 @@ import * as YAML from 'yaml' import {pick} from 'lodash' +import {cachified} from 'cachified' import {downloadFile} from './github.server' import {getErrorMessage, typedBoolean} from './misc' -import {redisCache} from './redis.server' -import {cachified} from './cache.server' +import {cache, shouldForceFresh} from './cache.server' const allCategories = [ 'teaching', @@ -150,12 +150,13 @@ async function getAllTestimonials({ request?: Request forceFresh?: boolean }) { + const key = 'content:data:testimonials.yml' const allTestimonials = await cachified({ - cache: redisCache, - key: 'content:data:testimonials.yml', - request, - forceFresh, - maxAge: 1000 * 60 * 60 * 24, + cache, + key, + forceFresh: await shouldForceFresh({forceFresh, request, key}), + ttl: 1000 * 60 * 60 * 24, + staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, getFreshValue: async (): Promise> => { const talksString = await downloadFile('content/data/testimonials.yml') const rawTestimonials = YAML.parse(talksString) diff --git a/app/utils/theme-provider.tsx b/app/utils/theme-provider.tsx index 76aa0f5b3..0fc184ad7 100644 --- a/app/utils/theme-provider.tsx +++ b/app/utils/theme-provider.tsx @@ -28,7 +28,7 @@ function ThemeProvider({ children: React.ReactNode specifiedTheme: Theme | null }) { - const [theme, setTheme] = React.useState(() => { + const [theme, setThemeState] = React.useState(() => { // On the server, if we don't have a specified theme then we should // return null and the clientThemeCode will set the theme for us // before hydration. Then (during hydration), this code will get the same @@ -52,30 +52,29 @@ function ThemeProvider({ persistThemeRef.current = persistTheme }, [persistTheme]) - const mountRun = React.useRef(false) - - React.useEffect(() => { - if (!mountRun.current) { - mountRun.current = true - return - } - if (!theme) return - - persistThemeRef.current.submit( - {theme}, - {action: 'action/set-theme', method: 'post'}, - ) - }, [theme]) - React.useEffect(() => { const mediaQuery = window.matchMedia(prefersLightMQ) const handleChange = () => { - setTheme(mediaQuery.matches ? Theme.LIGHT : Theme.DARK) + setThemeState(mediaQuery.matches ? Theme.LIGHT : Theme.DARK) } mediaQuery.addEventListener('change', handleChange) return () => mediaQuery.removeEventListener('change', handleChange) }, []) + const setTheme = React.useCallback( + (cb: Parameters[0]) => { + const newTheme = typeof cb === 'function' ? cb(theme) : cb + if (newTheme) { + persistThemeRef.current.submit( + {theme: newTheme}, + {action: 'action/set-theme', method: 'post'}, + ) + } + setThemeState(newTheme) + }, + [theme], + ) + return ( {children} diff --git a/app/utils/transistor.server.ts b/app/utils/transistor.server.ts index 4c8b88829..b3404cea4 100644 --- a/app/utils/transistor.server.ts +++ b/app/utils/transistor.server.ts @@ -1,4 +1,5 @@ import * as uuid from 'uuid' +import {cachified} from 'cachified' import type { TransistorErrorResponse, TransistorCreateEpisodeData, @@ -10,12 +11,10 @@ import type { TransistorUpdateEpisodeData, } from '~/types' import {getDomainUrl, getRequiredServerEnvVar, toBase64} from './misc' -import {redisCache} from './redis.server' -import {cachified} from './cache.server' +import {cache, shouldForceFresh} from './cache.server' import {getEpisodePath} from './call-kent' import {getDirectAvatarForUser} from './user-info.server' import {stripHtml} from './markdown.server' -import type {Team} from '@prisma/client' const transistorApiSecret = getRequiredServerEnvVar('TRANSISTOR_API_SECRET') const podcastId = getRequiredServerEnvVar('CALL_KENT_PODCAST_ID', '67890') @@ -73,7 +72,7 @@ async function createEpisode({ summary: string description: string keywords: string - user: {firstName: string; email: string; team: Team} + user: {firstName: string; email: string; team: string} request: Request avatar?: string | null }) { @@ -253,12 +252,16 @@ async function getCachedEpisodes({ forceFresh?: boolean }) { return cachified({ - cache: redisCache, + cache, key: episodesCacheKey, getFreshValue: getEpisodes, - maxAge: 1000 * 60 * 60 * 24, - forceFresh, - request, + ttl: 1000 * 60 * 60 * 24, + staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, + forceFresh: await shouldForceFresh({ + forceFresh, + request, + key: episodesCacheKey, + }), checkValue: (value: unknown) => Array.isArray(value) && value.every( diff --git a/app/utils/twitter.server.ts b/app/utils/twitter.server.ts index 9fecce86a..08c5b68c4 100644 --- a/app/utils/twitter.server.ts +++ b/app/utils/twitter.server.ts @@ -10,6 +10,8 @@ import { getRequiredServerEnvVar, typedBoolean, } from './misc' +import cachified from 'cachified' +import {cache, lruCache} from './cache.server' const token = getRequiredServerEnvVar('TWITTER_BEARER_TOKEN') @@ -130,8 +132,23 @@ type TweetErrorJsonResponse = { }> } -// fetch tweet from API +type TweetRateLimitErrorJsonResponse = { + title: 'Too Many Requests' + detail: 'Too Many Requests' + type: 'about:blank' + status: 429 +} + async function getTweet(tweetId: string) { + return cachified({ + key: `tweet:${tweetId}`, + cache: lruCache, + ttl: 1000 * 60, + getFreshValue: () => getTweetImpl(tweetId), + }) +} + +async function getTweetImpl(tweetId: string) { const url = new URL(`https://api.twitter.com/2/tweets/${tweetId}`) const params = { 'tweet.fields': 'public_metrics,created_at', @@ -150,7 +167,10 @@ async function getTweet(tweetId: string) { }, }) const tweetJson = await response.json() - return tweetJson as TweetJsonResponse | TweetErrorJsonResponse + return tweetJson as + | TweetJsonResponse + | TweetErrorJsonResponse + | TweetRateLimitErrorJsonResponse } const playSvg = `` @@ -358,6 +378,16 @@ async function buildTweetHTML( } async function getTweetEmbedHTML(urlString: string) { + return cachified({ + key: `tweet:embed:${urlString}`, + ttl: 1000 * 60 * 60 * 24, + cache, + staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30 * 6, + getFreshValue: () => getTweetEmbedHTMLImpl(urlString), + }) +} + +async function getTweetEmbedHTMLImpl(urlString: string) { const url = new URL(urlString) const tweetId = url.pathname.split('/').pop() @@ -368,6 +398,10 @@ async function getTweetEmbedHTML(urlString: string) { let tweet try { tweet = await getTweet(tweetId) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if ('status' in tweet && tweet.status === 429) { + throw new Error(`Rate limited: ${tweetId}`) + } if (!('data' in tweet)) { throw new Error('Oh no, tweet has no data.') } @@ -381,7 +415,7 @@ async function getTweetEmbedHTML(urlString: string) { console.error(er) } } - return '' + throw error } } diff --git a/app/utils/user-info.server.ts b/app/utils/user-info.server.ts index bc9d466bb..9d5e3b3c4 100644 --- a/app/utils/user-info.server.ts +++ b/app/utils/user-info.server.ts @@ -2,10 +2,9 @@ import type {User} from '~/types' import {getImageBuilder, images} from '../images' import * as ck from '../convertkit/convertkit.server' import * as discord from './discord.server' -import type {Timings} from './metrics.server' import {getAvatar, getDomainUrl, getOptionalTeam} from './misc' -import {redisCache} from './redis.server' -import {cachified} from './cache.server' +import {cache, shouldForceFresh} from './cache.server' +import cachified from 'cachified' type UserInfo = { avatar: { @@ -56,20 +55,20 @@ const getDiscordCacheKey = (discordId: string) => `discord:${discordId}` async function getUserInfo( user: User, - { - request, - forceFresh, - timings, - }: {request: Request; forceFresh?: boolean; timings?: Timings}, + {request, forceFresh}: {request: Request; forceFresh?: boolean}, ) { const {discordId, convertKitId, email} = user const [discordUser, convertKitInfo] = await Promise.all([ discordId ? cachified({ - cache: redisCache, - request, - forceFresh, - maxAge: 1000 * 60 * 60 * 24 * 30, + cache, + forceFresh: await shouldForceFresh({ + forceFresh, + request, + key: getDiscordCacheKey(discordId), + }), + ttl: 1000 * 60 * 60 * 24 * 30, + staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, key: getDiscordCacheKey(discordId), checkValue: (value: unknown) => typeof value === 'object' && value !== null && 'id' in value, @@ -81,11 +80,14 @@ async function getUserInfo( : null, convertKitId ? cachified({ - cache: redisCache, - request, - forceFresh, - maxAge: 1000 * 60 * 60 * 24 * 30, - timings, + cache, + forceFresh: await shouldForceFresh({ + forceFresh, + request, + key: getConvertKitCacheKey(convertKitId), + }), + ttl: 1000 * 60 * 60 * 24 * 30, + staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, key: getConvertKitCacheKey(convertKitId), checkValue: (value: unknown) => typeof value === 'object' && value !== null && 'tags' in value, @@ -121,12 +123,12 @@ async function getUserInfo( return userInfo } -function deleteConvertKitCache(convertKitId: string | number) { - return redisCache.del(getConvertKitCacheKey(String(convertKitId))) +async function deleteConvertKitCache(convertKitId: string | number) { + await cache.delete(getConvertKitCacheKey(String(convertKitId))) } -function deleteDiscordCache(discordId: string) { - return redisCache.del(getDiscordCacheKey(discordId)) +async function deleteDiscordCache(discordId: string) { + await cache.delete(getDiscordCacheKey(discordId)) } export { diff --git a/app/utils/workshop-tickets.server.ts b/app/utils/workshop-tickets.server.ts index e599ee94c..7653d1218 100644 --- a/app/utils/workshop-tickets.server.ts +++ b/app/utils/workshop-tickets.server.ts @@ -1,5 +1,5 @@ -import {cachified} from './cache.server' -import {redisCache} from './redis.server' +import {cachified} from 'cachified' +import {cache, shouldForceFresh} from './cache.server' type TiToDiscount = { code: string @@ -156,14 +156,15 @@ async function getCachedScheduledEvents({ request: Request forceFresh?: boolean }) { + const key = 'tito:scheduled-events' const scheduledEvents = await cachified({ - key: 'tito:scheduled-events', - cache: redisCache, + key, + cache, getFreshValue: getScheduledEvents, checkValue: (value: unknown) => Array.isArray(value), - request, - forceFresh, - maxAge: 1000 * 60 * 24, + forceFresh: await shouldForceFresh({forceFresh, request, key}), + ttl: 1000 * 60 * 24, + staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, }) return scheduledEvents } diff --git a/app/utils/workshops.server.ts b/app/utils/workshops.server.ts index 6a58127bd..65dbb7a7c 100644 --- a/app/utils/workshops.server.ts +++ b/app/utils/workshops.server.ts @@ -1,11 +1,10 @@ import * as YAML from 'yaml' import {markdownToHtmlUnwrapped} from './markdown.server' -import type {Timings} from './metrics.server' -import {redisCache} from './redis.server' -import {cachified} from './cache.server' +import {cachified} from 'cachified' import {downloadDirList, downloadFile} from './github.server' import {typedBoolean} from './misc' import type {Workshop} from '~/types' +import {cache, shouldForceFresh} from './cache.server' type RawWorkshop = { title?: string @@ -20,22 +19,20 @@ type RawWorkshop = { prerequisite?: string } -function getWorkshops({ +async function getWorkshops({ request, forceFresh, - timings, }: { request?: Request forceFresh?: boolean - timings?: Timings }) { + const key = 'content:workshops' return cachified({ - cache: redisCache, - key: 'content:workshops', - maxAge: 1000 * 60 * 60 * 24 * 7, - request, - forceFresh, - timings, + cache, + key, + ttl: 1000 * 60 * 60 * 24 * 7, + staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, + forceFresh: await shouldForceFresh({forceFresh, request, key}), getFreshValue: async () => { const dirList = await downloadDirList(`content/workshops`) const workshopFileList = dirList diff --git a/content/blog/aha-testing/index.mdx b/content/blog/aha-testing/index.mdx index 789d3c4d5..f77cf8b5d 100644 --- a/content/blog/aha-testing/index.mdx +++ b/content/blog/aha-testing/index.mdx @@ -15,8 +15,6 @@ meta: translations: - language: ็ฎ€ไฝ“ไธญๆ–‡ link: https://juejin.cn/post/7086704811927666719/ - - language: ๆ—ฅๆœฌ่ชž - link: https://makotot.dev/posts/aha-testing-translation-ja bannerCloudinaryId: unsplash/photo-1522424427542-e6fc86ff5253 bannerCredit: Photo by [Alexandru Goman](https://unsplash.com/photos/CM-qccHaQ04) diff --git a/content/blog/answers-to-common-questions-about-render-props.mdx b/content/blog/answers-to-common-questions-about-render-props.mdx index a90a11d65..54ec169c4 100644 --- a/content/blog/answers-to-common-questions-about-render-props.mdx +++ b/content/blog/answers-to-common-questions-about-render-props.mdx @@ -122,10 +122,6 @@ Another fairly common question is how to get access to the render prop arguments in lifecycle hooks (because your render prop function is called within the context of the `render` of your component, how do you get it into `componentDidMount`. -[This](https://twitter.com/SavePointSam/status/954515218616340480) was asked by -[@SavePointSam](https://twitter.com/SavePointSam): - -https://twitter.com/SavePointSam/status/954515218616340480 The answer to this is actually sort of hidden in the answer to Mark's question above. Notice that thanks to React's composability, we can create a separate diff --git a/content/blog/how-i-built-a-modern-website-in-2021.mdx b/content/blog/how-i-built-a-modern-website-in-2021.mdx index 65ffd63d6..5b77367a0 100644 --- a/content/blog/how-i-built-a-modern-website-in-2021.mdx +++ b/content/blog/how-i-built-a-modern-website-in-2021.mdx @@ -843,7 +843,7 @@ And oh, what if I wanted to also get all the posts this user has read? Do I need some graphql resolver magic? Nope! Check this out: ```ts -const users = await prismaRead.user.findMany({ +const users = await prisma.user.findMany({ select: { id: true, email: true, @@ -876,8 +876,8 @@ Now _that's_ what I'm talking about! And with Remix, I can easily query directly in my `loader`, and then have that **typed** data available in my component: ```tsx -export async function loader({request}: LoaderArgs) { - const users = await prismaRead.user.findMany({ +export async function loader({request}: DataFunctionArgs) { + const users = await prisma.user.findMany({ select: { id: true, email: true, diff --git a/cypress.config.ts b/cypress.config.ts deleted file mode 100644 index 9cfe730fc..000000000 --- a/cypress.config.ts +++ /dev/null @@ -1,43 +0,0 @@ -import {defineConfig} from 'cypress' - -export default defineConfig({ - e2e: { - setupNodeEvents: (on, config) => { - const isDev = config.watchForFileChanges - const port = process.env.PORT ?? (isDev ? '3000' : '8811') - const configOverrides: Partial = { - projectId: '4rxk45', - baseUrl: `http://localhost:${port}`, - viewportWidth: 1030, - viewportHeight: 800, - video: !process.env.CI, - screenshotOnRunFailure: !process.env.CI, - } - - // To use this: - // cy.task('log', whateverYouWantInTheTerminal) - on('task', { - log: message => { - console.log(message) - - return null - }, - }) - - on('before:browser:launch', (browser, options) => { - if (browser.name === 'chrome') { - options.args.push( - '--no-sandbox', - '--allow-file-access-from-files', - '--use-fake-ui-for-media-stream', - '--use-fake-device-for-media-stream', - '--use-file-for-fake-audio-capture=cypress/fixtures/sample.wav', - ) - } - return options - }) - - return {...config, ...configOverrides} - }, - }, -}) diff --git a/cypress/e2e/calls.cy.ts b/cypress/e2e/calls.cy.ts deleted file mode 100644 index cc59937cf..000000000 --- a/cypress/e2e/calls.cy.ts +++ /dev/null @@ -1,57 +0,0 @@ -import {faker} from '@faker-js/faker' - -describe('call in', () => { - it('should allow creating a call, response, and podcast', () => { - const title = faker.lorem.words(2) - - cy.login() - cy.visit('/calls') - cy.findAllByRole('link', {name: /record/i}) - .first() - .click() - cy.findByRole('link', {name: /new recording/i}).click() - cy.findByRole('main').within(() => { - cy.findByRole('button', {name: /current.*device/i}).click() - // this is hidden by the label, but it's definitely clickable - cy.findByRole('checkbox', {name: /default/i}).click({force: true}) - cy.findByRole('button', {name: /start/i}).click() - cy.wait(50) - cy.findByRole('button', {name: /pause/i}).click() - cy.findByRole('button', {name: /resume/i}).click() - cy.wait(50) - cy.findByRole('button', {name: /stop/i}).click() - cy.findByRole('button', {name: /re-record/i}).click() - - cy.findByRole('button', {name: /start/i}).click() - cy.wait(500) - cy.findByRole('button', {name: /stop/i}).click() - - cy.findByRole('button', {name: /accept/i}).click() - cy.findByRole('textbox', {name: /title/i}).type(title) - cy.findByRole('textbox', {name: /description/i}).type( - faker.lorem.paragraph(), - {delay: 0}, - ) - cy.findByRole('textbox', {name: /keywords/i}).type( - faker.lorem.words(3).split(' ').join(','), - {delay: 0}, - ) - cy.findByRole('button', {name: /submit/i}).click() - }) - - // login as admin - cy.login({role: 'ADMIN'}) - cy.visit('/calls/admin') - cy.findByRole('main').within(() => { - cy.findByRole('link', {name: new RegExp(title, 'i')}).click() - - cy.findByRole('button', {name: /start/i}).click() - cy.wait(500) - cy.findByRole('button', {name: /stop/i}).click() - - cy.findByRole('button', {name: /accept/i}).click() - // processing the audio takes a while, so let the timeout run - cy.findByRole('button', {name: /submit/i}).click({timeout: 10000}) - }) - }) -}) diff --git a/cypress/e2e/contact.cy.ts b/cypress/e2e/contact.cy.ts deleted file mode 100644 index ed686783a..000000000 --- a/cypress/e2e/contact.cy.ts +++ /dev/null @@ -1,57 +0,0 @@ -import {faker} from '@faker-js/faker' - -describe('contact', () => { - it('should allow a typical user flow', () => { - const firstName = faker.name.firstName() - const emailData = { - email: faker.internet.email( - firstName, - faker.name.lastName(), - 'example.com', - ), - firstName, - subject: `CONTACT: ${faker.lorem.words(3)}`, - body: faker.lorem.paragraphs(1).slice(0, 60), - } - const bodyPart1 = emailData.body.slice(0, 30) - const bodyPart2 = emailData.body.slice(30) - cy.login({email: emailData.email, firstName: emailData.firstName}) - cy.visit('/contact') - - cy.findByRole('main').within(() => { - cy.findByRole('textbox', {name: /name/i}).should( - 'have.value', - emailData.firstName, - ) - cy.findByRole('textbox', {name: /email/i}).should( - 'have.value', - emailData.email, - ) - cy.findByRole('textbox', {name: /subject/i}).type(emailData.subject) - cy.findByRole('textbox', {name: /body/i}).type(bodyPart1) - - cy.findByRole('button', {name: /send/i}).click() - - cy.findByRole('alert').should('contain', /too short/i) - - cy.findByRole('textbox', {name: /body/i}).type(bodyPart2) - - cy.findByRole('button', {name: /send/i}).click() - }) - - cy.wait(200) - cy.readFile('mocks/msw.local.json').then( - (data: {email: {html: string}}) => { - expect(data.email).to.include({ - from: `"${emailData.firstName}" <${emailData.email}>`, - subject: emailData.subject, - text: emailData.body, - }) - expect(data.email.html).to.match(/ { - it('should allow a user to connect their discord account', () => { - cy.login() - - cy.visit('/me') - - cy.findByRole('main').within(() => { - cy.findByRole('link', {name: /connect/i}).then(link => { - const href = link.attr('href') as string - const redirectURI = new URL(href).searchParams.get('redirect_uri') - if (!redirectURI) { - throw new Error( - 'The connect link does not have a redirect_uri parameter.', - ) - } - - const nextLocation = new URL(redirectURI) - nextLocation.searchParams.set('code', 'test_discord_auth_code') - cy.visit(nextLocation.toString()) - }) - }) - - cy.findByRole('main').within(() => { - // eventually this should probably be improved but it's ok for now. - // using hard coded IDs like this is not awesome. - cy.findByDisplayValue(/test_discord_username/i) - }) - }) -}) diff --git a/cypress/e2e/onboarding.cy.ts b/cypress/e2e/onboarding.cy.ts deleted file mode 100644 index f86334603..000000000 --- a/cypress/e2e/onboarding.cy.ts +++ /dev/null @@ -1,40 +0,0 @@ -import {faker} from '@faker-js/faker' - -describe('onboarding', () => { - it('should allow a user to register a new account', () => { - const firstName = faker.name.firstName() - const email = faker.internet.email( - firstName, - faker.name.lastName(), - 'example.com', - ) - cy.visit('/') - - cy.findByRole('navigation').within(() => { - cy.findByRole('link', {name: /login/i}).click() - }) - - cy.findByRole('main').within(() => { - cy.findByRole('textbox', {name: /email/i}).type(`${email}{enter}`) - cy.wait(200) - cy.readFile('mocks/msw.local.json').then( - (data: {email: {text: string}}) => { - const magicLink = data.email.text.match(/(http.+magic.+)\n/)?.[1] - if (magicLink) { - return cy.visit(magicLink) - } - throw new Error('Could not find magic link email') - }, - ) - }) - - cy.findByRole('main').within(() => { - cy.findByRole('textbox', {name: /name/i}).type(firstName) - cy.findByRole('group', {name: /team/i}).within(() => { - // checkbox is covered with a