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 CHANGED
@@ -35,25 +35,25 @@ pnpm add swish-recorder
35
35
 
36
36
  ## Usage
37
37
 
38
- ### Vanilla JS
38
+ ### Recommended: `beforeSendHandler` (zero-miss)
39
39
 
40
- Call `SwishRecorder.init` right after `posthog.init`. The project token is read automatically from the PostHog instanceno extra credentials needed.
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 (manual init)
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
- loaded: (posthog) => {
105
- SwishRecorder.init({ posthog })
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
- No separate `posthog.init` call needed — `PostHogProvider` takes care of it.
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.config.token
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.config.token
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
- const token = posthog.config.token;
96
- this._log(`init \u2014 token=${token.slice(0, 12)}\u2026`);
97
- if (!token) {
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
- const snapshotHandler = (event) => {
102
- if ((event == null ? void 0 : event.event) === "$snapshot") {
103
- const props = event.properties;
104
- this._ingestSnapshot(
105
- props.$session_id,
106
- props.$window_id,
107
- props.$snapshot_data,
108
- props.distinct_id
109
- );
110
- } else if (this.config.debug && event) {
111
- this._log(`before_send passthrough \u2014 event=${event.event}`);
112
- }
113
- return event;
114
- };
115
- const existing = posthog.config.before_send;
116
- this._log(
117
- `before_send \u2014 existing handler: ${existing ? Array.isArray(existing) ? `array(${existing.length})` : "function" : "none"}`
118
- );
119
- posthog.set_config({
120
- before_send: existing ? Array.isArray(existing) ? [...existing, snapshotHandler] : [existing, snapshotHandler] : snapshotHandler
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(
@@ -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
- readonly config: {
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
- readonly config: {
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.config.token
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.config.token
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
- const token = posthog.config.token;
72
- this._log(`init \u2014 token=${token.slice(0, 12)}\u2026`);
73
- if (!token) {
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
- const snapshotHandler = (event) => {
78
- if ((event == null ? void 0 : event.event) === "$snapshot") {
79
- const props = event.properties;
80
- this._ingestSnapshot(
81
- props.$session_id,
82
- props.$window_id,
83
- props.$snapshot_data,
84
- props.distinct_id
85
- );
86
- } else if (this.config.debug && event) {
87
- this._log(`before_send passthrough \u2014 event=${event.event}`);
88
- }
89
- return event;
90
- };
91
- const existing = posthog.config.before_send;
92
- this._log(
93
- `before_send \u2014 existing handler: ${existing ? Array.isArray(existing) ? `array(${existing.length})` : "function" : "none"}`
94
- );
95
- posthog.set_config({
96
- before_send: existing ? Array.isArray(existing) ? [...existing, snapshotHandler] : [existing, snapshotHandler] : snapshotHandler
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":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swish-recorder",
3
- "version": "0.1.6",
3
+ "version": "0.2.1",
4
4
  "description": "First-party session recording SDK for Swish",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",