diff --git a/packages/error-overlay/README.md b/packages/error-overlay/README.md new file mode 100644 index 000000000..19ed58eb7 --- /dev/null +++ b/packages/error-overlay/README.md @@ -0,0 +1,16 @@ +# vite-plugin-error-overlay + +Vite plugin to show client runtime error via builtin error overlay. + +cf. https://github.com/vitejs/vite/pull/6274 + +## usage + +```ts +import { defineConfig } from "vite"; +import { vitePluginErrorOverlay } from "@hiogawa/vite-plugin-error-overlay"; + +export default defineConfig({ + plugins: [vitePluginErrorOverlay()], +}); +``` diff --git a/packages/error-overlay/examples/basic/README.md b/packages/error-overlay/examples/basic/README.md new file mode 100644 index 000000000..75dddc2d6 --- /dev/null +++ b/packages/error-overlay/examples/basic/README.md @@ -0,0 +1,5 @@ +# react ssr example + +```sh +pnpm -C packages/error-overlay/examples/basic dev +``` diff --git a/packages/error-overlay/examples/basic/index.html b/packages/error-overlay/examples/basic/index.html new file mode 100644 index 000000000..584f56dd1 --- /dev/null +++ b/packages/error-overlay/examples/basic/index.html @@ -0,0 +1,15 @@ + + + + + vite-ssr-react + + + +
+ + + diff --git a/packages/error-overlay/examples/basic/package.json b/packages/error-overlay/examples/basic/package.json new file mode 100644 index 000000000..64e8677e7 --- /dev/null +++ b/packages/error-overlay/examples/basic/package.json @@ -0,0 +1,20 @@ +{ + "name": "@hiogawa/vite-plugin-error-overlay-examples-react", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build && vite build --ssr", + "preview": "vite preview" + }, + "dependencies": { + "@hiogawa/vite-plugin-ssr-middleware": "latest", + "@hiogawa/vite-plugin-error-overlay": "latest", + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@vitejs/plugin-react": "^4.2.1", + "react": "19.0.0-canary-4c12339ce-20240408", + "react-dom": "19.0.0-canary-4c12339ce-20240408", + "vite": "latest" + } +} diff --git a/packages/error-overlay/examples/basic/src/app.tsx b/packages/error-overlay/examples/basic/src/app.tsx new file mode 100644 index 000000000..7a020e489 --- /dev/null +++ b/packages/error-overlay/examples/basic/src/app.tsx @@ -0,0 +1,39 @@ +import React from "react"; + +export function App() { + const [input, setInput] = React.useState(""); + const [counter, setCounter] = React.useState(0); + + return ( +
+

Example

+
{import.meta.env.SSR ? "Hey server!" : "Hi client!"}
+
Input {input}
+ { + setInput(e.target.value); + }} + /> +
Counter: {counter}
+
+ + + +
+
+ ); +} diff --git a/packages/error-overlay/examples/basic/src/entry-client.tsx b/packages/error-overlay/examples/basic/src/entry-client.tsx new file mode 100644 index 000000000..0bf50a270 --- /dev/null +++ b/packages/error-overlay/examples/basic/src/entry-client.tsx @@ -0,0 +1,8 @@ +import { hydrateRoot } from "react-dom/client"; +import { App } from "./app"; + +function main() { + hydrateRoot(document.getElementById("root")!, ); +} + +main(); diff --git a/packages/error-overlay/examples/basic/src/entry-server.tsx b/packages/error-overlay/examples/basic/src/entry-server.tsx new file mode 100644 index 000000000..0031ca184 --- /dev/null +++ b/packages/error-overlay/examples/basic/src/entry-server.tsx @@ -0,0 +1,23 @@ +import fs from "node:fs"; +import type http from "node:http"; +import { renderToString } from "react-dom/server"; +import type { ViteDevServer } from "vite"; +import { App } from "./app"; + +export default async function handler( + req: http.IncomingMessage & { viteDevServer: ViteDevServer }, + res: http.ServerResponse, +) { + let html: string; + if (import.meta.env.DEV) { + html = await fs.promises.readFile("./index.html", "utf-8"); + html = await req.viteDevServer.transformIndexHtml("/", html); + } else { + html = await fs.promises.readFile("./dist/client/index.html", "utf-8"); + } + + const ssrHtml = renderToString(); + html = html.replace("", ssrHtml); + + res.setHeader("content-type", "text/html").end(html); +} diff --git a/packages/error-overlay/examples/basic/tsconfig.json b/packages/error-overlay/examples/basic/tsconfig.json new file mode 100644 index 000000000..3df9d1825 --- /dev/null +++ b/packages/error-overlay/examples/basic/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.base.json", + "include": ["src", "vite.config.ts"], + "compilerOptions": { + "types": ["vite/client"], + "jsx": "react-jsx" + } +} diff --git a/packages/error-overlay/examples/basic/vite.config.ts b/packages/error-overlay/examples/basic/vite.config.ts new file mode 100644 index 000000000..3798b849f --- /dev/null +++ b/packages/error-overlay/examples/basic/vite.config.ts @@ -0,0 +1,19 @@ +import { vitePluginErrorOverlay } from "@hiogawa/vite-plugin-error-overlay"; +import { + vitePluginLogger, + vitePluginSsrMiddleware, +} from "@hiogawa/vite-plugin-ssr-middleware"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig((_env) => ({ + clearScreen: false, + plugins: [ + react(), + vitePluginErrorOverlay(), + vitePluginLogger(), + vitePluginSsrMiddleware({ + entry: "/src/entry-server.tsx", + }), + ], +})); diff --git a/packages/error-overlay/package.json b/packages/error-overlay/package.json new file mode 100644 index 000000000..75dac86cc --- /dev/null +++ b/packages/error-overlay/package.json @@ -0,0 +1,30 @@ +{ + "name": "@hiogawa/vite-plugin-error-overlay", + "version": "0.0.0", + "homepage": "https://github.com/hi-ogawa/vite-plugins/tree/main/packages/error-overlay", + "repository": { + "type": "git", + "url": "https://github.com/hi-ogawa/vite-plugins", + "directory": "packages/error-overlay" + }, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "prepack": "tsup --clean", + "release": "pnpm publish --no-git-checks --access public" + }, + "peerDependencies": { + "vite": "*" + } +} diff --git a/packages/error-overlay/src/index.ts b/packages/error-overlay/src/index.ts new file mode 100644 index 000000000..1ae6fc05c --- /dev/null +++ b/packages/error-overlay/src/index.ts @@ -0,0 +1,69 @@ +import { type Plugin, type WebSocketClient } from "vite"; +import { name as packageName } from "../package.json"; + +const virtualName = "virtual:runtime-error-overlay"; + +export function vitePluginErrorOverlay(options?: { + filter?: (error: Error) => boolean; +}): Plugin { + return { + name: packageName, + apply: "serve", + transformIndexHtml() { + return [ + { + tag: "script", + // TODO: base? + attrs: { type: "module", src: "/@id/__x00__" + virtualName }, + }, + ]; + }, + resolveId(source, _importer, _options) { + return source === virtualName ? "\0" + virtualName : undefined; + }, + load(id, _options) { + if (id === "\0" + virtualName) { + return `(${clientScriptFn.toString()})()`; + } + return; + }, + configureServer(server) { + server.hot.on("custom:runtime-error", (...args: any[]) => { + const [data, client] = args as [unknown, WebSocketClient]; + const error = Object.assign(new Error(), data); + if (options?.filter?.(error) ?? true) { + client.send({ + type: "error", + err: { + message: error.message, + stack: error.stack ?? "", // TODO: solve sourcemap + }, + }); + } + }); + }, + }; +} + +function clientScriptFn() { + if (import.meta.hot) { + window.addEventListener("error", (evt) => { + sendError(evt.error); + }); + + window.addEventListener("unhandledrejection", (evt) => { + sendError(evt.reason); + }); + + function sendError(e: unknown) { + const error = + e instanceof Error ? e : new Error("(unknown error)", { cause: e }); + const serialized = { + message: error.message, + stack: error.stack, + cause: error.cause, + }; + import.meta.hot?.send("custom:runtime-error", serialized); + } + } +} diff --git a/packages/error-overlay/tsconfig.json b/packages/error-overlay/tsconfig.json new file mode 100644 index 000000000..e43227cbe --- /dev/null +++ b/packages/error-overlay/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "types": ["vite/client"] + } +} diff --git a/packages/error-overlay/tsup.config.ts b/packages/error-overlay/tsup.config.ts new file mode 100644 index 000000000..492ef6afb --- /dev/null +++ b/packages/error-overlay/tsup.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e03cf720d..1ca121a11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,39 @@ importers: specifier: ^3.34.2 version: 3.34.2 + packages/error-overlay: + dependencies: + vite: + specifier: ^5.2.3 + version: 5.2.3(@types/node@20.11.28) + + packages/error-overlay/examples/basic: + dependencies: + '@hiogawa/vite-plugin-error-overlay': + specifier: latest + version: link:../.. + '@hiogawa/vite-plugin-ssr-middleware': + specifier: latest + version: link:../../../vite-plugin-ssr-middleware + '@types/react': + specifier: ^18.2.66 + version: 18.2.66 + '@types/react-dom': + specifier: ^18.2.22 + version: 18.2.22 + '@vitejs/plugin-react': + specifier: ^4.2.1 + version: 4.2.1(vite@5.2.3) + react: + specifier: 19.0.0-canary-4c12339ce-20240408 + version: 19.0.0-canary-4c12339ce-20240408 + react-dom: + specifier: 19.0.0-canary-4c12339ce-20240408 + version: 19.0.0-canary-4c12339ce-20240408(react@19.0.0-canary-4c12339ce-20240408) + vite: + specifier: ^5.2.3 + version: 5.2.3(@types/node@20.11.28) + packages/react-server: dependencies: '@tanstack/history':