Skip to content

Commit 28d0c5c

Browse files
refactor(audience): move demo to sibling sdk-sample-app package
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>
1 parent 60c8827 commit 28d0c5c

13 files changed

Lines changed: 130 additions & 39 deletions

File tree

.eslintignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,3 @@ packages/internal/generated-clients/src/
2323
packages/game-bridge/scripts/**/*.js
2424
packages/audience/sdk/rollup.dts.config.js
2525
packages/audience/sdk/tsup.config.js
26-
packages/audience/sdk/demo/

packages/audience/sdk/demo/README.md renamed to packages/audience/sdk-sample-app/README.md

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,15 @@
1-
# @imtbl/audience — Demo
1+
# @imtbl/audience-sdk-sample-app
22

33
Single-page interactive harness that exercises every public method on the `Audience` class against the real Immutable backend.
44

55
## Run
66

77
```sh
8-
cd packages/audience/sdk
9-
pnpm demo
8+
cd packages/audience/sdk-sample-app
9+
pnpm dev
1010
```
1111

12-
This runs `pnpm build` (ESM + CDN bundle + types) then serves the package root on `http://localhost:3456`. Open:
13-
14-
```
15-
http://localhost:3456/demo/
16-
```
12+
This builds `@imtbl/audience` (ESM + CDN bundle + types) and then serves this sample app on `http://localhost:3456`. Open that URL directly — `index.html` is the entry point. The sample-app's local `serve.mjs` routes `/vendor/imtbl-audience.global.js` to the CDN bundle in `../sdk/dist/cdn/`, so the sdk's build output stays the single source of truth.
1713

1814
Stop the server with `Ctrl+C`.
1915

@@ -48,7 +44,7 @@ These are test-only keys registered for audience tracking. Safe to commit and sh
4844

4945
## Troubleshooting
5046

51-
- **`window.ImmutableAudience is undefined`** in the demo page: the CDN bundle failed to load. Re-run `pnpm build` from `packages/audience/sdk` and confirm `dist/cdn/imtbl-audience.global.js` exists.
47+
- **`window.ImmutableAudience is undefined`** in the demo page: the CDN bundle failed to load. Re-run `pnpm dev` from `packages/audience/sdk-sample-app` and confirm `../sdk/dist/cdn/imtbl-audience.global.js` exists.
5248
- **`POST /v1/audience/messages` returns 400**: the publishable key format is wrong. Must start with `pk_imapik-`.
5349
- **`POST /v1/audience/messages` returns 403**: the key isn't registered for audience tracking on the backend. Use one of the keys in the table above.
5450
- **Identify button is a no-op**: consent is not `full`. Click **Set full** first.
@@ -57,10 +53,12 @@ These are test-only keys registered for audience tracking. Safe to commit and sh
5753
## Files
5854

5955
```
60-
demo/
61-
index.html # single page, loads ../dist/cdn/imtbl-audience.global.js
56+
sdk-sample-app/
57+
index.html # single page, loads /vendor/imtbl-audience.global.js
6258
demo.js # vanilla ES2020, no modules; reads window.ImmutableAudience
6359
demo.css # light theme, hand-written CSS, no external deps
60+
serve.mjs # tiny Node static server; routes /vendor/ to ../sdk/dist/cdn/
61+
package.json # private workspace package; @imtbl/audience as workspace dep
6462
README.md # this file
6563
```
6664

packages/audience/sdk/demo/index.html renamed to packages/audience/sdk-sample-app/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ <h2 class="panel-title">Event Log</h2>
169169
</footer>
170170
</main>
171171

