Skip to content

Commit 42565f8

Browse files
feat(audience): add CDN bundle (SDK-115)
Ships @imtbl/audience as a single-file IIFE bundle for <script>-tag embedding — no npm install, no bundler required. Public API: - Studios load dist/cdn/imtbl-audience.global.js, then call ImmutableAudience.init({publishableKey: '...'}) to get an instance. - Global surface: init, AudienceError, AudienceEvents, IdentityType, canIdentify, canTrack, version. Flat — no class on the global. Every peer is a conscious forever-commitment, nothing leaks by default. Adding the class back later is non-breaking additive. - TypeScript consumers get ImmutableAudienceGlobal re-exported from the npm entry for typing window.ImmutableAudience. Build (new tsup.cdn.js): - format: iife, globalName: ImmutableAudience, platform: browser, target: es2018 - noExternal: [/.*/] — inlines every transitive dep; a "no bundler" bundle has to ship self-contained - treeshake + minify; no source maps (keeps SRI hash stable for studios pinning) - Wired into `pnpm build` alongside the existing ESM/CJS builds - Watch-mode and production version-fallback strings aligned to '0.0.0-local' for local dev builds Robustness: - Double-import guard: a second load is ignored, a warning logs the already-loaded version and tells the studio to remove the old <script> tag to upgrade - globalThis with a window fallback for Safari < 12.1 (the es2018 target predates the globalThis baseline) Tests: - src/cdn.test.ts: source-level unit tests (global shape, guard) - test/cdn-artifact.test.ts: reads the built IIFE off disk, evaluates it in a jsdom realm, asserts the full global shape, and calls init() end-to-end via a mocked fetch. Catches failure modes a source-level test cannot — e.g., a broken tsup config that ships with the source-level test still passing - Chained into `pnpm test` so CI's nx affected -t test runs both
1 parent 55a08c4 commit 42565f8

File tree

10 files changed

+265
-5
lines changed

10 files changed

+265
-5
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { Config } from 'jest';
2+
3+
// Runs the CDN artifact smoke test in isolation from the default suite.
4+
// Default suite (jest.config.ts) scopes to src/ and tests TypeScript source;
5+
// this config scopes to test/ and asserts against the built IIFE in dist/cdn.
6+
const config: Config = {
7+
roots: ['<rootDir>/test'],
8+
// jsdom, not node: at least one transitive dep in the bundle touches a
9+
// browser global (navigator, sessionStorage, or similar) during module
10+
// init, so a bare node realm throws before the side-effect global
11+
// assignment runs. jsdom provides those globals, matching the real
12+
// <script>-tag target environment the bundle is built for.
13+
testEnvironment: 'jsdom',
14+
transform: {
15+
'^.+\\.(t|j)sx?$': '@swc/jest',
16+
},
17+
};
18+
19+
export default config;

packages/audience/sdk/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,16 @@
5252
},
5353
"repository": "immutable/ts-immutable-sdk.git",
5454
"scripts": {
55-
"build": "pnpm transpile && pnpm typegen",
55+
"build": "pnpm transpile && pnpm transpile:cdn && pnpm typegen",
5656
"transpile": "tsup --config tsup.config.js",
57+
"transpile:cdn": "tsup --config ./tsup.cdn.js",
5758
"typegen": "tsc --customConditions default --emitDeclarationOnly --outDir dist/types && rollup -c rollup.dts.config.js && find dist/types -name '*.d.ts' ! -name 'index.d.ts' -delete",
5859
"lint": "eslint ./src --ext .ts,.jsx,.tsx --max-warnings=0",
5960
"pack:root": "pnpm pack --pack-destination $(dirname $(pnpm root -w))",
6061
"prepack": "node scripts/prepack.mjs",
6162
"postpack": "node scripts/postpack.mjs",
62-
"test": "jest --passWithNoTests",
63+
"test": "jest --passWithNoTests && pnpm test:cdn-artifact",
64+
"test:cdn-artifact": "pnpm transpile:cdn && jest --config jest.cdn-artifact.config.ts",
6365
"test:watch": "jest --watch",
6466
"typecheck": "tsc --customConditions development --noEmit --jsx preserve"
6567
},
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { ImmutableAudienceGlobal } from './cdn';
2+
3+
describe('cdn entry point', () => {
4+
beforeEach(() => {
5+
delete (globalThis as unknown as { ImmutableAudience?: unknown }).ImmutableAudience;
6+
jest.resetModules();
7+
});
8+
9+
afterEach(() => {
10+
delete (globalThis as unknown as { ImmutableAudience?: unknown }).ImmutableAudience;
11+
});
12+
13+
it('attaches the SDK surface to window.ImmutableAudience', async () => {
14+
await import('./cdn');
15+
const g = (globalThis as unknown as {
16+
ImmutableAudience?: ImmutableAudienceGlobal;
17+
}).ImmutableAudience;
18+
19+
expect(g).toBeDefined();
20+
expect(typeof g!.init).toBe('function');
21+
expect(typeof g!.AudienceEvents).toBe('object');
22+
expect(typeof g!.canTrack).toBe('function');
23+
expect(typeof g!.canIdentify).toBe('function');
24+
expect(g!.IdentityType.Passport).toBe('passport');
25+
expect(g!.IdentityType.Steam).toBe('steam');
26+
expect(g!.IdentityType.Custom).toBe('custom');
27+
expect(typeof g!.version).toBe('string');
28+
expect(g!.version.length).toBeGreaterThan(0);
29+
30+
const err = new g!.AudienceError({
31+
code: 'NETWORK_ERROR',
32+
message: 'test',
33+
status: 0,
34+
endpoint: 'https://example.com',
35+
});
36+
expect(err).toBeInstanceOf(Error);
37+
});
38+
39+
it('does not overwrite a pre-existing global', async () => {
40+
const sentinel = { Audience: 'FAKE' };
41+
(globalThis as unknown as { ImmutableAudience?: unknown }).ImmutableAudience = sentinel;
42+
43+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
44+
await import('./cdn');
45+
46+
expect(
47+
(globalThis as unknown as { ImmutableAudience?: unknown }).ImmutableAudience,
48+
).toBe(sentinel);
49+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('loaded twice'));
50+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Remove the old <script> tag'));
51+
warn.mockRestore();
52+
});
53+
});

