Skip to content

feat(audience): interactive demo page and CDN bundle#2837

Open
ImmutableJeffrey wants to merge 10 commits intomainfrom
feat/audience-web-sdk-demo
Open

feat(audience): interactive demo page and CDN bundle#2837
ImmutableJeffrey wants to merge 10 commits intomainfrom
feat/audience-web-sdk-demo

Conversation

@ImmutableJeffrey
Copy link
Copy Markdown
Contributor

@ImmutableJeffrey ImmutableJeffrey commented Apr 8, 2026

What this PR does

Adds two things needed to ship @imtbl/audience for studios that embed it with a <script> tag:

  1. A single-file build of the SDK at dist/cdn/imtbl-audience.global.js. Studios drop one <script> tag into their HTML, then use ImmutableAudience.Audience.init({...}) to start tracking. No bundler, no npm install.
  2. An interactive demo page that loads that single-file build and exercises every public method on the Audience class against the real dev / sandbox backend. Live event log, one button per method, test keys baked in. Fastest way to sanity-check SDK changes end-to-end.

How to try it

cd packages/audience/sdk-sample-app
pnpm dev

Open http://localhost:3456/. Test publishable keys and a 10-step "what to try" walkthrough are in the sample-app's README.

Addressing the review feedback

@nattb8 asked two things in their review:

Shouldn't the demo live outside the sdk? Copy passport - audience/sdk-sample-app or audience/demo. Also, this is included in the CDN bundle too. Don't think we want that.

1. Demo moved out of the sdk package. It now lives in its own private workspace package at packages/audience/sdk-sample-app, matching the passport-sdk-sample-app / checkout-sdk-sample-app / dex-sdk-sample-app convention. The sdk package is now just the shippable library — no demo files mixed in with the build artifacts.

2. Demo was never bundled into the single-file SDK. Verified this before doing the restructure: src/cdn.ts (the entry point for the single-file build) imports only ./sdk, ./config, and @imtbl/audience-core — nothing in the TypeScript source references the demo's HTML/JS/CSS, so the minified output doesn't contain any of it. And the sdk's "files": ["dist"] field already excluded the demo directory from the published npm tarball. Confirmed by running pnpm pack on the sdk package and listing the archive contents — it has only dist/{browser,cdn,node,types}, LICENSE.md, README.md, and package.json. No demo files, no sample-app files. The restructure in point 1 removes any ambiguity at the directory level regardless.

What changed at a glance

packages/audience/sdk-sample-app/       NEW — the interactive demo, private workspace package
├── README.md                             how to run, test keys, walkthrough
├── index.html, demo.js, demo.css         the demo page itself
├── serve.mjs                             ~90-line Node static file server, no deps
└── package.json                          depends on @imtbl/audience via workspace link

packages/audience/sdk/
├── src/cdn.ts, cdn.test.ts             NEW — single-file SDK entry point + smoke test
├── tsup.cdn.js                         NEW — build config for the single-file build
├── README.md                           NEW — package docs (install, quickstart, API, errors)
└── package.json                          adds the single-file build step to `pnpm build`

pnpm-workspace.yaml                       registers the new sample-app package
pnpm-lock.yaml                            picks up the new workspace link (6 lines)

The branch is rebased onto current main, so it sits on top of #2838 (which introduced the @imtbl/audience publishing pipeline). The two build systems coexist cleanly: the existing tsup.config.js builds dist/browser + dist/node and the new tsup.cdn.js builds dist/cdn, without either touching the other's output.

Ten commits total — follow them in order if you want to see how the demo came together incrementally.

Related tickets

  • Closes SDK-49 (Web SDK: CDN bundle, demo page, README)

Test plan

  • pnpm --filter @imtbl/audience-core --filter @imtbl/audience run lint — clean on both packages
  • Same filter + run typecheck — clean
  • Same filter + run test — 113 core tests + 51 sdk tests all pass
  • pnpm --filter @imtbl/audience run build — produces all expected artifacts: browser ESM, node CJS+ESM, single-file web build (52 KB), and the rolled-up .d.ts
  • pnpm --filter @imtbl/audience-sdk-sample-app run dev — demo loads at http://localhost:3456/, every method exercisable, 200 responses from the sandbox audience API
  • pnpm pack on packages/audience/sdk — tarball is clean: only shipping artifacts, no demo files or sample-app files
  • CI green on this branch

