diff --git a/epicshop/package-lock.json b/epicshop/package-lock.json index 80839fb..4f7fb35 100644 --- a/epicshop/package-lock.json +++ b/epicshop/package-lock.json @@ -4,6 +4,7 @@ "requires": true, "packages": { "": { + "hasInstallScript": true, "dependencies": { "@epic-web/workshop-app": "^6.90.3", "@epic-web/workshop-utils": "^6.90.3", diff --git a/epicshop/package.json b/epicshop/package.json index bbb37fa..90298fd 100644 --- a/epicshop/package.json +++ b/epicshop/package.json @@ -1,5 +1,9 @@ { "type": "module", + "scripts": { + "postinstall": "node ./patch-workshop-app.js", + "test:patch-workshop-app": "node --test ./patch-workshop-app.test.js" + }, "dependencies": { "@epic-web/workshop-app": "^6.90.3", "@epic-web/workshop-utils": "^6.90.3", diff --git a/epicshop/patch-workshop-app.js b/epicshop/patch-workshop-app.js new file mode 100644 index 0000000..33fd6ad --- /dev/null +++ b/epicshop/patch-workshop-app.js @@ -0,0 +1,193 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const defaultServerBuildPath = path.join( + here, + 'node_modules', + '@epic-web', + 'workshop-app', + 'build', + 'server', + 'index.js', +) +const defaultServerRuntimePath = path.join( + here, + 'node_modules', + '@epic-web', + 'workshop-app', + 'dist', + 'server', + 'index.js', +) + +const notFoundActionFunctionName = 'action$splatNotFound' +const notFoundActionFunction = `async function ${notFoundActionFunctionName}() { + throw new Response("Not found", { + status: 404 + }); +} +` + +const loaderEndNeedle = ` throw new Response("Not found", { + status: 404 + }); +} +const $ = UNSAFE_withComponentProps` + +const patchedLoaderEndNeedle = ` throw new Response("Not found", { + status: 404 + }); +} +${notFoundActionFunction}const $ = UNSAFE_withComponentProps` + +const routeModuleNeedle = ` ErrorBoundary: ErrorBoundary$7, + default: $, + loader: loader$L +}, Symbol.toStringTag` + +const patchedRouteModuleNeedle = ` ErrorBoundary: ErrorBoundary$7, + action: ${notFoundActionFunctionName}, + default: $, + loader: loader$L +}, Symbol.toStringTag` + +const routeManifestNeedle = + '"routes/$": { "id": "routes/$", "parentId": "root", "path": "*", "index": void 0, "caseSensitive": void 0, "hasAction": false,' + +const patchedRouteManifestNeedle = + '"routes/$": { "id": "routes/$", "parentId": "root", "path": "*", "index": void 0, "caseSensitive": void 0, "hasAction": true,' + +const repeatedSlashMiddleware = `app.use((req, res, next) => { + const requestPath = req.originalUrl.split("?")[0]; + if (req.method !== "GET" && req.method !== "HEAD" && req.method !== "OPTIONS" && /^\\/{2,}$/.test(requestPath)) { + res.status(404).send("Not found"); + return; + } + next(); +}); +` + +const requestContextMiddlewareNeedle = + 'app.use((_req, _res, next) => requestContext.run({}, next));\n' + +const patchedRequestContextMiddlewareNeedle = `${requestContextMiddlewareNeedle}${repeatedSlashMiddleware}` + +export function patchWorkshopAppServerBuild(source) { + const hasActionFunction = source.includes( + `async function ${notFoundActionFunctionName}()`, + ) + const hasActionExport = source.includes( + `action: ${notFoundActionFunctionName}`, + ) + const hasActionManifest = source.includes(patchedRouteManifestNeedle) + + if (hasActionFunction && hasActionExport && hasActionManifest) { + return { patched: false, source } + } + + if (hasActionFunction !== hasActionExport) { + throw new Error( + 'Found a partial workshop-app splat action patch. Reinstall dependencies and rerun the patch.', + ) + } + + let patchedSource = source + let patched = false + + if (!hasActionFunction) { + if (!patchedSource.includes(loaderEndNeedle)) { + throw new Error( + 'Could not find the workshop-app splat route loader to patch.', + ) + } + + if (!patchedSource.includes(routeModuleNeedle)) { + throw new Error( + 'Could not find the workshop-app splat route module to patch.', + ) + } + + patchedSource = patchedSource + .replace(loaderEndNeedle, patchedLoaderEndNeedle) + .replace(routeModuleNeedle, patchedRouteModuleNeedle) + patched = true + } + + if (!hasActionManifest) { + if (!patchedSource.includes(routeManifestNeedle)) { + throw new Error( + 'Could not find the workshop-app splat route manifest entry to patch.', + ) + } + + patchedSource = patchedSource.replace( + routeManifestNeedle, + patchedRouteManifestNeedle, + ) + patched = true + } + + return { + patched, + source: patchedSource, + } +} + +export function patchWorkshopAppServerRuntime(source) { + if (source.includes(repeatedSlashMiddleware)) { + return { patched: false, source } + } + + if (!source.includes(requestContextMiddlewareNeedle)) { + throw new Error( + 'Could not find the workshop-app request context middleware to patch.', + ) + } + + return { + patched: true, + source: source.replace( + requestContextMiddlewareNeedle, + patchedRequestContextMiddlewareNeedle, + ), + } +} + +export async function patchWorkshopApp({ + serverBuildPath = defaultServerBuildPath, + serverRuntimePath = defaultServerRuntimePath, +} = {}) { + const [serverBuildSource, serverRuntimeSource] = await Promise.all([ + fs.readFile(serverBuildPath, 'utf8'), + fs.readFile(serverRuntimePath, 'utf8'), + ]) + const serverBuildResult = patchWorkshopAppServerBuild(serverBuildSource) + const serverRuntimeResult = patchWorkshopAppServerRuntime(serverRuntimeSource) + + await Promise.all([ + serverBuildResult.patched + ? fs.writeFile(serverBuildPath, serverBuildResult.source) + : null, + serverRuntimeResult.patched + ? fs.writeFile(serverRuntimePath, serverRuntimeResult.source) + : null, + ]) + + if (serverBuildResult.patched || serverRuntimeResult.patched) { + console.log( + 'Patched @epic-web/workshop-app splat route to return a normal 404 for POST scanner traffic.', + ) + } + + return { + patched: serverBuildResult.patched || serverRuntimeResult.patched, + serverBuild: serverBuildResult, + serverRuntime: serverRuntimeResult, + } +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + await patchWorkshopApp() +} diff --git a/epicshop/patch-workshop-app.test.js b/epicshop/patch-workshop-app.test.js new file mode 100644 index 0000000..07ccbd7 --- /dev/null +++ b/epicshop/patch-workshop-app.test.js @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + patchWorkshopApp, + patchWorkshopAppServerBuild, + patchWorkshopAppServerRuntime, +} from './patch-workshop-app.js' + +const unpatchedServerBuildFixture = `async function loader$L({ + params +}) { + const splat = params["*"]; + const segments = splat?.split("/") ?? []; + if (segments.length > 0 && !isNaN(Number(segments[0]))) { + const newPath = \`/exercise/\${splat}\`; + return new Response(null, { + status: 302, + headers: { + Location: newPath + } + }); + } + throw new Response("Not found", { + status: 404 + }); +} +const $ = UNSAFE_withComponentProps(function NotFound() { + return /* @__PURE__ */ jsx(ErrorBoundary$7, {}); +}); +const ErrorBoundary$7 = UNSAFE_withErrorBoundaryProps(function ErrorBoundary2() { + return null; +}); +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": "*", "index": void 0, "caseSensitive": void 0, "hasAction": false, "hasLoader": true } } }; +` + +const unpatchedServerRuntimeFixture = `app.use((_req, _res, next) => requestContext.run({}, next)); +app.options("*splat", (_req, res) => { + res.set("Allow", "GET, HEAD, POST, OPTIONS"); + res.sendStatus(204); +}); +` + +test('adds a 404 action to the workshop-app splat route module', () => { + const result = patchWorkshopAppServerBuild(unpatchedServerBuildFixture) + + assert.equal(result.patched, true) + assert.match(result.source, /async function action\$splatNotFound\(\)/) + assert.match(result.source, /action: action\$splatNotFound/) + assert.match(result.source, /"routes\/\$": .*"hasAction": true/) +}) + +test('does not modify an already patched server build', () => { + const firstResult = patchWorkshopAppServerBuild(unpatchedServerBuildFixture) + const secondResult = patchWorkshopAppServerBuild(firstResult.source) + + assert.equal(secondResult.patched, false) + assert.equal(secondResult.source, firstResult.source) +}) + +test('fails loudly when the expected route shape changes', () => { + assert.throws( + () => patchWorkshopAppServerBuild('const route1 = {}'), + /Could not find the workshop-app splat route loader/, + ) +}) + +test('adds a narrow repeated-slash POST guard before React Router', () => { + const result = patchWorkshopAppServerRuntime(unpatchedServerRuntimeFixture) + + assert.equal(result.patched, true) + assert.match(result.source, /requestPath = req\.originalUrl\.split/) + assert.match(result.source, /\^\\\/\{2,\}\$/) +}) + +test('does not modify an already patched server runtime', () => { + const firstResult = patchWorkshopAppServerRuntime(unpatchedServerRuntimeFixture) + const secondResult = patchWorkshopAppServerRuntime(firstResult.source) + + assert.equal(secondResult.patched, false) + assert.equal(secondResult.source, firstResult.source) +}) + +test('patches the installed workshop-app build', async () => { + const result = await patchWorkshopApp() + + assert.equal(typeof result.patched, 'boolean') + assert.match(result.serverBuild.source, /action: action\$splatNotFound/) + assert.match(result.serverBuild.source, /"routes\/\$": .*"hasAction": true/) + assert.match(result.serverRuntime.source, /\^\\\/\{2,\}\$/) +}) diff --git a/package.json b/package.json index 83bb4b9..208622e 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "test:e2e": "pkgmgr run test:e2e --silent --prefix playground", "test:e2e:dev": "pkgmgr run test:e2e:dev --silent --prefix playground", "test:e2e:run": "pkgmgr run test:e2e:run --silent --prefix playground", + "test:patch-workshop-app": "npm run test:patch-workshop-app --prefix epicshop", "setup": "pkgmgrx epicshop setup", "setup:custom": "node ./epicshop/setup-custom.js", "lint": "eslint . --concurrency=auto",