diff --git a/apps/dotcom/package.json b/apps/dotcom/package.json index 1f158738a..4c1e17f96 100644 --- a/apps/dotcom/package.json +++ b/apps/dotcom/package.json @@ -38,6 +38,7 @@ "devDependencies": { "@jest/globals": "30.0.0-alpha.2", "@sentry/cli": "^2.25.0", + "@tldraw/validate": "workspace:*", "@types/qrcode": "^1.5.0", "@types/react": "^18.2.47", "@typescript-eslint/utils": "^5.59.0", @@ -45,6 +46,7 @@ "dotenv": "^16.3.1", "eslint": "^8.37.0", "fast-glob": "^3.3.1", + "json5": "^2.2.3", "lazyrepo": "0.0.0-alpha.27", "vite": "^5.0.0", "ws": "^8.16.0" diff --git a/apps/dotcom/scripts/build.ts b/apps/dotcom/scripts/build.ts index b2a327779..e9e734921 100644 --- a/apps/dotcom/scripts/build.ts +++ b/apps/dotcom/scripts/build.ts @@ -4,8 +4,30 @@ import { exec } from '../../../scripts/lib/exec' import { Config } from './vercel-output-config' import { config } from 'dotenv' +import json5 from 'json5' import { nicelog } from '../../../scripts/lib/nicelog' -import { SPA_ROUTE_FILTERS } from '../spaRouteFilters' + +import { T } from '@tldraw/validate' + +// We load the list of routes that should be forwarded to our SPA's index.html here. +// It uses a jest snapshot file because deriving the set of routes from our +// react-router config works fine in our test environment, but is tricky to get running in this +// build script environment for various reasons (no global React, tsx being weird about decorators, etc). +function loadSpaRoutes() { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const routesJson = require('../src/__snapshots__/routes.test.tsx.snap')['the_routes 1'] + const routes = T.arrayOf( + T.object({ + reactRouterPattern: T.string, + vercelRouterPattern: T.string, + }) + ).validate(json5.parse(routesJson)) + return routes.map((route) => ({ + check: true, + src: route.vercelRouterPattern, + dest: '/index.html', + })) +} config({ path: './.env.local', @@ -14,6 +36,9 @@ config({ nicelog('The multiplayer server is', process.env.MULTIPLAYER_SERVER) async function build() { + // make sure we have the latest routes + await exec('yarn', ['test', 'src/routes.test.tsx']) + const spaRoutes = loadSpaRoutes() await exec('vite', ['build', '--emptyOutDir']) await exec('yarn', ['run', '-T', 'sentry-cli', 'sourcemaps', 'inject', 'dist/assets']) // Clear output static folder (in case we are running locally and have already built the app once before) @@ -22,12 +47,6 @@ async function build() { await exec('cp', ['-r', 'dist', '.vercel/output/static']) await exec('rm', ['-rf', ...glob.sync('.vercel/output/static/**/*.js.map')]) - const spaRoutes = SPA_ROUTE_FILTERS.map((route) => ({ - check: true, - src: route, - dest: '/index.html', - })) - writeFileSync( '.vercel/output/config.json', JSON.stringify( diff --git a/apps/dotcom/spaRouteFilters.ts b/apps/dotcom/spaRouteFilters.ts deleted file mode 100644 index 910dfab6a..000000000 --- a/apps/dotcom/spaRouteFilters.ts +++ /dev/null @@ -1,8 +0,0 @@ -// This value is used to configure Vercel routing to rewrite dotcom SPA routes to /index.html. -// It is also tested in routes.test.ts to make sure it matches all React Router routes. -// -// It is a list of string-encoded regexes matching SPA routes to be spliced into -// Vercel's "build output spec" in scripts/build.ts. -// -// Make sure it's not overly broad, because otherwise we won't give correct 404 responses. -export const SPA_ROUTE_FILTERS = ['^/$', '^/r/?$', '^/new/?$', '^/r/.*', '^/s/.*', '^/v/.*'] diff --git a/apps/dotcom/src/__snapshots__/routes.test.tsx.snap b/apps/dotcom/src/__snapshots__/routes.test.tsx.snap new file mode 100644 index 000000000..db16700e1 --- /dev/null +++ b/apps/dotcom/src/__snapshots__/routes.test.tsx.snap @@ -0,0 +1,38 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`the_routes 1`] = ` +[ + { + "reactRouterPattern": "/", + "vercelRouterPattern": "^//?$", + }, + { + "reactRouterPattern": "/new", + "vercelRouterPattern": "^/new/?$", + }, + { + "reactRouterPattern": "/r", + "vercelRouterPattern": "^/r/?$", + }, + { + "reactRouterPattern": "/r/:boardId/history", + "vercelRouterPattern": "^/r/[^/]*/history/?$", + }, + { + "reactRouterPattern": "/r/:boardId/history/:timestamp", + "vercelRouterPattern": "^/r/[^/]*/history/[^/]*/?$", + }, + { + "reactRouterPattern": "/r/:roomId", + "vercelRouterPattern": "^/r/[^/]*/?$", + }, + { + "reactRouterPattern": "/s/:roomId", + "vercelRouterPattern": "^/s/[^/]*/?$", + }, + { + "reactRouterPattern": "/v/:roomId", + "vercelRouterPattern": "^/v/[^/]*/?$", + }, +] +`; diff --git a/apps/dotcom/src/routes.test.ts b/apps/dotcom/src/routes.test.ts deleted file mode 100644 index e578f5802..000000000 --- a/apps/dotcom/src/routes.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { expect } from '@jest/globals' -import type { MatcherFunction } from 'expect' -import { RouteObject } from 'react-router-dom' -import { SPA_ROUTE_FILTERS } from '../spaRouteFilters' -import { router } from './routes' - -const toMatchAny: MatcherFunction<[regexes: unknown]> = function (actual, regexes) { - if ( - typeof actual !== 'string' || - !Array.isArray(regexes) || - regexes.some((regex) => typeof regex !== 'string') - ) { - throw new Error('toMatchAny takes a string and an array of strings') - } - - const pass = regexes.map((regex) => new RegExp(regex)).some((regex) => actual.match(regex)) - if (pass) { - return { - message: () => - `expected ${this.utils.printReceived(actual)} not to match any of the regexes ${this.utils.printExpected(regexes)}`, - pass: true, - } - } else { - return { - message: () => - `expected ${this.utils.printReceived(actual)} to match at least one of the regexes ${this.utils.printExpected(regexes)}`, - pass: false, - } - } -} - -expect.extend({ toMatchAny }) - -function extractContentPaths(routeObject: RouteObject): string[] { - const paths: string[] = [] - - if (routeObject.path && routeObject.path !== '*') { - paths.push(routeObject.path) - } - - if (routeObject.children) { - routeObject.children.forEach((child) => { - paths.push(...extractContentPaths(child)) - }) - } - - return paths -} - -test('SPA_ROUTE_FILTERS match all React routes', () => { - router.flatMap(extractContentPaths).forEach((path) => { - expect(path).toMatchAny(SPA_ROUTE_FILTERS) - }) -}) - -test("SPA_ROUTE_FILTERS don't match assets", () => { - expect('/assets/test.png').not.toMatchAny(SPA_ROUTE_FILTERS) -}) diff --git a/apps/dotcom/src/routes.test.tsx b/apps/dotcom/src/routes.test.tsx new file mode 100644 index 000000000..a68dafb3a --- /dev/null +++ b/apps/dotcom/src/routes.test.tsx @@ -0,0 +1,185 @@ +import { expect } from '@jest/globals' +import type { MatcherFunction } from 'expect' +import { join } from 'path' +import { ReactElement } from 'react' +import { Route, RouteObject, createRoutesFromElements } from 'react-router-dom' +import { router } from './routes' + +const toMatchAny: MatcherFunction<[regexes: unknown]> = function (actual, regexes) { + if ( + typeof actual !== 'string' || + !Array.isArray(regexes) || + regexes.some((regex) => typeof regex !== 'string') + ) { + throw new Error('toMatchAny takes a string and an array of strings') + } + + const pass = regexes.map((regex) => new RegExp(regex)).some((regex) => actual.match(regex)) + if (pass) { + return { + message: () => + `expected ${this.utils.printReceived(actual)} not to match any of the regexes ${this.utils.printExpected(regexes)}`, + pass: true, + } + } else { + return { + message: () => + `expected ${this.utils.printReceived(actual)} to match at least one of the regexes ${this.utils.printExpected(regexes)}`, + pass: false, + } + } +} + +expect.extend({ toMatchAny }) + +function extractContentPaths(routeObject: RouteObject): string[] { + const path = routeObject.path || '' + + const paths: string[] = [] + if ( + path && + (routeObject.element || routeObject.Component || routeObject.lazy || routeObject.loader) + ) { + // This is a contentful route so it gets included on its own. + // Technically not every route with a .lazy or a .loader has content, but we don't have a way to check that + // and it's not a huge deal if they don't 404 correctly. + paths.push(path) + } + if (routeObject.children && routeObject.children.length > 0) { + // this route has children, so we need to recurse + paths.push( + ...routeObject.children.flatMap((child) => + extractContentPaths(child).map((childPath) => join(path, childPath)) + ) + ) + } + + return paths +} + +function convertReactToVercel(path: string): string { + // react-router supports wildcard routes https://reactrouter.com/en/main/route/route#splats + // but we don't use them yet so just fail for now until we need them (if ever) + if (path.endsWith('*')) { + throw new Error(`Wildcard routes like '${path}' are not supported yet (you can add support!)`) + } + // react-router supports optional route segments https://reactrouter.com/en/main/route/route#optional-segments + // but we don't use them yet so just fail for now until we need them (if ever) + if (path.match(/\?\//)) { + throw new Error( + `Optional route segments like in '${path}' are not supported yet (you can add this)` + ) + } + // make sure colons are immediately preceded by a slash + if (path.match(/[^/]:/)) { + throw new Error(`Colons in route segments must be immediately preceded by a slash in '${path}'`) + } + if (!path.startsWith('/')) { + throw new Error(`Route paths must start with a slash, but '${path}' does not`) + } + // Wrap in explicit start and end of string anchors (^ and $) + // and replace :param with [^/]* to match any string of non-slash characters, including the empty string + return '^' + path.replace(/:[^/]+/g, '[^/]*') + '/?$' +} + +const spaRoutes = router + .flatMap(extractContentPaths) + .sort() + // ignore the root catch-all route + .filter((path) => path !== '/*' && path !== '*') + .map((path) => ({ + reactRouterPattern: path, + vercelRouterPattern: convertReactToVercel(path), + })) + +const allvercelRouterPatterns = spaRoutes.map((route) => route.vercelRouterPattern) + +test('the_routes', () => { + expect(spaRoutes).toMatchSnapshot() +}) + +test('all React routes match', () => { + for (const route of spaRoutes) { + expect(route.reactRouterPattern).toMatch(new RegExp(route.vercelRouterPattern)) + for (const otherRoute of spaRoutes) { + if (route === otherRoute) continue + expect(route.reactRouterPattern).not.toMatch(new RegExp(otherRoute.vercelRouterPattern)) + } + } +}) + +test("non-react routes don't match", () => { + // lil smoke test for basic patterns + expect('/').toMatchAny(allvercelRouterPatterns) + expect('/new').toMatchAny(allvercelRouterPatterns) + expect('/r/whatever').toMatchAny(allvercelRouterPatterns) + expect('/r/whatever/').toMatchAny(allvercelRouterPatterns) + + expect('/assets/test.png').not.toMatchAny(allvercelRouterPatterns) + expect('/twitter-social.png').not.toMatchAny(allvercelRouterPatterns) + expect('/robots.txt').not.toMatchAny(allvercelRouterPatterns) + expect('/static/css/index.css').not.toMatchAny(allvercelRouterPatterns) +}) + +test('convertReactToVercel', () => { + expect(() => + convertReactToVercel('/r/:roomId/history?/:timestamp') + ).toThrowErrorMatchingInlineSnapshot( + `"Optional route segments like in '/r/:roomId/history?/:timestamp' are not supported yet (you can add this)"` + ) + expect(() => convertReactToVercel('/r/:roomId/history/*')).toThrowErrorMatchingInlineSnapshot( + `"Wildcard routes like '/r/:roomId/history/*' are not supported yet (you can add support!)"` + ) + expect(() => convertReactToVercel('/r/foo:roomId/history')).toThrowErrorMatchingInlineSnapshot( + `"Colons in route segments must be immediately preceded by a slash in '/r/foo:roomId/history'"` + ) + expect(() => convertReactToVercel('r/:roomId/history')).toThrowErrorMatchingInlineSnapshot( + `"Route paths must start with a slash, but 'r/:roomId/history' does not"` + ) +}) + +function extract(...routes: ReactElement[]) { + return createRoutesFromElements( + {routes.map((r, i) => ({ ...r, key: i.toString() }))} + ) + .flatMap(extractContentPaths) + .sort() +} + +describe('extractContentPaths', () => { + it('only includes routes with content', () => { + expect(extract(} />)).toEqual(['/foo']) + expect(extract( 'hi'} />)).toEqual(['/foo']) + expect( + extract( Promise.resolve({ Component: () => 'foo' })} />) + ).toEqual(['/foo']) + expect(extract( null} />)).toEqual(['/foo']) + expect(extract()).toEqual([]) + expect(extract()).toEqual([]) + expect( + extract( + + + + ) + ).toEqual([]) + }) + it('does not include parent routes without content', () => { + expect( + extract( + + } /> + + ) + ).toEqual(['/foo/bar']) + }) + it('does include parent routes with content', () => { + expect( + extract( + }> + } /> + + ) + ).toEqual(['/foo', '/foo/bar']) + }) +}) diff --git a/apps/dotcom/tsconfig.json b/apps/dotcom/tsconfig.json index 26a728317..2eb327536 100644 --- a/apps/dotcom/tsconfig.json +++ b/apps/dotcom/tsconfig.json @@ -33,6 +33,9 @@ }, { "path": "../../packages/tlsync" + }, + { + "path": "../../packages/validate" } ] } diff --git a/yarn.lock b/yarn.lock index af696fad0..c519eab51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11851,6 +11851,7 @@ __metadata: "@tldraw/assets": "workspace:*" "@tldraw/tldraw": "workspace:*" "@tldraw/tlsync": "workspace:*" + "@tldraw/validate": "workspace:*" "@types/qrcode": "npm:^1.5.0" "@types/react": "npm:^18.2.47" "@typescript-eslint/utils": "npm:^5.59.0" @@ -11861,6 +11862,7 @@ __metadata: eslint: "npm:^8.37.0" fast-glob: "npm:^3.3.1" idb: "npm:^7.1.1" + json5: "npm:^2.2.3" lazyrepo: "npm:0.0.0-alpha.27" nanoid: "npm:4.0.2" qrcode: "npm:^1.5.1"