From 92a4bdea6bf393e7d4459c84846a23f1713e3dec Mon Sep 17 00:00:00 2001 From: Ryan Christian <33403762+rschristian@users.noreply.github.com> Date: Sun, 18 Feb 2024 08:28:50 -0600 Subject: [PATCH 01/12] fix: Normalize URLs w/ trailing slashes --- src/prerender.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/prerender.ts b/src/prerender.ts index 2cda4ea..42845a0 100644 --- a/src/prerender.ts +++ b/src/prerender.ts @@ -291,7 +291,7 @@ export function PrerenderPlugin({ if (result.links) { for (let url of result.links) { const parsed = new URL(url, "http://localhost"); - url = parsed.pathname; + url = parsed.pathname.replace(/\/$/, '') || '/'; // ignore external links and ones we've already picked up if (seen.has(url) || parsed.origin !== "http://localhost") continue; seen.add(url); From 728a8f9ca7bc5d6689242b081bedc8f6881282c0 Mon Sep 17 00:00:00 2001 From: Ryan Christian Date: Mon, 19 Feb 2024 00:32:21 -0600 Subject: [PATCH 02/12] feat: Return `Response` from patched fetch --- src/prerender.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/prerender.ts b/src/prerender.ts index 42845a0..2a438ab 100644 --- a/src/prerender.ts +++ b/src/prerender.ts @@ -169,15 +169,20 @@ export function PrerenderPlugin({ // @ts-ignore globalThis.fetch = async (url: string, opts: RequestInit | undefined) => { if (/^\//.test(url)) { - const text = () => - fs.readFile( - `${path.join( - viteConfig.root, - viteConfig.build.outDir, - )}/${url.replace(/^\//, "")}`, - "utf-8", + try { + return new Response( + await fs.readFile( + `${path.join( + viteConfig.root, + viteConfig.build.outDir, + )}/${url.replace(/^\//, "")}`, + "utf-8", + ), ); - return { text, json: () => text().then(JSON.parse) }; + } catch (e: any) { + if (e.code !== "ENOENT") throw e; + return new Response(null, { status: 404 }); + } } return nodeFetch(url, opts); @@ -291,7 +296,7 @@ export function PrerenderPlugin({ if (result.links) { for (let url of result.links) { const parsed = new URL(url, "http://localhost"); - url = parsed.pathname.replace(/\/$/, '') || '/'; + url = parsed.pathname.replace(/\/$/, "") || "/"; // ignore external links and ones we've already picked up if (seen.has(url) || parsed.origin !== "http://localhost") continue; seen.add(url); From a551c03a8906674e3be986705c9d9e9f88f21b5f Mon Sep 17 00:00:00 2001 From: Ryan Christian Date: Mon, 19 Feb 2024 00:32:39 -0600 Subject: [PATCH 03/12] test: Revise fetch test to use response --- demo/src/components/LocalFetch.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/demo/src/components/LocalFetch.tsx b/demo/src/components/LocalFetch.tsx index 11eea19..6e636bc 100644 --- a/demo/src/components/LocalFetch.tsx +++ b/demo/src/components/LocalFetch.tsx @@ -4,7 +4,8 @@ const cache = new Map(); async function load(url: string) { const res = await fetch(url); - return await res.text(); + if (res.ok) return await res.text(); + throw new Error(`Failed to fetch ${url}!`); } function useFetch(url: string) { From 85e4bb4be9ead062327edb2dcb9ba8f6af2600ed Mon Sep 17 00:00:00 2001 From: Ryan Christian Date: Mon, 19 Feb 2024 23:56:12 -0600 Subject: [PATCH 04/12] feat: Form code frame from sourcemaps for prerender errors --- package-lock.json | 76 ++++++++++++++++++++++++++++++++++++++++------- package.json | 6 +++- src/prerender.ts | 56 ++++++++++++++++++++++++++++++---- 3 files changed, 121 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index f41ece7..044233a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,15 +18,19 @@ "kolorist": "^1.8.0", "magic-string": "0.30.5", "node-html-parser": "^6.1.10", - "resolve": "^1.22.8" + "resolve": "^1.22.8", + "source-map": "^0.7.4", + "stack-trace": "^1.0.0-pre2" }, "devDependencies": { "@babel/core": "^7.15.8", + "@types/babel__code-frame": "^7.0.6", "@types/babel__core": "^7.1.14", "@types/debug": "^4.1.5", "@types/estree": "^0.0.50", "@types/node": "^14.14.33", "@types/resolve": "^1.20.1", + "@types/stack-trace": "^0.0.33", "lint-staged": "^10.5.4", "preact": "^10.19.2", "preact-iso": "^2.3.2", @@ -604,6 +608,12 @@ "node": ">= 8.0.0" } }, + "node_modules/@types/babel__code-frame": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@types/babel__code-frame/-/babel__code-frame-7.0.6.tgz", + "integrity": "sha512-Anitqkl3+KrzcW2k77lRlg/GfLZLWXBuNgbEcIOU6M92yw42vsd3xV/Z/yAHEj8m+KUjL6bWOVOFqX8PFPJ4LA==", + "dev": true + }, "node_modules/@types/babel__core": { "version": "7.1.14", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.14.tgz", @@ -675,6 +685,12 @@ "integrity": "sha512-Ku5+GPFa12S3W26Uwtw+xyrtIpaZsGYHH6zxNbZlstmlvMYSZRzOwzwsXbxlVUbHyUucctSyuFtu6bNxwYomIw==", "dev": true }, + "node_modules/@types/stack-trace": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/stack-trace/-/stack-trace-0.0.33.tgz", + "integrity": "sha512-O7in6531Bbvlb2KEsJ0dq0CHZvc3iWSR5ZYMtvGgnHA56VgriAN/AU2LorfmcvAl2xc9N5fbCTRyMRRl8nd74g==", + "dev": true + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -2421,12 +2437,11 @@ } }, "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", "engines": { - "node": ">=0.10.0" + "node": ">= 8" } }, "node_modules/source-map-js": { @@ -2447,6 +2462,23 @@ "source-map": "^0.6.0" } }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-trace": { + "version": "1.0.0-pre2", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0-pre2.tgz", + "integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==", + "engines": { + "node": ">=16" + } + }, "node_modules/string-argv": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz", @@ -3152,6 +3184,12 @@ "picomatch": "^2.2.2" } }, + "@types/babel__code-frame": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@types/babel__code-frame/-/babel__code-frame-7.0.6.tgz", + "integrity": "sha512-Anitqkl3+KrzcW2k77lRlg/GfLZLWXBuNgbEcIOU6M92yw42vsd3xV/Z/yAHEj8m+KUjL6bWOVOFqX8PFPJ4LA==", + "dev": true + }, "@types/babel__core": { "version": "7.1.14", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.14.tgz", @@ -3223,6 +3261,12 @@ "integrity": "sha512-Ku5+GPFa12S3W26Uwtw+xyrtIpaZsGYHH6zxNbZlstmlvMYSZRzOwzwsXbxlVUbHyUucctSyuFtu6bNxwYomIw==", "dev": true }, + "@types/stack-trace": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/stack-trace/-/stack-trace-0.0.33.tgz", + "integrity": "sha512-O7in6531Bbvlb2KEsJ0dq0CHZvc3iWSR5ZYMtvGgnHA56VgriAN/AU2LorfmcvAl2xc9N5fbCTRyMRRl8nd74g==", + "dev": true + }, "aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -4364,10 +4408,9 @@ } }, "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==" }, "source-map-js": { "version": "1.0.2", @@ -4382,8 +4425,21 @@ "requires": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } } }, + "stack-trace": { + "version": "1.0.0-pre2", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0-pre2.tgz", + "integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==" + }, "string-argv": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz", diff --git a/package.json b/package.json index b5687ac..0fe76df 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,9 @@ "kolorist": "^1.8.0", "magic-string": "0.30.5", "node-html-parser": "^6.1.10", - "resolve": "^1.22.8" + "resolve": "^1.22.8", + "source-map": "^0.7.4", + "stack-trace": "^1.0.0-pre2" }, "peerDependencies": { "@babel/core": "7.x", @@ -50,11 +52,13 @@ }, "devDependencies": { "@babel/core": "^7.15.8", + "@types/babel__code-frame": "^7.0.6", "@types/babel__core": "^7.1.14", "@types/debug": "^4.1.5", "@types/estree": "^0.0.50", "@types/node": "^14.14.33", "@types/resolve": "^1.20.1", + "@types/stack-trace": "^0.0.33", "lint-staged": "^10.5.4", "preact": "^10.19.2", "preact-iso": "^2.3.2", diff --git a/src/prerender.ts b/src/prerender.ts index 2a438ab..dd26a7e 100644 --- a/src/prerender.ts +++ b/src/prerender.ts @@ -1,9 +1,11 @@ import path from "node:path"; - import { promises as fs } from "node:fs"; import MagicString from "magic-string"; import { parse as htmlParse } from "node-html-parser"; +import { SourceMapConsumer } from "source-map"; +import { parse as StackTraceParse } from "stack-trace"; +import { codeFrameColumns } from "@babel/code-frame"; import type { Plugin, ResolvedConfig } from "vite"; @@ -116,6 +118,9 @@ export function PrerenderPlugin({ apply: "build", enforce: "post", configResolved(config) { + // Enable sourcemaps at least for prerendering for usable error messages + config.build.sourcemap = true; + viteConfig = config; }, async options(opts) { @@ -212,7 +217,7 @@ export function PrerenderPlugin({ JSON.stringify({ type: "module" }), ); - let prerenderEntry; + let prerenderEntry: OutputChunk | undefined; for (const output of Object.keys(bundle)) { if (!/\.js$/.test(output) || bundle[output].type !== "chunk") continue; @@ -222,7 +227,7 @@ export function PrerenderPlugin({ ); if ((bundle[output] as OutputChunk).exports?.includes("prerender")) { - prerenderEntry = bundle[output]; + prerenderEntry = bundle[output] as OutputChunk; } } if (!prerenderEntry) { @@ -238,15 +243,18 @@ export function PrerenderPlugin({ ); prerender = m.prerender; } catch (e) { - const isReferenceError = e instanceof ReferenceError; + const stack = StackTraceParse(e as Error).find(s => + s.getFileName().includes(tmpDir), + ); - const message = ` + const isReferenceError = e instanceof ReferenceError; + let message = `\n ${e} This ${ isReferenceError ? "is most likely" : "could be" } caused by using DOM/Web APIs which are not available - available to the prerendering process which runs in Node. Consider + available to the prerendering process running in Node. Consider wrapping the offending code in a window check like so: if (typeof window !== "undefined") { @@ -254,6 +262,42 @@ export function PrerenderPlugin({ } `.replace(/^\t{5}/gm, ""); + const sourceMapContent = prerenderEntry.map; + if (stack && sourceMapContent) { + await SourceMapConsumer.with( + sourceMapContent, + null, + async consumer => { + let { source, line, column } = consumer.originalPositionFor({ + line: stack.getLineNumber(), + column: stack.getColumnNumber(), + }); + + if (!source || line == null || column == null) { + message += `\nUnable to locate source map for error!\n`; + this.error(message); + } + + // `source-map` returns 0-indexed column numbers + column += 1; + + const sourcePath = path.join( + viteConfig.root, + source.replace(/^(..\/)*/, ""), + ); + const sourceContent = await fs.readFile(sourcePath, "utf-8"); + + const frame = codeFrameColumns(sourceContent, { + start: { line, column }, + }); + message += ` + > ${sourcePath}:${line}:${column}\n + ${frame} + `.replace(/^\t{7}/gm, ""); + }, + ); + } + this.error(message); } From 41cfa32296e3e4276b0f6f5054e612daadcbb945 Mon Sep 17 00:00:00 2001 From: Ryan Christian Date: Mon, 19 Feb 2024 23:56:19 -0600 Subject: [PATCH 05/12] test: Fix demo --- demo/package.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 demo/package.json diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 0000000..bedb411 --- /dev/null +++ b/demo/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} From b268bcf545d088957a6c9bca65b2c4e800c4b832 Mon Sep 17 00:00:00 2001 From: Ryan Christian <33403762+rschristian@users.noreply.github.com> Date: Tue, 20 Feb 2024 00:05:27 -0600 Subject: [PATCH 06/12] Update src/prerender.ts --- src/prerender.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/prerender.ts b/src/prerender.ts index dd26a7e..73a7922 100644 --- a/src/prerender.ts +++ b/src/prerender.ts @@ -118,7 +118,7 @@ export function PrerenderPlugin({ apply: "build", enforce: "post", configResolved(config) { - // Enable sourcemaps at least for prerendering for usable error messages + // Enable sourcemaps for generating more actionable error messages config.build.sourcemap = true; viteConfig = config; From f0c4e538afc80d7d516ea3411717dedcf3e99ed9 Mon Sep 17 00:00:00 2001 From: Ryan Christian <33403762+rschristian@users.noreply.github.com> Date: Wed, 21 Feb 2024 02:20:11 -0600 Subject: [PATCH 07/12] docs: Rewrite prerendering instructions --- README.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c30bfbd..2025c18 100644 --- a/README.md +++ b/README.md @@ -75,19 +75,31 @@ preact({ | Option | Type | Default | Description | |---|---|---|---| | `enabled` | `boolean` | `false` | Enables prerendering | -| `prerenderScript` | `string` | `undefined` | Absolute path to script containing exported `prerender()` function. If not provided, will try to find the prerender script in the scripts listed in your HTML entrypoint | | `renderTarget` | `string` | `"body"` | Query selector for where to insert prerender result in your HTML template | -| `additionalPrerenderRoutes` | `string` | `undefined` | Prerendering will automatically discover links to prerender, but if there are unliked pages that you want to prererender (such as a `/404` page), use this option to specify them | +| `prerenderScript` | `string` | `undefined` | Absolute path to script containing exported `prerender()` function. If not provided, will try to find the prerender script in the scripts listed in your HTML entrypoint | +| `additionalPrerenderRoutes` | `string[]` | `undefined` | Prerendering will crawl your site automatically, but you'd like to prerender some pages that may not be found (such as a `/404` page), use this option to specify them | + +To prerender your app, you'll need to do three things: +1. Enable prerendering in the plugin options +2. Specify your render target, if you want the HTML to be inserted anywhere other than the `document.body`. This location likely should match `render()`, i.e., `render(, document.querySelector('#app'))` -> `'#app'` +4. Create and export a `prerender` function from a script. You could add this to your app entrypoint or create a completely separate file for it, either will work. See below for a usage example +5. Specify where your `prerender` function is by either a) adding a `prerender` attribute to the script tag that contains it in your entry HTML (`