diff --git a/epicshop/package-lock.json b/epicshop/package-lock.json index 72e1efafb..a0338236e 100644 --- a/epicshop/package-lock.json +++ b/epicshop/package-lock.json @@ -5,9 +5,9 @@ "packages": { "": { "dependencies": { - "@epic-web/workshop-app": "^6.90.3", - "@epic-web/workshop-utils": "^6.90.3", - "epicshop": "^6.90.3", + "@epic-web/workshop-app": "^6.90.4", + "@epic-web/workshop-utils": "^6.90.4", + "epicshop": "^6.90.4", "execa": "^8.0.1", "fs-extra": "^11.2.0" } @@ -432,9 +432,9 @@ } }, "node_modules/@epic-web/workshop-app": { - "version": "6.90.3", - "resolved": "https://registry.npmjs.org/@epic-web/workshop-app/-/workshop-app-6.90.3.tgz", - "integrity": "sha512-/ZeOFpgyzBKU5BWCqNz5ETwvnrP/NEFLagXlyZ4/Eg7xwsXVuGaP3yaCuei+8q21nM9tg8lAfobLtBOABMDldA==", + "version": "6.90.4", + "resolved": "https://registry.npmjs.org/@epic-web/workshop-app/-/workshop-app-6.90.4.tgz", + "integrity": "sha512-fKIOENAyOIwd8iEP8zpG85QXtxHoYne0i9s81Zc1OrHIYGb8BqSAM/nMaOpMNMn9+BxNZDM3ttOwgBqekLRHnw==", "dependencies": { "@conform-to/react": "^1.19.1", "@conform-to/zod": "^1.19.1", @@ -443,8 +443,8 @@ "@epic-web/invariant": "^1.0.0", "@epic-web/remember": "^1.1.0", "@epic-web/restore-scroll": "^2.0.0", - "@epic-web/workshop-presence": "6.90.3", - "@epic-web/workshop-utils": "6.90.3", + "@epic-web/workshop-presence": "6.90.4", + "@epic-web/workshop-utils": "6.90.4", "@mdx-js/mdx": "^3.1.1", "@mux/mux-player-react": "^3.12.0", "@nasa-gcn/remix-seo": "^2.0.1", @@ -643,18 +643,18 @@ } }, "node_modules/@epic-web/workshop-presence": { - "version": "6.90.3", - "resolved": "https://registry.npmjs.org/@epic-web/workshop-presence/-/workshop-presence-6.90.3.tgz", - "integrity": "sha512-we6MmgPyuDxsN2H9fD32vvlu9ow7/ZYoeKXQHnVXSuhxQ8myRUVAbhRR5k+BZrEy7Vnqua4xyy0S7nwc5BuL3g==", + "version": "6.90.4", + "resolved": "https://registry.npmjs.org/@epic-web/workshop-presence/-/workshop-presence-6.90.4.tgz", + "integrity": "sha512-BSl+MmLkLb8HSaypgt/Eia5QpbuOJGXCIDTxNuVoXzzYa5Z0VT3qMKgR9s/1gyY5GJZQ50rlTuQY7MFLeLLcPA==", "dependencies": { - "@epic-web/workshop-utils": "6.90.3", + "@epic-web/workshop-utils": "6.90.4", "zod": "^4.3.6" } }, "node_modules/@epic-web/workshop-utils": { - "version": "6.90.3", - "resolved": "https://registry.npmjs.org/@epic-web/workshop-utils/-/workshop-utils-6.90.3.tgz", - "integrity": "sha512-zVqA6A4nnClubdlsiGoWH/xb35SLI/pSVEBh02J44nOzE4ajVorqoJUul2EFnNRxMWd5h21Vig+N1yNK/6D67A==", + "version": "6.90.4", + "resolved": "https://registry.npmjs.org/@epic-web/workshop-utils/-/workshop-utils-6.90.4.tgz", + "integrity": "sha512-7nUxIJrLuK2UvAWXb20CW4lmxvwdFruv2+TczqgLZw+40f1LgF0SS0IZC9iQKljWqzhw+TyD6kdKlpntMg3txw==", "dependencies": { "@epic-web/cachified": "^5.6.2", "@epic-web/invariant": "^1.0.0", @@ -6466,11 +6466,11 @@ } }, "node_modules/epicshop": { - "version": "6.90.3", - "resolved": "https://registry.npmjs.org/epicshop/-/epicshop-6.90.3.tgz", - "integrity": "sha512-wNmKCji3MyCizbSwnAXGchFNGEUAHqfJIbG2J5/43Ybh1lKWPVWk/8vt/cG9Xhmht3LhJ2IhdFLEQ+dsPYijWg==", + "version": "6.90.4", + "resolved": "https://registry.npmjs.org/epicshop/-/epicshop-6.90.4.tgz", + "integrity": "sha512-kjXqXfmWuQcW9PnGR/YwZpT9d4evggu/1v3KR06o7xx58ZSCrJL3JJimxGG6owXuEl9k7+k4yArCqgaIeC4nwQ==", "dependencies": { - "@epic-web/workshop-utils": "6.90.3", + "@epic-web/workshop-utils": "6.90.4", "@inquirer/prompts": "^8.4.2", "@sentry/node": "^10.50.0", "chalk": "^5.6.2", diff --git a/epicshop/package.json b/epicshop/package.json index c13205624..f1f01c096 100644 --- a/epicshop/package.json +++ b/epicshop/package.json @@ -1,9 +1,13 @@ { "type": "module", + "scripts": { + "postinstall": "node ./patch-workshop-app.js", + "test": "node --test ./patch-workshop-app.test.js" + }, "dependencies": { - "@epic-web/workshop-app": "^6.90.3", - "@epic-web/workshop-utils": "^6.90.3", - "epicshop": "^6.90.3", + "@epic-web/workshop-app": "^6.90.4", + "@epic-web/workshop-utils": "^6.90.4", + "epicshop": "^6.90.4", "execa": "^8.0.1", "fs-extra": "^11.2.0" } diff --git a/epicshop/patch-workshop-app.js b/epicshop/patch-workshop-app.js new file mode 100644 index 000000000..6a5552306 --- /dev/null +++ b/epicshop/patch-workshop-app.js @@ -0,0 +1,93 @@ +import fs from 'node:fs/promises' +import { createRequire } from 'node:module' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const require = createRequire(import.meta.url) +const catchAllRouteId = 'routes/$' + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function getWorkshopAppServerBuildPath() { + const packageJsonPath = require.resolve('@epic-web/workshop-app/package.json') + return path.join(path.dirname(packageJsonPath), 'build/server/index.js') +} + +export function patchServerBuild(contents) { + let patchedContents = contents + const routeModuleName = contents.match( + /const routes = \{[\s\S]*?"routes\/\$":\s*\{[\s\S]*?module:\s*(route\d+)/, + )?.[1] + + if (!routeModuleName) { + throw new Error(`Unable to find ${catchAllRouteId} module in workshop app build`) + } + + const routeModulePattern = new RegExp( + `const ${escapeRegExp(routeModuleName)} = /\\* @__PURE__ \\*/ Object\\.freeze\\(/\\* @__PURE__ \\*/ Object\\.defineProperty\\(\\{([\\s\\S]*?)\\n\\}, Symbol\\.toStringTag`, + ) + const routeModuleMatch = contents.match(routeModulePattern) + + if (!routeModuleMatch) { + throw new Error(`Unable to find ${catchAllRouteId} route module declaration`) + } + + const routeModuleBody = routeModuleMatch[1] + const loaderName = routeModuleBody.match( + /\n\s+loader:\s*([A-Za-z_$][\w$]*)/, + )?.[1] + + if (!loaderName) { + throw new Error(`Unable to find ${catchAllRouteId} loader export`) + } + + let patchedRouteAction = false + if (!/\n\s+action:/.test(routeModuleBody)) { + const patchedRouteModuleBody = routeModuleBody.replace( + /\n(\s+)loader:\s*([A-Za-z_$][\w$]*)/, + (_match, indent, loader) => + `\n${indent}action: ${loaderName},\n${indent}loader: ${loader}`, + ) + const routeModuleBodyStart = + routeModuleMatch.index + routeModuleMatch[0].indexOf(routeModuleBody) + patchedContents = + patchedContents.slice(0, routeModuleBodyStart) + + patchedRouteModuleBody + + patchedContents.slice(routeModuleBodyStart + routeModuleBody.length) + patchedRouteAction = true + } + + let patchedManifest = false + patchedContents = patchedContents.replace( + /"routes\/\$": \{([\s\S]{0,600}?)"hasAction": false/g, + (match, routeManifestPrefix) => { + patchedManifest = true + return `"routes/$": {${routeManifestPrefix}"hasAction": true` + }, + ) + + return { contents: patchedContents, patchedRouteAction, patchedManifest } +} + +export async function patchInstalledWorkshopApp() { + const serverBuildPath = getWorkshopAppServerBuildPath() + const originalContents = await fs.readFile(serverBuildPath, 'utf8') + const result = patchServerBuild(originalContents) + + if (result.contents !== originalContents) { + await fs.writeFile(serverBuildPath, result.contents) + } + + const actionStatus = result.patchedRouteAction ? 'added' : 'already present' + const manifestStatus = result.patchedManifest ? 'updated' : 'already current' + console.log( + `Patched @epic-web/workshop-app ${catchAllRouteId}: action ${actionStatus}, manifest ${manifestStatus}.`, + ) +} + +const currentFilePath = fileURLToPath(import.meta.url) +if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) { + await patchInstalledWorkshopApp() +} diff --git a/epicshop/patch-workshop-app.test.js b/epicshop/patch-workshop-app.test.js new file mode 100644 index 000000000..a50d0e07a --- /dev/null +++ b/epicshop/patch-workshop-app.test.js @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { patchServerBuild } from './patch-workshop-app.js' + +const fixture = ` +const route1 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ + __proto__: null, + ErrorBoundary: ErrorBoundary$7, + default: $, + loader: loader$L +}, Symbol.toStringTag, { value: "Module" })); +const serverManifest = { "routes": { "routes/$": { "id": "routes/$", "parentId": "root", "path": "*", "hasAction": false, "hasLoader": true } } }; +const routes = { + "routes/$": { + id: "routes/$", + parentId: "root", + path: "*", + module: route1 + } +}; +` + +test('patches the workshop app catch-all route with an action', () => { + const result = patchServerBuild(fixture) + + assert.equal(result.patchedRouteAction, true) + assert.equal(result.patchedManifest, true) + assert.match(result.contents, /action: loader\$L,\n loader: loader\$L/) + assert.match(result.contents, /"routes\/\$": \{[^}]*"hasAction": true/) +}) + +test('leaves an already patched catch-all route unchanged', () => { + const once = patchServerBuild(fixture) + const twice = patchServerBuild(once.contents) + + assert.equal(twice.patchedRouteAction, false) + assert.equal(twice.contents, once.contents) +}) diff --git a/exercises/07.error-handling/05.problem.not-found/README.mdx b/exercises/07.error-handling/05.problem.not-found/README.mdx index a46914ef8..348541d2e 100644 --- a/exercises/07.error-handling/05.problem.not-found/README.mdx +++ b/exercises/07.error-handling/05.problem.not-found/README.mdx @@ -29,12 +29,16 @@ As we're following the `remix-flat-routes` convention, to create a route that matches `/*`, 🐨 we'll create a file at . With that file created, you need to 🐨 create a loader that throws a `404` -response: +response. Also export an action that uses the same behavior so non-GET requests +to missing routes get the expected 404 response instead of falling through to a +framework "no action" error: ```tsx export async function loader() { throw new Response('Not found', { status: 404 }) } + +export const action = loader ``` Next, let's 🐨 export the `ErrorBoundary`: diff --git a/exercises/07.error-handling/05.solution.not-found/app/routes/$.tsx b/exercises/07.error-handling/05.solution.not-found/app/routes/$.tsx index 5f9d08d3b..04e27ce9b 100644 --- a/exercises/07.error-handling/05.solution.not-found/app/routes/$.tsx +++ b/exercises/07.error-handling/05.solution.not-found/app/routes/$.tsx @@ -12,6 +12,8 @@ export async function loader() { throw new Response('Not found', { status: 404 }) } +export const action = loader + export default function NotFound() { // due to the loader, this component will never be rendered, but we'll return // the error boundary just in case. diff --git a/exercises/07.error-handling/05.solution.not-found/tests/e2e/smoke.test.ts b/exercises/07.error-handling/05.solution.not-found/tests/e2e/smoke.test.ts index 486d18a6f..960be46a2 100644 --- a/exercises/07.error-handling/05.solution.not-found/tests/e2e/smoke.test.ts +++ b/exercises/07.error-handling/05.solution.not-found/tests/e2e/smoke.test.ts @@ -2,5 +2,14 @@ import { test, expect } from '@playwright/test' test('can visit the home page', async ({ page }) => { await page.goto('/') - await expect(page.getByText('Hello World')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Epic Notes' })).toBeVisible() +}) + +test('post requests to missing routes return the not found response', async ({ + request, +}) => { + const response = await request.post('/connectors/resource/index.php') + + expect(response.status()).toBe(404) + expect(await response.text()).toContain('Not found') })