@ImmutableJeffrey ImmutableJeffrey requested review from a team as code owners April 8, 2026 23:24
@nx-cloud
Copy link
Copy Markdown

nx-cloud bot commented Apr 8, 2026

View your CI Pipeline Execution ↗ for commit 28d0c5c

Command Status Duration Result
nx run-many -p @imtbl/sdk,@imtbl/checkout-widge... ✅ Succeeded 2s View ↗
nx affected -t build,lint,test ✅ Succeeded 2s View ↗

☁️ Nx Cloud last updated this comment at 2026-04-09 13:35:54 UTC

@ImmutableJeffrey ImmutableJeffrey force-pushed the feat/audience-sdk-foundation branch from 7a073b4 to 7da2977 Compare April 9, 2026 00:28
Base automatically changed from feat/audience-sdk-foundation to main April 9, 2026 01:18
@ImmutableJeffrey ImmutableJeffrey force-pushed the feat/audience-web-sdk-demo branch from f354692 to a92939b Compare April 9, 2026 08:24
Copy link
Copy Markdown
Contributor

@nattb8 nattb8 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the demo live outside the sdk? Copy passport - audience/sdk-sample-app or audience/demo.

Also, this is included in the CDN bundle too. Don't think we want that.

ImmutableJeffrey added a commit that referenced this pull request Apr 9, 2026
Addresses review feedback on #2837 from @nattb8: the interactive demo
should live in its own workspace package (matching the repo convention
used by passport/sdk-sample-app, checkout/sdk-sample-app, dex/sdk-sample-app,
bridge/bridge-sample-app) rather than inside the published @imtbl/audience
package directory.

Why this matters beyond aesthetics:

