Lightweight exception capture and webhook delivery for JavaScript runtimes, focused on Next.js (App Router) first. Sibling project to the oopsie_exceptions Ruby gem — sends the same JSON webhook payload shape so one Oopsie collector receives exceptions from Ruby + JS apps.
Captures unhandled exceptions on server and browser, enriches them with request/user/context data, and POSTs structured JSON to one or more webhooks.
| Package | Purpose |
|---|---|
@oopsie-exceptions/core |
Runtime-agnostic client, payload builder, filters, transport interface. Zero deps. |
@oopsie-exceptions/node |
Node runtime — fetch transport, uncaughtException/unhandledRejection, AsyncLocalStorage context. |
@oopsie-exceptions/browser |
Browser runtime — fetch transport (keepalive), window.onerror + unhandledrejection. |
@oopsie-exceptions/nextjs |
Next.js App Router bindings — instrumentation.ts hooks, <OopsieClient />, GlobalErrorReporter, server-action + route-handler wrappers. |
npm install @oopsie-exceptions/core @oopsie-exceptions/node @oopsie-exceptions/browser @oopsie-exceptions/nextjsModeled directly on Sentry's sentry.{client,server}.config.ts. The webhook URL and token are literal strings — your token is write-only at the collector, same security model as a Sentry DSN.
// oopsie.client.config.ts
import type { ClientConfig } from "@oopsie-exceptions/core";
import { BrowserTransport, browserServerInfo } from "@oopsie-exceptions/browser";
const config: ClientConfig = {
appName: "my-app",
environment: process.env.NEXT_PUBLIC_VERCEL_ENV ?? "development",
webhooks: [{
url: "https://oopsie.example.com/api/v1/exceptions",
headers: { Authorization: "Bearer pk_write_only_token_abc123..." },
}],
transport: new BrowserTransport(),
serverInfo: browserServerInfo,
// Optional:
// ignoreErrors: ["AbortError", /NetworkError/],
// beforeNotify: (p) => { p.context.release = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA; return p; },
};
export default config;// oopsie.server.config.ts
import type { ClientConfig } from "@oopsie-exceptions/core";
import { NodeTransport, AsyncLocalStorageContextStore, nodeServerInfo } from "@oopsie-exceptions/node";
const config: ClientConfig = {
appName: "my-app",
environment: process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? "development",
webhooks: [{
url: "https://oopsie.example.com/api/v1/exceptions",
headers: { Authorization: `Bearer ${process.env.OOPSIE_SERVER_TOKEN}` },
}],
transport: new NodeTransport(),
contextStore: new AsyncLocalStorageContextStore(),
serverInfo: nodeServerInfo,
};
export default config;// instrumentation.ts (project root, or src/instrumentation.ts)
import config from "./oopsie.server.config";
import { configureServer, onRequestError } from "@oopsie-exceptions/nextjs/instrumentation";
export function register() {
configureServer(config);
}
export { onRequestError };The config file contains class instances (BrowserTransport, etc.) which can't be serialized across the server→client boundary as React props. Mirror Sentry's pattern: a small "use client" wrapper imports the config and calls configureClient on mount.
// app/oopsie-bootstrap.tsx
"use client";
import { configureClient } from "@oopsie-exceptions/nextjs";
import { installGlobalHandlers } from "@oopsie-exceptions/browser";
import { useEffect, useRef } from "react";
import oopsieConfig from "../oopsie.client.config";
export function OopsieBootstrap() {
const didInit = useRef(false);
useEffect(() => {
if (didInit.current) return;
didInit.current = true;
const client = configureClient(oopsieConfig);
const uninstall = installGlobalHandlers(client);
return () => { uninstall(); };
}, []);
return null;
}// app/layout.tsx (server component)
import { OopsieBootstrap } from "./oopsie-bootstrap";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<OopsieBootstrap />
{children}
</body>
</html>
);
}// app/global-error.tsx
"use client";
import { GlobalErrorReporter } from "@oopsie-exceptions/nextjs/global-error";
export default function GlobalError({
error,
reset,
}: { error: Error & { digest?: string }; reset: () => void }) {
return <GlobalErrorReporter error={error} reset={reset} />;
}That's it. Route Handler, Server Action, Server Component, and middleware errors are caught by onRequestError. Browser errors and unhandled rejections are caught by <OopsieClient />. Render errors show the global error UI and are reported once per error identity.
// Server-side (Route Handler, Server Action, API, etc.):
import { captureException } from "@oopsie-exceptions/nextjs/instrumentation";
await captureException(err, { context: { orderId: 123 }, handled: true });
// Client component:
import { captureException } from "@oopsie-exceptions/nextjs";
await captureException(err, { handled: true });For framework-agnostic use outside Next.js, construct an OopsieClient directly:
import { OopsieClient } from "@oopsie-exceptions/core";
const client = new OopsieClient({
appName: "MyApp",
environment: "production",
webhooks: [{ url: "https://oopsie.example.com/api/v1/exceptions",
headers: { Authorization: `Bearer ${process.env.OOPSIE_TOKEN}` } }],
});
client.captureException(err);Matches the Ruby gem for shared fields so the same collector accepts both:
ruby_version is kept (null for JS) so dashboards built against the Ruby payload keep working.
| Option | Default | Description |
|---|---|---|
appName |
— (required) | Identifier sent in every payload. |
environment |
— (required) | Environment label (production, staging, etc.). |
webhooks |
— (required) | Array<{ url, headers?, name? }> — fan-out target list. |
enabled |
true |
Master kill switch. |
asyncDelivery |
true |
Fire-and-forget delivery. Set false to await transport. |
timeoutMs |
10000 |
HTTP timeout via AbortSignal. |
filterParameters |
["password", "password_confirmation", "secret", "token", "api_key"] |
Keys (strings or RegExp) to redact in context. |
filterHeaders |
["authorization", "cookie", "set-cookie"] |
Full header names to strip (case-insensitive). |
ignoreErrors |
[] |
`(string |
beforeNotify |
null |
`(payload) => payload |
transport |
NoopTransport |
Supply NodeTransport / BrowserTransport. |
contextStore |
InMemoryContextStore |
Swap in AsyncLocalStorageContextStore on servers. |
serverInfo |
() => { hostname: null, pid: null, ruby_version: null } |
Function producing the server block. |
captureRequestBody |
false |
Include first 10KB of JSON request bodies (mirrors Ruby). |
The webhook payload shape is frozen across both implementations so one Oopsie collector can ingest errors from any mix of runtimes:
- Shared top-level keys:
notifier,version,timestamp,app,error,context,server. errorsubfields (class_name,message,backtrace,first_line,causes,handled) match one-for-one.server.ruby_versionis always present — Ruby fills it, JS pins it tonull. JS addsnode_version(anduser_agent,url,viewportin the browser) alongside.- Parameter + header filtering semantics match (substring-include for strings, RegExp for patterns).
error.causechain walked to a max depth of 10, cause messages truncated to 1000 chars.
npm install
npm run build
npm run test
npm run typecheck
npm run lintMIT — see LICENSE.
{ "notifier": "OopsieExceptions", "version": "<pkg version>", "timestamp": "<ISO 8601>", "app": { "name": "...", "environment": "..." }, "error": { "class_name", "message", "backtrace", "first_line", "causes", "handled" }, "context": { "request": {...}, "user": {...}, "action": "...", "job": {...} }, "server": { "hostname", "pid", "ruby_version": null, "node_version": "..." } }