diff --git a/netlify-server.ts b/netlify-server.ts new file mode 100644 index 00000000..d016a1d5 --- /dev/null +++ b/netlify-server.ts @@ -0,0 +1,63 @@ +import type { AppLoadContext, ServerBuild } from '@netlify/remix-runtime' +import { createRequestHandler as createRemixRequestHandler } from '@netlify/remix-runtime' +import type { Context } from '@netlify/edge-functions' + +type LoadContext = AppLoadContext & Context + +/** + * A function that returns the value to use as `context` in route `loader` and + * `action` functions. + * + * You can think of this as an escape hatch that allows you to pass + * environment/platform-specific values through to your loader/action. + */ +export type GetLoadContextFunction = (request: Request, context: Context) => Promise | LoadContext + +export type RequestHandler = (request: Request, context: LoadContext) => Promise + +export function createRequestHandler({ + build, + mode, + getLoadContext, +}: { + build: ServerBuild + mode?: string + getLoadContext?: GetLoadContextFunction +}): RequestHandler { + const remixHandler = createRemixRequestHandler(build, mode) + + const assetPath = build.assets.url.split('/').slice(0, -1).join('/') + + return async (request: Request, context: LoadContext): Promise => { + const { pathname } = new URL(request.url) + // Skip the handler for static files + if (pathname.startsWith(`${assetPath}/`)) { + // Temporary fix - passing the request to the Netlify static asset handler causes a 203 Not Content error. Passing the through remix works, but I asssume isn't ideal + // console.log('Skipping Remix handler for static file', pathname) + // return; + } + try { + const loadContext = (await getLoadContext?.(request, context)) || context + + const response = await remixHandler(request, loadContext) + + // A useful header for debugging + response.headers.set('x-nf-runtime', 'Edge') + + if (response.status === 404) { + // Check if there is a matching static file + const originResponse = await context.next({ + sendConditionalRequest: true, + }) + if (originResponse.status !== 404) { + return originResponse + } + } + return response + } catch (error: unknown) { + console.error(error) + + return new Response('Internal Error', { status: 500 }) + } + } +} diff --git a/netlify.toml b/netlify.toml index ec357029..4fbf0b70 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,18 +1,13 @@ [build] command = "remix build" - functions = "netlify/functions" publish = "public" [dev] command = "remix watch" port = 3000 + autoLaunch = false -[[redirects]] - from = "/*" - to = "/.netlify/functions/server" - status = 200 - -[[headers]] - for = "/build/*" - [headers.values] - "Cache-Control" = "public, max-age=31536000, s-maxage=31536000" +# [[headers]] +# for = "/build/*" +# [headers.values] +# "Cache-Control" = "public, max-age=31536000, s-maxage=31536000" diff --git a/package.json b/package.json index 6c34191d..d77cf5c5 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "@actions/github": "^5.1.1", "@babel/core": "^7.21.0", "@babel/preset-react": "^7.18.6", + "@netlify/edge-functions": "^2.0.0", "@netlify/functions": "^1.4.0", + "@netlify/remix-edge-adapter": "^1.0.0", "@remix-run/dev": "^1.13.0", "@remix-run/netlify": "^1.13.0", "@remix-run/node": "^1.13.0", @@ -28,6 +30,7 @@ "@types/react": "^18.0.28", "@types/react-dom": "^18.0.11", "country-emoji": "^1.5.6", + "isbot": "^3.6.6", "joi": "^17.8.1", "normalize.css": "^8.0.1", "prop-types": "^15.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20cc8860..8cfb85d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,9 @@ specifiers: '@actions/github': ^5.1.1 '@babel/core': ^7.21.0 '@babel/preset-react': ^7.18.6 + '@netlify/edge-functions': ^2.0.0 '@netlify/functions': ^1.4.0 + '@netlify/remix-edge-adapter': ^1.0.0 '@remix-run/dev': ^1.13.0 '@remix-run/netlify': ^1.13.0 '@remix-run/node': ^1.13.0 @@ -21,6 +23,7 @@ specifiers: eslint: ^8.34.0 eslint-config-wesbos: ^3.2.3 husky: ^8.0.3 + isbot: ^3.6.6 joi: ^17.8.1 lint-staged: ^13.1.2 normalize.css: ^8.0.1 @@ -41,7 +44,9 @@ dependencies: '@actions/github': 5.1.1 '@babel/core': 7.21.0 '@babel/preset-react': 7.18.6_@babel+core@7.21.0 + '@netlify/edge-functions': 2.0.0 '@netlify/functions': 1.4.0 + '@netlify/remix-edge-adapter': 1.0.0 '@remix-run/dev': 1.13.0_@remix-run+serve@1.13.0 '@remix-run/netlify': 1.13.0_@netlify+functions@1.4.0 '@remix-run/node': 1.13.0 @@ -52,6 +57,7 @@ dependencies: '@types/react': 18.0.28 '@types/react-dom': 18.0.11 country-emoji: 1.5.6 + isbot: 3.6.6 joi: 17.8.1 normalize.css: 8.0.1 prop-types: 15.8.1 @@ -1382,6 +1388,17 @@ packages: postcss-selector-parser: 6.0.11 dev: true + /@deno/shim-deno-test/0.3.3: + resolution: {integrity: sha512-Ge0Tnl7zZY0VvEfgsyLhjid8DzI1d0La0dgm+3m0/A8gZXgp5xwlyIyue5e4SCUuVB/3AH/0lun9LcJhhTwmbg==} + dev: false + + /@deno/shim-deno/0.10.0: + resolution: {integrity: sha512-E7rQ0Hk33V45xQXKEnCxizdSP5C+hhqw1H3xWXsct3kYFWgG93B5gN3LKlyvcxbckt8d67jVa6s+y5duRYawvg==} + dependencies: + '@deno/shim-deno-test': 0.3.3 + which: 2.0.2 + dev: false + /@emotion/hash/0.9.0: resolution: {integrity: sha512-14FtKiHhy2QoPIzdTcvh//8OyBlknNs2nXRwIhG904opCby3l+9Xaf/wuPvICBF0rc1ZCNBd3nKe9cd2mecVkQ==} dev: false @@ -1715,6 +1732,13 @@ packages: '@jridgewell/resolve-uri': 3.1.0 '@jridgewell/sourcemap-codec': 1.4.14 + /@netlify/edge-functions/2.0.0: + resolution: {integrity: sha512-mRVGnPNA4YayDLPwnO1ZrcWwBODPj5BQPbx3/FUlQtZ5ow2D+PjMPQr8IcFm0HfMJQgtHZS39p9VS6PRSi1ePw==} + engines: {node: ^14.16.0 || >=16.0.0} + dependencies: + '@deno/shim-deno': 0.10.0 + dev: false + /@netlify/functions/1.4.0: resolution: {integrity: sha512-gy7ULTIRroc2/jyFVGx1djCmmBMVisIwrvkqggq5B6iDcInRSy2Tpkm+V5C63hKJVkNRskKWtLQKm9ecCaQTjA==} engines: {node: '>=8.3.0'} @@ -1722,6 +1746,18 @@ packages: is-promise: 4.0.0 dev: false + /@netlify/remix-edge-adapter/1.0.0: + resolution: {integrity: sha512-NdTDRW8X/Iyh/ywHcf5H7HUkLoc/k2oxxOMIO5/rm0LHMN2bGFhbOxMBfi0PtFsa/Jn+efbNTkCM15JXoITzSw==} + dependencies: + '@netlify/remix-runtime': 1.0.0 + dev: false + + /@netlify/remix-runtime/1.0.0: + resolution: {integrity: sha512-w1nLlMABrW5uJoQc3eADWZ4wgnRqcqpNOQ+ZMQnoXSwukUGGN6JwGs3/BP+f/8F4CCy7sakbbF+akBmyKIKciQ==} + dependencies: + '@remix-run/server-runtime': 1.13.0 + dev: false + /@nicolo-ribaudo/eslint-scope-5-internals/5.1.1-v1: resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==} dependencies: @@ -4881,6 +4917,11 @@ packages: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} dev: true + /isbot/3.6.6: + resolution: {integrity: sha512-98aGl1Spbx1led422YFrusDJ4ZutSNOymb2avZ2V4BCCjF3MqAF2k+J2zoaLYahubaFkb+3UyvbVDVlk/Ngrew==} + engines: {node: '>=12'} + dev: false + /isexe/2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} diff --git a/remix.config.js b/remix.config.js index 2063012d..aac8e782 100644 --- a/remix.config.js +++ b/remix.config.js @@ -1,5 +1,7 @@ +const { config } = require("@netlify/remix-edge-adapter"); /** @type {import('@remix-run/dev').AppConfig} */ module.exports = { + ...config, appDirectory: "src", future: { unstable_postcss: true, @@ -9,5 +11,5 @@ module.exports = { process.env.NETLIFY || process.env.NETLIFY_LOCAL ? "./server.js" : undefined, - serverBuildPath: ".netlify/functions-internal/server.js", + // serverBuildPath: ".netlify/functions-internal/server.js", }; diff --git a/server.js b/server.js deleted file mode 100644 index b981b1a2..00000000 --- a/server.js +++ /dev/null @@ -1,47 +0,0 @@ -import { createRequestHandler } from "@remix-run/netlify"; -import * as build from "@remix-run/dev/server-build"; - -/* - * Returns a context object with at most 3 keys: - * - `netlifyGraphToken`: raw authentication token to use with Netlify Graph - * - `clientNetlifyGraphAccessToken`: For use with JWTs generated by - * `netlify-graph-auth`. - * - `netlifyGraphSignature`: a signature for subscription events. Will be - * present if a secret is set. - */ -function getLoadContext(event, context) { - let rawAuthorizationString; - let netlifyGraphToken; - - if (event.authlifyToken != null) { - netlifyGraphToken = event.authlifyToken; - } - - const authHeader = event.headers["authorization"]; - const graphSignatureHeader = event.headers["x-netlify-graph-signature"]; - - if (authHeader != null && /Bearer /gi.test(authHeader)) { - rawAuthorizationString = authHeader.split(" ")[1]; - } - - const loadContext = { - clientNetlifyGraphAccessToken: rawAuthorizationString, - netlifyGraphToken: netlifyGraphToken, - netlifyGraphSignature: graphSignatureHeader, - }; - - // Remove keys with undefined values - Object.keys(loadContext).forEach((key) => { - if (loadContext[key] == null) { - delete loadContext[key]; - } - }); - - return loadContext; -} - -export const handler = createRequestHandler({ - build, - getLoadContext, - mode: process.env.NODE_ENV, -}); diff --git a/server.ts b/server.ts new file mode 100644 index 00000000..87c1eedb --- /dev/null +++ b/server.ts @@ -0,0 +1,15 @@ +// Import path interpreted by the Remix compiler +import * as build from "@remix-run/dev/server-build"; +// import { createRequestHandler } from "@netlify/remix-edge-adapter"; +import { createRequestHandler } from "./netlify-server"; + +export default createRequestHandler({ + build, + // process.env.NODE_ENV is provided by Remix at compile time + mode: process.env.NODE_ENV, +}); + +export const config = { + cache: "manual", + path: "/*", +}; diff --git a/src/entry.server.tsx b/src/entry.server.tsx index 253d2c5b..6630113c 100644 --- a/src/entry.server.tsx +++ b/src/entry.server.tsx @@ -1,25 +1,35 @@ -import { PassThrough } from 'stream'; import type { EntryContext } from '@remix-run/node'; -import { Response } from '@remix-run/node'; import { RemixServer } from '@remix-run/react'; -import { renderToPipeableStream } from 'react-dom/server'; +import { renderToReadableStream } from 'react-dom/server'; const ABORT_DELAY = 5000; +export async function streamToText(stream: ReadableStream): Promise { + let result = ''; + const reader = stream.pipeThrough(new TextDecoderStream()).getReader(); + while (true) { // eslint-disable-line no-constant-condition + const { done, value } = await reader.read(); + if (done) { + break; + } + + result += value; + } + return result; +} + type CachedResponse = { - html:string; + html: string; date: Date; } const cache = new Map(); - -export default function handleRequest( +export default async function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext ) { - console.log(request.url); // check if we have a cached response in memory const cachedResponse = cache.get(request.url); if (cachedResponse) { @@ -37,48 +47,35 @@ export default function handleRequest( } } + let didError = false; + const chunks: Uint8Array[] = []; - return new Promise((resolve, reject) => { - let didError = false; - const chunks: Uint8Array[] = []; - - const { pipe, abort } = renderToPipeableStream( - , - { - onShellReady: () => { - const body = new PassThrough(); - - body - .on('data', (data) => { - chunks.push(data); - }) - .on('end', () => { - const html = Buffer.concat(chunks).toString('utf8'); - cache.set(request.url, { html: html.replace('Rendered Fresh', `Served from Cache ${new Date().toString()}`), date: new Date() }); - }) - - responseHeaders.set('Content-Type', 'text/html'); - - resolve( - new Response(body, { - headers: responseHeaders, - status: didError ? 500 : responseStatusCode, - }) - ); - - pipe(body); - }, - onShellError: (err: unknown) => { - reject(err); - }, - onError: (error: unknown) => { - didError = true; - - console.error(error); - } + const body = await renderToReadableStream( + , + { + onError: (error: unknown) => { + didError = true; + console.error(error); } - ); + } + ); - setTimeout(abort, ABORT_DELAY); + // tee the stream so we can cache it and send it to the client + const [toReponse, toCache] = body.tee(); + + streamToText(toCache).then(html => { + console.log('Caching', request.url); + cache.set(request.url, { + html: html.replace('Rendered Fresh',`Rendered from cache ${new Date().toISOString()}`), + date: new Date(), + }); }); + + const headers = new Headers(responseHeaders); + headers.set("Content-Type", "text/html"); + const response = new Response(toReponse, { + headers, + status: didError ? 500 : responseStatusCode, + }); + return response; } diff --git a/src/util/stats.ts b/src/util/stats.ts index 92bcb14c..b0f040c3 100644 --- a/src/util/stats.ts +++ b/src/util/stats.ts @@ -64,7 +64,6 @@ export function tags() { const counts = allTags.reduce(countInstances, {}); // sort and filter for any tags that only have 1 const tags = Object.entries(counts) - .sort(([, countA], [, countB]) => countB - countA) // Only show the tag if this topic has 3 or more people in it .filter(([, count]) => count >= 3) .map(([name, count]) => ({ name, count })); @@ -85,8 +84,11 @@ export function tags() { delete lowercaseTagMap[normalizedName]; } return acc; - }, []); - + }, []) + // Sort by name first + .sort((a, b) => b.name.toLowerCase() > a.name.toLowerCase()) + // Sort by count + .sort((a, b) => b.count - a.count); return [{ name: 'all', count: people.length }, ...normalizedTags]; } @@ -116,12 +118,12 @@ const normalizedTagMap = tags().reduce((acc, tag) => { }, {}); export function getPeople(tag?: string) { - return people + return [...people] .sort(() => Math.random() - 0.5) .map((person) => { const normalizedPerson = { ...person, - // Clean out people that added basically the same tags twice + // Clean out people that added basically the same tags twice tags: unique( person.tags.map((tag) => normalizedTagMap[normalizeTag(tag)] || tag) ), diff --git a/static/fonts.css b/static/fonts.css deleted file mode 100644 index e0a145a6..00000000 --- a/static/fonts.css +++ /dev/null @@ -1,16 +0,0 @@ - /* Fonts */ - @font-face { - font-family: 'Fira Mono'; - font-weight: 400; - font-style: normal; - src: url('../src/fonts/fira_mono-regular-webfont.woff2') format('woff2'), - url('../src/fonts/fira_mono-regular-webfont.woff') format('woff'); - font-display: swap; - } - @font-face { - font-family: 'Fira Mono'; - font-weight: 400; - font-style: italic; - src: url('../src/fonts/fira_mono-regular_italic-webfont.woff2') format('woff2'), url('../src/fonts/fira_mono-regular_italic-webfont.woff') format('woff'); - font-display: swap; - }