@brunyee-studio/onus-sdk 0.1.0

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/dist/index.js ADDED
@@ -0,0 +1,40 @@
1
+ import {
2
+ MAX_ENVELOPE_BYTES,
3
+ REPLAY_ID_RE,
4
+ SDK_NAME,
5
+ SDK_VERSION,
6
+ buildEnvelope,
7
+ buildSentryAuthHeader,
8
+ captureCustomEvent,
9
+ captureEvent,
10
+ captureException,
11
+ captureMessage,
12
+ eventId,
13
+ flush,
14
+ getActiveReplayId,
15
+ init,
16
+ isEnabled,
17
+ parseDsn,
18
+ resetForTest,
19
+ setActiveReplayId
20
+ } from "./chunk-SLWBPQ6C.js";
21
+ export {
22
+ MAX_ENVELOPE_BYTES,
23
+ REPLAY_ID_RE,
24
+ SDK_NAME,
25
+ SDK_VERSION,
26
+ buildEnvelope,
27
+ buildSentryAuthHeader,
28
+ captureCustomEvent,
29
+ captureEvent,
30
+ captureException,
31
+ captureMessage,
32
+ eventId,
33
+ flush,
34
+ getActiveReplayId,
35
+ init,
36
+ isEnabled,
37
+ parseDsn,
38
+ resetForTest,
39
+ setActiveReplayId
40
+ };
@@ -0,0 +1,126 @@
1
+ /**
2
+ * DSN parsing for @onus/sdk (ONUS-68).
3
+ *
4
+ * Mirrors `src/lib/ingest/dsn.ts` semantics (the server side of the same
5
+ * protocol): `{PROTOCOL}://{PUBLIC_KEY}@{HOST}/{NUMERIC_PROJECT_ID}` with no
6
+ * sub-path. The envelope endpoint derives as `{protocol}://{host}/api/{id}/envelope/`.
7
+ */
8
+ interface ParsedDsn {
9
+ /** Public key — sent as `sentry_key` in the X-Sentry-Auth header. */
10
+ key: string;
11
+ host: string;
12
+ /** Numeric ingest project id embedded in the flat DSN. */
13
+ projectId: string;
14
+ /** POST target for envelope payloads. */
15
+ envelopeUrl: string;
16
+ /** Origin used for CORS/referer comparisons. */
17
+ origin: string;
18
+ }
19
+ /**
20
+ * Parse `{protocol}://{key}@{host}/{numericId}`. Returns null for anything
21
+ * malformed — a broken DSN disables the SDK (no network sends), matching
22
+ * stock Sentry behavior of failing safe.
23
+ */
24
+ declare function parseDsn(raw: string | undefined | null): ParsedDsn | null;
25
+ /** Build the `X-Sentry-Auth` header value the ingest routes parse. */
26
+ declare function buildSentryAuthHeader(dsn: ParsedDsn, clientVersion: string): string;
27
+
28
+ /**
29
+ * Sentry-compatible envelope building for @onus/sdk (ONUS-68).
30
+ *
31
+ * Wire contract (`src/app/api/[projectId]/envelope/route.ts`):
32
+ * - first line: JSON envelope header (event_id, sent_at, sdk)
33
+ * - then per item: a JSON item header line (`{"type":...}`), a blank line,
34
+ * and the payload (JSON or raw bytes for gzipped replay recordings).
35
+ * - replay segments pair a `replay_event` item with a `replay_recording`
36
+ * item exactly as `extractReplayFrom` expects; the replay id must match
37
+ * the strict `[a-zA-Z0-9_-]{1,64}` regex the persist layer enforces.
38
+ * - error events carry `contexts.replay.replay_id` for error↔replay linking.
39
+ */
40
+ /** 1 MiB wire cap enforced by the ingest routes. */
41
+ declare const MAX_ENVELOPE_BYTES: number;
42
+ /** Strict replay-id regex enforced by `src/lib/ingest/replay.ts`. */
43
+ declare const REPLAY_ID_RE: RegExp;
44
+ type EnvelopeItem = {
45
+ type: 'event';
46
+ payload: Record<string, unknown>;
47
+ } | {
48
+ type: 'replay_event';
49
+ payload: Record<string, unknown>;
50
+ } | {
51
+ type: 'replay_recording';
52
+ payload: string;
53
+ length: number;
54
+ };
55
+ /** Server-side requirement (ONUS-41): every event carries an `event_id`. */
56
+ declare function eventId(): string;
57
+ /**
58
+ * Serialize an envelope as text. Returns null when the payload would exceed
59
+ * the server's 1 MiB wire cap — callers drop the event instead of guaranteed
60
+ * rejection (mirrors the SDK-side guard in Sentry's transport).
61
+ */
62
+ declare function buildEnvelope(header: {
63
+ event_id: string;
64
+ sent_at: string;
65
+ sdk: {
66
+ name: string;
67
+ version: string;
68
+ };
69
+ }, items: EnvelopeItem[]): string | null;
70
+
71
+ interface ReplayOptions {
72
+ dsn?: string;
73
+ environment?: string;
74
+ release?: string;
75
+ /**
76
+ * @onus/analytics session id (or getter) attached to each replay_event so
77
+ * the sessions surface can link playback (ONUS-155). A getter is evaluated
78
+ * per segment flush so rotated session ids stay fresh.
79
+ */
80
+ sessionId?: string | (() => string | null | undefined);
81
+ /** rrweb record options (eventsPerSecond caps, etc.); kept passthrough. */
82
+ recordOptions?: Record<string, unknown>;
83
+ /**
84
+ * How long buffered rrweb events wait before flushing as one segment
85
+ * envelope (ms, default 5000 — Sentry's segment cadence). Batching keeps a
86
+ * single interaction from producing one POST per DOM mutation.
87
+ */
88
+ segmentIntervalMs?: number;
89
+ /** Buffered events that force an immediate segment flush (default 100). */
90
+ maxEventsPerSegment?: number;
91
+ /** Test seam: recorder + fetch injection (defaults to lazy rrweb import). */
92
+ recorderFactory?: () => Promise<Recorder | null>;
93
+ fetchImpl?: typeof fetch;
94
+ }
95
+ /** Minimal structural subset of rrweb's record function used here. */
96
+ interface Recorder {
97
+ stop: () => void;
98
+ /**
99
+ * Optional test hook: receives the wired record options (including `emit`)
100
+ * so an injected recorder can drive the buffering path end-to-end.
101
+ */
102
+ wire?: (opts: unknown) => void;
103
+ }
104
+ interface ReplayState {
105
+ replayId: string;
106
+ stop: (() => void) | null;
107
+ /** Buffered rrweb events awaiting the next segment flush. */
108
+ buffer: unknown[];
109
+ /** Pending interval flush (one envelope per flush, not per event). */
110
+ timer: ReturnType<typeof setTimeout> | null;
111
+ }
112
+ /**
113
+ * Start session replay. No-op (returns null) when replay is already running,
114
+ * the DSN is invalid, the recorder factory fails, or rrweb cannot be loaded.
115
+ */
116
+ declare function startSessionReplay(options?: ReplayOptions): Promise<string | null>;
117
+ /** Stop replay. No-op when not running. Flushes any buffered tail. */
118
+ declare function stopSessionReplay(): void;
119
+ /** Currently active replay id (null when off). */
120
+ declare function activeReplayId(): string | null;
121
+ /** Test seam. */
122
+ declare function resetReplayForTest(): void;
123
+ /** Build + send one segment envelope (replay_event + replay_recording pair). */
124
+ declare function emitSegment(dsn: NonNullable<ReturnType<typeof parseDsn>>, options: ReplayOptions, state: ReplayState, event: unknown, segmentId: number, timestamp: string): Promise<boolean>;
125
+
126
+ export { type EnvelopeItem as E, MAX_ENVELOPE_BYTES as M, type ParsedDsn as P, type Recorder as R, REPLAY_ID_RE as a, buildEnvelope as b, buildSentryAuthHeader as c, type ReplayOptions as d, eventId as e, activeReplayId as f, emitSegment as g, stopSessionReplay as h, parseDsn as p, resetReplayForTest as r, startSessionReplay as s };
@@ -0,0 +1 @@
1
+ export { a as REPLAY_ID_RE, R as Recorder, d as ReplayOptions, f as activeReplayId, g as emitSegment, p as parseDsn, r as resetReplayForTest, s as startSessionReplay, h as stopSessionReplay } from './replay-D7ejwI0s.js';
package/dist/replay.js ADDED
@@ -0,0 +1,174 @@
1
+ import {
2
+ REPLAY_ID_RE,
3
+ SDK_NAME,
4
+ buildEnvelope,
5
+ eventId,
6
+ parseDsn,
7
+ sendEnvelope
8
+ } from "./chunk-SLWBPQ6C.js";
9
+
10
+ // src/replay.ts
11
+ var active = null;
12
+ async function startSessionReplay(options = {}) {
13
+ if (active) return active.replayId;
14
+ const dsn = parseDsn(options.dsn);
15
+ if (!dsn) return null;
16
+ const replayId = eventId().replace(/-/g, "");
17
+ const state = { replayId, stop: null, buffer: [], timer: null };
18
+ active = state;
19
+ let record = null;
20
+ if (options.recorderFactory) {
21
+ let recorder;
22
+ try {
23
+ recorder = await options.recorderFactory();
24
+ } catch {
25
+ active = null;
26
+ return null;
27
+ }
28
+ if (!recorder) {
29
+ active = null;
30
+ return null;
31
+ }
32
+ record = (_opts) => {
33
+ recorder.wire?.(_opts);
34
+ return () => recorder.stop();
35
+ };
36
+ } else {
37
+ try {
38
+ const mod = await import("rrweb");
39
+ const fn = mod.record;
40
+ if (typeof fn !== "function") {
41
+ active = null;
42
+ return null;
43
+ }
44
+ record = fn;
45
+ } catch {
46
+ active = null;
47
+ return null;
48
+ }
49
+ }
50
+ const segmentIntervalMs = options.segmentIntervalMs ?? 5e3;
51
+ const maxEventsPerSegment = options.maxEventsPerSegment ?? 100;
52
+ const narrowedDsn = dsn;
53
+ async function flushBuffer() {
54
+ if (state.timer !== null) {
55
+ clearTimeout(state.timer);
56
+ state.timer = null;
57
+ }
58
+ if (state.buffer.length === 0) return;
59
+ const events = state.buffer;
60
+ state.buffer = [];
61
+ await emitSegment(
62
+ narrowedDsn,
63
+ options,
64
+ state,
65
+ events,
66
+ ++segmentCounter,
67
+ (/* @__PURE__ */ new Date()).toISOString()
68
+ );
69
+ }
70
+ const stop = record({
71
+ emit: (event) => {
72
+ state.buffer.push(event);
73
+ if (state.buffer.length >= maxEventsPerSegment) {
74
+ void flushBuffer();
75
+ return;
76
+ }
77
+ if (state.timer === null) {
78
+ state.timer = setTimeout(() => {
79
+ state.timer = null;
80
+ void flushBuffer();
81
+ }, segmentIntervalMs);
82
+ }
83
+ },
84
+ ...options.recordOptions
85
+ });
86
+ state.stop = typeof stop === "function" ? () => {
87
+ stop();
88
+ void flushBuffer();
89
+ } : null;
90
+ if (typeof window !== "undefined") {
91
+ window.addEventListener(
92
+ "pagehide",
93
+ () => {
94
+ void flushBuffer();
95
+ },
96
+ { once: true }
97
+ );
98
+ }
99
+ return replayId;
100
+ }
101
+ var segmentCounter = 0;
102
+ function stopSessionReplay() {
103
+ const stopping = active;
104
+ active = null;
105
+ if (!stopping) return;
106
+ stopping.stop?.();
107
+ if (stopping.timer !== null) {
108
+ clearTimeout(stopping.timer);
109
+ stopping.timer = null;
110
+ }
111
+ stopping.buffer.length = 0;
112
+ }
113
+ function activeReplayId() {
114
+ return active?.replayId ?? null;
115
+ }
116
+ function resetReplayForTest() {
117
+ active?.stop?.();
118
+ active = null;
119
+ segmentCounter = 0;
120
+ }
121
+ function resolveSessionId(options) {
122
+ const raw = typeof options.sessionId === "function" ? options.sessionId() : options.sessionId;
123
+ return typeof raw === "string" && raw.length > 0 ? raw : null;
124
+ }
125
+ async function emitSegment(dsn, options, state, event, segmentId, timestamp) {
126
+ const recordingText = `{"segment_id":${segmentId}}
127
+ ${JSON.stringify(event ?? [])}`;
128
+ const sessionId = resolveSessionId(options);
129
+ const items = [
130
+ {
131
+ type: "replay_event",
132
+ payload: {
133
+ segment_id: segmentId,
134
+ replay_id: state.replayId,
135
+ timestamp,
136
+ ...sessionId ? { session_id: sessionId } : {},
137
+ ...options.environment ? { environment: options.environment } : {},
138
+ ...options.release ? { release: options.release } : {}
139
+ }
140
+ },
141
+ {
142
+ type: "replay_recording",
143
+ payload: recordingText,
144
+ // Byte length (not UTF-16 code units) — the server validates the
145
+ // declared length against payload bytes.
146
+ length: new TextEncoder().encode(recordingText).length
147
+ }
148
+ ];
149
+ const serialized = buildEnvelope(
150
+ {
151
+ event_id: eventId(),
152
+ sent_at: timestamp,
153
+ sdk: { name: SDK_NAME, version: "0.1.0" }
154
+ },
155
+ items
156
+ );
157
+ if (!serialized) return false;
158
+ const result = await sendEnvelope({
159
+ url: dsn.envelopeUrl,
160
+ authHeader: `Sentry sentry_key=${dsn.key}, sentry_version=7, sentry_client=onus.javascript/0.1.0`,
161
+ body: serialized,
162
+ fetchImpl: options.fetchImpl
163
+ });
164
+ return result.ok;
165
+ }
166
+ export {
167
+ REPLAY_ID_RE,
168
+ activeReplayId,
169
+ emitSegment,
170
+ parseDsn,
171
+ resetReplayForTest,
172
+ startSessionReplay,
173
+ stopSessionReplay
174
+ };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@brunyee-studio/onus-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Official Onus browser SDK: errors, session replay, and custom events via the Sentry-compatible envelope protocol",
5
+ "license": "MIT",
6
+ "author": "Brunyee Studio",
7
+ "files": [
8
+ "dist",
9
+ "README.md"
10
+ ],
11
+ "type": "module",
12
+ "main": "./dist/index.js",
13
+ "module": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js",
19
+ "require": "./dist/index.cjs"
20
+ },
21
+ "./replay": {
22
+ "types": "./dist/replay.d.ts",
23
+ "import": "./dist/replay.js"
24
+ }
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "scripts": {
30
+ "build": "tsup",
31
+ "dev": "tsup --watch",
32
+ "test": "vitest run",
33
+ "typecheck": "tsc --noEmit"
34
+ },
35
+ "devDependencies": {
36
+ "@vitest/coverage-v8": "^5.0.1",
37
+ "jsdom": "^26.1.0",
38
+ "tsup": "^8.5.0",
39
+ "typescript": "^5.9.2",
40
+ "vitest": "^5.0.1"
41
+ },
42
+ "peerDependencies": {
43
+ "rrweb": "^2.0.0"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "rrweb": {
47
+ "optional": true
48
+ }
49
+ }
50
+ }