packages/audience/sdk/src/cdn.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {
2+
AudienceError,
3+
IdentityType,
4+
canIdentify,
5+
canTrack,
6+
} from '@imtbl/audience-core';
7+
8+
import { Audience } from './sdk';
9+
import { AudienceEvents } from './events';
10+
import { LIBRARY_VERSION } from './config';
11+
12+
export type ImmutableAudienceGlobal = {
13+
init: typeof Audience.init;
14+
AudienceError: typeof AudienceError;
15+
AudienceEvents: typeof AudienceEvents;
16+
IdentityType: typeof IdentityType;
17+
canIdentify: typeof canIdentify;
18+
canTrack: typeof canTrack;
19+
version: string;
20+
};
21+
22+
// Fallback for es2018 targets that predate globalThis (Safari < 12.1).
23+
const globalObj = (
24+
typeof globalThis !== 'undefined' ? globalThis : window
25+
) as unknown as { ImmutableAudience?: ImmutableAudienceGlobal };
26+
27+
if (globalObj.ImmutableAudience) {
28+
const existingVersion = globalObj.ImmutableAudience.version ?? 'unknown';
29+
// eslint-disable-next-line no-console
30+
console.warn(
31+
`[@imtbl/audience] CDN bundle loaded twice; keeping v${existingVersion}. `
32+
+ 'Remove the old <script> tag to load a different version.',
33+
);
34+
} else {
35+
globalObj.ImmutableAudience = {
36+
init: Audience.init.bind(Audience),
37+
AudienceError,
38+
AudienceEvents,
39+
IdentityType,
40+
canIdentify,
41+
canTrack,
42+
version: LIBRARY_VERSION,
43+
};
44+
}