- @imtbl/audience is a published npm package with a dedicated build
  pipeline (#2838): local tsup.config.js, prepack/postpack scripts that
  strip workspace deps from package.json, rollup-plugin-dts to inline
  type re-exports. The sdk package directory should stay focused on
  shipping artifacts; a demo harness is not one.
- The demo was vanilla ES2020 (no TS, no modules, loaded via a script
  tag) while the sdk package is pure TypeScript. Co-locating them forced
  sdk/.eslintignore + an .eslintrc.cjs override block just to keep
  lint-staged from trying to parse demo/*.js with the TS parser. Both
  pieces of config disappear with this move.
- The existing repo-wide root .eslintignore already has a
  `**sample-app**/` glob (for passport/sdk-sample-app and friends), so
  the new directory is automatically excluded from root lint with zero
  local config.

Addresses the reviewer's secondary concern — "this is included in the
CDN bundle too" — at the structural level. For the record, verified
the demo was never literally bundled into dist/cdn/imtbl-audience.global.js:
src/cdn.ts imports only ./sdk, ./config, and @imtbl/audience-core, and
`files: ["dist"]` in package.json already excluded demo/ from the npm
tarball. Confirmed by packing the sdk and inspecting the tarball — it
only contains dist/browser, dist/cdn, dist/node, dist/types, plus
README.md, LICENSE.md, and package.json.

Changes:

New package — packages/audience/sdk-sample-app/
- package.json: private, @imtbl/audience as a workspace:* devDep,
  engines node >= 20.11, `pnpm dev` builds @imtbl/audience then runs
  the local serve script
- serve.mjs: ~90-line Node static server using only the stdlib.
  Serves the sample-app's own files from ./, and routes /vendor/
  to ../sdk/dist/cdn/ so the HTML can load the CDN bundle via a
  same-origin URL (keeps the demo's CSP happy). Blocks serve.mjs,
  package.json, and node_modules from being served, plus path
  traversal attempts via decodeURIComponent + a resolve/startsWith
  guard. Verified with curl: 200 for /, /demo.css, /demo.js and
  /vendor/imtbl-audience.global.js(.map); 403 for /package.json,
  /serve.mjs, /vendor/../../package.json, /%2e%2e/secret; 404 for
  /nonexistent.html.
- index.html, demo.js, demo.css, README.md: git-renamed from
  packages/audience/sdk/demo/. The only content change is in
  index.html — the <script src> moved from ../dist/cdn/... to
  vendor/... — plus README.md was updated with the new run
  instructions and a layout diagram for the new location.

Package cleanup — packages/audience/sdk/
- Remove the `demo` script from package.json (its entry point is
  gone now).
- Revert .eslintrc.cjs to main's 6-line baseline by dropping the
  22-line `demo/**/*.js` overrides block that the PR had added.
- Delete .eslintignore entirely (its only line was `demo/`).
- Update README.md's two `demo/` references to point at
  `../sdk-sample-app/README.md` instead.

Repo-level
- Drop the `packages/audience/sdk/demo/` line from root .eslintignore
  (the existing `**sample-app**/` glob covers the new location).
- Register `packages/audience/sdk-sample-app` in pnpm-workspace.yaml.
- pnpm-lock.yaml picks up a 6-line importer entry for the new package
  (just the workspace:* link to ../sdk, no external deps).

Verification:
- `pnpm --filter @imtbl/audience-core --filter @imtbl/audience run
  lint typecheck test` — 113 core + 51 sdk tests pass, lint/typecheck
  clean on both packages.
- `pnpm --filter @imtbl/audience run build` — ESM (browser+node),
  CDN IIFE (52.04 KB), and rolled-up .d.ts all build clean.
- `pnpm --filter @imtbl/audience-sdk-sample-app run dev` — builds
  the sdk, starts the local server, demo loads at http://localhost:3456/
  with the CDN bundle served from /vendor/.
- `pnpm pack --pack-destination /tmp/...` in the sdk — tarball
  contains only dist/{browser,cdn,node,types}, LICENSE.md, README.md,
  and package.json. No demo, no vendor, no sample-app, no scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ImmutableJeffrey ImmutableJeffrey force-pushed the feat/audience-web-sdk-demo branch from a92939b to 6f121bc Compare April 9, 2026 13:05
ImmutableJeffrey and others added 10 commits April 9, 2026 23:31
Adds a self-contained IIFE bundle of @imtbl/audience so studios can
load the SDK via a <script> tag without a build step. The bundle
inlines @imtbl/audience-core and exposes the Audience class on
window.ImmutableAudience.

Build config (tsup.cdn.js):
- IIFE format, minified, no externals
- define: replaces __SDK_VERSION__ at build time with the version
  from package.json so the runtime can surface it (e.g. in debug
  logs and the demo footer)
- cdn.ts entry exports SDK_VERSION and re-exports Audience/types

package.json adds the 'build:cdn' script and a 'demo' script that
builds then serves packages/audience/sdk on :3456.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds packages/audience/sdk/demo/: single-page interactive harness that
loads the CDN bundle and exercises the Audience class.

Initial surface:
- index.html with Setup panel (publishable key, environment,
  initial consent), Init + Shutdown buttons, status bar, event log
- demo.js wires Init -> Audience.init() and Shutdown -> audience.shutdown(),
  writes every SDK action to the event log with timestamps
- demo.css with a minimal baseline layout

Serves under `pnpm demo` from the package root at /demo/index.html.
Later commits add the rest of the public method buttons, styling
polish, and layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wires every public method on the Audience class to a button in the
demo, one section per method:

- Consent: three buttons (none, anonymous, full) call setConsent()
- Page: button + properties textarea -> page(props)
- Track: event name input + properties textarea -> track(name, props)
- Identify: ID input + identityType select + traits textarea ->
  identify(id, type, traits); separate 'Identify (traits only)' button
  for the anonymous-visitor overload
- Alias: from/to ID inputs + identityType selects -> alias(from, to)
- Reset: reset()
- Flush: flush()

Every action writes a log entry with the method name and the payload
that was sent. JSON parse errors on properties/traits inputs are
caught and surfaced in the log rather than thrown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Disable Init until the publishable key input has non-whitespace
  content (prevents calling Audience.init with an empty key).
- Colour-code the consent badge in the status bar by level: red for
  none, amber for anonymous, green for full. Readable at a glance.
- Add flushInterval and flushSize number inputs to the Setup panel
  so you can exercise non-default queue timings from the demo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On wide viewports the demo splits into two columns: controls on the
left, event log on the right, with a drag gutter between them to
adjust the split ratio. On narrow viewports the columns stack.

The event log itself is also resizable on both wide and narrow
layouts so you can see more history without scrolling. Keyboard
users can resize the gutter with arrow keys (role="separator",
aria-orientation, tabindex).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restyles the demo to match the light theme used by the passport SDK
sample app at https://github.com/immutable/passport-sample-app so
the two demos feel like a consistent family.

Changes:
- Light theme as the default (no dark mode toggle — see passport
  sample app for the design reference)
- Typography refined: passport sample app font stack, adjusted
  sizes and line-heights for readability
- Elevation applied to panels with subtle shadows and borders
- Primary button colour matched to the passport sample app (no
  more cyan accent)
- Input styling (including number inputs) normalised across the
  Setup panel and the method panels
- Header subtitle removed — redundant given the page title

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Final UX polish for the demo:

Footer and accessibility
- Footer renders the SDK version (read from SDK_VERSION exported
  from cdn.ts — adds the const + a cdn.test.ts for the guard)
- Event log marked with aria-live="polite" so screen readers
  announce new entries

Event log
- Copy button copies the full session's log to the clipboard
  (named just 'Copy' — clearer than a longer label)
- Auto-scroll to bottom on new entries, but only while the user
  is already at the bottom — if they scroll up to inspect older
  events, auto-scroll locks so the view doesn't jump away

Alias validation
- Real-time check on the Alias button: disabled while either
  ID is empty or (fromId, fromType) === (toId, toType). Mirrors
  core's isAliasValid() so the user gets immediate feedback
  instead of discovering the problem after clicking

Cleanup
- Remove dead #panel-slot selectors from demo.css and the empty
  <div id="panel-slot"> from index.html (never used)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds two READMEs:

packages/audience/sdk/README.md
  Package-level usage doc. Install (npm + CDN), quick start
  example, public method list with short signatures, consent
  level behaviour table, auto-tracked event list, cookie
  reference, and a pointer to the demo.

packages/audience/sdk/demo/README.md
  Demo harness usage doc. How to run (pnpm demo → serves
  localhost:3456), test publishable keys for dev + sandbox,
  a step-by-step 'what to try' script, environments table,
  troubleshooting for the common issues (bundle failing to
  load, 400/403 from the API, no BigQuery data), and a files
  layout section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Locks the demo's Content-Security-Policy to the minimum needed to
run — audience API endpoints only, nothing else.

- default-src 'self'
- script-src 'self' (no inline scripts, no eval)
- style-src 'self' (no inline styles)
- connect-src limited to https://api.dev.immutable.com and
  https://api.sandbox.immutable.com

Explicitly NOT in connect-src: api.immutable.com. The @imtbl/metrics
SDK bundled into the CDN posts its own telemetry there, and those
calls will be blocked by the browser with a CSP violation log. That
is intentional — the demo is a harness, not a product, and the metrics
bundle travelling along with the audience SDK shouldn't phone home
from a localhost demo page. The violations do not affect demo
behaviour; the audience calls still succeed.

README's Security section explains this so the CSP violation lines
in the console aren't mistaken for a bug.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses review feedback on #2837 from @nattb8: the interactive demo
should live in its own workspace package (matching the repo convention
used by passport/sdk-sample-app, checkout/sdk-sample-app, dex/sdk-sample-app,
bridge/bridge-sample-app) rather than inside the published @imtbl/audience
package directory.

Why this matters beyond aesthetics:

- @imtbl/audience is a published npm package with a dedicated build
  pipeline (#2838): local tsup.config.js, prepack/postpack scripts that
  strip workspace deps from package.json, rollup-plugin-dts to inline
  type re-exports. The sdk package directory should stay focused on
  shipping artifacts; a demo harness is not one.
- The demo was vanilla ES2020 (no TS, no modules, loaded via a script
  tag) while the sdk package is pure TypeScript. Co-locating them forced
  sdk/.eslintignore + an .eslintrc.cjs override block just to keep
  lint-staged from trying to parse demo/*.js with the TS parser. Both
  pieces of config disappear with this move.
- The existing repo-wide root .eslintignore already has a
  `**sample-app**/` glob (for passport/sdk-sample-app and friends), so
  the new directory is automatically excluded from root lint with zero
  local config.

Addresses the reviewer's secondary concern — "this is included in the
CDN bundle too" — at the structural level. For the record, verified
the demo was never literally bundled into dist/cdn/imtbl-audience.global.js:
src/cdn.ts imports only ./sdk, ./config, and @imtbl/audience-core, and
`files: ["dist"]` in package.json already excluded demo/ from the npm
tarball. Confirmed by packing the sdk and inspecting the tarball — it
only contains dist/browser, dist/cdn, dist/node, dist/types, plus
README.md, LICENSE.md, and package.json.

Changes:

New package — packages/audience/sdk-sample-app/
- package.json: private, @imtbl/audience as a workspace:* devDep,
  engines node >= 20.11, `pnpm dev` builds @imtbl/audience then runs
  the local serve script
- serve.mjs: ~90-line Node static server using only the stdlib.
  Serves the sample-app's own files from ./, and routes /vendor/
  to ../sdk/dist/cdn/ so the HTML can load the CDN bundle via a
  same-origin URL (keeps the demo's CSP happy). Blocks serve.mjs,
  package.json, and node_modules from being served, plus path
  traversal attempts via decodeURIComponent + a resolve/startsWith
  guard. Verified with curl: 200 for /, /demo.css, /demo.js and
  /vendor/imtbl-audience.global.js(.map); 403 for /package.json,
  /serve.mjs, /vendor/../../package.json, /%2e%2e/secret; 404 for
  /nonexistent.html.
- index.html, demo.js, demo.css, README.md: git-renamed from
  packages/audience/sdk/demo/. The only content change is in
  index.html — the <script src> moved from ../dist/cdn/... to
  vendor/... — plus README.md was updated with the new run
  instructions and a layout diagram for the new location.

Package cleanup — packages/audience/sdk/
- Remove the `demo` script from package.json (its entry point is
  gone now).
- Revert .eslintrc.cjs to main's 6-line baseline by dropping the
  22-line `demo/**/*.js` overrides block that the PR had added.
- Delete .eslintignore entirely (its only line was `demo/`).
- Update README.md's two `demo/` references to point at
  `../sdk-sample-app/README.md` instead.

Repo-level
- Drop the `packages/audience/sdk/demo/` line from root .eslintignore
  (the existing `**sample-app**/` glob covers the new location).
- Register `packages/audience/sdk-sample-app` in pnpm-workspace.yaml.
- pnpm-lock.yaml picks up a 6-line importer entry for the new package
  (just the workspace:* link to ../sdk, no external deps).

Verification:
- `pnpm --filter @imtbl/audience-core --filter @imtbl/audience run
  lint typecheck test` — 113 core + 51 sdk tests pass, lint/typecheck
  clean on both packages.
- `pnpm --filter @imtbl/audience run build` — ESM (browser+node),
  CDN IIFE (52.04 KB), and rolled-up .d.ts all build clean.
- `pnpm --filter @imtbl/audience-sdk-sample-app run dev` — builds
  the sdk, starts the local server, demo loads at http://localhost:3456/
  with the CDN bundle served from /vendor/.
- `pnpm pack --pack-destination /tmp/...` in the sdk — tarball
  contains only dist/{browser,cdn,node,types}, LICENSE.md, README.md,
  and package.json. No demo, no vendor, no sample-app, no scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ImmutableJeffrey ImmutableJeffrey force-pushed the feat/audience-web-sdk-demo branch from 6f121bc to 28d0c5c Compare April 9, 2026 13:33
expect(warn).toHaveBeenCalledWith(expect.stringContaining('loaded twice'));
warn.mockRestore();
});
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The as any cast is needed because the inline type shapes AudienceError as typeof Error. If you type it as typeof AudienceError instead, you can construct it without the cast and the compiler will catch a broken constructor signature.

AudienceError: typeof Error;
IdentityType: Record<string, string>;
version: string;
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using typeof Audience (imported from ./sdk) here would turn this into a compile-time check that the CDN export shape matches the real class.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants