Skip to content

Commit 43a935c

Browse files
Handle catch-all POSTs as not found
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
1 parent 2be4813 commit 43a935c

7 files changed

Lines changed: 166 additions & 23 deletions

File tree

epicshop/package-lock.json

Lines changed: 19 additions & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

epicshop/package.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
{
22
"type": "module",
3+
"scripts": {
4+
"postinstall": "node ./patch-workshop-app.js",
5+
"test": "node --test ./patch-workshop-app.test.js"
6+
},
37
"dependencies": {
4-
"@epic-web/workshop-app": "^6.90.3",
5-
"@epic-web/workshop-utils": "^6.90.3",
6-
"epicshop": "^6.90.3",
8+
"@epic-web/workshop-app": "^6.90.4",
9+
"@epic-web/workshop-utils": "^6.90.4",
10+
"epicshop": "^6.90.4",
711
"execa": "^8.0.1",
812
"fs-extra": "^11.2.0"
913
}

epicshop/patch-workshop-app.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import fs from 'node:fs/promises'
2+
import { createRequire } from 'node:module'
3+
import path from 'node:path'
4+
import { fileURLToPath } from 'node:url'
5+
6+
const require = createRequire(import.meta.url)
7+
const catchAllRouteId = 'routes/$'
8+
9+
function escapeRegExp(value) {
10+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
11+
}
12+
13+
function getWorkshopAppServerBuildPath() {
14+
const packageJsonPath = require.resolve('@epic-web/workshop-app/package.json')
15+
return path.join(path.dirname(packageJsonPath), 'build/server/index.js')
16+
}
17+
18+
export function patchServerBuild(contents) {
19+
const routeModuleName = contents.match(
20+
/"routes\/\$":\s*\{[\s\S]*?module:\s*(route\d+)/,
21+
)?.[1]
22+
23+
if (!routeModuleName) {
24+
throw new Error(`Unable to find ${catchAllRouteId} module in workshop app build`)
25+
}
26+
27+
const routeModulePattern = new RegExp(
28+
`const ${escapeRegExp(routeModuleName)} = /\\* @__PURE__ \\*/ Object\\.freeze\\(/\\* @__PURE__ \\*/ Object\\.defineProperty\\(\\{([\\s\\S]*?)\\n\\}, Symbol\\.toStringTag`,
29+
)
30+
const routeModuleMatch = contents.match(routeModulePattern)
31+
32+
if (!routeModuleMatch) {
33+
throw new Error(`Unable to find ${catchAllRouteId} route module declaration`)
34+
}
35+
36+
const routeModuleBody = routeModuleMatch[1]
37+
const loaderName = routeModuleBody.match(
38+
/\n\s+loader:\s*([A-Za-z_$][\w$]*)/,
39+
)?.[1]
40+
41+
if (!loaderName) {
42+
throw new Error(`Unable to find ${catchAllRouteId} loader export`)
43+
}
44+
45+
let patchedRouteAction = false
46+
if (!/\n\s+action:/.test(routeModuleBody)) {
47+
const patchedRouteModuleBody = routeModuleBody.replace(
48+
/\n(\s+)loader:\s*([A-Za-z_$][\w$]*)/,
49+
`\n$1action: ${loaderName},\n$1loader: $2`,
50+
)
51+
contents = contents.replace(routeModuleBody, patchedRouteModuleBody)
52+
patchedRouteAction = true
53+
}
54+
55+
let patchedManifest = false
56+
contents = contents.replace(
57+
/"routes\/\$": \{([\s\S]{0,600}?)"hasAction": false/g,
58+
(match, routeManifestPrefix) => {
59+
patchedManifest = true
60+
return `"routes/$": {${routeManifestPrefix}"hasAction": true`
61+
},
62+
)
63+
64+
return { contents, patchedRouteAction, patchedManifest }
65+
}
66+
67+
export async function patchInstalledWorkshopApp() {
68+
const serverBuildPath = getWorkshopAppServerBuildPath()
69+
const originalContents = await fs.readFile(serverBuildPath, 'utf8')
70+
const result = patchServerBuild(originalContents)
71+
72+
if (result.contents !== originalContents) {
73+
await fs.writeFile(serverBuildPath, result.contents)
74+
}
75+
76+
const actionStatus = result.patchedRouteAction ? 'added' : 'already present'
77+
const manifestStatus = result.patchedManifest ? 'updated' : 'already current'
78+
console.log(
79+
`Patched @epic-web/workshop-app ${catchAllRouteId}: action ${actionStatus}, manifest ${manifestStatus}.`,
80+
)
81+
}
82+
83+
const currentFilePath = fileURLToPath(import.meta.url)
84+
if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) {
85+
await patchInstalledWorkshopApp()
86+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import assert from 'node:assert/strict'
2+
import test from 'node:test'
3+
import { patchServerBuild } from './patch-workshop-app.js'
4+
5+
const fixture = `
6+
const route1 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
7+
__proto__: null,
8+
ErrorBoundary: ErrorBoundary$7,
9+
default: $,
10+
loader: loader$L
11+
}, Symbol.toStringTag, { value: "Module" }));
12+
const serverManifest = { "routes": { "routes/$": { "id": "routes/$", "parentId": "root", "path": "*", "hasAction": false, "hasLoader": true } } };
13+
const routes = {
14+
"routes/$": {
15+
id: "routes/$",
16+
parentId: "root",
17+
path: "*",
18+
module: route1
19+
}
20+
};
21+
`
22+
23+
test('patches the workshop app catch-all route with an action', () => {
24+
const result = patchServerBuild(fixture)
25+
26+
assert.equal(result.patchedRouteAction, true)
27+
assert.equal(result.patchedManifest, true)
28+
assert.match(result.contents, /action: loader\$L,\n loader: loader\$L/)
29+
assert.match(result.contents, /"routes\/\$": \{[^}]*"hasAction": true/)
30+
})
31+
32+
test('leaves an already patched catch-all route unchanged', () => {
33+
const once = patchServerBuild(fixture)
34+
const twice = patchServerBuild(once.contents)
35+
36+
assert.equal(twice.patchedRouteAction, false)
37+
assert.equal(twice.contents, once.contents)
38+
})

exercises/07.error-handling/05.problem.not-found/README.mdx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,16 @@ As we're following the `remix-flat-routes` convention, to create a route that
2929
matches `/*`, 🐨 we'll create a file at <InlineFile file="app/routes/$.tsx" />.
3030

3131
With that file created, you need to 🐨 create a loader that throws a `404`
32-
response:
32+
response. Also export an action that uses the same behavior so non-GET requests
33+
to missing routes get the expected 404 response instead of falling through to a
34+
framework "no action" error:
3335

3436
```tsx
3537
export async function loader() {
3638
throw new Response('Not found', { status: 404 })
3739
}
40+
41+
export const action = loader
3842
```
3943

4044
Next, let's 🐨 export the `ErrorBoundary`:

exercises/07.error-handling/05.solution.not-found/app/routes/$.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export async function loader() {
1212
throw new Response('Not found', { status: 404 })
1313
}
1414

15+
export const action = loader
16+
1517
export default function NotFound() {
1618
// due to the loader, this component will never be rendered, but we'll return
1719
// the error boundary just in case.

exercises/07.error-handling/05.solution.not-found/tests/e2e/smoke.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,12 @@ test('can visit the home page', async ({ page }) => {
44
await page.goto('/')
55
await expect(page.getByText('Hello World')).toBeVisible()
66
})
7+
8+
test('post requests to missing routes return the not found response', async ({
9+
request,
10+
}) => {
11+
const response = await request.post('/connectors/resource/index.php')
12+
13+
expect(response.status()).toBe(404)
14+
expect(await response.text()).toContain('Not found')
15+
})

0 commit comments

Comments
 (0)