packages/audience/sdk/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { Audience } from './sdk';
22
export { AudienceEvents } from './events';
33
export { IdentityType, canTrack, canIdentify } from '@imtbl/audience-core';
4+
export type { ImmutableAudienceGlobal } from './cdn';
45
export type { AudienceConfig } from './types';
56
export type {
67
AudienceEventName,
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
4+
// Tests the actual built IIFE, not the TypeScript source. Catches failure
5+
// modes that src/cdn.test.ts cannot: a broken tsup.cdn.js config, a missing
6+
// noExternal entry, an unreplaced __SDK_VERSION__ placeholder, or an IIFE
7+
// wrapper that clobbers the side-effect global assignment.
8+
//
9+
// Runs under testEnvironment: 'jsdom' so `window`, `navigator`, and
10+
// `globalThis` are all present — matching a real <script>-tag load.
11+
12+
const ARTIFACT_PATH = path.resolve(
13+
__dirname,
14+
'../dist/cdn/imtbl-audience.global.js',
15+
);
16+
17+
type AudienceInstance = {
18+
track: (...args: unknown[]) => unknown;
19+
shutdown: () => void;
20+
};
21+
22+
type AudienceGlobal = {
23+
init: (config: { publishableKey: string; [k: string]: unknown }) => AudienceInstance;
24+
AudienceError: unknown;
25+
AudienceEvents: Record<string, string>;
26+
IdentityType: Record<string, string>;
27+
canIdentify: unknown;
28+
canTrack: unknown;
29+
version: string;
30+
};
31+
32+
describe('CDN bundle artifact', () => {
33+
let g: AudienceGlobal;
34+
let fetchMock: jest.Mock;
35+
36+
beforeAll(() => {
37+
if (!fs.existsSync(ARTIFACT_PATH)) {
38+
throw new Error(
39+
`CDN artifact not found at ${ARTIFACT_PATH}. `
40+
+ 'Run `pnpm transpile:cdn` (or `pnpm build`) before this test.',
41+
);
42+
}
43+
44+
// jsdom does not provide fetch; stub it before the bundle (or any init
45+
// call) touches it. Returns a generic OK so transport code does not
46+
// blow up when flushing queued events during the init() smoke test.
47+
fetchMock = jest.fn().mockResolvedValue({
48+
ok: true,
49+
status: 200,
50+
json: async () => ({}),
51+
text: async () => '',
52+
});
53+
(globalThis as unknown as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
54+
55+
const source = fs.readFileSync(ARTIFACT_PATH, 'utf8');
56+
// Evaluates the pre-built bundle in the test's realm, the same way a
57+
// <script> tag does. Not user input — it's our own build output — so
58+
// the implied-eval rule doesn't apply. vm.runInThisContext was tried
59+
// first but runs in Node's root context, bypassing jsdom's window.
60+
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
61+
new Function(source)();
62+
g = (globalThis as unknown as { ImmutableAudience: AudienceGlobal })
63+
.ImmutableAudience;
64+
});
65+
66+
afterAll(() => {
67+
delete (globalThis as unknown as { ImmutableAudience?: unknown })
68+
.ImmutableAudience;
69+
delete (globalThis as unknown as { fetch?: unknown }).fetch;
70+
});
71+
72+
it('attaches ImmutableAudience to globalThis as a side effect', () => {
73+
expect(g).toBeDefined();
74+
});
75+
76+
it('exposes every runtime value that the npm entry exports', () => {
77+
expect(typeof g.init).toBe('function');
78+
expect(typeof g.AudienceError).toBe('function');
79+
expect(typeof g.AudienceEvents).toBe('object');
80+
expect(typeof g.IdentityType).toBe('object');
81+
expect(typeof g.canIdentify).toBe('function');
82+
expect(typeof g.canTrack).toBe('function');
83+
expect(typeof g.version).toBe('string');
84+
});
85+
86+
it('replaces the __SDK_VERSION__ placeholder at build time', () => {
87+
expect(g.version).not.toBe('__SDK_VERSION__');
88+
expect(g.version.length).toBeGreaterThan(0);
89+
});
90+
91+
it('populates the IdentityType enum', () => {
92+
expect(g.IdentityType.Passport).toBe('passport');
93+
expect(g.IdentityType.Steam).toBe('steam');
94+
expect(g.IdentityType.Custom).toBe('custom');
95+
});
96+
97+
it('init() returns a working Audience instance end-to-end', () => {
98+
// Happy path a studio would copy-paste: ImmutableAudience.init({...}).
99+
// Verifies the IIFE wrapper preserved the class constructor end-to-end,
100+
// not just the type of init.
101+
const audience = g.init({ publishableKey: 'pk_test_smoketest' });
102+
try {
103+
expect(audience).toBeDefined();
104+
expect(typeof audience.track).toBe('function');
105+
expect(typeof audience.shutdown).toBe('function');
106+
} finally {
107+
audience.shutdown();
108+
}
109+
});
110+
});
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"extends": "./tsconfig.json",
3-
"include": ["src"],
3+
"include": ["src", "test", "jest.cdn-artifact.config.ts"],
44
"exclude": []
55
}

packages/audience/sdk/tsconfig.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,12 @@
66
"customConditions": ["development"]
77
},
88
"include": ["src"],
9-
"exclude": ["dist", "jest.config.ts", "node_modules", "src/**/*.test.ts"]
9+
"exclude": [
10+
"dist",
11+
"jest.config.ts",
12+
"jest.cdn-artifact.config.ts",
13+
"node_modules",
14+
"src/**/*.test.ts",
15+
"test"
16+
]
1017
}

packages/audience/sdk/tsup.cdn.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// @ts-check
2+
import { defineConfig } from 'tsup';
3+
import { replace } from 'esbuild-plugin-replace';
4+
import pkg from './package.json' with { type: 'json' };
5+
6+
// IIFE bundle of @imtbl/audience for <script>-tag loading. Runs after the
7+
// ESM/CJS build in tsup.config.js — `clean: false` preserves that output.
8+
export default defineConfig({
9+
entry: { 'imtbl-audience': 'src/cdn.ts' },
10+
format: ['iife'],
11+
outDir: 'dist/cdn',
12+
outExtension: () => ({ js: '.global.js' }),
13+
minify: true,
14+
clean: false,
15+
target: 'es2018',
16+
platform: 'browser',
17+
dts: false,
18+
treeshake: true,
19+
// IIFE has no runtime module resolution — inline everything, including npm deps.
20+
noExternal: [/.*/],
21+
esbuildPlugins: [
22+
replace({ __SDK_VERSION__: pkg.version === '0.0.0' ? '0.0.0-local' : pkg.version }),
23+
],
24+
});

packages/audience/sdk/tsup.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export default defineConfig((options) => {
2929
modules: ['crypto', 'buffer', 'process'],
3030
}),
3131
replace({
32-
__SDK_VERSION__: pkg.version === '0.0.0' ? '2.0.0' : pkg.version,
32+
__SDK_VERSION__: pkg.version === '0.0.0' ? '0.0.0-local' : pkg.version,
3333
}),
3434
],
3535
};

0 commit comments

Comments
 (0)