@sparkletree/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/dist/analytics.d.ts +75 -0
- package/dist/analytics.d.ts.map +1 -0
- package/dist/analytics.js +170 -0
- package/dist/analytics.js.map +1 -0
- package/dist/audio.d.ts +85 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +465 -0
- package/dist/audio.js.map +1 -0
- package/dist/client.d.ts +79 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +251 -0
- package/dist/client.js.map +1 -0
- package/dist/context.d.ts +104 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +277 -0
- package/dist/context.js.map +1 -0
- package/dist/fragments.d.ts +108 -0
- package/dist/fragments.d.ts.map +1 -0
- package/dist/fragments.js +223 -0
- package/dist/fragments.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +70 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +97 -0
- package/dist/protocol.js.map +1 -0
- package/dist/publishableKey.d.ts +38 -0
- package/dist/publishableKey.d.ts.map +1 -0
- package/dist/publishableKey.js +69 -0
- package/dist/publishableKey.js.map +1 -0
- package/dist/safeUrl.d.ts +56 -0
- package/dist/safeUrl.d.ts.map +1 -0
- package/dist/safeUrl.js +117 -0
- package/dist/safeUrl.js.map +1 -0
- package/dist/sse.d.ts +51 -0
- package/dist/sse.d.ts.map +1 -0
- package/dist/sse.js +137 -0
- package/dist/sse.js.map +1 -0
- package/dist/state.d.ts +321 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +594 -0
- package/dist/state.js.map +1 -0
- package/dist/text.d.ts +10 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +29 -0
- package/dist/text.js.map +1 -0
- package/dist/theme.d.ts +52 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +88 -0
- package/dist/theme.js.map +1 -0
- package/dist/trust.d.ts +52 -0
- package/dist/trust.d.ts.map +1 -0
- package/dist/trust.js +95 -0
- package/dist/trust.js.map +1 -0
- package/dist/variant.d.ts +117 -0
- package/dist/variant.d.ts.map +1 -0
- package/dist/variant.js +167 -0
- package/dist/variant.js.map +1 -0
- package/dist/wire.d.ts +564 -0
- package/dist/wire.d.ts.map +1 -0
- package/dist/wire.js +133 -0
- package/dist/wire.js.map +1 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SparkleTree
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# @sparkletree/core
|
|
2
|
+
|
|
3
|
+
The framework-agnostic half of the SparkleTree SDK. Wire protocol, copy
|
|
4
|
+
choreography, theme, trust chrome, analytics, variant pinning, audio.
|
|
5
|
+
|
|
6
|
+
**This is the only thing that speaks the wire.** If you are building a React
|
|
7
|
+
app, install [`@sparkletree/react`](https://www.npmjs.com/package/@sparkletree/react)
|
|
8
|
+
instead — it depends on this. Reach for `core` directly when you are wiring up
|
|
9
|
+
Vue, Svelte, Solid, or plain DOM.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @sparkletree/core
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## The shortest useful example
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { openStream, decideChrome } from "@sparkletree/core";
|
|
19
|
+
|
|
20
|
+
const handle = openStream({
|
|
21
|
+
target: {
|
|
22
|
+
apiBase: "https://your-embed-api.example",
|
|
23
|
+
organizationId: "org_123",
|
|
24
|
+
campaignId: "camp_123",
|
|
25
|
+
island: "hero",
|
|
26
|
+
},
|
|
27
|
+
// Required. This is what the visitor reads if the network is slow, the
|
|
28
|
+
// campaign is paused, or generation fails. Write it as the only version.
|
|
29
|
+
initial: { headline: "Software that meets the moment" },
|
|
30
|
+
// True when that copy is ALREADY on screen (SSR, or carried across a
|
|
31
|
+
// navigation). It changes the choreography: copy a reader can see is never
|
|
32
|
+
// typed over.
|
|
33
|
+
initialIsOnScreen: false,
|
|
34
|
+
onState: (state) => {
|
|
35
|
+
const { marked } = decideChrome(state.contentSource, false);
|
|
36
|
+
render(state.fields.headline.text, marked);
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const final = await handle.finished;
|
|
41
|
+
handle.close();
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`openStream` returns `{ choreographer, finished, close }`. `choreographer.getState()`
|
|
45
|
+
is the current state; `finished` resolves with the terminal state; `close()`
|
|
46
|
+
aborts the request and releases timers.
|
|
47
|
+
|
|
48
|
+
## What you get
|
|
49
|
+
|
|
50
|
+
| Export | |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `openStream` | The client. Opens the SSE stream and drives the choreographer. |
|
|
53
|
+
| `fetchContent` | Fetches `/content`. **Nothing calls it** — `openStream` does not, and the React path does not. Exported for consumers who want a first-paint fetch of their own. |
|
|
54
|
+
| `CopyChoreographer`, `COPY_FIELDS`, `COPY_HOLD_MS` | The state machine, if you want to drive it yourself. |
|
|
55
|
+
| `SseParser`, `readSseStream` | SSE parsing, usable standalone. |
|
|
56
|
+
| `decideChrome`, `viewerPrefersStandard`, `setViewerPrefersStandard`, `STANDARD_MODE_KEY` | Trust chrome decisions. |
|
|
57
|
+
| `applyTheme`, `themeStyle`, `tokenName`, `tokenSource`, `TOKEN_PREFIX` | Theme tokens. |
|
|
58
|
+
| `track`, `ImpressionRecorder`, `observeVisibility`, `sessionId` | Analytics. |
|
|
59
|
+
| `pageVariants`, `VariantRegistry`, `RESERVATION_TIMEOUT_MS` | Variant pinning — one page view, one variant. |
|
|
60
|
+
| `mountAudio`, `activePlayer`, `destroyActiveAudio` | Audio engine (no chrome). |
|
|
61
|
+
| `buildStreamUrl`, `buildContentUrl`, `negotiatedProtocol`, `PROTOCOL_*`, `SDK_PARAM`, `VARIANT_PARAM` | Protocol. |
|
|
62
|
+
|
|
63
|
+
## Copy state
|
|
64
|
+
|
|
65
|
+
`state.fields.{greeting,headline,body,cta}` each carry
|
|
66
|
+
`{ text, source, mode, typing, settled }`, already choreographed — you render
|
|
67
|
+
`text` and honour `typing`/`settled` for presentation only. Alongside them:
|
|
68
|
+
`phase`, `error`, `rewrites`, `contentSource`, `degraded`, `variantId`,
|
|
69
|
+
`theme`, `layout`, `backgroundImage`, `videoUrl`, `sequence`, `ctaStyle`,
|
|
70
|
+
`ctaAction`, `meta`.
|
|
71
|
+
|
|
72
|
+
`phase` moves `idle → holding? → live → settled | failed`. Nothing throws and
|
|
73
|
+
nothing blanks: on `failed`, whatever was painted stays painted and undelivered
|
|
74
|
+
fields fall back to your `initial` text.
|
|
75
|
+
|
|
76
|
+
## The rules this enforces for you
|
|
77
|
+
|
|
78
|
+
1. **First paint is real copy.** No skeleton, and no option for one.
|
|
79
|
+
2. **Copy changes at most once.** Text a reader may be mid-sentence on is never
|
|
80
|
+
wiped and retyped. Deltas aimed at copy already on screen buffer off-canvas
|
|
81
|
+
and land once, composed.
|
|
82
|
+
3. **The server declares delivery mode**; the client never guesses from timing.
|
|
83
|
+
4. **Adapted copy is marked.** `decideChrome` returns `marked` for `generated`
|
|
84
|
+
and `cached`, never for `static`. Marking authored copy as adapted is a
|
|
85
|
+
worse failure than not marking adapted copy.
|
|
86
|
+
|
|
87
|
+
Full documentation, including the React quickstart and the reasoning behind
|
|
88
|
+
each rule, is in the [SDK README](https://gitlab.com/sparkletree/sparkletree-sdk/-/blob/main/README.md).
|
|
89
|
+
|
|
90
|
+
## Requirements
|
|
91
|
+
|
|
92
|
+
ES2022, **ESM only**, Node >= 18. `fetch`, `ReadableStream` and
|
|
93
|
+
`AbortController` must exist — inject `fetchImpl` if you need to substitute
|
|
94
|
+
them. No dependencies.
|
|
95
|
+
|
|
96
|
+
There is no `require` export condition, which is worth knowing in two places:
|
|
97
|
+
|
|
98
|
+
- **From CommonJS**, `require("@sparkletree/core")` fails on Node 18 with
|
|
99
|
+
`ERR_REQUIRE_ESM`. Use `await import("@sparkletree/core")`. (Node 26 loads it
|
|
100
|
+
either way, so this depends on your runtime, not your code.)
|
|
101
|
+
- **In TypeScript**, `moduleResolution: "bundler"` resolves cleanly. So does
|
|
102
|
+
`"node16"`/`"nodenext"` *if your own package.json has `"type": "module"`*.
|
|
103
|
+
Without it you get `TS1479` telling you to use a dynamic import — accurate,
|
|
104
|
+
not a false positive, and the most likely way a Node backend meets this
|
|
105
|
+
package.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analytics: session, impression, click.
|
|
3
|
+
*
|
|
4
|
+
* Impression ownership (plan v1.1 §3.4) is the load-bearing idea. The SDK
|
|
5
|
+
* renders inside the customer's page, so it is the only party that knows an
|
|
6
|
+
* impression actually happened — the server knows only that bytes were
|
|
7
|
+
* requested, which is not the same thing and is spectacularly not the same
|
|
8
|
+
* thing behind a CDN. So every SDK request carries `sdk=1`, the server
|
|
9
|
+
* suppresses its own impression, and this module owns the count.
|
|
10
|
+
*
|
|
11
|
+
* Which means the rules here are not conveniences, they are the integrity of
|
|
12
|
+
* CVG:
|
|
13
|
+
*
|
|
14
|
+
* - ONE impression per island per page view. Not per render, not per state
|
|
15
|
+
* change, not per React strict-mode double-invoke.
|
|
16
|
+
* - VISIBILITY, not mount. A component rendered below the fold, or in a
|
|
17
|
+
* background tab, has not been seen.
|
|
18
|
+
* - the impression carries what the viewer was actually shown, including
|
|
19
|
+
* whether the trust chrome was on.
|
|
20
|
+
*/
|
|
21
|
+
import type { ContentSource } from "./wire.js";
|
|
22
|
+
export type EventType = "impression" | "click" | "conversion" | "custom";
|
|
23
|
+
export interface AnalyticsContext {
|
|
24
|
+
apiBase: string;
|
|
25
|
+
organizationId: string;
|
|
26
|
+
campaignId: string;
|
|
27
|
+
variantId?: string;
|
|
28
|
+
surfaceId?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface ImpressionDetail {
|
|
31
|
+
contentSource: ContentSource;
|
|
32
|
+
/** Was the adaptation mark rendered? Reported so chrome state is measurable. */
|
|
33
|
+
chromeMarked: boolean;
|
|
34
|
+
/** W8 — set when the server served a deterministic winner. */
|
|
35
|
+
degraded?: string | null;
|
|
36
|
+
island?: string;
|
|
37
|
+
}
|
|
38
|
+
export declare function sessionId(): string;
|
|
39
|
+
/** POST an event. Fire-and-forget: telemetry never blocks or breaks a render. */
|
|
40
|
+
export declare function track(context: AnalyticsContext, eventType: EventType, eventData?: Record<string, unknown>): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Records at most one impression per island per page view.
|
|
43
|
+
*
|
|
44
|
+
* The guard is an instance, not a module global, so two islands on one page
|
|
45
|
+
* each get their own — while a single island being re-rendered, re-subscribed
|
|
46
|
+
* or double-invoked by React strict mode gets exactly one.
|
|
47
|
+
*/
|
|
48
|
+
export declare class ImpressionRecorder {
|
|
49
|
+
private readonly context;
|
|
50
|
+
private fired;
|
|
51
|
+
constructor(context: AnalyticsContext);
|
|
52
|
+
get hasFired(): boolean;
|
|
53
|
+
/** Idempotent. Returns true only for the call that actually recorded. */
|
|
54
|
+
record(detail: ImpressionDetail): boolean;
|
|
55
|
+
}
|
|
56
|
+
export interface VisibilityOptions {
|
|
57
|
+
/** Fraction of the element that must be visible. */
|
|
58
|
+
threshold?: number;
|
|
59
|
+
/**
|
|
60
|
+
* How long it must stay that visible before it counts. A creative that
|
|
61
|
+
* scrolled past during a fling was not seen by anybody.
|
|
62
|
+
*/
|
|
63
|
+
dwellMs?: number;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Fire `onVisible` once, when the element has been meaningfully on screen.
|
|
67
|
+
*
|
|
68
|
+
* Where IntersectionObserver does not exist, this fires immediately rather
|
|
69
|
+
* than never: under-counting every impression in older browsers would be a
|
|
70
|
+
* silent, systematic bias in the numbers a customer is billed and optimised
|
|
71
|
+
* against. Over-counting there is the lesser wrong, and it is at least
|
|
72
|
+
* visible.
|
|
73
|
+
*/
|
|
74
|
+
export declare function observeVisibility(element: Element, onVisible: () => void, options?: VisibilityOptions): () => void;
|
|
75
|
+
//# sourceMappingURL=analytics.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"analytics.d.ts","sourceRoot":"","sources":["../src/analytics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAE/C,MAAM,MAAM,SAAS,GAAG,YAAY,GAAG,OAAO,GAAG,YAAY,GAAG,QAAQ,CAAC;AAEzE,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,aAAa,EAAE,aAAa,CAAC;IAC7B,gFAAgF;IAChF,YAAY,EAAE,OAAO,CAAC;IACtB,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAeD,wBAAgB,SAAS,IAAI,MAAM,CAclC;AAcD,iFAAiF;AACjF,wBAAsB,KAAK,CACzB,OAAO,EAAE,gBAAgB,EACzB,SAAS,EAAE,SAAS,EACpB,SAAS,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACtC,OAAO,CAAC,IAAI,CAAC,CA8Bf;AAED;;;;;;GAMG;AACH,qBAAa,kBAAkB;IAGjB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAFpC,OAAO,CAAC,KAAK,CAAS;gBAEO,OAAO,EAAE,gBAAgB;IAEtD,IAAI,QAAQ,IAAI,OAAO,CAEtB;IAED,yEAAyE;IACzE,MAAM,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO;CAW1C;AAED,MAAM,WAAW,iBAAiB;IAChC,oDAAoD;IACpD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,OAAO,EAChB,SAAS,EAAE,MAAM,IAAI,EACrB,OAAO,GAAE,iBAAsB,GAC9B,MAAM,IAAI,CA0CZ"}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analytics: session, impression, click.
|
|
3
|
+
*
|
|
4
|
+
* Impression ownership (plan v1.1 §3.4) is the load-bearing idea. The SDK
|
|
5
|
+
* renders inside the customer's page, so it is the only party that knows an
|
|
6
|
+
* impression actually happened — the server knows only that bytes were
|
|
7
|
+
* requested, which is not the same thing and is spectacularly not the same
|
|
8
|
+
* thing behind a CDN. So every SDK request carries `sdk=1`, the server
|
|
9
|
+
* suppresses its own impression, and this module owns the count.
|
|
10
|
+
*
|
|
11
|
+
* Which means the rules here are not conveniences, they are the integrity of
|
|
12
|
+
* CVG:
|
|
13
|
+
*
|
|
14
|
+
* - ONE impression per island per page view. Not per render, not per state
|
|
15
|
+
* change, not per React strict-mode double-invoke.
|
|
16
|
+
* - VISIBILITY, not mount. A component rendered below the fold, or in a
|
|
17
|
+
* background tab, has not been seen.
|
|
18
|
+
* - the impression carries what the viewer was actually shown, including
|
|
19
|
+
* whether the trust chrome was on.
|
|
20
|
+
*/
|
|
21
|
+
const SESSION_KEY = "st:session";
|
|
22
|
+
/**
|
|
23
|
+
* Stable per-tab session id.
|
|
24
|
+
*
|
|
25
|
+
* sessionStorage, not localStorage: a session is a visit, and a
|
|
26
|
+
* localStorage-backed id would make one visitor's return look like a
|
|
27
|
+
* continuation of a visit that ended days ago. Falls back to a memory-only id
|
|
28
|
+
* where storage is unavailable, which degrades attribution rather than
|
|
29
|
+
* breaking rendering.
|
|
30
|
+
*/
|
|
31
|
+
let memorySessionId = null;
|
|
32
|
+
export function sessionId() {
|
|
33
|
+
try {
|
|
34
|
+
if (typeof sessionStorage !== "undefined") {
|
|
35
|
+
const existing = sessionStorage.getItem(SESSION_KEY);
|
|
36
|
+
if (existing)
|
|
37
|
+
return existing;
|
|
38
|
+
const created = randomId();
|
|
39
|
+
sessionStorage.setItem(SESSION_KEY, created);
|
|
40
|
+
return created;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Storage disabled or partitioned — fall through.
|
|
45
|
+
}
|
|
46
|
+
memorySessionId ??= randomId();
|
|
47
|
+
return memorySessionId;
|
|
48
|
+
}
|
|
49
|
+
function randomId() {
|
|
50
|
+
const globalCrypto = typeof crypto !== "undefined" ? crypto : undefined;
|
|
51
|
+
if (globalCrypto?.randomUUID)
|
|
52
|
+
return globalCrypto.randomUUID();
|
|
53
|
+
if (globalCrypto?.getRandomValues) {
|
|
54
|
+
const bytes = globalCrypto.getRandomValues(new Uint8Array(16));
|
|
55
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
56
|
+
}
|
|
57
|
+
// Last resort. Only reached in environments with no crypto at all, where
|
|
58
|
+
// the id's only job is to group one tab's events with each other.
|
|
59
|
+
return `st-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`;
|
|
60
|
+
}
|
|
61
|
+
/** POST an event. Fire-and-forget: telemetry never blocks or breaks a render. */
|
|
62
|
+
export async function track(context, eventType, eventData = {}) {
|
|
63
|
+
try {
|
|
64
|
+
// URL building and serialisation live INSIDE the try: a bad apiBase or an
|
|
65
|
+
// unserialisable payload (a BigInt, a cycle) throws synchronously, and
|
|
66
|
+
// callers fire this as `void track(...)` — an escape here is an
|
|
67
|
+
// unhandled rejection in the customer's page.
|
|
68
|
+
const url = new URL("/api/embed/v1/track", context.apiBase).toString();
|
|
69
|
+
const body = JSON.stringify({
|
|
70
|
+
organizationId: context.organizationId,
|
|
71
|
+
campaignId: context.campaignId,
|
|
72
|
+
variantId: context.variantId,
|
|
73
|
+
surfaceId: context.surfaceId,
|
|
74
|
+
eventType,
|
|
75
|
+
eventData: { ...eventData, sessionId: sessionId(), sdk: true },
|
|
76
|
+
});
|
|
77
|
+
// keepalive so an impression recorded as the viewer navigates away still
|
|
78
|
+
// makes it out; without it the last impression of every visit is lost,
|
|
79
|
+
// which biases CVG toward pages people stayed on.
|
|
80
|
+
await fetch(url, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "Content-Type": "application/json" },
|
|
83
|
+
body,
|
|
84
|
+
keepalive: true,
|
|
85
|
+
credentials: "omit",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Swallowed on purpose. A failed beacon is a lost data point; a thrown
|
|
90
|
+
// beacon inside a render tree is a broken customer page.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Records at most one impression per island per page view.
|
|
95
|
+
*
|
|
96
|
+
* The guard is an instance, not a module global, so two islands on one page
|
|
97
|
+
* each get their own — while a single island being re-rendered, re-subscribed
|
|
98
|
+
* or double-invoked by React strict mode gets exactly one.
|
|
99
|
+
*/
|
|
100
|
+
export class ImpressionRecorder {
|
|
101
|
+
context;
|
|
102
|
+
fired = false;
|
|
103
|
+
constructor(context) {
|
|
104
|
+
this.context = context;
|
|
105
|
+
}
|
|
106
|
+
get hasFired() {
|
|
107
|
+
return this.fired;
|
|
108
|
+
}
|
|
109
|
+
/** Idempotent. Returns true only for the call that actually recorded. */
|
|
110
|
+
record(detail) {
|
|
111
|
+
if (this.fired)
|
|
112
|
+
return false;
|
|
113
|
+
this.fired = true;
|
|
114
|
+
void track(this.context, "impression", {
|
|
115
|
+
contentSource: detail.contentSource,
|
|
116
|
+
chromeMarked: detail.chromeMarked,
|
|
117
|
+
...(detail.degraded ? { degraded: detail.degraded } : {}),
|
|
118
|
+
...(detail.island ? { island: detail.island } : {}),
|
|
119
|
+
});
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Fire `onVisible` once, when the element has been meaningfully on screen.
|
|
125
|
+
*
|
|
126
|
+
* Where IntersectionObserver does not exist, this fires immediately rather
|
|
127
|
+
* than never: under-counting every impression in older browsers would be a
|
|
128
|
+
* silent, systematic bias in the numbers a customer is billed and optimised
|
|
129
|
+
* against. Over-counting there is the lesser wrong, and it is at least
|
|
130
|
+
* visible.
|
|
131
|
+
*/
|
|
132
|
+
export function observeVisibility(element, onVisible, options = {}) {
|
|
133
|
+
// IntersectionObserver THROWS on a threshold outside [0,1], and this is
|
|
134
|
+
// caller-supplied — clamping turns a render-tree crash into a slightly
|
|
135
|
+
// eager or slightly strict impression.
|
|
136
|
+
const threshold = Math.min(1, Math.max(0, options.threshold ?? 0.5));
|
|
137
|
+
const dwellMs = options.dwellMs ?? 300;
|
|
138
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
139
|
+
onVisible();
|
|
140
|
+
return () => { };
|
|
141
|
+
}
|
|
142
|
+
let dwellTimer = null;
|
|
143
|
+
let done = false;
|
|
144
|
+
const observer = new IntersectionObserver((entries) => {
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
if (done)
|
|
147
|
+
return;
|
|
148
|
+
if (entry.isIntersecting && entry.intersectionRatio >= threshold) {
|
|
149
|
+
dwellTimer ??= setTimeout(() => {
|
|
150
|
+
done = true;
|
|
151
|
+
observer.disconnect();
|
|
152
|
+
onVisible();
|
|
153
|
+
}, dwellMs);
|
|
154
|
+
}
|
|
155
|
+
else if (dwellTimer) {
|
|
156
|
+
// Left the viewport before the dwell elapsed: it does not count, and
|
|
157
|
+
// the clock restarts rather than resuming.
|
|
158
|
+
clearTimeout(dwellTimer);
|
|
159
|
+
dwellTimer = null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}, { threshold: [0, threshold, 1] });
|
|
163
|
+
observer.observe(element);
|
|
164
|
+
return () => {
|
|
165
|
+
if (dwellTimer)
|
|
166
|
+
clearTimeout(dwellTimer);
|
|
167
|
+
observer.disconnect();
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
//# sourceMappingURL=analytics.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"analytics.js","sourceRoot":"","sources":["../src/analytics.ts"],"names":[],"mappings":"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"}
|
package/dist/audio.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio engine — W3, plan v1.1 decision (b): audio is CORE-ONLY.
|
|
3
|
+
*
|
|
4
|
+
* `window.__squidAudio` was an inline IIFE in the shell template. Moving it
|
|
5
|
+
* here makes it versioned and patchable: a provider changes their embed API
|
|
6
|
+
* and one package bump reaches every consumer, instead of every customer
|
|
7
|
+
* needing to re-copy a component.
|
|
8
|
+
*
|
|
9
|
+
* The accepted trade-off is that consumers cannot customise playback
|
|
10
|
+
* behaviour without forking. The mitigation is the shape of this module: what
|
|
11
|
+
* moves into core is the ENGINE — adapters, gesture wiring, the listen clock,
|
|
12
|
+
* media session — and NOT the chrome. `getState()` and `on()` expose
|
|
13
|
+
* everything a copied component needs to render its own player UI against an
|
|
14
|
+
* engine it does not own. Moving the shell's markup in here as well would have
|
|
15
|
+
* been "just moving the file", and would have made every consumer's player
|
|
16
|
+
* look like signage.
|
|
17
|
+
*
|
|
18
|
+
* Two invariants worth stating because both are load-bearing:
|
|
19
|
+
*
|
|
20
|
+
* - AUTOPLAY IS NEVER USED. Tap to play, always. Autoplaying audio in
|
|
21
|
+
* someone else's page is the single fastest way for a customer to remove
|
|
22
|
+
* the SDK.
|
|
23
|
+
* - ONE PLAYER AT A TIME. Mounting a second destroys the first, so a page
|
|
24
|
+
* that cycles variants cannot end up with two tracks over each other.
|
|
25
|
+
*/
|
|
26
|
+
export type AudioProvider = "suno" | "spotify";
|
|
27
|
+
export interface AudioBlock {
|
|
28
|
+
provider: AudioProvider;
|
|
29
|
+
/** Direct media URL (suno) or provider URI (spotify). */
|
|
30
|
+
audioUrl?: string;
|
|
31
|
+
trackRef?: string;
|
|
32
|
+
title?: string;
|
|
33
|
+
artist?: string;
|
|
34
|
+
durationMs?: number;
|
|
35
|
+
loop?: boolean;
|
|
36
|
+
artwork?: {
|
|
37
|
+
url: string;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export type AudioEvent = "play" | "pause" | "progress" | "complete" | "error" | "ready";
|
|
41
|
+
export interface AudioState {
|
|
42
|
+
provider: AudioProvider;
|
|
43
|
+
playing: boolean;
|
|
44
|
+
positionMs: number;
|
|
45
|
+
durationMs: number;
|
|
46
|
+
/** 0..1. Zero when the duration is not yet known — never NaN. */
|
|
47
|
+
percent: number;
|
|
48
|
+
/** Wall-clock time this track was actually audible. Drives listen analytics. */
|
|
49
|
+
listenedMs: number;
|
|
50
|
+
ended: boolean;
|
|
51
|
+
errored: boolean;
|
|
52
|
+
}
|
|
53
|
+
export interface AudioCapabilities {
|
|
54
|
+
/**
|
|
55
|
+
* May the consumer render its own transport UI? False for providers whose
|
|
56
|
+
* embed is a fixed iframe we are not permitted to hide or reskin.
|
|
57
|
+
*/
|
|
58
|
+
customChrome: boolean;
|
|
59
|
+
/** Does playback work without the viewer holding a provider account? */
|
|
60
|
+
anonymousPlayback: boolean;
|
|
61
|
+
}
|
|
62
|
+
export interface AudioHandle {
|
|
63
|
+
provider: AudioProvider;
|
|
64
|
+
caps: AudioCapabilities;
|
|
65
|
+
getState(): AudioState;
|
|
66
|
+
play(): void;
|
|
67
|
+
pause(): void;
|
|
68
|
+
toggle(): void;
|
|
69
|
+
/** No-op on providers that do not expose seeking. */
|
|
70
|
+
seek(positionMs: number): void;
|
|
71
|
+
on(event: AudioEvent, listener: (state: AudioState) => void): () => void;
|
|
72
|
+
destroy(): void;
|
|
73
|
+
}
|
|
74
|
+
/** The currently mounted player, if any. */
|
|
75
|
+
export declare function activePlayer(): AudioHandle | null;
|
|
76
|
+
/**
|
|
77
|
+
* Mount a player for `block` inside `host`.
|
|
78
|
+
*
|
|
79
|
+
* Destroys any existing player first — the single-player invariant. A page
|
|
80
|
+
* cycling variants would otherwise stack tracks on top of each other, and the
|
|
81
|
+
* viewer has no way to stop one they can no longer see.
|
|
82
|
+
*/
|
|
83
|
+
export declare function mountAudio(block: AudioBlock, host: HTMLElement): AudioHandle | null;
|
|
84
|
+
export declare function destroyActiveAudio(): void;
|
|
85
|
+
//# sourceMappingURL=audio.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audio.d.ts","sourceRoot":"","sources":["../src/audio.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,SAAS,CAAC;AAE/C,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,aAAa,CAAC;IACxB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,OAAO,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3B;AAED,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,UAAU,GAAG,UAAU,GAAG,OAAO,GAAG,OAAO,CAAC;AAExF,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,aAAa,CAAC;IACxB,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,OAAO,EAAE,MAAM,CAAC;IAChB,gFAAgF;IAChF,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,OAAO,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,YAAY,EAAE,OAAO,CAAC;IACtB,wEAAwE;IACxE,iBAAiB,EAAE,OAAO,CAAC;CAC5B;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,aAAa,CAAC;IACxB,IAAI,EAAE,iBAAiB,CAAC;IACxB,QAAQ,IAAI,UAAU,CAAC;IACvB,IAAI,IAAI,IAAI,CAAC;IACb,KAAK,IAAI,IAAI,CAAC;IACd,MAAM,IAAI,IAAI,CAAC;IACf,qDAAqD;IACrD,IAAI,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IACzE,OAAO,IAAI,IAAI,CAAC;CACjB;AAkbD,4CAA4C;AAC5C,wBAAgB,YAAY,IAAI,WAAW,GAAG,IAAI,CAEjD;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,WAAW,GAAG,WAAW,GAAG,IAAI,CAYnF;AAED,wBAAgB,kBAAkB,IAAI,IAAI,CAIzC"}
|