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':