@bigbertha/sdk 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +153 -2
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +3 -0
- package/dist/browser/index.d.ts +9 -0
- package/dist/browser/index.js +164 -0
- package/dist/browser/user-agent.d.ts +4 -0
- package/dist/browser/user-agent.js +38 -0
- package/dist/build/index.d.ts +39 -0
- package/dist/build/index.js +177 -0
- package/dist/build/release.d.ts +8 -0
- package/dist/build/release.js +36 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +89 -0
- package/dist/core/client.d.ts +39 -0
- package/dist/core/client.js +211 -0
- package/dist/core/debug-ids.d.ts +3 -0
- package/dist/core/debug-ids.js +35 -0
- package/dist/core/env.d.ts +2 -0
- package/dist/core/env.js +18 -0
- package/dist/core/hub.d.ts +12 -0
- package/dist/core/hub.js +27 -0
- package/dist/core/scrub.d.ts +2 -0
- package/dist/core/scrub.js +11 -0
- package/dist/core/stack.d.ts +5 -0
- package/dist/core/stack.js +45 -0
- package/dist/core/transport.d.ts +15 -0
- package/dist/core/transport.js +48 -0
- package/dist/core/types.d.ts +37 -0
- package/dist/core/types.js +1 -0
- package/dist/core/version.d.ts +1 -0
- package/dist/core/version.js +1 -0
- package/dist/edge/index.d.ts +5 -0
- package/dist/edge/index.js +25 -0
- package/dist/generated/ingest.d.ts +153 -0
- package/dist/generated/ingest.js +2 -0
- package/dist/next/client.d.ts +2 -0
- package/dist/next/client.js +34 -0
- package/dist/next/config.d.ts +8 -0
- package/dist/next/config.js +36 -0
- package/dist/next/index.d.ts +16 -0
- package/dist/next/index.js +22 -0
- package/dist/node/index.d.ts +19 -0
- package/dist/node/index.js +98 -0
- package/dist/vite/index.d.ts +44 -0
- package/dist/vite/index.js +63 -0
- package/package.json +58 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Felix Owens
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,154 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @bigbertha/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Error reporting for bertha. Browser, Node, edge and Next.js, with no dependencies and
|
|
4
|
+
source maps matched by debug id rather than by file path.
|
|
5
|
+
|
|
6
|
+
- About 4.4 kB gzipped in the browser (`npm run size` enforces the budget).
|
|
7
|
+
- Batches events, backs off when bertha says so, and sends what's queued as the page or
|
|
8
|
+
process goes away.
|
|
9
|
+
- Scrubs emails, bearer tokens and secret-looking query values by default.
|
|
10
|
+
- The build plugins give each bundle a debug id, upload its map and delete the `.map`
|
|
11
|
+
files, so maps never ship to browsers.
|
|
12
|
+
|
|
13
|
+
Each project's setup page in bertha shows the exact snippet for its runtimes, with its
|
|
14
|
+
keys filled in. This file covers the same ground in more detail.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @bigbertha/sdk
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Each version is also attached to its GitHub release as `bigbertha-sdk-<version>.tgz`.
|
|
23
|
+
To build one yourself: `npm ci && npm run build && npm pack` in this folder.
|
|
24
|
+
|
|
25
|
+
## Keys
|
|
26
|
+
|
|
27
|
+
Every project has two:
|
|
28
|
+
|
|
29
|
+
- **Ingest key** (`BERTHA_KEY`): sends events. It ships to browsers, so it's public; a
|
|
30
|
+
leaked key can only send events, at most 50 a second.
|
|
31
|
+
- **Upload key** (`BERTHA_UPLOAD_KEY`): uploads source maps and records deploys. Keep it
|
|
32
|
+
in CI secrets only.
|
|
33
|
+
|
|
34
|
+
`bertha project keys <name>` on the server prints both. Rotating the ingest key in
|
|
35
|
+
project settings stops the old one at once.
|
|
36
|
+
|
|
37
|
+
## Next.js
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# .env.local and your host
|
|
41
|
+
NEXT_PUBLIC_BERTHA_KEY="…"
|
|
42
|
+
NEXT_PUBLIC_BERTHA_URL="https://bertha.example.com"
|
|
43
|
+
BERTHA_UPLOAD_KEY="…" # CI only
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// instrumentation.ts: server and edge errors, including onRequestError
|
|
48
|
+
export { register, onRequestError } from "@bigbertha/sdk/next"
|
|
49
|
+
|
|
50
|
+
// instrumentation-client.ts: browser errors and navigation breadcrumbs
|
|
51
|
+
export { onRouterTransitionStart } from "@bigbertha/sdk/next/client"
|
|
52
|
+
|
|
53
|
+
// next.config.ts: stamps the release, uploads maps after `next build`
|
|
54
|
+
import { withBertha } from "@bigbertha/sdk/next/config"
|
|
55
|
+
export default withBertha({ /* your config */ })
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`withBertha` options: `release`, `keepMaps` (default false), `disableUpload`.
|
|
59
|
+
|
|
60
|
+
## Vite (browser)
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// src/main.ts
|
|
64
|
+
import { init } from "@bigbertha/sdk/browser"
|
|
65
|
+
init({ key: import.meta.env.VITE_BERTHA_KEY, url: import.meta.env.VITE_BERTHA_URL })
|
|
66
|
+
|
|
67
|
+
// vite.config.ts
|
|
68
|
+
import { bertha } from "@bigbertha/sdk/vite"
|
|
69
|
+
export default defineConfig({ plugins: [bertha()] })
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The plugin turns on hidden source maps if the build has none. It only uploads when
|
|
73
|
+
`BERTHA_UPLOAD_KEY` is set, so local builds stay offline and keep their maps.
|
|
74
|
+
Options: `release`, `url`, `uploadKey`, `keepMaps`.
|
|
75
|
+
|
|
76
|
+
## Node
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { init } from "@bigbertha/sdk/node"
|
|
80
|
+
init() // reads BERTHA_KEY, BERTHA_URL, BERTHA_RELEASE and NODE_ENV
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`init` reports uncaught exceptions and unhandled rejections, flushes, then exits as Node
|
|
84
|
+
would (`handlers: false` turns that off). It also sends a heartbeat every 20 minutes so
|
|
85
|
+
bertha can tell "no errors" from "not running" (`heartbeat: false`).
|
|
86
|
+
|
|
87
|
+
`wrapHandler(fn)` reports what a request handler throws and rethrows it.
|
|
88
|
+
|
|
89
|
+
For bundled Node code, give the output debug ids and upload its maps after the build:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npx bertha sourcemaps upload dist
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Edge
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { init } from "@bigbertha/sdk/edge"
|
|
99
|
+
init({ key: env.BERTHA_KEY, url: env.BERTHA_URL })
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Capturing by hand
|
|
103
|
+
|
|
104
|
+
Every entry exports the same functions:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
captureException(err, { level: "warning", tags: { job: "nightly" }, fingerprint: ["nightly-import"] })
|
|
108
|
+
captureMessage("Payment provider slow", { level: "warning" })
|
|
109
|
+
addBreadcrumb({ category: "cart", message: "Added 2 items" })
|
|
110
|
+
setUser({ id: "u_42" }) // hashed on the server, never shown in full
|
|
111
|
+
setTag("tenant", "harbour")
|
|
112
|
+
await flush()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Options
|
|
116
|
+
|
|
117
|
+
| Option | Default | |
|
|
118
|
+
| --- | --- | --- |
|
|
119
|
+
| `key`, `url` | from env (Node, edge) | Required in the browser. |
|
|
120
|
+
| `release` | stamped at build time | The plugins stamp it from `BERTHA_RELEASE`, the CI's commit sha or `git HEAD`. |
|
|
121
|
+
| `environment` | `BERTHA_ENVIRONMENT` or `NODE_ENV` (Node, edge), else production | Free text is mapped to production, staging or development. |
|
|
122
|
+
| `sampleRate` | `1` | Share of events to send. |
|
|
123
|
+
| `beforeSend` | | Change an event, or return `null` to drop it. |
|
|
124
|
+
| `scrub` | `true` | Redact emails, bearer tokens and secret query values. |
|
|
125
|
+
| `tags` | | Tags on every event. |
|
|
126
|
+
| `debug` | `false` | Log what the SDK does. |
|
|
127
|
+
|
|
128
|
+
## Deploys
|
|
129
|
+
|
|
130
|
+
Record each deploy from CI, after it goes out:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
npx bertha deploy --env production
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
It sends the release and the last commit's sha, message and author. The release page
|
|
137
|
+
then shows what the deploy brought in and what went quiet after it. A first event from an
|
|
138
|
+
unknown release also creates the release, without the commit details.
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
npm run check # types
|
|
144
|
+
npm test # unit tests
|
|
145
|
+
npm run size # browser bundle budget
|
|
146
|
+
npm run e2e # the real bertha binary and the example apps, end to end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`npm run e2e` uses `../server/target/debug/bertha`, or `BERTHA_BIN`. `-- --next` adds the
|
|
150
|
+
Next.js example (`npm install` in `examples/next-app` first).
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT. See [LICENSE](LICENSE).
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Client } from "../core/client.ts";
|
|
2
|
+
import type { Options } from "../core/types.ts";
|
|
3
|
+
export { addBreadcrumb, captureException, captureMessage, flush, getClient, setTag, setUser } from "../core/hub.ts";
|
|
4
|
+
export type { CaptureContext, IngestEvent, Options } from "../core/types.ts";
|
|
5
|
+
export interface BrowserOptions extends Options {
|
|
6
|
+
/** Which breadcrumbs to record automatically. All on by default. */
|
|
7
|
+
breadcrumbs?: Partial<Record<"fetch" | "xhr" | "navigation" | "click" | "console", boolean>>;
|
|
8
|
+
}
|
|
9
|
+
export declare function init(options: BrowserOptions): Client | undefined;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// @bigbertha/sdk/browser: uncaught errors and rejections, with breadcrumbs from
|
|
2
|
+
// fetch, XHR, navigation, clicks and the console.
|
|
3
|
+
import { Client } from "../core/client.js";
|
|
4
|
+
import { setClient } from "../core/hub.js";
|
|
5
|
+
import { scrubUrl } from "../core/scrub.js";
|
|
6
|
+
import { fetchTransport, streamGzip } from "../core/transport.js";
|
|
7
|
+
import { VERSION } from "../core/version.js";
|
|
8
|
+
import { userAgent } from "./user-agent.js";
|
|
9
|
+
export { addBreadcrumb, captureException, captureMessage, flush, getClient, setTag, setUser } from "../core/hub.js";
|
|
10
|
+
let installed = false;
|
|
11
|
+
export function init(options) {
|
|
12
|
+
if (typeof window === "undefined")
|
|
13
|
+
return undefined;
|
|
14
|
+
if (!options.key || !options.url) {
|
|
15
|
+
console.warn("[bertha] init needs a key and a url. Nothing will be sent.");
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
const nativeFetch = window.fetch.bind(window);
|
|
19
|
+
const client = new Client(options, fetchTransport({
|
|
20
|
+
url: options.url,
|
|
21
|
+
key: options.key,
|
|
22
|
+
keyInQuery: true,
|
|
23
|
+
gzip: streamGzip,
|
|
24
|
+
fetch: nativeFetch,
|
|
25
|
+
beacon: (url, body) => !!navigator.sendBeacon?.(url, new Blob([body], { type: "text/plain" })),
|
|
26
|
+
}), { name: "bertha.browser", version: VERSION }, { ...userAgent(navigator.userAgent), userAgent: navigator.userAgent });
|
|
27
|
+
setClient(client);
|
|
28
|
+
if (installed)
|
|
29
|
+
return client;
|
|
30
|
+
installed = true;
|
|
31
|
+
window.addEventListener("error", (e) => {
|
|
32
|
+
// Resource load errors (img, script) bubble here too, without an error object.
|
|
33
|
+
if (!e.error && !e.message)
|
|
34
|
+
return;
|
|
35
|
+
client.captureException(e.error ?? new ErrorFromEvent(e), { level: "error" });
|
|
36
|
+
});
|
|
37
|
+
window.addEventListener("unhandledrejection", (e) => client.captureException(e.reason));
|
|
38
|
+
addEventListener("pagehide", () => void client.flush(true));
|
|
39
|
+
document.addEventListener("visibilitychange", () => {
|
|
40
|
+
if (document.visibilityState === "hidden")
|
|
41
|
+
void client.flush(true);
|
|
42
|
+
});
|
|
43
|
+
const on = { fetch: true, xhr: true, navigation: true, click: true, console: true, ...options.breadcrumbs };
|
|
44
|
+
const base = options.url.replace(/\/+$/, "");
|
|
45
|
+
const crumb = (category, message, level) => client.addBreadcrumb(level ? { category, message, level } : { category, message });
|
|
46
|
+
if (on.fetch)
|
|
47
|
+
instrumentFetch(base, crumb);
|
|
48
|
+
if (on.xhr)
|
|
49
|
+
instrumentXhr(base, crumb);
|
|
50
|
+
if (on.navigation)
|
|
51
|
+
instrumentHistory(crumb);
|
|
52
|
+
if (on.click)
|
|
53
|
+
instrumentClicks(crumb);
|
|
54
|
+
if (on.console)
|
|
55
|
+
instrumentConsole(crumb);
|
|
56
|
+
return client;
|
|
57
|
+
}
|
|
58
|
+
class ErrorFromEvent extends Error {
|
|
59
|
+
constructor(e) {
|
|
60
|
+
super(e.message);
|
|
61
|
+
this.name = "Error";
|
|
62
|
+
this.stack = `${e.message}\n at ${e.filename}:${e.lineno}:${e.colno}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function requestLine(method, url, status) {
|
|
66
|
+
return `${method.toUpperCase()} ${scrubUrl(url)} ${status}`;
|
|
67
|
+
}
|
|
68
|
+
function instrumentFetch(base, crumb) {
|
|
69
|
+
const original = window.fetch;
|
|
70
|
+
window.fetch = async function (input, init) {
|
|
71
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
72
|
+
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
|
|
73
|
+
if (url.startsWith(base))
|
|
74
|
+
return original.call(window, input, init);
|
|
75
|
+
try {
|
|
76
|
+
const res = await original.call(window, input, init);
|
|
77
|
+
crumb("fetch", requestLine(method, url, res.status), res.status >= 400 ? "error" : undefined);
|
|
78
|
+
return res;
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
crumb("fetch", requestLine(method, url, "failed"), "error");
|
|
82
|
+
throw e;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function instrumentXhr(base, crumb) {
|
|
87
|
+
const proto = XMLHttpRequest.prototype;
|
|
88
|
+
const open = proto.open;
|
|
89
|
+
const send = proto.send;
|
|
90
|
+
const meta = new WeakMap();
|
|
91
|
+
proto.open = function (method, url, ...rest) {
|
|
92
|
+
meta.set(this, [method, String(url)]);
|
|
93
|
+
return open.call(this, method, url, ...rest);
|
|
94
|
+
};
|
|
95
|
+
proto.send = function (body) {
|
|
96
|
+
const m = meta.get(this);
|
|
97
|
+
if (m && !m[1].startsWith(base)) {
|
|
98
|
+
this.addEventListener("loadend", () => crumb("fetch", requestLine(m[0], m[1], this.status || "failed"), !this.status || this.status >= 400 ? "error" : undefined));
|
|
99
|
+
}
|
|
100
|
+
return send.call(this, body);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function instrumentHistory(crumb) {
|
|
104
|
+
let last = location.href;
|
|
105
|
+
const record = () => {
|
|
106
|
+
const to = location.href;
|
|
107
|
+
if (to === last)
|
|
108
|
+
return;
|
|
109
|
+
crumb("navigation", `${scrubUrl(path(last))} → ${scrubUrl(path(to))}`);
|
|
110
|
+
last = to;
|
|
111
|
+
};
|
|
112
|
+
for (const m of ["pushState", "replaceState"]) {
|
|
113
|
+
const original = history[m];
|
|
114
|
+
history[m] = function (...args) {
|
|
115
|
+
const r = original.apply(this, args);
|
|
116
|
+
record();
|
|
117
|
+
return r;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
addEventListener("popstate", record);
|
|
121
|
+
}
|
|
122
|
+
function path(href) {
|
|
123
|
+
try {
|
|
124
|
+
const u = new URL(href);
|
|
125
|
+
return u.pathname + u.search;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return href;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function instrumentClicks(crumb) {
|
|
132
|
+
document.addEventListener("click", (e) => {
|
|
133
|
+
const el = e.target?.closest?.("a,button,[role=button],input,select,label,summary");
|
|
134
|
+
if (el)
|
|
135
|
+
crumb("ui", describeElement(el));
|
|
136
|
+
}, { capture: true, passive: true });
|
|
137
|
+
}
|
|
138
|
+
function describeElement(el) {
|
|
139
|
+
const tag = el.tagName.toLowerCase();
|
|
140
|
+
const label = el.getAttribute("aria-label") ??
|
|
141
|
+
el.getAttribute("data-testid") ??
|
|
142
|
+
(tag === "input" ? el.getAttribute("name") : el.textContent?.trim().replace(/\s+/g, " ").slice(0, 60));
|
|
143
|
+
const id = el.id ? `#${el.id}` : "";
|
|
144
|
+
return label ? `Clicked ${tag}${id} "${label}"` : `Clicked ${tag}${id}`;
|
|
145
|
+
}
|
|
146
|
+
function instrumentConsole(crumb) {
|
|
147
|
+
for (const level of ["warn", "error"]) {
|
|
148
|
+
const original = console[level];
|
|
149
|
+
console[level] = (...args) => {
|
|
150
|
+
const text = args.map((a) => (typeof a === "string" ? a : a instanceof Error ? a.message : safeJson(a))).join(" ");
|
|
151
|
+
if (!text.startsWith("[bertha]"))
|
|
152
|
+
crumb("console", text.slice(0, 500), level === "warn" ? "warning" : "error");
|
|
153
|
+
original.apply(console, args);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function safeJson(v) {
|
|
158
|
+
try {
|
|
159
|
+
return JSON.stringify(v) ?? String(v);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return String(v);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Just enough user-agent reading to tag events by browser and OS.
|
|
2
|
+
const BROWSERS = [
|
|
3
|
+
[/Edg(?:e|A|iOS)?\/(\d+)/, "Edge"],
|
|
4
|
+
[/OPR\/(\d+)/, "Opera"],
|
|
5
|
+
[/SamsungBrowser\/(\d+)/, "Samsung Internet"],
|
|
6
|
+
[/Firefox\/(\d+)/, "Firefox"],
|
|
7
|
+
[/FxiOS\/(\d+)/, "Firefox"],
|
|
8
|
+
[/CriOS\/(\d+)/, "Chrome"],
|
|
9
|
+
[/Chrome\/(\d+)/, "Chrome"],
|
|
10
|
+
[/Version\/(\d+)(?:\.\d+)?.*Safari/, "Safari"],
|
|
11
|
+
];
|
|
12
|
+
export function userAgent(ua) {
|
|
13
|
+
const out = {};
|
|
14
|
+
for (const [re, name] of BROWSERS) {
|
|
15
|
+
const m = re.exec(ua);
|
|
16
|
+
if (m) {
|
|
17
|
+
out.browser = `${name} ${m[1]}`;
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const ios = /(?:iPhone|iPad|iPod).*? OS (\d+)/.exec(ua);
|
|
22
|
+
const android = /Android (\d+)/.exec(ua);
|
|
23
|
+
const mac = /Mac OS X (\d+)[._](\d+)/.exec(ua);
|
|
24
|
+
const win = /Windows NT (\d+\.\d+)/.exec(ua);
|
|
25
|
+
if (ios)
|
|
26
|
+
out.os = `iOS ${ios[1]}`;
|
|
27
|
+
else if (android)
|
|
28
|
+
out.os = `Android ${android[1]}`;
|
|
29
|
+
else if (win)
|
|
30
|
+
out.os = win[1] === "10.0" ? "Windows 10+" : `Windows NT ${win[1]}`;
|
|
31
|
+
else if (mac)
|
|
32
|
+
out.os = `macOS ${mac[1] === "10" ? `10.${mac[2]}` : mac[1]}`;
|
|
33
|
+
else if (/CrOS/.test(ua))
|
|
34
|
+
out.os = "ChromeOS";
|
|
35
|
+
else if (/Linux/.test(ua))
|
|
36
|
+
out.os = "Linux";
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export { detectRelease, lastCommit } from "./release.ts";
|
|
2
|
+
export interface ProcessOptions {
|
|
3
|
+
/** The build output. */
|
|
4
|
+
dir: string;
|
|
5
|
+
release?: string;
|
|
6
|
+
/** bertha's URL. Without it (or the key) nothing is uploaded. */
|
|
7
|
+
url?: string;
|
|
8
|
+
/** The project's upload key (BERTHA_UPLOAD_KEY). */
|
|
9
|
+
uploadKey?: string;
|
|
10
|
+
/** Keep the .map files in the output. A function decides per bundle. Default false. */
|
|
11
|
+
keepMaps?: boolean | ((file: string) => boolean);
|
|
12
|
+
/** Directory names to skip. */
|
|
13
|
+
skip?: string[];
|
|
14
|
+
log?: (message: string) => void;
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
export interface Processed {
|
|
18
|
+
file: string;
|
|
19
|
+
debugId: string;
|
|
20
|
+
uploaded: boolean;
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface Report {
|
|
24
|
+
files: Processed[];
|
|
25
|
+
uploaded: number;
|
|
26
|
+
failed: number;
|
|
27
|
+
/** Leftover .js.map files removed because no bundle referenced them. */
|
|
28
|
+
removedOrphans: number;
|
|
29
|
+
/** Why nothing was uploaded, when nothing was. */
|
|
30
|
+
skipped?: string;
|
|
31
|
+
}
|
|
32
|
+
export declare function processDirectory(o: ProcessOptions): Promise<Report>;
|
|
33
|
+
/** A stable UUID from the bundle's content, so rebuilding the same code gives the same id. */
|
|
34
|
+
export declare function idFor(code: string): string;
|
|
35
|
+
/** The line appended to a bundle. Runs once when the bundle loads; never throws. */
|
|
36
|
+
export declare function snippet(debugId: string, release?: string): string;
|
|
37
|
+
/** Appends the snippet, before a trailing sourceMappingURL comment if there is one. */
|
|
38
|
+
export declare function stamp(code: string, debugId: string, release?: string): string;
|
|
39
|
+
export declare function reason(res: Response): Promise<string>;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// After a build: give each bundle that has a source map a debug id, upload the maps
|
|
2
|
+
// to bertha, and take them out of the output so they aren't served.
|
|
3
|
+
//
|
|
4
|
+
// The id goes in a line appended to the bundle. Appending (never prepending) keeps
|
|
5
|
+
// every existing mapping valid. At runtime that line records the bundle's own URL
|
|
6
|
+
// from `new Error().stack` against its id, so events can name the map for each frame
|
|
7
|
+
// wherever the file was served from.
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
11
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
12
|
+
import { gzipSync } from "node:zlib";
|
|
13
|
+
export { detectRelease, lastCommit } from "./release.js";
|
|
14
|
+
const JS = /\.(?:m|c)?js$/;
|
|
15
|
+
const ORPHAN_MAP = /\.(?:m|c)?js\.map$/;
|
|
16
|
+
const MAP_COMMENT = /\n?\/\/[#@] sourceMappingURL=(\S+)[ \t]*\r?\n?$/;
|
|
17
|
+
const DEBUG_ID_COMMENT = /\/\/# debugId=([0-9a-fA-F-]{36})/;
|
|
18
|
+
const CONCURRENCY = 4;
|
|
19
|
+
const UPLOAD_TIMEOUT_MS = 60_000;
|
|
20
|
+
export async function processDirectory(o) {
|
|
21
|
+
const root = resolve(o.dir);
|
|
22
|
+
const log = o.log ?? (() => { });
|
|
23
|
+
const skip = new Set(["node_modules", ...(o.skip ?? [])]);
|
|
24
|
+
const bundles = (await walk(root, skip)).filter((f) => JS.test(f));
|
|
25
|
+
const found = [];
|
|
26
|
+
for (const file of bundles) {
|
|
27
|
+
const code = await readFile(file, "utf8");
|
|
28
|
+
const map = await findMap(file, code);
|
|
29
|
+
if (map)
|
|
30
|
+
found.push({ file, code, map });
|
|
31
|
+
}
|
|
32
|
+
const canUpload = !!(o.url && o.uploadKey);
|
|
33
|
+
const report = {
|
|
34
|
+
files: [],
|
|
35
|
+
uploaded: 0,
|
|
36
|
+
failed: 0,
|
|
37
|
+
removedOrphans: 0,
|
|
38
|
+
skipped: canUpload ? undefined : "No upload key or URL, so the maps stay local.",
|
|
39
|
+
};
|
|
40
|
+
await pool(found, CONCURRENCY, async ({ file, code, map }) => {
|
|
41
|
+
const debugId = DEBUG_ID_COMMENT.exec(code)?.[1]?.toLowerCase() ?? idFor(code);
|
|
42
|
+
const rel = relative(root, file).split(sep).join("/");
|
|
43
|
+
const keep = typeof o.keepMaps === "function" ? o.keepMaps(rel) : !!o.keepMaps;
|
|
44
|
+
const json = JSON.parse(map.text);
|
|
45
|
+
json.debugId = debugId;
|
|
46
|
+
const mapText = JSON.stringify(json);
|
|
47
|
+
let out = DEBUG_ID_COMMENT.test(code) ? code : stamp(code, debugId, o.release);
|
|
48
|
+
if (keep && map.path)
|
|
49
|
+
await writeFile(map.path, mapText);
|
|
50
|
+
else if (!keep) {
|
|
51
|
+
out = out.replace(MAP_COMMENT, "\n");
|
|
52
|
+
if (map.path)
|
|
53
|
+
await rm(map.path, { force: true });
|
|
54
|
+
}
|
|
55
|
+
if (out !== code)
|
|
56
|
+
await writeFile(file, out);
|
|
57
|
+
const entry = { file: rel, debugId, uploaded: false };
|
|
58
|
+
if (canUpload) {
|
|
59
|
+
try {
|
|
60
|
+
await upload(o, { debugId, file: rel, release: o.release }, mapText);
|
|
61
|
+
entry.uploaded = true;
|
|
62
|
+
report.uploaded++;
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
entry.error = e instanceof Error ? e.message : String(e);
|
|
66
|
+
report.failed++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
report.files.push(entry);
|
|
70
|
+
});
|
|
71
|
+
// Maps no bundle points at (renamed polyfills and the like) are source code too.
|
|
72
|
+
if (o.keepMaps !== true) {
|
|
73
|
+
const keepFn = typeof o.keepMaps === "function" ? o.keepMaps : undefined;
|
|
74
|
+
for (const f of await walk(root, skip)) {
|
|
75
|
+
if (!ORPHAN_MAP.test(f) || !existsSync(f))
|
|
76
|
+
continue;
|
|
77
|
+
if (keepFn?.(relative(root, f.replace(/\.map$/, "")).split(sep).join("/")))
|
|
78
|
+
continue;
|
|
79
|
+
await rm(f, { force: true });
|
|
80
|
+
report.removedOrphans++;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
report.files.sort((a, b) => a.file.localeCompare(b.file));
|
|
84
|
+
for (const f of report.files)
|
|
85
|
+
if (f.error)
|
|
86
|
+
log(`Couldn't upload the map for ${f.file}: ${f.error}`);
|
|
87
|
+
if (found.length === 0)
|
|
88
|
+
log(`No bundles with source maps in ${o.dir}.`);
|
|
89
|
+
else if (canUpload)
|
|
90
|
+
log(`Uploaded ${report.uploaded} of ${found.length} source maps${o.release ? ` for ${o.release}` : ""}.`);
|
|
91
|
+
else
|
|
92
|
+
log(`Stamped ${found.length} bundles with debug ids. ${report.skipped}`);
|
|
93
|
+
return report;
|
|
94
|
+
}
|
|
95
|
+
async function findMap(file, code) {
|
|
96
|
+
const ref = MAP_COMMENT.exec(code)?.[1];
|
|
97
|
+
if (ref?.startsWith("data:")) {
|
|
98
|
+
const comma = ref.indexOf(",");
|
|
99
|
+
const data = ref.slice(comma + 1);
|
|
100
|
+
const text = ref.slice(0, comma).endsWith(";base64")
|
|
101
|
+
? Buffer.from(data, "base64").toString("utf8")
|
|
102
|
+
: decodeURIComponent(data);
|
|
103
|
+
return { text };
|
|
104
|
+
}
|
|
105
|
+
const candidates = ref && !/^[a-z]+:/i.test(ref) ? [join(dirname(file), decodeURI(ref.split("?")[0]))] : [];
|
|
106
|
+
candidates.push(`${file}.map`);
|
|
107
|
+
for (const path of candidates) {
|
|
108
|
+
if (existsSync(path))
|
|
109
|
+
return { text: await readFile(path, "utf8"), path };
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
/** A stable UUID from the bundle's content, so rebuilding the same code gives the same id. */
|
|
114
|
+
export function idFor(code) {
|
|
115
|
+
const h = createHash("sha256").update(code).digest("hex");
|
|
116
|
+
const variant = ((parseInt(h[16], 16) & 0x3) | 0x8).toString(16);
|
|
117
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${variant}${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
118
|
+
}
|
|
119
|
+
/** The line appended to a bundle. Runs once when the bundle loads; never throws. */
|
|
120
|
+
export function snippet(debugId, release) {
|
|
121
|
+
const r = release ? `g.__berthaRelease=${JSON.stringify(release)}` : "";
|
|
122
|
+
return (`;!function(){try{var g=typeof globalThis<"u"?globalThis:self,s=(new Error).stack;` +
|
|
123
|
+
`s&&((g.__berthaIds=g.__berthaIds||{})[s]="${debugId}");${r}}catch(e){}}();\n//# debugId=${debugId}\n`);
|
|
124
|
+
}
|
|
125
|
+
/** Appends the snippet, before a trailing sourceMappingURL comment if there is one. */
|
|
126
|
+
export function stamp(code, debugId, release) {
|
|
127
|
+
const m = MAP_COMMENT.exec(code);
|
|
128
|
+
const body = m ? code.slice(0, m.index) : code;
|
|
129
|
+
const tail = m ? m[0].replace(/^\n?/, "") : "";
|
|
130
|
+
return `${body.endsWith("\n") ? body : `${body}\n`}${snippet(debugId, release)}${tail}`;
|
|
131
|
+
}
|
|
132
|
+
async function upload(o, q, map) {
|
|
133
|
+
const f = o.fetch ?? fetch;
|
|
134
|
+
const params = new URLSearchParams({ debugId: q.debugId, file: q.file });
|
|
135
|
+
if (q.release)
|
|
136
|
+
params.set("release", q.release);
|
|
137
|
+
const res = await f(`${o.url.replace(/\/+$/, "")}/ingest/v1/sourcemaps?${params}`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { authorization: `Bearer ${o.uploadKey}`, "content-type": "application/json" },
|
|
140
|
+
body: new Uint8Array(gzipSync(map)),
|
|
141
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
142
|
+
});
|
|
143
|
+
if (!res.ok)
|
|
144
|
+
throw new Error(await reason(res));
|
|
145
|
+
}
|
|
146
|
+
export async function reason(res) {
|
|
147
|
+
try {
|
|
148
|
+
const body = (await res.json());
|
|
149
|
+
if (body.error)
|
|
150
|
+
return `${body.error} (HTTP ${res.status})`;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// not JSON
|
|
154
|
+
}
|
|
155
|
+
return `HTTP ${res.status}`;
|
|
156
|
+
}
|
|
157
|
+
async function walk(dir, skip) {
|
|
158
|
+
const out = [];
|
|
159
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
160
|
+
const path = join(dir, entry.name);
|
|
161
|
+
if (entry.isDirectory()) {
|
|
162
|
+
if (!skip.has(entry.name))
|
|
163
|
+
out.push(...(await walk(path, skip)));
|
|
164
|
+
}
|
|
165
|
+
else if (entry.isFile())
|
|
166
|
+
out.push(path);
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
async function pool(items, n, run) {
|
|
171
|
+
let next = 0;
|
|
172
|
+
const worker = async () => {
|
|
173
|
+
while (next < items.length)
|
|
174
|
+
await run(items[next++]);
|
|
175
|
+
};
|
|
176
|
+
await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
|
|
177
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The release for this build: env first (CI sets these), then git, then package.json. */
|
|
2
|
+
export declare function detectRelease(cwd?: string): string | undefined;
|
|
3
|
+
export interface Commit {
|
|
4
|
+
sha: string;
|
|
5
|
+
message: string;
|
|
6
|
+
author: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function lastCommit(cwd?: string): Commit | undefined;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/** The release for this build: env first (CI sets these), then git, then package.json. */
|
|
5
|
+
export function detectRelease(cwd = process.cwd()) {
|
|
6
|
+
for (const name of ["BERTHA_RELEASE", "VERCEL_GIT_COMMIT_SHA", "GITHUB_SHA", "CF_PAGES_COMMIT_SHA", "COMMIT_SHA"]) {
|
|
7
|
+
const v = process.env[name]?.trim();
|
|
8
|
+
if (v)
|
|
9
|
+
return v;
|
|
10
|
+
}
|
|
11
|
+
const sha = git(cwd, ["rev-parse", "HEAD"]);
|
|
12
|
+
if (sha)
|
|
13
|
+
return sha;
|
|
14
|
+
try {
|
|
15
|
+
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
|
|
16
|
+
return pkg.version;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function lastCommit(cwd = process.cwd()) {
|
|
23
|
+
const out = git(cwd, ["log", "-1", "--format=%H%x00%s%x00%an"]);
|
|
24
|
+
if (!out)
|
|
25
|
+
return undefined;
|
|
26
|
+
const [sha, message, author] = out.split("\0");
|
|
27
|
+
return sha && message !== undefined && author !== undefined ? { sha, message, author } : undefined;
|
|
28
|
+
}
|
|
29
|
+
function git(cwd, args) {
|
|
30
|
+
try {
|
|
31
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function main(argv: string[], out?: (s: string) => void): Promise<number>;
|