swish-recorder 0.1.6 → 0.2.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/README.md +23 -17
- package/dist/index.cjs +118 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +30 -1
- package/dist/index.d.ts +30 -1
- package/dist/index.js +118 -27
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,25 +35,25 @@ pnpm add swish-recorder
|
|
|
35
35
|
|
|
36
36
|
## Usage
|
|
37
37
|
|
|
38
|
-
###
|
|
38
|
+
### Recommended: `beforeSendHandler` (zero-miss)
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
Inject the handler directly into PostHog's config so it captures snapshots from the very first event — including the initial DOM snapshot that fires before `loaded` callbacks.
|
|
41
41
|
|
|
42
42
|
```ts
|
|
43
43
|
import posthog from 'posthog-js'
|
|
44
44
|
import { SwishRecorder } from 'swish-recorder'
|
|
45
45
|
|
|
46
|
+
const swish = SwishRecorder.beforeSendHandler()
|
|
47
|
+
|
|
46
48
|
posthog.init('phc_your_project_token', {
|
|
47
49
|
api_host: 'https://eu.i.posthog.com',
|
|
48
50
|
defaults: '2026-01-30',
|
|
51
|
+
before_send: swish.handler,
|
|
52
|
+
loaded: (ph) => swish.bind(ph), // starts session lifecycle tracking
|
|
49
53
|
})
|
|
50
|
-
|
|
51
|
-
SwishRecorder.init({ posthog })
|
|
52
54
|
```
|
|
53
55
|
|
|
54
|
-
### React
|
|
55
|
-
|
|
56
|
-
Initialize PostHog before mounting your app, then pass the instance to `PostHogProvider` via `client=`. Call `SwishRecorder.init` in between — before `createRoot`.
|
|
56
|
+
### React
|
|
57
57
|
|
|
58
58
|
```tsx
|
|
59
59
|
// src/main.tsx
|
|
@@ -64,13 +64,15 @@ import { PostHogProvider } from 'posthog-js/react'
|
|
|
64
64
|
import { SwishRecorder } from 'swish-recorder'
|
|
65
65
|
import App from './App'
|
|
66
66
|
|
|
67
|
+
const swish = SwishRecorder.beforeSendHandler()
|
|
68
|
+
|
|
67
69
|
posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_TOKEN, {
|
|
68
70
|
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
|
|
69
71
|
defaults: '2026-01-30',
|
|
72
|
+
before_send: swish.handler,
|
|
73
|
+
loaded: (ph) => swish.bind(ph),
|
|
70
74
|
})
|
|
71
75
|
|
|
72
|
-
SwishRecorder.init({ posthog })
|
|
73
|
-
|
|
74
76
|
createRoot(document.getElementById('root')!).render(
|
|
75
77
|
<StrictMode>
|
|
76
78
|
<PostHogProvider client={posthog}>
|
|
@@ -80,12 +82,8 @@ createRoot(document.getElementById('root')!).render(
|
|
|
80
82
|
)
|
|
81
83
|
```
|
|
82
84
|
|
|
83
|
-
> If you use `@posthog/react` instead of `posthog-js/react`, replace the import accordingly — the API is identical.
|
|
84
|
-
|
|
85
85
|
### React (PostHogProvider only)
|
|
86
86
|
|
|
87
|
-
If you let `PostHogProvider` handle initialization (passing `apiKey` + `options` directly), PostHog isn't available until after it loads. Use the `loaded` callback to init SwishRecorder at that point:
|
|
88
|
-
|
|
89
87
|
```tsx
|
|
90
88
|
// src/main.tsx
|
|
91
89
|
import { StrictMode } from 'react'
|
|
@@ -94,6 +92,8 @@ import { PostHogProvider } from 'posthog-js/react'
|
|
|
94
92
|
import { SwishRecorder } from 'swish-recorder'
|
|
95
93
|
import App from './App'
|
|
96
94
|
|
|
95
|
+
const swish = SwishRecorder.beforeSendHandler()
|
|
96
|
+
|
|
97
97
|
createRoot(document.getElementById('root')!).render(
|
|
98
98
|
<StrictMode>
|
|
99
99
|
<PostHogProvider
|
|
@@ -101,9 +101,8 @@ createRoot(document.getElementById('root')!).render(
|
|
|
101
101
|
options={{
|
|
102
102
|
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
|
|
103
103
|
defaults: '2026-01-30',
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
},
|
|
104
|
+
before_send: swish.handler,
|
|
105
|
+
loaded: (ph) => swish.bind(ph),
|
|
107
106
|
}}
|
|
108
107
|
>
|
|
109
108
|
<App />
|
|
@@ -112,7 +111,14 @@ createRoot(document.getElementById('root')!).render(
|
|
|
112
111
|
)
|
|
113
112
|
```
|
|
114
113
|
|
|
115
|
-
|
|
114
|
+
### Legacy: `SwishRecorder.init`
|
|
115
|
+
|
|
116
|
+
Still works, but may miss the initial DOM snapshot on pages where PostHog starts recording from cached config before the `loaded` callback fires.
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
posthog.init('phc_your_project_token', { ... })
|
|
120
|
+
SwishRecorder.init({ posthog })
|
|
121
|
+
```
|
|
116
122
|
|
|
117
123
|
---
|
|
118
124
|
|
package/dist/index.cjs
CHANGED
|
@@ -25,6 +25,17 @@ __export(index_exports, {
|
|
|
25
25
|
module.exports = __toCommonJS(index_exports);
|
|
26
26
|
var API_HOST = "https://api.swish.is";
|
|
27
27
|
var PREFIX = "[swish-recorder]";
|
|
28
|
+
var VERSION = "0.2.1";
|
|
29
|
+
function getToken(posthog) {
|
|
30
|
+
var _a;
|
|
31
|
+
const token = (_a = posthog.config) == null ? void 0 : _a.token;
|
|
32
|
+
if (!token) throw new Error(`${PREFIX} PostHog instance has no config.token`);
|
|
33
|
+
return token;
|
|
34
|
+
}
|
|
35
|
+
function getBeforeSend(posthog) {
|
|
36
|
+
var _a;
|
|
37
|
+
return (_a = posthog.config) == null ? void 0 : _a.before_send;
|
|
38
|
+
}
|
|
28
39
|
var SwishRecorder = class _SwishRecorder {
|
|
29
40
|
constructor(config) {
|
|
30
41
|
this.config = config;
|
|
@@ -45,6 +56,79 @@ var SwishRecorder = class _SwishRecorder {
|
|
|
45
56
|
instance._setup();
|
|
46
57
|
return instance;
|
|
47
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Create a `before_send` handler to pass directly into PostHog's config.
|
|
61
|
+
* This ensures snapshot interception is active from the very first event,
|
|
62
|
+
* avoiding the race condition where PostHog starts recording from persisted
|
|
63
|
+
* config before `SwishRecorder.init()` can hook in.
|
|
64
|
+
*
|
|
65
|
+
* Usage:
|
|
66
|
+
* const swish = SwishRecorder.beforeSendHandler({ apiHost: '...' })
|
|
67
|
+
* posthog.init(token, { before_send: swish.handler })
|
|
68
|
+
* // later, after posthog is ready:
|
|
69
|
+
* swish.bind(posthog) // starts session lifecycle tracking
|
|
70
|
+
*/
|
|
71
|
+
static beforeSendHandler(opts) {
|
|
72
|
+
var _a, _b;
|
|
73
|
+
const apiHost = (_a = opts == null ? void 0 : opts.apiHost) != null ? _a : API_HOST;
|
|
74
|
+
const debug = (_b = opts == null ? void 0 : opts.debug) != null ? _b : false;
|
|
75
|
+
let token = null;
|
|
76
|
+
let pendingSnapshots = [];
|
|
77
|
+
const log = (...args) => {
|
|
78
|
+
if (debug) console.log(PREFIX, ...args);
|
|
79
|
+
};
|
|
80
|
+
const warn = (...args) => {
|
|
81
|
+
if (debug) console.warn(PREFIX, ...args);
|
|
82
|
+
};
|
|
83
|
+
const sendSnapshot = (props, ts) => {
|
|
84
|
+
fetch(`${apiHost}/replays/ingest`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: {
|
|
87
|
+
"Content-Type": "application/json",
|
|
88
|
+
"X-PostHog-Token": token
|
|
89
|
+
},
|
|
90
|
+
body: JSON.stringify({
|
|
91
|
+
session_id: props.$session_id,
|
|
92
|
+
window_id: props.$window_id,
|
|
93
|
+
snapshot_data: props.$snapshot_data,
|
|
94
|
+
distinct_id: props.distinct_id,
|
|
95
|
+
timestamp: ts,
|
|
96
|
+
sdk_version: VERSION
|
|
97
|
+
})
|
|
98
|
+
}).then((res) => {
|
|
99
|
+
if (debug) {
|
|
100
|
+
if (res.ok) log(`ingest OK \u2014 ${res.status}`);
|
|
101
|
+
else warn(`ingest failed \u2014 ${res.status} ${res.statusText}`);
|
|
102
|
+
}
|
|
103
|
+
}).catch((err) => warn("ingest error \u2014", err));
|
|
104
|
+
};
|
|
105
|
+
const handler = (event) => {
|
|
106
|
+
if ((event == null ? void 0 : event.event) === "$snapshot") {
|
|
107
|
+
const props = event.properties;
|
|
108
|
+
const ts = Date.now();
|
|
109
|
+
if (token) {
|
|
110
|
+
sendSnapshot(props, ts);
|
|
111
|
+
} else {
|
|
112
|
+
pendingSnapshots.push({ props, ts });
|
|
113
|
+
log(`buffered snapshot (pre-bind) \u2014 ${pendingSnapshots.length} pending`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return event;
|
|
117
|
+
};
|
|
118
|
+
const bind = (posthog) => {
|
|
119
|
+
token = getToken(posthog);
|
|
120
|
+
log(`bind \u2014 token=${token.slice(0, 12)}\u2026`);
|
|
121
|
+
if (pendingSnapshots.length > 0) {
|
|
122
|
+
log(`flushing ${pendingSnapshots.length} buffered snapshots`);
|
|
123
|
+
for (const { props, ts } of pendingSnapshots) {
|
|
124
|
+
sendSnapshot(props, ts);
|
|
125
|
+
}
|
|
126
|
+
pendingSnapshots = [];
|
|
127
|
+
}
|
|
128
|
+
return _SwishRecorder.init({ posthog, apiHost, debug, _skipBeforeSend: true });
|
|
129
|
+
};
|
|
130
|
+
return { handler, bind };
|
|
131
|
+
}
|
|
48
132
|
_sendSessionEnd(sessionId, reason) {
|
|
49
133
|
this._log(`session end \u2014 session=${sessionId} reason=${reason}`);
|
|
50
134
|
navigator.sendBeacon(
|
|
@@ -54,7 +138,7 @@ var SwishRecorder = class _SwishRecorder {
|
|
|
54
138
|
JSON.stringify({
|
|
55
139
|
session_id: sessionId,
|
|
56
140
|
reason,
|
|
57
|
-
token: this.config.posthog
|
|
141
|
+
token: getToken(this.config.posthog)
|
|
58
142
|
})
|
|
59
143
|
],
|
|
60
144
|
{ type: "application/json" }
|
|
@@ -69,14 +153,15 @@ var SwishRecorder = class _SwishRecorder {
|
|
|
69
153
|
method: "POST",
|
|
70
154
|
headers: {
|
|
71
155
|
"Content-Type": "application/json",
|
|
72
|
-
"X-PostHog-Token": this.config.posthog
|
|
156
|
+
"X-PostHog-Token": getToken(this.config.posthog)
|
|
73
157
|
},
|
|
74
158
|
body: JSON.stringify({
|
|
75
159
|
session_id: sessionId,
|
|
76
160
|
window_id: windowId,
|
|
77
161
|
snapshot_data: snapshotData,
|
|
78
162
|
distinct_id: distinctId,
|
|
79
|
-
timestamp: Date.now()
|
|
163
|
+
timestamp: Date.now(),
|
|
164
|
+
sdk_version: VERSION
|
|
80
165
|
})
|
|
81
166
|
}).then((res) => {
|
|
82
167
|
if (this.config.debug) {
|
|
@@ -92,33 +177,39 @@ var SwishRecorder = class _SwishRecorder {
|
|
|
92
177
|
}
|
|
93
178
|
_setup() {
|
|
94
179
|
const { posthog } = this.config;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
180
|
+
let token;
|
|
181
|
+
try {
|
|
182
|
+
token = getToken(posthog);
|
|
183
|
+
} catch (e) {
|
|
98
184
|
this._warn("no PostHog token found \u2014 recording disabled");
|
|
99
185
|
return;
|
|
100
186
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
props
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
187
|
+
this._log(`init \u2014 token=${token.slice(0, 12)}\u2026`);
|
|
188
|
+
if (!this.config._skipBeforeSend) {
|
|
189
|
+
const snapshotHandler = (event) => {
|
|
190
|
+
if ((event == null ? void 0 : event.event) === "$snapshot") {
|
|
191
|
+
const props = event.properties;
|
|
192
|
+
this._ingestSnapshot(
|
|
193
|
+
props.$session_id,
|
|
194
|
+
props.$window_id,
|
|
195
|
+
props.$snapshot_data,
|
|
196
|
+
props.distinct_id
|
|
197
|
+
);
|
|
198
|
+
} else if (this.config.debug && event) {
|
|
199
|
+
this._log(`before_send passthrough \u2014 event=${event.event}`);
|
|
200
|
+
}
|
|
201
|
+
return event;
|
|
202
|
+
};
|
|
203
|
+
const existing = getBeforeSend(posthog);
|
|
204
|
+
this._log(
|
|
205
|
+
`before_send \u2014 existing handler: ${existing ? Array.isArray(existing) ? `array(${existing.length})` : "function" : "none"}`
|
|
206
|
+
);
|
|
207
|
+
posthog.set_config({
|
|
208
|
+
before_send: existing ? Array.isArray(existing) ? [...existing, snapshotHandler] : [existing, snapshotHandler] : snapshotHandler
|
|
209
|
+
});
|
|
210
|
+
} else {
|
|
211
|
+
this._log("before_send \u2014 already registered via beforeSendHandler()");
|
|
212
|
+
}
|
|
122
213
|
this.unsubscribeSessionId = posthog.onSessionId(
|
|
123
214
|
(newSessionId, _windowId, changeReason) => {
|
|
124
215
|
this._log(
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["// Minimal PostHog interface — no posthog-js dependency needed\ninterface PostHogLike {\n set_config(config: { before_send?: unknown }): void;\n onSessionId(\n callback: (\n sessionId: string,\n windowId: string | null | undefined,\n changeReason?: {\n activityTimeout?: boolean;\n sessionPastMaximumLength?: boolean;\n },\n ) => void,\n ): () => void;\n readonly config: {\n token: string;\n before_send?: unknown;\n };\n}\n\ntype BeforeSendFn = (\n event: { event: string; properties: Record<string, unknown> } | null,\n) => { event: string; properties: Record<string, unknown> } | null;\n\nconst API_HOST = \"https://api.swish.is\";\nconst PREFIX = \"[swish-recorder]\";\n\nexport interface SwishRecorderConfig {\n /** PostHog instance from your app — token is read automatically. */\n posthog: PostHogLike;\n /** Enable console logging for debugging. */\n debug?: boolean;\n /** Override the Swish API URL. Useful for local development (e.g. \"http://localhost:8000\"). */\n apiHost?: string;\n}\n\nexport class SwishRecorder {\n private currentSessionId: string | null = null;\n private unsubscribeSessionId: (() => void) | null = null;\n private handleUnload: (() => void) | null = null;\n\n private readonly apiHost: string;\n\n private constructor(private readonly config: SwishRecorderConfig) {\n this.apiHost = config.apiHost ?? API_HOST;\n }\n\n private _log(...args: unknown[]): void {\n if (this.config.debug) console.log(PREFIX, ...args);\n }\n\n private _warn(...args: unknown[]): void {\n if (this.config.debug) console.warn(PREFIX, ...args);\n }\n\n static init(config: SwishRecorderConfig): SwishRecorder {\n const instance = new SwishRecorder(config);\n instance._setup();\n return instance;\n }\n\n private _sendSessionEnd(sessionId: string, reason: string): void {\n this._log(`session end — session=${sessionId} reason=${reason}`);\n navigator.sendBeacon(\n `${this.apiHost}/replays/session-end`,\n new Blob(\n [\n JSON.stringify({\n session_id: sessionId,\n reason,\n token: this.config.posthog.config.token,\n }),\n ],\n { type: \"application/json\" },\n ),\n );\n }\n\n private _ingestSnapshot(\n sessionId: string,\n windowId: string,\n snapshotData: unknown,\n distinctId: string,\n ): void {\n this._log(\n `ingesting snapshot — session=${sessionId} window=${windowId} distinct_id=${distinctId}`,\n );\n fetch(`${this.apiHost}/replays/ingest`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-PostHog-Token\": this.config.posthog.config.token,\n },\n body: JSON.stringify({\n session_id: sessionId,\n window_id: windowId,\n snapshot_data: snapshotData,\n distinct_id: distinctId,\n timestamp: Date.now(),\n }),\n })\n .then((res) => {\n if (this.config.debug) {\n if (res.ok) {\n this._log(`ingest OK — ${res.status}`);\n } else {\n this._warn(`ingest failed — ${res.status} ${res.statusText}`);\n }\n }\n })\n .catch((err) => {\n this._warn(\"ingest error —\", err);\n });\n }\n\n private _setup(): void {\n const { posthog } = this.config;\n const token = posthog.config.token;\n\n this._log(`init — token=${token.slice(0, 12)}…`);\n\n if (!token) {\n this._warn(\"no PostHog token found — recording disabled\");\n return;\n }\n\n // 1. Intercept $snapshot events via before_send (non-destructive append)\n const snapshotHandler: BeforeSendFn = (event) => {\n if (event?.event === \"$snapshot\") {\n const props = event.properties;\n this._ingestSnapshot(\n props.$session_id as string,\n props.$window_id as string,\n props.$snapshot_data,\n props.distinct_id as string,\n );\n } else if (this.config.debug && event) {\n this._log(`before_send passthrough — event=${event.event}`);\n }\n return event;\n };\n\n const existing = posthog.config.before_send;\n this._log(\n `before_send — existing handler: ${existing ? (Array.isArray(existing) ? `array(${(existing as unknown[]).length})` : \"function\") : \"none\"}`,\n );\n posthog.set_config({\n before_send: existing\n ? Array.isArray(existing)\n ? [...(existing as BeforeSendFn[]), snapshotHandler]\n : [existing as BeforeSendFn, snapshotHandler]\n : snapshotHandler,\n });\n\n // 2. Session rotation signal — fires on idle timeout or max session length\n this.unsubscribeSessionId = posthog.onSessionId(\n (newSessionId, _windowId, changeReason) => {\n this._log(\n `session ID changed — new=${newSessionId} reason=${JSON.stringify(changeReason ?? null)}`,\n );\n if (this.currentSessionId && changeReason) {\n const reason = changeReason.activityTimeout\n ? \"activity_timeout\"\n : changeReason.sessionPastMaximumLength\n ? \"session_length\"\n : null;\n if (reason) this._sendSessionEnd(this.currentSessionId, reason);\n }\n this.currentSessionId = newSessionId;\n },\n );\n\n // 3. Tab close — sendBeacon survives page unload, fetch does not\n this.handleUnload = () => {\n if (this.currentSessionId)\n this._sendSessionEnd(this.currentSessionId, \"tab_close\");\n };\n window.addEventListener(\"beforeunload\", this.handleUnload);\n\n this._log(\"ready\");\n }\n\n destroy(): void {\n this._log(\"destroy\");\n this.unsubscribeSessionId?.();\n if (this.handleUnload) {\n window.removeEventListener(\"beforeunload\", this.handleUnload);\n }\n this.unsubscribeSessionId = null;\n this.handleUnload = null;\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["// Minimal PostHog interface — no posthog-js dependency needed.\n//\n// posthog-js types the `loaded` callback parameter as\n// `PostHogInterface = Omit<PostHog, 'config' | 'init'>`, which strips\n// `config` from the type even though the runtime object always has it.\n// We keep `config` optional here so users can pass either the full\n// PostHog instance or the `loaded` callback parameter without type errors.\n// At runtime we assert `config` exists — it always does.\ninterface PostHogLike {\n set_config(config: { before_send?: unknown }): void;\n onSessionId(\n callback: (\n sessionId: string,\n windowId: string | null | undefined,\n changeReason?: {\n noSessionId?: boolean;\n activityTimeout?: boolean;\n sessionPastMaximumLength?: boolean;\n },\n ) => void,\n ): () => void;\n config?: {\n token: string;\n before_send?: unknown;\n };\n}\n\n/**\n * Compatible with posthog-js BeforeSendFn / CaptureResult.\n * We use `any` for the public handler type so it's assignable to PostHog's\n * `before_send` without users needing type casts — the actual shapes evolve\n * across posthog-js versions and we only inspect `event` + `properties`.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype BeforeSendFn = (event: any) => any;\n\nconst API_HOST = \"https://api.swish.is\";\nconst PREFIX = \"[swish-recorder]\";\nconst VERSION = \"0.2.1\";\n\n/** Read posthog.config.token — always exists at runtime even when typed away. */\nfunction getToken(posthog: PostHogLike): string {\n const token = (posthog as { config?: { token?: string } }).config?.token;\n if (!token) throw new Error(`${PREFIX} PostHog instance has no config.token`);\n return token;\n}\n\n/** Read posthog.config.before_send — may be undefined. */\nfunction getBeforeSend(posthog: PostHogLike): unknown {\n return (posthog as { config?: { before_send?: unknown } }).config\n ?.before_send;\n}\n\nexport interface SwishRecorderConfig {\n /** PostHog instance from your app — token is read automatically. */\n posthog: PostHogLike;\n /** Enable console logging for debugging. */\n debug?: boolean;\n /** Override the Swish API URL. Useful for local development (e.g. \"http://localhost:8000\"). */\n apiHost?: string;\n /** @internal Skip before_send registration (already handled by beforeSendHandler). */\n _skipBeforeSend?: boolean;\n}\n\nexport class SwishRecorder {\n private currentSessionId: string | null = null;\n private unsubscribeSessionId: (() => void) | null = null;\n private handleUnload: (() => void) | null = null;\n\n private readonly apiHost: string;\n\n private constructor(private readonly config: SwishRecorderConfig) {\n this.apiHost = config.apiHost ?? API_HOST;\n }\n\n private _log(...args: unknown[]): void {\n if (this.config.debug) console.log(PREFIX, ...args);\n }\n\n private _warn(...args: unknown[]): void {\n if (this.config.debug) console.warn(PREFIX, ...args);\n }\n\n static init(config: SwishRecorderConfig): SwishRecorder {\n const instance = new SwishRecorder(config);\n instance._setup();\n return instance;\n }\n\n /**\n * Create a `before_send` handler to pass directly into PostHog's config.\n * This ensures snapshot interception is active from the very first event,\n * avoiding the race condition where PostHog starts recording from persisted\n * config before `SwishRecorder.init()` can hook in.\n *\n * Usage:\n * const swish = SwishRecorder.beforeSendHandler({ apiHost: '...' })\n * posthog.init(token, { before_send: swish.handler })\n * // later, after posthog is ready:\n * swish.bind(posthog) // starts session lifecycle tracking\n */\n static beforeSendHandler(opts?: {\n apiHost?: string;\n debug?: boolean;\n }): { handler: BeforeSendFn; bind: (posthog: PostHogLike) => SwishRecorder } {\n const apiHost = opts?.apiHost ?? API_HOST;\n const debug = opts?.debug ?? false;\n let token: string | null = null;\n let pendingSnapshots: { props: Record<string, unknown>; ts: number }[] = [];\n\n const log = (...args: unknown[]) => {\n if (debug) console.log(PREFIX, ...args);\n };\n const warn = (...args: unknown[]) => {\n if (debug) console.warn(PREFIX, ...args);\n };\n\n const sendSnapshot = (props: Record<string, unknown>, ts: number) => {\n fetch(`${apiHost}/replays/ingest`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-PostHog-Token\": token!,\n },\n body: JSON.stringify({\n session_id: props.$session_id,\n window_id: props.$window_id,\n snapshot_data: props.$snapshot_data,\n distinct_id: props.distinct_id,\n timestamp: ts,\n sdk_version: VERSION,\n }),\n })\n .then((res) => {\n if (debug) {\n if (res.ok) log(`ingest OK — ${res.status}`);\n else warn(`ingest failed — ${res.status} ${res.statusText}`);\n }\n })\n .catch((err) => warn(\"ingest error —\", err));\n };\n\n const handler: BeforeSendFn = (event) => {\n if (event?.event === \"$snapshot\") {\n const props = event.properties;\n const ts = Date.now();\n if (token) {\n sendSnapshot(props, ts);\n } else {\n // Buffer until bind() provides the token\n pendingSnapshots.push({ props, ts });\n log(`buffered snapshot (pre-bind) — ${pendingSnapshots.length} pending`);\n }\n }\n return event;\n };\n\n const bind = (posthog: PostHogLike): SwishRecorder => {\n token = getToken(posthog);\n log(`bind — token=${token.slice(0, 12)}…`);\n\n // Flush any snapshots captured before bind\n if (pendingSnapshots.length > 0) {\n log(`flushing ${pendingSnapshots.length} buffered snapshots`);\n for (const { props, ts } of pendingSnapshots) {\n sendSnapshot(props, ts);\n }\n pendingSnapshots = [];\n }\n\n return SwishRecorder.init({ posthog, apiHost, debug, _skipBeforeSend: true });\n };\n\n return { handler, bind };\n }\n\n private _sendSessionEnd(sessionId: string, reason: string): void {\n this._log(`session end — session=${sessionId} reason=${reason}`);\n navigator.sendBeacon(\n `${this.apiHost}/replays/session-end`,\n new Blob(\n [\n JSON.stringify({\n session_id: sessionId,\n reason,\n token: getToken(this.config.posthog),\n }),\n ],\n { type: \"application/json\" },\n ),\n );\n }\n\n private _ingestSnapshot(\n sessionId: string,\n windowId: string,\n snapshotData: unknown,\n distinctId: string,\n ): void {\n this._log(\n `ingesting snapshot — session=${sessionId} window=${windowId} distinct_id=${distinctId}`,\n );\n fetch(`${this.apiHost}/replays/ingest`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-PostHog-Token\": getToken(this.config.posthog),\n },\n body: JSON.stringify({\n session_id: sessionId,\n window_id: windowId,\n snapshot_data: snapshotData,\n distinct_id: distinctId,\n timestamp: Date.now(),\n sdk_version: VERSION,\n }),\n })\n .then((res) => {\n if (this.config.debug) {\n if (res.ok) {\n this._log(`ingest OK — ${res.status}`);\n } else {\n this._warn(`ingest failed — ${res.status} ${res.statusText}`);\n }\n }\n })\n .catch((err) => {\n this._warn(\"ingest error —\", err);\n });\n }\n\n private _setup(): void {\n const { posthog } = this.config;\n let token: string;\n try {\n token = getToken(posthog);\n } catch {\n this._warn(\"no PostHog token found — recording disabled\");\n return;\n }\n\n this._log(`init — token=${token.slice(0, 12)}…`);\n\n // 1. Intercept $snapshot events via before_send (non-destructive append)\n // Skip if already registered via beforeSendHandler()\n if (!this.config._skipBeforeSend) {\n const snapshotHandler: BeforeSendFn = (event) => {\n if (event?.event === \"$snapshot\") {\n const props = event.properties;\n this._ingestSnapshot(\n props.$session_id as string,\n props.$window_id as string,\n props.$snapshot_data,\n props.distinct_id as string,\n );\n } else if (this.config.debug && event) {\n this._log(`before_send passthrough — event=${event.event}`);\n }\n return event;\n };\n\n const existing = getBeforeSend(posthog);\n this._log(\n `before_send — existing handler: ${existing ? (Array.isArray(existing) ? `array(${(existing as unknown[]).length})` : \"function\") : \"none\"}`,\n );\n posthog.set_config({\n before_send: existing\n ? Array.isArray(existing)\n ? [...(existing as BeforeSendFn[]), snapshotHandler]\n : [existing as BeforeSendFn, snapshotHandler]\n : snapshotHandler,\n });\n } else {\n this._log(\"before_send — already registered via beforeSendHandler()\");\n }\n\n // 2. Session rotation signal — fires on idle timeout or max session length\n this.unsubscribeSessionId = posthog.onSessionId(\n (newSessionId, _windowId, changeReason) => {\n this._log(\n `session ID changed — new=${newSessionId} reason=${JSON.stringify(changeReason ?? null)}`,\n );\n if (this.currentSessionId && changeReason) {\n const reason = changeReason.activityTimeout\n ? \"activity_timeout\"\n : changeReason.sessionPastMaximumLength\n ? \"session_length\"\n : null;\n if (reason) this._sendSessionEnd(this.currentSessionId, reason);\n }\n this.currentSessionId = newSessionId;\n },\n );\n\n // 3. Tab close — sendBeacon survives page unload, fetch does not\n this.handleUnload = () => {\n if (this.currentSessionId)\n this._sendSessionEnd(this.currentSessionId, \"tab_close\");\n };\n window.addEventListener(\"beforeunload\", this.handleUnload);\n\n this._log(\"ready\");\n }\n\n destroy(): void {\n this._log(\"destroy\");\n this.unsubscribeSessionId?.();\n if (this.handleUnload) {\n window.removeEventListener(\"beforeunload\", this.handleUnload);\n }\n this.unsubscribeSessionId = null;\n this.handleUnload = null;\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
CHANGED
|
@@ -3,14 +3,22 @@ interface PostHogLike {
|
|
|
3
3
|
before_send?: unknown;
|
|
4
4
|
}): void;
|
|
5
5
|
onSessionId(callback: (sessionId: string, windowId: string | null | undefined, changeReason?: {
|
|
6
|
+
noSessionId?: boolean;
|
|
6
7
|
activityTimeout?: boolean;
|
|
7
8
|
sessionPastMaximumLength?: boolean;
|
|
8
9
|
}) => void): () => void;
|
|
9
|
-
|
|
10
|
+
config?: {
|
|
10
11
|
token: string;
|
|
11
12
|
before_send?: unknown;
|
|
12
13
|
};
|
|
13
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Compatible with posthog-js BeforeSendFn / CaptureResult.
|
|
17
|
+
* We use `any` for the public handler type so it's assignable to PostHog's
|
|
18
|
+
* `before_send` without users needing type casts — the actual shapes evolve
|
|
19
|
+
* across posthog-js versions and we only inspect `event` + `properties`.
|
|
20
|
+
*/
|
|
21
|
+
type BeforeSendFn = (event: any) => any;
|
|
14
22
|
interface SwishRecorderConfig {
|
|
15
23
|
/** PostHog instance from your app — token is read automatically. */
|
|
16
24
|
posthog: PostHogLike;
|
|
@@ -18,6 +26,8 @@ interface SwishRecorderConfig {
|
|
|
18
26
|
debug?: boolean;
|
|
19
27
|
/** Override the Swish API URL. Useful for local development (e.g. "http://localhost:8000"). */
|
|
20
28
|
apiHost?: string;
|
|
29
|
+
/** @internal Skip before_send registration (already handled by beforeSendHandler). */
|
|
30
|
+
_skipBeforeSend?: boolean;
|
|
21
31
|
}
|
|
22
32
|
declare class SwishRecorder {
|
|
23
33
|
private readonly config;
|
|
@@ -29,6 +39,25 @@ declare class SwishRecorder {
|
|
|
29
39
|
private _log;
|
|
30
40
|
private _warn;
|
|
31
41
|
static init(config: SwishRecorderConfig): SwishRecorder;
|
|
42
|
+
/**
|
|
43
|
+
* Create a `before_send` handler to pass directly into PostHog's config.
|
|
44
|
+
* This ensures snapshot interception is active from the very first event,
|
|
45
|
+
* avoiding the race condition where PostHog starts recording from persisted
|
|
46
|
+
* config before `SwishRecorder.init()` can hook in.
|
|
47
|
+
*
|
|
48
|
+
* Usage:
|
|
49
|
+
* const swish = SwishRecorder.beforeSendHandler({ apiHost: '...' })
|
|
50
|
+
* posthog.init(token, { before_send: swish.handler })
|
|
51
|
+
* // later, after posthog is ready:
|
|
52
|
+
* swish.bind(posthog) // starts session lifecycle tracking
|
|
53
|
+
*/
|
|
54
|
+
static beforeSendHandler(opts?: {
|
|
55
|
+
apiHost?: string;
|
|
56
|
+
debug?: boolean;
|
|
57
|
+
}): {
|
|
58
|
+
handler: BeforeSendFn;
|
|
59
|
+
bind: (posthog: PostHogLike) => SwishRecorder;
|
|
60
|
+
};
|
|
32
61
|
private _sendSessionEnd;
|
|
33
62
|
private _ingestSnapshot;
|
|
34
63
|
private _setup;
|
package/dist/index.d.ts
CHANGED
|
@@ -3,14 +3,22 @@ interface PostHogLike {
|
|
|
3
3
|
before_send?: unknown;
|
|
4
4
|
}): void;
|
|
5
5
|
onSessionId(callback: (sessionId: string, windowId: string | null | undefined, changeReason?: {
|
|
6
|
+
noSessionId?: boolean;
|
|
6
7
|
activityTimeout?: boolean;
|
|
7
8
|
sessionPastMaximumLength?: boolean;
|
|
8
9
|
}) => void): () => void;
|
|
9
|
-
|
|
10
|
+
config?: {
|
|
10
11
|
token: string;
|
|
11
12
|
before_send?: unknown;
|
|
12
13
|
};
|
|
13
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Compatible with posthog-js BeforeSendFn / CaptureResult.
|
|
17
|
+
* We use `any` for the public handler type so it's assignable to PostHog's
|
|
18
|
+
* `before_send` without users needing type casts — the actual shapes evolve
|
|
19
|
+
* across posthog-js versions and we only inspect `event` + `properties`.
|
|
20
|
+
*/
|
|
21
|
+
type BeforeSendFn = (event: any) => any;
|
|
14
22
|
interface SwishRecorderConfig {
|
|
15
23
|
/** PostHog instance from your app — token is read automatically. */
|
|
16
24
|
posthog: PostHogLike;
|
|
@@ -18,6 +26,8 @@ interface SwishRecorderConfig {
|
|
|
18
26
|
debug?: boolean;
|
|
19
27
|
/** Override the Swish API URL. Useful for local development (e.g. "http://localhost:8000"). */
|
|
20
28
|
apiHost?: string;
|
|
29
|
+
/** @internal Skip before_send registration (already handled by beforeSendHandler). */
|
|
30
|
+
_skipBeforeSend?: boolean;
|
|
21
31
|
}
|
|
22
32
|
declare class SwishRecorder {
|
|
23
33
|
private readonly config;
|
|
@@ -29,6 +39,25 @@ declare class SwishRecorder {
|
|
|
29
39
|
private _log;
|
|
30
40
|
private _warn;
|
|
31
41
|
static init(config: SwishRecorderConfig): SwishRecorder;
|
|
42
|
+
/**
|
|
43
|
+
* Create a `before_send` handler to pass directly into PostHog's config.
|
|
44
|
+
* This ensures snapshot interception is active from the very first event,
|
|
45
|
+
* avoiding the race condition where PostHog starts recording from persisted
|
|
46
|
+
* config before `SwishRecorder.init()` can hook in.
|
|
47
|
+
*
|
|
48
|
+
* Usage:
|
|
49
|
+
* const swish = SwishRecorder.beforeSendHandler({ apiHost: '...' })
|
|
50
|
+
* posthog.init(token, { before_send: swish.handler })
|
|
51
|
+
* // later, after posthog is ready:
|
|
52
|
+
* swish.bind(posthog) // starts session lifecycle tracking
|
|
53
|
+
*/
|
|
54
|
+
static beforeSendHandler(opts?: {
|
|
55
|
+
apiHost?: string;
|
|
56
|
+
debug?: boolean;
|
|
57
|
+
}): {
|
|
58
|
+
handler: BeforeSendFn;
|
|
59
|
+
bind: (posthog: PostHogLike) => SwishRecorder;
|
|
60
|
+
};
|
|
32
61
|
private _sendSessionEnd;
|
|
33
62
|
private _ingestSnapshot;
|
|
34
63
|
private _setup;
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
var API_HOST = "https://api.swish.is";
|
|
3
3
|
var PREFIX = "[swish-recorder]";
|
|
4
|
+
var VERSION = "0.2.1";
|
|
5
|
+
function getToken(posthog) {
|
|
6
|
+
var _a;
|
|
7
|
+
const token = (_a = posthog.config) == null ? void 0 : _a.token;
|
|
8
|
+
if (!token) throw new Error(`${PREFIX} PostHog instance has no config.token`);
|
|
9
|
+
return token;
|
|
10
|
+
}
|
|
11
|
+
function getBeforeSend(posthog) {
|
|
12
|
+
var _a;
|
|
13
|
+
return (_a = posthog.config) == null ? void 0 : _a.before_send;
|
|
14
|
+
}
|
|
4
15
|
var SwishRecorder = class _SwishRecorder {
|
|
5
16
|
constructor(config) {
|
|
6
17
|
this.config = config;
|
|
@@ -21,6 +32,79 @@ var SwishRecorder = class _SwishRecorder {
|
|
|
21
32
|
instance._setup();
|
|
22
33
|
return instance;
|
|
23
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Create a `before_send` handler to pass directly into PostHog's config.
|
|
37
|
+
* This ensures snapshot interception is active from the very first event,
|
|
38
|
+
* avoiding the race condition where PostHog starts recording from persisted
|
|
39
|
+
* config before `SwishRecorder.init()` can hook in.
|
|
40
|
+
*
|
|
41
|
+
* Usage:
|
|
42
|
+
* const swish = SwishRecorder.beforeSendHandler({ apiHost: '...' })
|
|
43
|
+
* posthog.init(token, { before_send: swish.handler })
|
|
44
|
+
* // later, after posthog is ready:
|
|
45
|
+
* swish.bind(posthog) // starts session lifecycle tracking
|
|
46
|
+
*/
|
|
47
|
+
static beforeSendHandler(opts) {
|
|
48
|
+
var _a, _b;
|
|
49
|
+
const apiHost = (_a = opts == null ? void 0 : opts.apiHost) != null ? _a : API_HOST;
|
|
50
|
+
const debug = (_b = opts == null ? void 0 : opts.debug) != null ? _b : false;
|
|
51
|
+
let token = null;
|
|
52
|
+
let pendingSnapshots = [];
|
|
53
|
+
const log = (...args) => {
|
|
54
|
+
if (debug) console.log(PREFIX, ...args);
|
|
55
|
+
};
|
|
56
|
+
const warn = (...args) => {
|
|
57
|
+
if (debug) console.warn(PREFIX, ...args);
|
|
58
|
+
};
|
|
59
|
+
const sendSnapshot = (props, ts) => {
|
|
60
|
+
fetch(`${apiHost}/replays/ingest`, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: {
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
"X-PostHog-Token": token
|
|
65
|
+
},
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
session_id: props.$session_id,
|
|
68
|
+
window_id: props.$window_id,
|
|
69
|
+
snapshot_data: props.$snapshot_data,
|
|
70
|
+
distinct_id: props.distinct_id,
|
|
71
|
+
timestamp: ts,
|
|
72
|
+
sdk_version: VERSION
|
|
73
|
+
})
|
|
74
|
+
}).then((res) => {
|
|
75
|
+
if (debug) {
|
|
76
|
+
if (res.ok) log(`ingest OK \u2014 ${res.status}`);
|
|
77
|
+
else warn(`ingest failed \u2014 ${res.status} ${res.statusText}`);
|
|
78
|
+
}
|
|
79
|
+
}).catch((err) => warn("ingest error \u2014", err));
|
|
80
|
+
};
|
|
81
|
+
const handler = (event) => {
|
|
82
|
+
if ((event == null ? void 0 : event.event) === "$snapshot") {
|
|
83
|
+
const props = event.properties;
|
|
84
|
+
const ts = Date.now();
|
|
85
|
+
if (token) {
|
|
86
|
+
sendSnapshot(props, ts);
|
|
87
|
+
} else {
|
|
88
|
+
pendingSnapshots.push({ props, ts });
|
|
89
|
+
log(`buffered snapshot (pre-bind) \u2014 ${pendingSnapshots.length} pending`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return event;
|
|
93
|
+
};
|
|
94
|
+
const bind = (posthog) => {
|
|
95
|
+
token = getToken(posthog);
|
|
96
|
+
log(`bind \u2014 token=${token.slice(0, 12)}\u2026`);
|
|
97
|
+
if (pendingSnapshots.length > 0) {
|
|
98
|
+
log(`flushing ${pendingSnapshots.length} buffered snapshots`);
|
|
99
|
+
for (const { props, ts } of pendingSnapshots) {
|
|
100
|
+
sendSnapshot(props, ts);
|
|
101
|
+
}
|
|
102
|
+
pendingSnapshots = [];
|
|
103
|
+
}
|
|
104
|
+
return _SwishRecorder.init({ posthog, apiHost, debug, _skipBeforeSend: true });
|
|
105
|
+
};
|
|
106
|
+
return { handler, bind };
|
|
107
|
+
}
|
|
24
108
|
_sendSessionEnd(sessionId, reason) {
|
|
25
109
|
this._log(`session end \u2014 session=${sessionId} reason=${reason}`);
|
|
26
110
|
navigator.sendBeacon(
|
|
@@ -30,7 +114,7 @@ var SwishRecorder = class _SwishRecorder {
|
|
|
30
114
|
JSON.stringify({
|
|
31
115
|
session_id: sessionId,
|
|
32
116
|
reason,
|
|
33
|
-
token: this.config.posthog
|
|
117
|
+
token: getToken(this.config.posthog)
|
|
34
118
|
})
|
|
35
119
|
],
|
|
36
120
|
{ type: "application/json" }
|
|
@@ -45,14 +129,15 @@ var SwishRecorder = class _SwishRecorder {
|
|
|
45
129
|
method: "POST",
|
|
46
130
|
headers: {
|
|
47
131
|
"Content-Type": "application/json",
|
|
48
|
-
"X-PostHog-Token": this.config.posthog
|
|
132
|
+
"X-PostHog-Token": getToken(this.config.posthog)
|
|
49
133
|
},
|
|
50
134
|
body: JSON.stringify({
|
|
51
135
|
session_id: sessionId,
|
|
52
136
|
window_id: windowId,
|
|
53
137
|
snapshot_data: snapshotData,
|
|
54
138
|
distinct_id: distinctId,
|
|
55
|
-
timestamp: Date.now()
|
|
139
|
+
timestamp: Date.now(),
|
|
140
|
+
sdk_version: VERSION
|
|
56
141
|
})
|
|
57
142
|
}).then((res) => {
|
|
58
143
|
if (this.config.debug) {
|
|
@@ -68,33 +153,39 @@ var SwishRecorder = class _SwishRecorder {
|
|
|
68
153
|
}
|
|
69
154
|
_setup() {
|
|
70
155
|
const { posthog } = this.config;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
156
|
+
let token;
|
|
157
|
+
try {
|
|
158
|
+
token = getToken(posthog);
|
|
159
|
+
} catch (e) {
|
|
74
160
|
this._warn("no PostHog token found \u2014 recording disabled");
|
|
75
161
|
return;
|
|
76
162
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
props
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
163
|
+
this._log(`init \u2014 token=${token.slice(0, 12)}\u2026`);
|
|
164
|
+
if (!this.config._skipBeforeSend) {
|
|
165
|
+
const snapshotHandler = (event) => {
|
|
166
|
+
if ((event == null ? void 0 : event.event) === "$snapshot") {
|
|
167
|
+
const props = event.properties;
|
|
168
|
+
this._ingestSnapshot(
|
|
169
|
+
props.$session_id,
|
|
170
|
+
props.$window_id,
|
|
171
|
+
props.$snapshot_data,
|
|
172
|
+
props.distinct_id
|
|
173
|
+
);
|
|
174
|
+
} else if (this.config.debug && event) {
|
|
175
|
+
this._log(`before_send passthrough \u2014 event=${event.event}`);
|
|
176
|
+
}
|
|
177
|
+
return event;
|
|
178
|
+
};
|
|
179
|
+
const existing = getBeforeSend(posthog);
|
|
180
|
+
this._log(
|
|
181
|
+
`before_send \u2014 existing handler: ${existing ? Array.isArray(existing) ? `array(${existing.length})` : "function" : "none"}`
|
|
182
|
+
);
|
|
183
|
+
posthog.set_config({
|
|
184
|
+
before_send: existing ? Array.isArray(existing) ? [...existing, snapshotHandler] : [existing, snapshotHandler] : snapshotHandler
|
|
185
|
+
});
|
|
186
|
+
} else {
|
|
187
|
+
this._log("before_send \u2014 already registered via beforeSendHandler()");
|
|
188
|
+
}
|
|
98
189
|
this.unsubscribeSessionId = posthog.onSessionId(
|
|
99
190
|
(newSessionId, _windowId, changeReason) => {
|
|
100
191
|
this._log(
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["// Minimal PostHog interface — no posthog-js dependency needed\ninterface PostHogLike {\n set_config(config: { before_send?: unknown }): void;\n onSessionId(\n callback: (\n sessionId: string,\n windowId: string | null | undefined,\n changeReason?: {\n activityTimeout?: boolean;\n sessionPastMaximumLength?: boolean;\n },\n ) => void,\n ): () => void;\n readonly config: {\n token: string;\n before_send?: unknown;\n };\n}\n\ntype BeforeSendFn = (\n event: { event: string; properties: Record<string, unknown> } | null,\n) => { event: string; properties: Record<string, unknown> } | null;\n\nconst API_HOST = \"https://api.swish.is\";\nconst PREFIX = \"[swish-recorder]\";\n\nexport interface SwishRecorderConfig {\n /** PostHog instance from your app — token is read automatically. */\n posthog: PostHogLike;\n /** Enable console logging for debugging. */\n debug?: boolean;\n /** Override the Swish API URL. Useful for local development (e.g. \"http://localhost:8000\"). */\n apiHost?: string;\n}\n\nexport class SwishRecorder {\n private currentSessionId: string | null = null;\n private unsubscribeSessionId: (() => void) | null = null;\n private handleUnload: (() => void) | null = null;\n\n private readonly apiHost: string;\n\n private constructor(private readonly config: SwishRecorderConfig) {\n this.apiHost = config.apiHost ?? API_HOST;\n }\n\n private _log(...args: unknown[]): void {\n if (this.config.debug) console.log(PREFIX, ...args);\n }\n\n private _warn(...args: unknown[]): void {\n if (this.config.debug) console.warn(PREFIX, ...args);\n }\n\n static init(config: SwishRecorderConfig): SwishRecorder {\n const instance = new SwishRecorder(config);\n instance._setup();\n return instance;\n }\n\n private _sendSessionEnd(sessionId: string, reason: string): void {\n this._log(`session end — session=${sessionId} reason=${reason}`);\n navigator.sendBeacon(\n `${this.apiHost}/replays/session-end`,\n new Blob(\n [\n JSON.stringify({\n session_id: sessionId,\n reason,\n token: this.config.posthog.config.token,\n }),\n ],\n { type: \"application/json\" },\n ),\n );\n }\n\n private _ingestSnapshot(\n sessionId: string,\n windowId: string,\n snapshotData: unknown,\n distinctId: string,\n ): void {\n this._log(\n `ingesting snapshot — session=${sessionId} window=${windowId} distinct_id=${distinctId}`,\n );\n fetch(`${this.apiHost}/replays/ingest`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-PostHog-Token\": this.config.posthog.config.token,\n },\n body: JSON.stringify({\n session_id: sessionId,\n window_id: windowId,\n snapshot_data: snapshotData,\n distinct_id: distinctId,\n timestamp: Date.now(),\n }),\n })\n .then((res) => {\n if (this.config.debug) {\n if (res.ok) {\n this._log(`ingest OK — ${res.status}`);\n } else {\n this._warn(`ingest failed — ${res.status} ${res.statusText}`);\n }\n }\n })\n .catch((err) => {\n this._warn(\"ingest error —\", err);\n });\n }\n\n private _setup(): void {\n const { posthog } = this.config;\n const token = posthog.config.token;\n\n this._log(`init — token=${token.slice(0, 12)}…`);\n\n if (!token) {\n this._warn(\"no PostHog token found — recording disabled\");\n return;\n }\n\n // 1. Intercept $snapshot events via before_send (non-destructive append)\n const snapshotHandler: BeforeSendFn = (event) => {\n if (event?.event === \"$snapshot\") {\n const props = event.properties;\n this._ingestSnapshot(\n props.$session_id as string,\n props.$window_id as string,\n props.$snapshot_data,\n props.distinct_id as string,\n );\n } else if (this.config.debug && event) {\n this._log(`before_send passthrough — event=${event.event}`);\n }\n return event;\n };\n\n const existing = posthog.config.before_send;\n this._log(\n `before_send — existing handler: ${existing ? (Array.isArray(existing) ? `array(${(existing as unknown[]).length})` : \"function\") : \"none\"}`,\n );\n posthog.set_config({\n before_send: existing\n ? Array.isArray(existing)\n ? [...(existing as BeforeSendFn[]), snapshotHandler]\n : [existing as BeforeSendFn, snapshotHandler]\n : snapshotHandler,\n });\n\n // 2. Session rotation signal — fires on idle timeout or max session length\n this.unsubscribeSessionId = posthog.onSessionId(\n (newSessionId, _windowId, changeReason) => {\n this._log(\n `session ID changed — new=${newSessionId} reason=${JSON.stringify(changeReason ?? null)}`,\n );\n if (this.currentSessionId && changeReason) {\n const reason = changeReason.activityTimeout\n ? \"activity_timeout\"\n : changeReason.sessionPastMaximumLength\n ? \"session_length\"\n : null;\n if (reason) this._sendSessionEnd(this.currentSessionId, reason);\n }\n this.currentSessionId = newSessionId;\n },\n );\n\n // 3. Tab close — sendBeacon survives page unload, fetch does not\n this.handleUnload = () => {\n if (this.currentSessionId)\n this._sendSessionEnd(this.currentSessionId, \"tab_close\");\n };\n window.addEventListener(\"beforeunload\", this.handleUnload);\n\n this._log(\"ready\");\n }\n\n destroy(): void {\n this._log(\"destroy\");\n this.unsubscribeSessionId?.();\n if (this.handleUnload) {\n window.removeEventListener(\"beforeunload\", this.handleUnload);\n }\n this.unsubscribeSessionId = null;\n this.handleUnload = null;\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["// Minimal PostHog interface — no posthog-js dependency needed.\n//\n// posthog-js types the `loaded` callback parameter as\n// `PostHogInterface = Omit<PostHog, 'config' | 'init'>`, which strips\n// `config` from the type even though the runtime object always has it.\n// We keep `config` optional here so users can pass either the full\n// PostHog instance or the `loaded` callback parameter without type errors.\n// At runtime we assert `config` exists — it always does.\ninterface PostHogLike {\n set_config(config: { before_send?: unknown }): void;\n onSessionId(\n callback: (\n sessionId: string,\n windowId: string | null | undefined,\n changeReason?: {\n noSessionId?: boolean;\n activityTimeout?: boolean;\n sessionPastMaximumLength?: boolean;\n },\n ) => void,\n ): () => void;\n config?: {\n token: string;\n before_send?: unknown;\n };\n}\n\n/**\n * Compatible with posthog-js BeforeSendFn / CaptureResult.\n * We use `any` for the public handler type so it's assignable to PostHog's\n * `before_send` without users needing type casts — the actual shapes evolve\n * across posthog-js versions and we only inspect `event` + `properties`.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype BeforeSendFn = (event: any) => any;\n\nconst API_HOST = \"https://api.swish.is\";\nconst PREFIX = \"[swish-recorder]\";\nconst VERSION = \"0.2.1\";\n\n/** Read posthog.config.token — always exists at runtime even when typed away. */\nfunction getToken(posthog: PostHogLike): string {\n const token = (posthog as { config?: { token?: string } }).config?.token;\n if (!token) throw new Error(`${PREFIX} PostHog instance has no config.token`);\n return token;\n}\n\n/** Read posthog.config.before_send — may be undefined. */\nfunction getBeforeSend(posthog: PostHogLike): unknown {\n return (posthog as { config?: { before_send?: unknown } }).config\n ?.before_send;\n}\n\nexport interface SwishRecorderConfig {\n /** PostHog instance from your app — token is read automatically. */\n posthog: PostHogLike;\n /** Enable console logging for debugging. */\n debug?: boolean;\n /** Override the Swish API URL. Useful for local development (e.g. \"http://localhost:8000\"). */\n apiHost?: string;\n /** @internal Skip before_send registration (already handled by beforeSendHandler). */\n _skipBeforeSend?: boolean;\n}\n\nexport class SwishRecorder {\n private currentSessionId: string | null = null;\n private unsubscribeSessionId: (() => void) | null = null;\n private handleUnload: (() => void) | null = null;\n\n private readonly apiHost: string;\n\n private constructor(private readonly config: SwishRecorderConfig) {\n this.apiHost = config.apiHost ?? API_HOST;\n }\n\n private _log(...args: unknown[]): void {\n if (this.config.debug) console.log(PREFIX, ...args);\n }\n\n private _warn(...args: unknown[]): void {\n if (this.config.debug) console.warn(PREFIX, ...args);\n }\n\n static init(config: SwishRecorderConfig): SwishRecorder {\n const instance = new SwishRecorder(config);\n instance._setup();\n return instance;\n }\n\n /**\n * Create a `before_send` handler to pass directly into PostHog's config.\n * This ensures snapshot interception is active from the very first event,\n * avoiding the race condition where PostHog starts recording from persisted\n * config before `SwishRecorder.init()` can hook in.\n *\n * Usage:\n * const swish = SwishRecorder.beforeSendHandler({ apiHost: '...' })\n * posthog.init(token, { before_send: swish.handler })\n * // later, after posthog is ready:\n * swish.bind(posthog) // starts session lifecycle tracking\n */\n static beforeSendHandler(opts?: {\n apiHost?: string;\n debug?: boolean;\n }): { handler: BeforeSendFn; bind: (posthog: PostHogLike) => SwishRecorder } {\n const apiHost = opts?.apiHost ?? API_HOST;\n const debug = opts?.debug ?? false;\n let token: string | null = null;\n let pendingSnapshots: { props: Record<string, unknown>; ts: number }[] = [];\n\n const log = (...args: unknown[]) => {\n if (debug) console.log(PREFIX, ...args);\n };\n const warn = (...args: unknown[]) => {\n if (debug) console.warn(PREFIX, ...args);\n };\n\n const sendSnapshot = (props: Record<string, unknown>, ts: number) => {\n fetch(`${apiHost}/replays/ingest`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-PostHog-Token\": token!,\n },\n body: JSON.stringify({\n session_id: props.$session_id,\n window_id: props.$window_id,\n snapshot_data: props.$snapshot_data,\n distinct_id: props.distinct_id,\n timestamp: ts,\n sdk_version: VERSION,\n }),\n })\n .then((res) => {\n if (debug) {\n if (res.ok) log(`ingest OK — ${res.status}`);\n else warn(`ingest failed — ${res.status} ${res.statusText}`);\n }\n })\n .catch((err) => warn(\"ingest error —\", err));\n };\n\n const handler: BeforeSendFn = (event) => {\n if (event?.event === \"$snapshot\") {\n const props = event.properties;\n const ts = Date.now();\n if (token) {\n sendSnapshot(props, ts);\n } else {\n // Buffer until bind() provides the token\n pendingSnapshots.push({ props, ts });\n log(`buffered snapshot (pre-bind) — ${pendingSnapshots.length} pending`);\n }\n }\n return event;\n };\n\n const bind = (posthog: PostHogLike): SwishRecorder => {\n token = getToken(posthog);\n log(`bind — token=${token.slice(0, 12)}…`);\n\n // Flush any snapshots captured before bind\n if (pendingSnapshots.length > 0) {\n log(`flushing ${pendingSnapshots.length} buffered snapshots`);\n for (const { props, ts } of pendingSnapshots) {\n sendSnapshot(props, ts);\n }\n pendingSnapshots = [];\n }\n\n return SwishRecorder.init({ posthog, apiHost, debug, _skipBeforeSend: true });\n };\n\n return { handler, bind };\n }\n\n private _sendSessionEnd(sessionId: string, reason: string): void {\n this._log(`session end — session=${sessionId} reason=${reason}`);\n navigator.sendBeacon(\n `${this.apiHost}/replays/session-end`,\n new Blob(\n [\n JSON.stringify({\n session_id: sessionId,\n reason,\n token: getToken(this.config.posthog),\n }),\n ],\n { type: \"application/json\" },\n ),\n );\n }\n\n private _ingestSnapshot(\n sessionId: string,\n windowId: string,\n snapshotData: unknown,\n distinctId: string,\n ): void {\n this._log(\n `ingesting snapshot — session=${sessionId} window=${windowId} distinct_id=${distinctId}`,\n );\n fetch(`${this.apiHost}/replays/ingest`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-PostHog-Token\": getToken(this.config.posthog),\n },\n body: JSON.stringify({\n session_id: sessionId,\n window_id: windowId,\n snapshot_data: snapshotData,\n distinct_id: distinctId,\n timestamp: Date.now(),\n sdk_version: VERSION,\n }),\n })\n .then((res) => {\n if (this.config.debug) {\n if (res.ok) {\n this._log(`ingest OK — ${res.status}`);\n } else {\n this._warn(`ingest failed — ${res.status} ${res.statusText}`);\n }\n }\n })\n .catch((err) => {\n this._warn(\"ingest error —\", err);\n });\n }\n\n private _setup(): void {\n const { posthog } = this.config;\n let token: string;\n try {\n token = getToken(posthog);\n } catch {\n this._warn(\"no PostHog token found — recording disabled\");\n return;\n }\n\n this._log(`init — token=${token.slice(0, 12)}…`);\n\n // 1. Intercept $snapshot events via before_send (non-destructive append)\n // Skip if already registered via beforeSendHandler()\n if (!this.config._skipBeforeSend) {\n const snapshotHandler: BeforeSendFn = (event) => {\n if (event?.event === \"$snapshot\") {\n const props = event.properties;\n this._ingestSnapshot(\n props.$session_id as string,\n props.$window_id as string,\n props.$snapshot_data,\n props.distinct_id as string,\n );\n } else if (this.config.debug && event) {\n this._log(`before_send passthrough — event=${event.event}`);\n }\n return event;\n };\n\n const existing = getBeforeSend(posthog);\n this._log(\n `before_send — existing handler: ${existing ? (Array.isArray(existing) ? `array(${(existing as unknown[]).length})` : \"function\") : \"none\"}`,\n );\n posthog.set_config({\n before_send: existing\n ? Array.isArray(existing)\n ? [...(existing as BeforeSendFn[]), snapshotHandler]\n : [existing as BeforeSendFn, snapshotHandler]\n : snapshotHandler,\n });\n } else {\n this._log(\"before_send — already registered via beforeSendHandler()\");\n }\n\n // 2. Session rotation signal — fires on idle timeout or max session length\n this.unsubscribeSessionId = posthog.onSessionId(\n (newSessionId, _windowId, changeReason) => {\n this._log(\n `session ID changed — new=${newSessionId} reason=${JSON.stringify(changeReason ?? null)}`,\n );\n if (this.currentSessionId && changeReason) {\n const reason = changeReason.activityTimeout\n ? \"activity_timeout\"\n : changeReason.sessionPastMaximumLength\n ? \"session_length\"\n : null;\n if (reason) this._sendSessionEnd(this.currentSessionId, reason);\n }\n this.currentSessionId = newSessionId;\n },\n );\n\n // 3. Tab close — sendBeacon survives page unload, fetch does not\n this.handleUnload = () => {\n if (this.currentSessionId)\n this._sendSessionEnd(this.currentSessionId, \"tab_close\");\n };\n window.addEventListener(\"beforeunload\", this.handleUnload);\n\n this._log(\"ready\");\n }\n\n destroy(): void {\n this._log(\"destroy\");\n this.unsubscribeSessionId?.();\n if (this.handleUnload) {\n window.removeEventListener(\"beforeunload\", this.handleUnload);\n }\n this.unsubscribeSessionId = null;\n this.handleUnload = null;\n }\n}\n"],"mappings":"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","names":[]}
|