Skip to content

Commit 90add33

Browse files
feat(audience): CDN bundle review hardening
- Flatten the CDN surface to ImmutableAudience.init(...). Expose AudienceEvents, AudienceError, IdentityType, canTrack, canIdentify, and version as peer properties. No class on the global — every peer is a conscious forever-commitment, nothing leaks by default. Matches the flat-init pattern used by every comparable feature- rich SDK (Sentry, LogRocket, Mixpanel, Amplitude). - Re-export ImmutableAudienceGlobal from the npm entry so TS consumers can type a window.ImmutableAudience reference. - Add pnpm test:cdn-artifact. Reads dist/cdn/imtbl-audience.global.js off disk, evaluates it in a jsdom realm, asserts the full global shape, and calls init() end-to-end (via a mocked fetch) to verify the IIFE wrapper preserved the class constructor. Catches the failure mode src/cdn.test.ts cannot see: a broken tsup.cdn.js config that ships with the source-level test still passing. - Chain test:cdn-artifact into the default test script so CI (nx affected -t test) actually runs it. Previously the smoke test was opt-in only and would never have failed a PR. - Align the watch-mode version fallback in tsup.config.js with the CDN config (both now '0.0.0-local'). Removes two dev-version strings in one package. - Enable treeshake in tsup.cdn.js to match the ESM/CJS production configs. Bundle 52.53 KB -> 51.79 KB. - Remove sourcemap: true from tsup.cdn.js. tsup.config.js never enables source maps for production; the CDN config silently did, driving SRI hash churn for studios pinning a hash. - Log the already-loaded version in the double-import warning and tell the studio to remove the old <script> tag to upgrade. The old warning said what happened, not what to do.
1 parent 441a0a5 commit 90add33

10 files changed

Lines changed: 168 additions & 10 deletions

File tree

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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@
6060
"pack:root": "pnpm pack --pack-destination $(dirname $(pnpm root -w))",
6161
"prepack": "node scripts/prepack.mjs",
6262
"postpack": "node scripts/postpack.mjs",
63-
"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",
6465
"test:watch": "jest --watch",
6566
"typecheck": "tsc --customConditions development --noEmit --jsx preserve"
6667
},

packages/audience/sdk/src/cdn.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ describe('cdn entry point', () => {
1717
}).ImmutableAudience;
1818

1919
expect(g).toBeDefined();
20-
expect(typeof g!.Audience.init).toBe('function');
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');
2124
expect(g!.IdentityType.Passport).toBe('passport');
2225
expect(g!.IdentityType.Steam).toBe('steam');
2326
expect(g!.IdentityType.Custom).toBe('custom');
@@ -44,6 +47,7 @@ describe('cdn entry point', () => {
4447
(globalThis as unknown as { ImmutableAudience?: unknown }).ImmutableAudience,
4548
).toBe(sentinel);
4649
expect(warn).toHaveBeenCalledWith(expect.stringContaining('loaded twice'));
50+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Remove the old <script> tag'));
4751
warn.mockRestore();
4852
});
4953
});

packages/audience/sdk/src/cdn.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
1-
import { AudienceError, IdentityType } from '@imtbl/audience-core';
1+
import {
2+
AudienceError,
3+
IdentityType,
4+
canIdentify,
5+
canTrack,
6+
} from '@imtbl/audience-core';
27

38
import { Audience } from './sdk';
9+
import { AudienceEvents } from './events';
410
import { LIBRARY_VERSION } from './config';
511

612
export type ImmutableAudienceGlobal = {
7-
Audience: typeof Audience;
13+
init: typeof Audience.init;
814
AudienceError: typeof AudienceError;
15+
AudienceEvents: typeof AudienceEvents;
916
IdentityType: typeof IdentityType;
17+
canIdentify: typeof canIdentify;
18+
canTrack: typeof canTrack;
1019
version: string;
1120
};
1221

@@ -16,13 +25,20 @@ const globalObj = (
1625
) as unknown as { ImmutableAudience?: ImmutableAudienceGlobal };
1726

1827
if (globalObj.ImmutableAudience) {
28+
const existingVersion = globalObj.ImmutableAudience.version ?? 'unknown';
1929
// eslint-disable-next-line no-console
20-
console.warn('[@imtbl/audience] CDN bundle loaded twice; keeping the first instance.');
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+
);
2134
} else {
2235
globalObj.ImmutableAudience = {
23-
Audience,
36+
init: Audience.init.bind(Audience),
2437
AudienceError,
38+
AudienceEvents,
2539
IdentityType,
40+
canIdentify,
41+
canTrack,
2642
version: LIBRARY_VERSION,
2743
};
2844
}

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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ export default defineConfig({
1111
outDir: 'dist/cdn',
1212
outExtension: () => ({ js: '.global.js' }),
1313
minify: true,
14-
sourcemap: true,
1514
clean: false,
1615
target: 'es2018',
1716
platform: 'browser',
1817
dts: false,
18+
treeshake: true,
1919
// IIFE has no runtime module resolution — inline everything, including npm deps.
2020
noExternal: [/.*/],
2121
esbuildPlugins: [

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)