@hexclave/js 1.0.18 → 1.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dev-tool/dev-tool-styles.js +7 -14
- package/dist/dev-tool/dev-tool-styles.js.map +1 -1
- package/dist/esm/dev-tool/dev-tool-styles.js +7 -14
- package/dist/esm/dev-tool/dev-tool-styles.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +34 -5
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js +28 -7
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/common.js +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/event-tracker.d.ts +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/event-tracker.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/event-tracker.js +15 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/event-tracker.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/event-tracker.test.js +31 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/event-tracker.test.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.js +2 -2
- package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.d.ts +6 -4
- package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.js +21 -2
- package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.test.js +64 -2
- package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.test.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/interfaces/client-app.d.ts +5 -4
- package/dist/esm/lib/hexclave-app/apps/interfaces/client-app.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/interfaces/client-app.js.map +1 -1
- package/dist/esm/lib/hexclave-app/project-configs/index.d.ts +7 -0
- package/dist/esm/lib/hexclave-app/project-configs/index.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/projects/index.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/projects/index.js +1 -1
- package/dist/esm/lib/hexclave-app/projects/index.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +34 -5
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +1 -0
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js +27 -6
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/common.js +1 -1
- package/dist/lib/hexclave-app/apps/implementations/event-tracker.d.ts +1 -0
- package/dist/lib/hexclave-app/apps/implementations/event-tracker.d.ts.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/event-tracker.js +15 -1
- package/dist/lib/hexclave-app/apps/implementations/event-tracker.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/event-tracker.test.js +31 -0
- package/dist/lib/hexclave-app/apps/implementations/event-tracker.test.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/server-app-impl.js +2 -2
- package/dist/lib/hexclave-app/apps/implementations/server-app-impl.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/session-replay.d.ts +6 -4
- package/dist/lib/hexclave-app/apps/implementations/session-replay.d.ts.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/session-replay.js +21 -1
- package/dist/lib/hexclave-app/apps/implementations/session-replay.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/session-replay.test.js +62 -0
- package/dist/lib/hexclave-app/apps/implementations/session-replay.test.js.map +1 -1
- package/dist/lib/hexclave-app/apps/interfaces/client-app.d.ts +5 -4
- package/dist/lib/hexclave-app/apps/interfaces/client-app.d.ts.map +1 -1
- package/dist/lib/hexclave-app/apps/interfaces/client-app.js.map +1 -1
- package/dist/lib/hexclave-app/project-configs/index.d.ts +7 -0
- package/dist/lib/hexclave-app/project-configs/index.d.ts.map +1 -1
- package/dist/lib/hexclave-app/projects/index.d.ts.map +1 -1
- package/dist/lib/hexclave-app/projects/index.js +1 -1
- package/dist/lib/hexclave-app/projects/index.js.map +1 -1
- package/package.json +2 -2
- package/src/dev-tool/dev-tool-styles.ts +7 -14
- package/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts +36 -0
- package/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +47 -7
- package/src/lib/hexclave-app/apps/implementations/event-tracker.test.ts +41 -0
- package/src/lib/hexclave-app/apps/implementations/event-tracker.ts +16 -0
- package/src/lib/hexclave-app/apps/implementations/server-app-impl.ts +2 -2
- package/src/lib/hexclave-app/apps/implementations/session-replay.test.ts +85 -2
- package/src/lib/hexclave-app/apps/implementations/session-replay.ts +24 -3
- package/src/lib/hexclave-app/apps/interfaces/client-app.ts +5 -4
- package/src/lib/hexclave-app/project-configs/index.ts +8 -0
- package/src/lib/hexclave-app/projects/index.ts +13 -11
|
@@ -65,7 +65,7 @@ import { EventTracker } from "./event-tracker";
|
|
|
65
65
|
import type { CrossDomainHandoffParams } from "./redirect-page-urls";
|
|
66
66
|
import { crossDomainAuthQueryParams, getCrossDomainHandoffParamsFromCurrentUrl, planRedirectToHandler } from "./redirect-page-urls";
|
|
67
67
|
import { subscribeSessionRefresh } from "./session-refresh-subscription";
|
|
68
|
-
import { AnalyticsOptions, SessionRecorder, analyticsOptionsFromJson, analyticsOptionsToJson } from "./session-replay";
|
|
68
|
+
import { AnalyticsOptions, SessionRecorder, analyticsOptionsFromJson, analyticsOptionsToJson, getSessionReplayOptions } from "./session-replay";
|
|
69
69
|
|
|
70
70
|
import { mountClickmapOverlay } from "../../../../clickmap";
|
|
71
71
|
import { mountDevTool } from "../../../../dev-tool";
|
|
@@ -84,6 +84,38 @@ const nestedCrossDomainAuthQueryParams = {
|
|
|
84
84
|
afterCallbackRedirectUrl: "after_callback_redirect_url",
|
|
85
85
|
} as const;
|
|
86
86
|
|
|
87
|
+
function getRedirectHelperInstruction(handlerName: string): string {
|
|
88
|
+
if (handlerName === "handler") {
|
|
89
|
+
return "Use a page-specific redirect helper such as app.redirectToSignIn() instead.";
|
|
90
|
+
}
|
|
91
|
+
const redirectMethodName = `redirectTo${handlerName.slice(0, 1).toUpperCase()}${handlerName.slice(1)}`;
|
|
92
|
+
return `Use app.${redirectMethodName}() instead.`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function createUrlsForPublicAccess(options: {
|
|
96
|
+
urls: ResolvedHandlerUrls,
|
|
97
|
+
projectId: string,
|
|
98
|
+
}): Readonly<ResolvedHandlerUrls> {
|
|
99
|
+
const hostedUrlNames = new Set(
|
|
100
|
+
Object.entries(options.urls)
|
|
101
|
+
.filter(([, url]) => isHostedHandlerUrlForProject({ url, projectId: options.projectId }))
|
|
102
|
+
.map(([handlerName]) => handlerName),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
return new Proxy(options.urls, {
|
|
106
|
+
get(target, property, receiver) {
|
|
107
|
+
if (typeof property === "string" && hostedUrlNames.has(property)) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`app.urls.${property} cannot be used when this app is configured to use hosted components. ` +
|
|
110
|
+
"`app.urls` is static and does not include the runtime redirect-back, cross-domain auth, or sign-out state required by hosted components. " +
|
|
111
|
+
getRedirectHelperInstruction(property),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return Reflect.get(target, property, receiver);
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
87
119
|
const oauthCallbackResponseQueryParams = ["code", "state", "error", "error_description", "errorCode", "message", "details"] as const;
|
|
88
120
|
|
|
89
121
|
const allClientApps = new Map<string, [checkString: string | undefined, app: StackClientApp<any, any>]>();
|
|
@@ -629,13 +661,14 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
629
661
|
|
|
630
662
|
const analyticsEnabled = this._analyticsOptions?.enabled !== false;
|
|
631
663
|
|
|
632
|
-
|
|
664
|
+
const sessionReplayOptions = getSessionReplayOptions(this._analyticsOptions);
|
|
665
|
+
if (analyticsEnabled && isBrowserLike() && this._hasPersistentTokenStore() && sessionReplayOptions.enabled) {
|
|
633
666
|
this._sessionRecorder = new SessionRecorder({
|
|
634
667
|
projectId: this.projectId,
|
|
635
668
|
sendBatch: async (body, opts) => {
|
|
636
669
|
return await this._interface.sendSessionReplayBatch(body, await getAnalyticsSession(), opts);
|
|
637
670
|
},
|
|
638
|
-
},
|
|
671
|
+
}, sessionReplayOptions);
|
|
639
672
|
this._sessionRecorder.start();
|
|
640
673
|
}
|
|
641
674
|
|
|
@@ -1629,7 +1662,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
1629
1662
|
teamId: crud.id,
|
|
1630
1663
|
email: options.email,
|
|
1631
1664
|
session,
|
|
1632
|
-
callbackUrl: options.callbackUrl ?? constructRedirectUrl(app.
|
|
1665
|
+
callbackUrl: options.callbackUrl ?? constructRedirectUrl(app._getUrls().teamInvitation, "callbackUrl"),
|
|
1633
1666
|
});
|
|
1634
1667
|
await app._teamInvitationsCache.refresh([session, crud.id]);
|
|
1635
1668
|
},
|
|
@@ -1681,7 +1714,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
1681
1714
|
async sendVerificationEmail(options?: { callbackUrl?: string }) {
|
|
1682
1715
|
await app._interface.sendCurrentUserContactChannelVerificationEmail(
|
|
1683
1716
|
crud.id,
|
|
1684
|
-
options?.callbackUrl || constructRedirectUrl(app.
|
|
1717
|
+
options?.callbackUrl || constructRedirectUrl(app._getUrls().emailVerification, "callbackUrl"),
|
|
1685
1718
|
session
|
|
1686
1719
|
);
|
|
1687
1720
|
},
|
|
@@ -1958,7 +1991,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
1958
1991
|
{
|
|
1959
1992
|
provider,
|
|
1960
1993
|
redirectUrl: app._getOAuthCallbackRedirectUri(),
|
|
1961
|
-
errorRedirectUrl: app.
|
|
1994
|
+
errorRedirectUrl: app._getUrls().error,
|
|
1962
1995
|
providerScope: mergeScopeStrings(scopeString, (app._oauthScopesOnSignIn[provider as ProviderType] ?? []).join(" ")),
|
|
1963
1996
|
},
|
|
1964
1997
|
session,
|
|
@@ -2045,7 +2078,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
2045
2078
|
}
|
|
2046
2079
|
return await app._interface.sendVerificationEmail(
|
|
2047
2080
|
crud.primary_email,
|
|
2048
|
-
options?.callbackUrl ?? constructRedirectUrl(app.
|
|
2081
|
+
options?.callbackUrl ?? constructRedirectUrl(app._getUrls().emailVerification, "callbackUrl"),
|
|
2049
2082
|
session
|
|
2050
2083
|
);
|
|
2051
2084
|
},
|
|
@@ -2436,6 +2469,13 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
2436
2469
|
}
|
|
2437
2470
|
|
|
2438
2471
|
get urls(): Readonly<ResolvedHandlerUrls> {
|
|
2472
|
+
return createUrlsForPublicAccess({
|
|
2473
|
+
urls: this._getUrls(),
|
|
2474
|
+
projectId: this.projectId,
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
protected _getUrls(): Readonly<ResolvedHandlerUrls> {
|
|
2439
2479
|
return getUrls(this._urlOptions, { projectId: this.projectId });
|
|
2440
2480
|
}
|
|
2441
2481
|
|
|
@@ -389,4 +389,45 @@ describe("EventTracker", () => {
|
|
|
389
389
|
tracker.stop();
|
|
390
390
|
}
|
|
391
391
|
});
|
|
392
|
+
|
|
393
|
+
it("silently disables when server responds with ANALYTICS_NOT_ENABLED", async () => {
|
|
394
|
+
vi.useFakeTimers();
|
|
395
|
+
document.body.innerHTML = "<button>Click me</button>";
|
|
396
|
+
|
|
397
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
398
|
+
const sentBodies: string[] = [];
|
|
399
|
+
const tracker = new EventTracker({
|
|
400
|
+
projectId: "internal",
|
|
401
|
+
sendBatch: async (body) => {
|
|
402
|
+
sentBodies.push(body);
|
|
403
|
+
return Result.ok(new Response(
|
|
404
|
+
JSON.stringify({ code: "ANALYTICS_NOT_ENABLED", error: "Analytics is not enabled for this project." }),
|
|
405
|
+
{
|
|
406
|
+
status: 400,
|
|
407
|
+
headers: { "x-stack-known-error": "ANALYTICS_NOT_ENABLED" },
|
|
408
|
+
},
|
|
409
|
+
));
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
tracker.start();
|
|
415
|
+
|
|
416
|
+
// First flush sends the initial page-view event; server rejects it.
|
|
417
|
+
await advancePastFlush();
|
|
418
|
+
expect(sentBodies).toHaveLength(1);
|
|
419
|
+
|
|
420
|
+
// No console.warn should have been emitted.
|
|
421
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
422
|
+
|
|
423
|
+
// After disabling, new events should not accumulate or trigger further
|
|
424
|
+
// flushes.
|
|
425
|
+
document.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
426
|
+
await advancePastFlush();
|
|
427
|
+
expect(sentBodies).toHaveLength(1);
|
|
428
|
+
} finally {
|
|
429
|
+
tracker.stop();
|
|
430
|
+
warnSpy.mockRestore();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
392
433
|
});
|
|
@@ -112,6 +112,7 @@ type TrackedEvent = {
|
|
|
112
112
|
export class EventTracker {
|
|
113
113
|
private _started = false;
|
|
114
114
|
private _cancelled = false;
|
|
115
|
+
private _disabled = false;
|
|
115
116
|
private _detachListeners: (() => void) | null = null;
|
|
116
117
|
private _flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
117
118
|
private _events: TrackedEvent[] = [];
|
|
@@ -177,6 +178,7 @@ export class EventTracker {
|
|
|
177
178
|
}
|
|
178
179
|
|
|
179
180
|
private _pushEvent(event: TrackedEvent) {
|
|
181
|
+
if (this._disabled) return;
|
|
180
182
|
this._events.push(event);
|
|
181
183
|
this._approxBytes += JSON.stringify(event).length;
|
|
182
184
|
if (this._events.length >= MAX_EVENTS_PER_BATCH || this._approxBytes >= MAX_APPROX_BYTES_PER_BATCH) {
|
|
@@ -468,6 +470,8 @@ export class EventTracker {
|
|
|
468
470
|
}
|
|
469
471
|
|
|
470
472
|
private async _flush(options: { keepalive: boolean }) {
|
|
473
|
+
if (this._disabled) return;
|
|
474
|
+
|
|
471
475
|
// A keepalive flush means the page is unloading — a click still awaiting
|
|
472
476
|
// dead-click classification led to that unload, so it is alive by
|
|
473
477
|
// definition and ships unmarked.
|
|
@@ -504,6 +508,18 @@ export class EventTracker {
|
|
|
504
508
|
}
|
|
505
509
|
|
|
506
510
|
if (!res.data.ok) {
|
|
511
|
+
// If the server tells us analytics is not enabled for this project,
|
|
512
|
+
// silently disable the tracker — no point retrying or warning the user.
|
|
513
|
+
const knownError = res.data.headers.get("x-hexclave-known-error") ?? res.data.headers.get("x-stack-known-error");
|
|
514
|
+
if (knownError === "ANALYTICS_NOT_ENABLED") {
|
|
515
|
+
this._disabled = true;
|
|
516
|
+
if (this._flushTimer !== null) {
|
|
517
|
+
clearInterval(this._flushTimer);
|
|
518
|
+
this._flushTimer = null;
|
|
519
|
+
}
|
|
520
|
+
this._teardown();
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
507
523
|
console.warn("EventTracker flush failed:", res.data.status, await res.data.text());
|
|
508
524
|
}
|
|
509
525
|
}
|
|
@@ -356,7 +356,7 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
356
356
|
isPrimary: crud.is_primary,
|
|
357
357
|
usedForAuth: crud.used_for_auth,
|
|
358
358
|
async sendVerificationEmail(options?: { callbackUrl?: string }) {
|
|
359
|
-
await app._interface.sendServerContactChannelVerificationEmail(userId, crud.id, options?.callbackUrl ?? constructRedirectUrl(app.
|
|
359
|
+
await app._interface.sendServerContactChannelVerificationEmail(userId, crud.id, options?.callbackUrl ?? constructRedirectUrl(app._getUrls().emailVerification, "callbackUrl"));
|
|
360
360
|
},
|
|
361
361
|
async update(data: ServerContactChannelUpdateOptions) {
|
|
362
362
|
await app._interface.updateServerContactChannel(userId, crud.id, serverContactChannelUpdateOptionsToCrud(data));
|
|
@@ -922,7 +922,7 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
922
922
|
await app._interface.sendServerTeamInvitation({
|
|
923
923
|
teamId: crud.id,
|
|
924
924
|
email: options.email,
|
|
925
|
-
callbackUrl: options.callbackUrl ?? constructRedirectUrl(app.
|
|
925
|
+
callbackUrl: options.callbackUrl ?? constructRedirectUrl(app._getUrls().teamInvitation, "callbackUrl"),
|
|
926
926
|
});
|
|
927
927
|
await app._serverTeamInvitationsCache.refresh([crud.id]);
|
|
928
928
|
},
|
|
@@ -2,8 +2,23 @@
|
|
|
2
2
|
//===========================================
|
|
3
3
|
// THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
|
|
4
4
|
//===========================================
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
// @vitest-environment jsdom
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it, vi } from "vitest";
|
|
8
|
+
import { Result } from "@hexclave/shared/dist/utils/results";
|
|
9
|
+
import { analyticsOptionsFromJson, analyticsOptionsToJson, getSessionReplayOptions, SessionRecorder } from "./session-replay";
|
|
10
|
+
|
|
11
|
+
describe("session replay options", () => {
|
|
12
|
+
it("enables replays by default", () => {
|
|
13
|
+
expect(getSessionReplayOptions(undefined).enabled).toBe(true);
|
|
14
|
+
expect(getSessionReplayOptions({}).enabled).toBe(true);
|
|
15
|
+
expect(getSessionReplayOptions({ replays: {} }).enabled).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("preserves explicit replay opt-out", () => {
|
|
19
|
+
expect(getSessionReplayOptions({ replays: { enabled: false } }).enabled).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
7
22
|
|
|
8
23
|
describe("analytics option JSON conversion", () => {
|
|
9
24
|
it("preserves top-level analytics options when serializing replay block classes", () => {
|
|
@@ -31,3 +46,71 @@ describe("analytics option JSON conversion", () => {
|
|
|
31
46
|
expect(roundTripped?.replays?.blockClass).toEqual(/stack-sensitive/u);
|
|
32
47
|
});
|
|
33
48
|
});
|
|
49
|
+
|
|
50
|
+
describe("SessionRecorder flush", () => {
|
|
51
|
+
it("silently disables when server responds with ANALYTICS_NOT_ENABLED", async () => {
|
|
52
|
+
vi.useFakeTimers();
|
|
53
|
+
|
|
54
|
+
// Seed localStorage with a valid session so _flush doesn't fail on getOrRotateSession
|
|
55
|
+
const storageKey = `hexclave:session-replay:v1:test-project`;
|
|
56
|
+
localStorage.setItem(storageKey, JSON.stringify({
|
|
57
|
+
session_id: "test-session",
|
|
58
|
+
created_at_ms: Date.now(),
|
|
59
|
+
last_activity_ms: Date.now(),
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
const sentBodies: string[] = [];
|
|
63
|
+
const recorder = new SessionRecorder(
|
|
64
|
+
{
|
|
65
|
+
projectId: "test-project",
|
|
66
|
+
sendBatch: async (body) => {
|
|
67
|
+
sentBodies.push(body);
|
|
68
|
+
return Result.ok(new Response(
|
|
69
|
+
JSON.stringify({ code: "ANALYTICS_NOT_ENABLED", error: "Analytics is not enabled for this project." }),
|
|
70
|
+
{
|
|
71
|
+
status: 400,
|
|
72
|
+
headers: { "x-stack-known-error": "ANALYTICS_NOT_ENABLED" },
|
|
73
|
+
},
|
|
74
|
+
));
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{},
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
// Inject an event directly into the recorder's buffer to test flush behavior
|
|
84
|
+
// without needing rrweb. We access private fields for testing purposes.
|
|
85
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
86
|
+
(recorder as any)._events = [{ type: 2, timestamp: Date.now(), data: {} }];
|
|
87
|
+
|
|
88
|
+
// Manually trigger a tick (which calls _flush)
|
|
89
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
|
|
90
|
+
(recorder as any)._tick();
|
|
91
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
92
|
+
|
|
93
|
+
// One batch should have been sent
|
|
94
|
+
expect(sentBodies).toHaveLength(1);
|
|
95
|
+
|
|
96
|
+
// No console.warn about "SessionRecorder flush failed" should have been emitted
|
|
97
|
+
const flushWarnings = warnSpy.mock.calls.filter(
|
|
98
|
+
(args) => typeof args[0] === "string" && args[0].includes("SessionRecorder")
|
|
99
|
+
);
|
|
100
|
+
expect(flushWarnings).toHaveLength(0);
|
|
101
|
+
|
|
102
|
+
// After disabling, pushing new events and triggering another tick should not send
|
|
103
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
104
|
+
(recorder as any)._events = [{ type: 3, timestamp: Date.now(), data: {} }];
|
|
105
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
|
|
106
|
+
(recorder as any)._tick();
|
|
107
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
108
|
+
expect(sentBodies).toHaveLength(1);
|
|
109
|
+
} finally {
|
|
110
|
+
recorder.stop();
|
|
111
|
+
warnSpy.mockRestore();
|
|
112
|
+
localStorage.removeItem(storageKey);
|
|
113
|
+
vi.useRealTimers();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -11,7 +11,7 @@ export type AnalyticsReplayOptions = {
|
|
|
11
11
|
/**
|
|
12
12
|
* Whether session replays are enabled.
|
|
13
13
|
*
|
|
14
|
-
* @default
|
|
14
|
+
* @default true
|
|
15
15
|
*/
|
|
16
16
|
enabled?: boolean,
|
|
17
17
|
/**
|
|
@@ -44,12 +44,19 @@ export type AnalyticsOptions = {
|
|
|
44
44
|
*/
|
|
45
45
|
enabled?: boolean,
|
|
46
46
|
/**
|
|
47
|
-
* Options for session replay recording. Replays are
|
|
48
|
-
* set `enabled:
|
|
47
|
+
* Options for session replay recording. Replays are enabled by default;
|
|
48
|
+
* set `enabled: false` to opt out.
|
|
49
49
|
*/
|
|
50
50
|
replays?: AnalyticsReplayOptions,
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
export function getSessionReplayOptions(analyticsOptions: AnalyticsOptions | undefined): AnalyticsReplayOptions {
|
|
54
|
+
return {
|
|
55
|
+
...analyticsOptions?.replays,
|
|
56
|
+
enabled: analyticsOptions?.replays?.enabled ?? true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
53
60
|
/**
|
|
54
61
|
* Converts AnalyticsOptions to a JSON-safe representation.
|
|
55
62
|
* RegExp blockClass values are serialized as `{ __regexp, __flags }` objects.
|
|
@@ -161,6 +168,7 @@ export type SessionRecorderDeps = {
|
|
|
161
168
|
export class SessionRecorder {
|
|
162
169
|
private _started = false;
|
|
163
170
|
private _cancelled = false;
|
|
171
|
+
private _disabled = false;
|
|
164
172
|
private _stopRecording: (() => void) | null = null;
|
|
165
173
|
private _detachListeners: (() => void) | null = null;
|
|
166
174
|
private _flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
@@ -228,6 +236,7 @@ export class SessionRecorder {
|
|
|
228
236
|
}
|
|
229
237
|
|
|
230
238
|
private async _flush(options: { keepalive: boolean }) {
|
|
239
|
+
if (this._disabled) return;
|
|
231
240
|
if (this._events.length === 0) return;
|
|
232
241
|
// Prevent concurrent in-flight HTTP requests. When a flush is already
|
|
233
242
|
// in-flight, a second batch could race on the server (both call
|
|
@@ -265,6 +274,18 @@ export class SessionRecorder {
|
|
|
265
274
|
}
|
|
266
275
|
|
|
267
276
|
if (!res.data.ok) {
|
|
277
|
+
// If the server tells us analytics is not enabled for this project,
|
|
278
|
+
// silently disable the recorder — no point retrying or warning the user.
|
|
279
|
+
const knownError = res.data.headers.get("x-hexclave-known-error") ?? res.data.headers.get("x-stack-known-error");
|
|
280
|
+
if (knownError === "ANALYTICS_NOT_ENABLED") {
|
|
281
|
+
this._disabled = true;
|
|
282
|
+
if (this._flushTimer !== null) {
|
|
283
|
+
clearInterval(this._flushTimer);
|
|
284
|
+
this._flushTimer = null;
|
|
285
|
+
}
|
|
286
|
+
this._stopCurrentRecording();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
268
289
|
captureWarning("SessionRecorder.flush", new Error(`SessionRecorder flush failed: ${res.data.status} ${await res.data.text()}`));
|
|
269
290
|
}
|
|
270
291
|
} finally {
|
|
@@ -40,8 +40,8 @@ export type StackClientAppConstructorOptions<HasTokenStore extends boolean, Proj
|
|
|
40
40
|
noAutomaticPrefetch?: boolean,
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* Options for analytics and session recording. Replays are
|
|
44
|
-
* set `{ replays: { enabled:
|
|
43
|
+
* Options for analytics and session recording. Replays are enabled by default;
|
|
44
|
+
* set `{ replays: { enabled: false } }` to opt out.
|
|
45
45
|
*/
|
|
46
46
|
analytics?: AnalyticsOptions,
|
|
47
47
|
} & (
|
|
@@ -68,8 +68,9 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
|
|
|
68
68
|
readonly version: string,
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
* @deprecated `app.urls` is static and does not include runtime redirect-back
|
|
72
|
-
*
|
|
71
|
+
* @deprecated Do not use `app.urls` for navigation. It is static and does not include runtime redirect-back,
|
|
72
|
+
* cross-domain auth, or sign-out state. Use the matching `redirectToXyz()` method instead, for example
|
|
73
|
+
* `redirectToSignIn()`, `redirectToSignUp()`, `redirectToSignOut()`, or `redirectToAccountSettings()`.
|
|
73
74
|
*/
|
|
74
75
|
readonly urls: Readonly<ResolvedHandlerUrls>,
|
|
75
76
|
|
|
@@ -76,6 +76,14 @@ export type AdminOAuthProviderConfig = {
|
|
|
76
76
|
microsoftTenantId?: string,
|
|
77
77
|
appleBundleIds?: string[],
|
|
78
78
|
}
|
|
79
|
+
| {
|
|
80
|
+
type: 'custom_oidc',
|
|
81
|
+
clientId: string,
|
|
82
|
+
clientSecret: string,
|
|
83
|
+
issuerUrl: string,
|
|
84
|
+
scope?: string,
|
|
85
|
+
displayName?: string,
|
|
86
|
+
}
|
|
79
87
|
) & OAuthProviderConfig;
|
|
80
88
|
|
|
81
89
|
export type AdminProjectConfigUpdateOptions = {
|
|
@@ -176,17 +176,19 @@ export function adminProjectUpdateOptionsToCrud(options: AdminProjectUpdateOptio
|
|
|
176
176
|
domain: d.domain,
|
|
177
177
|
handler_path: d.handlerPath
|
|
178
178
|
})),
|
|
179
|
-
oauth_providers: options.config?.oauthProviders
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
179
|
+
oauth_providers: options.config?.oauthProviders
|
|
180
|
+
?.filter((p): p is Exclude<typeof p, { type: 'custom_oidc' }> => p.type !== 'custom_oidc')
|
|
181
|
+
.map((p) => ({
|
|
182
|
+
id: p.id as any,
|
|
183
|
+
type: p.type,
|
|
184
|
+
...(p.type === 'standard' && {
|
|
185
|
+
client_id: p.clientId,
|
|
186
|
+
client_secret: p.clientSecret,
|
|
187
|
+
facebook_config_id: p.facebookConfigId,
|
|
188
|
+
microsoft_tenant_id: p.microsoftTenantId,
|
|
189
|
+
apple_bundle_ids: p.appleBundleIds,
|
|
190
|
+
}),
|
|
191
|
+
})),
|
|
190
192
|
email_config: options.config?.emailConfig && (
|
|
191
193
|
options.config.emailConfig.type === 'shared' ? {
|
|
192
194
|
type: 'shared',
|