mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(studio): browser canary binding + leaf subpath imports
Adds the Studio (browser) binding so a canary can span the CLI and the editor,
and fixes a bundling mistake the studio test suite caught.
## The binding
Same public API as the CLI — `isCanaryEnabled("name")` — so a call site reads
identically whether it runs in Node or the browser. Three inputs differ:
- UNIT ID: `resolveStudioDistinctId()`, which already adopts
`window.__HF_CLI_DISTINCT_ID` when the CLI launched Studio. A CLI-launched
Studio therefore lands in the SAME cohort as the CLI: a rollout spanning
render and editor is coherent for that user instead of enrolling their
terminal but not their editor. A test pins that the id is passed through
unmodified — prefixing or re-hashing it would silently break that parity.
- OVERRIDE: no `process.env` in a page, so `?hf_canary_<name>=on` mirrored
into sessionStorage. Session scope is deliberate. A URL is the right carrier
(shareable — "support: open this link"), but persisting a URL-borne override
to localStorage would let one click silently pin a browser into a cohort
forever, long after anyone remembers why. Closing the tab is the reset;
`=reset` clears it explicitly.
- EXCLUSION: `navigator.webdriver` stands in for the CLI's `is_ci`. Automated
browsers mint a fresh localStorage id per run, so they would hop cohorts
between runs — noise in the signal, nothing learned about real users. An
override still reaches them, which is how you test a canary under Playwright.
Studio's `trackEvent` now attaches `canaries` to every event, mirroring the CLI.
## The bundling fix
Importing the `@hyperframes/core` barrel into studio browser code broke two
unrelated hook test files with an esbuild TextEncoder invariant violation. The
barrel re-exports the whole core surface (parsers, lint, studio-server), so it
drags a Node-oriented dependency graph into a browser bundle — the test
failure was the symptom, the bundle bloat was the bug.
`@hyperframes/core` now exposes `./canary` and `./canary-registry`, declared in
packages/core/package-subpaths.json (the generated source of truth for exports —
hand-editing package.json is reverted by the sync script) and marked
`environments: [browser, bun, node]`. Both the studio AND cli bindings import
the leaf modules; the CLI gets the same benefit for a different reason, since
this resolves on the startup path — the reason the producer is lazily loaded.
Verified: the two hook files pass again; 269 studio files / 2982 tests, 98
core / 1433, 166 cli / 2194 green, `bun run lint` clean including the subpath
check. Fault-injection confirms both design decisions are pinned — swapping
session for local storage fails the scope test, prefixing the unit id fails the
CLI/Studio cohort-parity test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
df1521a0b6
commit
71ee156dac
@@ -0,0 +1,174 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { evaluateCanary } from "@hyperframes/core/canary";
|
||||
|
||||
// Pin the registry: real entries move as rollouts ramp, and these tests are
|
||||
// about the BINDING (does the browser supply the right three inputs?), not
|
||||
// about whichever canaries happen to be live today.
|
||||
vi.mock("@hyperframes/core/canary-registry", async () => {
|
||||
const actual = await vi.importActual<typeof import("@hyperframes/core/canary-registry")>(
|
||||
"@hyperframes/core/canary-registry",
|
||||
);
|
||||
const defs = [
|
||||
{
|
||||
name: "on-everywhere",
|
||||
percentage: 100,
|
||||
description: "",
|
||||
owner: "t",
|
||||
sunsetAfter: "2099-01-01",
|
||||
},
|
||||
{
|
||||
name: "off-everywhere",
|
||||
percentage: 0,
|
||||
description: "",
|
||||
owner: "t",
|
||||
sunsetAfter: "2099-01-01",
|
||||
},
|
||||
];
|
||||
return { ...actual, CANARIES: defs, findCanary: (n: string) => defs.find((d) => d.name === n) };
|
||||
});
|
||||
|
||||
const {
|
||||
isCanaryEnabled,
|
||||
resolveCanary,
|
||||
activeCanaryNames,
|
||||
canaryParamName,
|
||||
__resetStudioCanaryCacheForTests,
|
||||
} = await import("./canary");
|
||||
const { resolveStudioDistinctId, __resetStudioDistinctIdForTests } = await import("./distinctId");
|
||||
|
||||
function setSearch(search: string): void {
|
||||
window.history.replaceState({}, "", `/${search}`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setSearch("");
|
||||
delete window.__HF_CLI_DISTINCT_ID;
|
||||
Object.defineProperty(navigator, "webdriver", { value: false, configurable: true });
|
||||
__resetStudioCanaryCacheForTests();
|
||||
__resetStudioDistinctIdForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setSearch("");
|
||||
__resetStudioCanaryCacheForTests();
|
||||
__resetStudioDistinctIdForTests();
|
||||
});
|
||||
|
||||
describe("studio canary binding", () => {
|
||||
it("reads the percentage from the shared registry", () => {
|
||||
expect(isCanaryEnabled("on-everywhere")).toBe(true);
|
||||
expect(isCanaryEnabled("off-everywhere")).toBe(false);
|
||||
});
|
||||
|
||||
it("an unregistered name is off, not a throw — a typo must not break the editor", () => {
|
||||
expect(isCanaryEnabled("nope")).toBe(false);
|
||||
expect(resolveCanary("nope").reason).toBe("out_of_cohort");
|
||||
});
|
||||
|
||||
it("derives the query param from the canary name", () => {
|
||||
expect(canaryParamName("de-parallel-router")).toBe("hf_canary_de_parallel_router");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL override", () => {
|
||||
it("turns a canary on and off from the query string", () => {
|
||||
setSearch("?hf_canary_off_everywhere=on");
|
||||
expect(resolveCanary("off-everywhere")).toMatchObject({ enabled: true, reason: "forced_on" });
|
||||
|
||||
__resetStudioCanaryCacheForTests();
|
||||
setSearch("?hf_canary_on_everywhere=off");
|
||||
expect(resolveCanary("on-everywhere")).toMatchObject({ enabled: false, reason: "forced_off" });
|
||||
});
|
||||
|
||||
it("survives losing the query string, so in-app navigation keeps the override", () => {
|
||||
setSearch("?hf_canary_off_everywhere=on");
|
||||
expect(isCanaryEnabled("off-everywhere")).toBe(true);
|
||||
|
||||
// Navigate away from the param — a real SPA drops it constantly.
|
||||
__resetStudioCanaryCacheForTests();
|
||||
setSearch("");
|
||||
expect(isCanaryEnabled("off-everywhere")).toBe(true);
|
||||
});
|
||||
|
||||
it("is session-scoped, not persisted to localStorage", () => {
|
||||
// A URL-borne override must not silently pin a browser into a cohort
|
||||
// forever; closing the tab is the reset.
|
||||
setSearch("?hf_canary_off_everywhere=on");
|
||||
expect(isCanaryEnabled("off-everywhere")).toBe(true);
|
||||
expect(JSON.stringify(localStorage).includes("canary")).toBe(false);
|
||||
expect(sessionStorage.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("=reset clears a stored override", () => {
|
||||
setSearch("?hf_canary_off_everywhere=on");
|
||||
expect(isCanaryEnabled("off-everywhere")).toBe(true);
|
||||
|
||||
__resetStudioCanaryCacheForTests();
|
||||
setSearch("?hf_canary_off_everywhere=reset");
|
||||
expect(isCanaryEnabled("off-everywhere")).toBe(false);
|
||||
|
||||
__resetStudioCanaryCacheForTests();
|
||||
setSearch("");
|
||||
expect(isCanaryEnabled("off-everywhere")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("automated browsers", () => {
|
||||
it("are excluded from percentage enrolment", () => {
|
||||
Object.defineProperty(navigator, "webdriver", { value: true, configurable: true });
|
||||
expect(resolveCanary("on-everywhere")).toMatchObject({ enabled: false, reason: "excluded" });
|
||||
});
|
||||
|
||||
it("still honour an explicit override, so a canary can be tested under automation", () => {
|
||||
Object.defineProperty(navigator, "webdriver", { value: true, configurable: true });
|
||||
setSearch("?hf_canary_on_everywhere=on");
|
||||
expect(resolveCanary("on-everywhere")).toMatchObject({ enabled: true, reason: "forced_on" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("cohort identity", () => {
|
||||
it("buckets on the Studio distinct id unmodified — so a CLI-launched Studio shares the CLI's cohort", () => {
|
||||
// distinctId.ts adopts window.__HF_CLI_DISTINCT_ID when the CLI launched
|
||||
// Studio. This asserts the binding passes that id through untouched: if it
|
||||
// prefixed or re-hashed it, the editor would land in a different cohort
|
||||
// than the terminal for the same user, and a rollout spanning both would
|
||||
// be incoherent.
|
||||
const cliId = "db0c1f4a-b95e-4c35-90c6-1a15bd76f717";
|
||||
window.__HF_CLI_DISTINCT_ID = cliId;
|
||||
__resetStudioDistinctIdForTests();
|
||||
__resetStudioCanaryCacheForTests();
|
||||
|
||||
expect(resolveStudioDistinctId()).toBe(cliId);
|
||||
// 50% so the answer is id-dependent rather than trivially true.
|
||||
const viaBinding = resolveCanary("on-everywhere").bucket;
|
||||
const direct = evaluateCanary({
|
||||
feature: "on-everywhere",
|
||||
unitId: cliId,
|
||||
percentage: 100,
|
||||
}).bucket;
|
||||
expect(viaBinding).toBe(direct);
|
||||
});
|
||||
|
||||
it("memoizes so a decision cannot change mid-session", () => {
|
||||
expect(isCanaryEnabled("off-everywhere")).toBe(false);
|
||||
// A late override must NOT flip a component that already rendered.
|
||||
setSearch("?hf_canary_off_everywhere=on");
|
||||
expect(isCanaryEnabled("off-everywhere")).toBe(false);
|
||||
__resetStudioCanaryCacheForTests();
|
||||
expect(isCanaryEnabled("off-everywhere")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("telemetry", () => {
|
||||
it("reports enrolled canaries, undefined when none", () => {
|
||||
expect(activeCanaryNames()).toBe("on-everywhere");
|
||||
|
||||
__resetStudioCanaryCacheForTests();
|
||||
setSearch("?hf_canary_on_everywhere=off");
|
||||
expect(activeCanaryNames()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Studio (browser) binding for the shared canary registry.
|
||||
//
|
||||
// `@hyperframes/core` owns the decision and is deliberately pure — the caller
|
||||
// supplies the unit id, the override and the exclusion. This file supplies
|
||||
// those three from the browser, mirroring `packages/cli/src/telemetry/canary.ts`
|
||||
// for the CLI. The public API is deliberately identical on both surfaces:
|
||||
//
|
||||
// import { isCanaryEnabled } from "../telemetry/canary";
|
||||
// if (isCanaryEnabled("my-feature")) { ... }
|
||||
//
|
||||
// so a call site reads the same whether it runs in Node or the browser, and a
|
||||
// canary can span both.
|
||||
//
|
||||
// Three things differ from the CLI, each for a reason:
|
||||
//
|
||||
// 1. UNIT ID — `resolveStudioDistinctId()` instead of the CLI's config file.
|
||||
// That function already adopts `window.__HF_CLI_DISTINCT_ID` when the CLI
|
||||
// launched Studio, so a CLI-launched Studio lands in the SAME cohort as the
|
||||
// CLI itself: a rollout spanning render and editor is coherent for that
|
||||
// user instead of enrolling their terminal but not their editor.
|
||||
//
|
||||
// 2. OVERRIDE — there is no `process.env` in a page, so the override is a URL
|
||||
// query param mirrored into sessionStorage (see `readOverride`).
|
||||
//
|
||||
// 3. EXCLUSION — `navigator.webdriver` stands in for the CLI's `is_ci`.
|
||||
// Automated browsers mint a fresh localStorage id per run, so their ids are
|
||||
// ephemeral and they would hop cohorts between runs — noise in the rollout
|
||||
// signal, and nothing learned about real users.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Deep subpath imports, NOT the "@hyperframes/core" barrel. Studio is a
|
||||
// browser bundle, and the barrel re-exports the whole core surface (parsers,
|
||||
// lint, studio-server); pulling that in here drags a Node-oriented dependency
|
||||
// graph into the bundle. These two modules are pure and leaf.
|
||||
import { evaluateCanary, parseCanaryOverride, type CanaryDecision } from "@hyperframes/core/canary";
|
||||
import { CANARIES, findCanary } from "@hyperframes/core/canary-registry";
|
||||
import { resolveStudioDistinctId } from "./distinctId";
|
||||
import { safeSessionStorage } from "../utils/safeStorage";
|
||||
|
||||
/** `my-feature` → `hf_canary_my_feature`, the query param and storage key. */
|
||||
export function canaryParamName(name: string): string {
|
||||
return `hf_canary_${name.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`;
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = "hyperframes-studio:canary:";
|
||||
|
||||
/**
|
||||
* Resolve a manual override for one canary.
|
||||
*
|
||||
* `?hf_canary_my_feature=on` (also off/true/false/1/0/yes/no), mirrored into
|
||||
* sessionStorage so it survives in-app navigation and reloads within the tab.
|
||||
*
|
||||
* SESSION scope, not local, is the deliberate part. A URL is the right carrier
|
||||
* — it is shareable, which is what "support: open this link" and "QA: repro
|
||||
* with this on" actually need. But persisting a URL-borne override to
|
||||
* localStorage would mean one click silently pins that browser into a cohort
|
||||
* forever, long after anyone remembers why. Session scope keeps the link
|
||||
* useful and lets closing the tab be the reset.
|
||||
*
|
||||
* `?hf_canary_my_feature=reset` clears it explicitly.
|
||||
*/
|
||||
function readOverride(name: string): boolean | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const key = canaryParamName(name);
|
||||
const storageKey = `${STORAGE_PREFIX}${name}`;
|
||||
const store = safeSessionStorage();
|
||||
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
raw = new URLSearchParams(window.location.search).get(key);
|
||||
} catch {
|
||||
raw = null;
|
||||
}
|
||||
|
||||
if (raw !== null) {
|
||||
if (raw.trim().toLowerCase() === "reset") {
|
||||
store?.removeItem(storageKey);
|
||||
return undefined;
|
||||
}
|
||||
// Persist for the tab session so the override outlives the query string.
|
||||
try {
|
||||
store?.setItem(storageKey, raw);
|
||||
} catch {
|
||||
/* storage full / blocked — the param still applies to this page load */
|
||||
}
|
||||
return parseCanaryOverride(raw);
|
||||
}
|
||||
|
||||
return parseCanaryOverride(store?.getItem(storageKey) ?? undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Automated browser? The browser analog of the CLI's CI exclusion.
|
||||
* `navigator.webdriver` is set by Playwright, Puppeteer and Selenium.
|
||||
*/
|
||||
function isAutomatedBrowser(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
return navigator.webdriver === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized per page load, for the same reason the CLI memoizes per process: a
|
||||
* canary must not change its mind mid-session. A component that mounted
|
||||
* enrolled has to stay enrolled, and the telemetry has to agree with what the
|
||||
* user actually saw.
|
||||
*/
|
||||
const decisions = new Map<string, CanaryDecision>();
|
||||
|
||||
/** Test-only: drop memoized decisions so cases don't leak into each other. */
|
||||
export function __resetStudioCanaryCacheForTests(): void {
|
||||
decisions.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Full decision including the reason. An unregistered name resolves to off
|
||||
* rather than throwing — a typo in a rollout control must never break the
|
||||
* editor.
|
||||
*/
|
||||
export function resolveCanary(name: string): CanaryDecision {
|
||||
const cached = decisions.get(name);
|
||||
if (cached) return cached;
|
||||
|
||||
const definition = findCanary(name);
|
||||
const decision: CanaryDecision = definition
|
||||
? evaluateCanary({
|
||||
feature: definition.name,
|
||||
unitId: resolveStudioDistinctId(),
|
||||
percentage: definition.percentage,
|
||||
override: readOverride(definition.name),
|
||||
exclude: isAutomatedBrowser(),
|
||||
})
|
||||
: { enabled: false, reason: "out_of_cohort" };
|
||||
|
||||
decisions.set(name, decision);
|
||||
return decision;
|
||||
}
|
||||
|
||||
/** Is this canary on for this Studio install? The everyday call. */
|
||||
export function isCanaryEnabled(name: string): boolean {
|
||||
return resolveCanary(name).enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comma-joined names of the canaries this install is enrolled in, or undefined
|
||||
* when none — attached to every Studio event so any metric can be split by
|
||||
* cohort, exactly as the CLI does.
|
||||
*/
|
||||
export function activeCanaryNames(): string | undefined {
|
||||
const active = CANARIES.filter((c) => resolveCanary(c.name).enabled).map((c) => c.name);
|
||||
return active.length > 0 ? active.join(",") : undefined;
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { getAnonymousId, hasShownNotice, isOptedOut, markNoticeShown } from "./config";
|
||||
import { getBrowserSystemMeta } from "./system";
|
||||
import { activeCanaryNames } from "./canary";
|
||||
|
||||
// Write-only PostHog project key, safe to embed in client code.
|
||||
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
|
||||
@@ -73,7 +74,10 @@ export function trackEvent(event: string, properties: EventProperties = {}): voi
|
||||
const sys = getBrowserSystemMeta();
|
||||
eventQueue.push({
|
||||
event,
|
||||
properties: { ...properties, ...sys },
|
||||
// `canaries` mirrors the CLI: the cohorts this install is enrolled in, on
|
||||
// EVERY event so any metric can be split by cohort. Resolved after the
|
||||
// shouldTrack guard, so opted-out users never pay for it.
|
||||
properties: { ...properties, ...sys, canaries: activeCanaryNames() },
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user