172-
<script src="../dist/cdn/imtbl-audience.global.js"></script>
172+
<script src="vendor/imtbl-audience.global.js"></script>
173173
<script src="demo.js"></script>
174174
</body>
175175
</html>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "@imtbl/audience-sdk-sample-app",
3+
"description": "Interactive demo harness for @imtbl/audience. Exercises every public method on the Audience class against the real dev/sandbox backend.",
4+
"version": "0.0.0",
5+
"author": "Immutable",
6+
"private": true,
7+
"engines": {
8+
"node": ">=20.11.0"
9+
},
10+
"devDependencies": {
11+
"@imtbl/audience": "workspace:*"
12+
},
13+
"scripts": {
14+
"dev": "pnpm --filter @imtbl/audience run build && node ./serve.mjs",
15+
"start": "pnpm dev"
16+
}
17+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
#!/usr/bin/env node
2+
/*
3+
* Static file server for the audience SDK sample app.
4+
*
5+
* Serves the sample-app's own files from this directory, and exposes the
6+
* CDN bundle (built into ../sdk/dist/cdn/) under /vendor/. This keeps the
7+
* sdk's dist/ as the single source of truth — no copy step, no gitignored
8+
* artifacts — while letting the demo's <script> tag load the bundle from
9+
* a same-origin URL (which keeps the CSP in index.html happy).
10+
*
11+
* Invoked by `pnpm dev`, after `pnpm --filter @imtbl/audience run build`
12+
* has populated ../sdk/dist/cdn/.
13+
*/
14+
import { createServer } from 'node:http';
15+
import { readFile } from 'node:fs/promises';
16+
import { resolve, join, extname } from 'node:path';
17+
import { fileURLToPath } from 'node:url';
18+
19+
const PORT = Number.parseInt(process.env.PORT ?? '3456', 10);
20+
// resolve() strips the trailing slash that fileURLToPath leaves on a dir URL,
21+
// so later `${HERE}/` concatenations yield clean paths, not `/foo//`.
22+
const HERE = resolve(fileURLToPath(new URL('.', import.meta.url)));
23+
const SDK_CDN = resolve(HERE, '..', 'sdk', 'dist', 'cdn');
24+
25+
const MIME = {
26+
'.html': 'text/html; charset=utf-8',
27+
'.js': 'application/javascript; charset=utf-8',
28+
'.mjs': 'application/javascript; charset=utf-8',
29+
'.css': 'text/css; charset=utf-8',
30+
'.map': 'application/json; charset=utf-8',
31+
'.ico': 'image/x-icon',
32+
'.svg': 'image/svg+xml',
33+
'.png': 'image/png',
34+
'.jpg': 'image/jpeg',
35+
'.json': 'application/json; charset=utf-8',
36+
};
37+
38+
/**
39+
* Resolve a request path to a file path inside one of the allowed roots.
40+
* Rejects paths that escape their root via .. segments.
41+
*/
42+
function resolveRequest(urlPath) {
43+
const decoded = decodeURIComponent(urlPath.split('?')[0]);
44+
const normalized = decoded === '/' ? '/index.html' : decoded;
45+
46+
if (normalized.startsWith('/vendor/')) {
47+
const rel = normalized.slice('/vendor/'.length);
48+
const filePath = resolve(SDK_CDN, rel);
49+
if (!filePath.startsWith(`${SDK_CDN}/`) && filePath !== SDK_CDN) return null;
50+
return filePath;
51+
}
52+
53+
const filePath = resolve(HERE, `.${normalized}`);
54+
if (!filePath.startsWith(`${HERE}/`) && filePath !== HERE) return null;
55+
// Don't serve this file or package metadata.
56+
const blocked = ['serve.mjs', 'package.json', 'node_modules'];
57+
for (const name of blocked) {
58+
if (filePath === join(HERE, name) || filePath.startsWith(`${join(HERE, name)}/`)) return null;
59+
}
60+
return filePath;
61+
}
62+
63+
const server = createServer(async (req, res) => {
64+
try {
65+
const filePath = resolveRequest(req.url ?? '/');
66+
if (!filePath) {
67+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
68+
res.end('403 forbidden');
69+
return;
70+
}
71+
const body = await readFile(filePath);
72+
const type = MIME[extname(filePath)] ?? 'application/octet-stream';
73+
res.writeHead(200, {
74+
'content-type': type,
75+
'cache-control': 'no-store',
76+
});
77+
res.end(body);
78+
} catch (err) {
79+
if (err && err.code === 'ENOENT') {
80+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
81+
res.end(`404 not found: ${req.url}`);
82+
return;
83+
}
84+
// eslint-disable-next-line no-console
85+
console.error('[serve]', err);
86+
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
87+
res.end('500 server error');
88+
}
89+
});
90+
91+
server.listen(PORT, () => {
92+
// eslint-disable-next-line no-console
93+
console.log(`audience sdk sample app: http://localhost:${PORT}/`);
94+
});

packages/audience/sdk/.eslintignore

Lines changed: 0 additions & 1 deletion
This file was deleted.

packages/audience/sdk/.eslintrc.cjs

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,4 @@ module.exports = {
44
project: './tsconfig.eslint.json',
55
tsconfigRootDir: __dirname,
66
},
7-
overrides: [
8-
{
9-
// demo/ is vanilla ES2020 — not part of the TS compilation graph.
10-
// Disable TypeScript-aware parser rules so lint-staged doesn't fail.
11-
files: ['demo/**/*.js'],
12-
parser: 'espree',
13-
parserOptions: {
14-
ecmaVersion: 2020,
15-
sourceType: 'script',
16-
project: null,
17-
},
18-
rules: {
19-
'@typescript-eslint/no-var-requires': 'off',
20-
'@typescript-eslint/no-unsafe-assignment': 'off',
21-
'@typescript-eslint/no-unsafe-call': 'off',
22-
'@typescript-eslint/no-unsafe-member-access': 'off',
23-
'@typescript-eslint/no-unsafe-return': 'off',
24-
'@typescript-eslint/no-explicit-any': 'off',
25-
'@typescript-eslint/explicit-module-boundary-types': 'off',
26-
},
27-
},
28-
],
297
};

packages/audience/sdk/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Once published to npm, you'll be able to load the package via CDN (no bundler re
2828
</script>
2929
```
3030

31-
Until the first npm release, you can build the CDN bundle locally from this repo: `cd packages/audience/sdk && pnpm build`. The output is at `dist/cdn/imtbl-audience.global.js`. See `demo/README.md` for the interactive demo that loads it.
31+
Until the first npm release, you can build the CDN bundle locally from this repo: `pnpm --filter @imtbl/audience run build`. The output is at `dist/cdn/imtbl-audience.global.js`. See `../sdk-sample-app/README.md` for the interactive demo that loads it.
3232

3333
## Quickstart
3434

@@ -138,7 +138,7 @@ Errors are delivered asynchronously (after the failing flush completes). Throwin
138138

139139
## Demo
140140

141-
There's an interactive demo under `demo/` that exercises every public method against the real backend. See `demo/README.md` for instructions.
141+
There's an interactive demo in the sibling workspace package `@imtbl/audience-sdk-sample-app` that exercises every public method against the real backend. See `../sdk-sample-app/README.md` for instructions.
142142

143143
## License
144144

0 commit comments

Comments
 (0)