diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index aac92ab883..696ea6805d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -198,3 +198,55 @@ jobs:
---
🔄 Updated automatically on each push to this PR
+
+ - name: Download feature bundle baseline
+ uses: dawidd6/action-download-artifact@v3
+ with:
+ workflow: publish.yml
+ branch: main
+ name: bundle-feature-baseline
+ path: e2e/bundle-check-app
+ if_no_artifact_found: warn
+ continue-on-error: true
+
+ - name: Run feature bundle check
+ id: bundle-features
+ run: |
+ pnpm nx nxBundle bundle-check-app --skip-sync --no-agents
+ {
+ echo 'report<> "$GITHUB_OUTPUT"
+
+ - name: Find feature bundle comment
+ id: find-feature-comment
+ uses: peter-evans/find-comment@v4
+ with:
+ issue-number: ${{ github.event.pull_request.number }}
+ comment-author: 'github-actions[bot]'
+ body-includes:
+
+ - name: Create or update feature bundle comment
+ uses: peter-evans/create-or-update-comment@v5
+ with:
+ comment-id: ${{ steps.find-feature-comment.outputs.comment-id }}
+ issue-number: ${{ github.event.pull_request.number }}
+ edit-mode: replace
+ body: |
+
+ ## Tree-shaken Feature Bundle Sizes
+
+ Minified + gzip level-9 cost of each SDK feature in isolation, as a consumer would receive it.
+
+ ${{ steps.bundle-features.outputs.report }}
+
+
+ How these are measured
+
+ Each fixture imports a single feature and is bundled with Rollup + esbuild + terser (full minification, ESM, tree-shaking on). Numbers reflect what a consumer ships and what their users download, not the raw dist size.
+
+
+
+ ---
+ Updated automatically on each push to this PR
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index ba1ee09e80..2a90f8cddd 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -93,6 +93,16 @@ jobs:
path: previous_sizes.json
retention-days: 30
+ - name: Run feature bundle check
+ run: pnpm nx nxBundle bundle-check-app --skip-sync
+
+ - name: Upload feature bundle baseline
+ uses: actions/upload-artifact@v5
+ with:
+ name: bundle-feature-baseline
+ path: e2e/bundle-check-app/dist/bundle-feature-baseline.json
+ retention-days: 30
+
snapshot:
# Guard against publishing snapshots from the protected release branch.
if: >-
diff --git a/e2e/bundle-check-app/fixtures/davinci-client-flow.ts b/e2e/bundle-check-app/fixtures/davinci-client-flow.ts
new file mode 100644
index 0000000000..a6cfebabb5
--- /dev/null
+++ b/e2e/bundle-check-app/fixtures/davinci-client-flow.ts
@@ -0,0 +1,31 @@
+import { davinci } from '@forgerock/davinci-client';
+
+const client = await davinci({
+ config: {
+ serverConfig: { wellknown: 'https://example.com/.well-known/openid-configuration' },
+ clientId: 'test-client',
+ redirectUri: 'https://example.com/callback',
+ scope: 'openid profile',
+ },
+});
+
+let node = await client.start();
+
+// Walk the flow until it reaches a terminal node
+while (node.status === 'continue') {
+ for (const collector of node.collectors) {
+ if (collector.category === 'SingleValueCollector' && collector.type === 'TextCollector') {
+ client.update(collector)('test-value');
+ }
+ if (collector.category === 'SingleValueCollector' && collector.type === 'PasswordCollector') {
+ client.update(collector)('test-password');
+ }
+ }
+ node = await client.next();
+}
+
+if (node.status === 'success') {
+ console.log('Login successful', node.session);
+} else if (node.status === 'error' || node.status === 'failure') {
+ console.error('Login failed', node.error);
+}
diff --git a/e2e/bundle-check-app/fixtures/davinci-client.ts b/e2e/bundle-check-app/fixtures/davinci-client.ts
new file mode 100644
index 0000000000..1dd7eee81e
--- /dev/null
+++ b/e2e/bundle-check-app/fixtures/davinci-client.ts
@@ -0,0 +1,13 @@
+import { davinci } from '@forgerock/davinci-client';
+
+const client = await davinci({
+ config: {
+ serverConfig: { wellknown: 'https://example.com/.well-known/openid-configuration' },
+ clientId: 'test-client',
+ redirectUri: 'https://example.com/callback',
+ scope: 'openid profile',
+ },
+});
+
+const node = await client.start();
+console.log(node);
diff --git a/e2e/bundle-check-app/fixtures/device-client.ts b/e2e/bundle-check-app/fixtures/device-client.ts
new file mode 100644
index 0000000000..8bd506fcd0
--- /dev/null
+++ b/e2e/bundle-check-app/fixtures/device-client.ts
@@ -0,0 +1,20 @@
+import { deviceClient } from '@forgerock/device-client';
+
+const client = deviceClient({
+ serverConfig: {
+ baseUrl: 'https://example.com/am',
+ },
+ realmPath: 'root',
+});
+
+// Retrieve OATH (TOTP/HOTP) devices for a user
+const oathDevices = await client.oath.get({
+ userId: 'user@example.com',
+});
+console.log(oathDevices);
+
+// Retrieve Push notification devices
+const pushDevices = await client.push.get({
+ userId: 'user@example.com',
+});
+console.log(pushDevices);
diff --git a/e2e/bundle-check-app/fixtures/effect-barrel-option.ts b/e2e/bundle-check-app/fixtures/effect-barrel-option.ts
new file mode 100644
index 0000000000..b76230a8f5
--- /dev/null
+++ b/e2e/bundle-check-app/fixtures/effect-barrel-option.ts
@@ -0,0 +1,13 @@
+// Same usage but imports from the top-level 'effect' barrel instead of 'effect/Option'
+// Tests whether the barrel import prevents tree-shaking vs the subpath
+import { Option, pipe } from 'effect';
+
+const result = pipe(
+ Option.fromNullable(Math.random() > 0.5 ? 'hello' : null),
+ Option.match({
+ onNone: () => 'none',
+ onSome: (v) => v.toUpperCase(),
+ }),
+);
+
+console.log(result);
diff --git a/e2e/bundle-check-app/fixtures/effect-subpath-option.ts b/e2e/bundle-check-app/fixtures/effect-subpath-option.ts
new file mode 100644
index 0000000000..d524b2c7cb
--- /dev/null
+++ b/e2e/bundle-check-app/fixtures/effect-subpath-option.ts
@@ -0,0 +1,14 @@
+// Imports Option via the subpath: effect/Option
+// Tests whether Rollup tree-shakes to only fromNullable + match
+import * as Option from 'effect/Option';
+import { pipe } from 'effect/Function';
+
+const result = pipe(
+ Option.fromNullable(Math.random() > 0.5 ? 'hello' : null),
+ Option.match({
+ onNone: () => 'none',
+ onSome: (v) => v.toUpperCase(),
+ }),
+);
+
+console.log(result);
diff --git a/e2e/bundle-check-app/fixtures/journey-client-device.ts b/e2e/bundle-check-app/fixtures/journey-client-device.ts
new file mode 100644
index 0000000000..a9ceb581a4
--- /dev/null
+++ b/e2e/bundle-check-app/fixtures/journey-client-device.ts
@@ -0,0 +1,14 @@
+import { journey } from '@forgerock/journey-client';
+import { Device } from '@forgerock/journey-client/device';
+
+const client = await journey({
+ config: { serverConfig: { wellknown: 'https://example.com/.well-known/openid-configuration' } },
+});
+
+const step = await client.start();
+
+if (step.type === 'Step') {
+ const device = new Device();
+ const profile = await device.getProfile({ collectLocation: false });
+ console.log(profile);
+}
diff --git a/e2e/bundle-check-app/fixtures/journey-client-policy.ts b/e2e/bundle-check-app/fixtures/journey-client-policy.ts
new file mode 100644
index 0000000000..3d7c9b8740
--- /dev/null
+++ b/e2e/bundle-check-app/fixtures/journey-client-policy.ts
@@ -0,0 +1,13 @@
+import { journey } from '@forgerock/journey-client';
+import { Policy } from '@forgerock/journey-client/policy';
+
+const client = await journey({
+ config: { serverConfig: { wellknown: 'https://example.com/.well-known/openid-configuration' } },
+});
+
+const step = await client.start();
+
+if (step.type === 'Step') {
+ const errors = Policy.parseErrors(step.callbacks);
+ console.log(errors);
+}
diff --git a/e2e/bundle-check-app/fixtures/journey-client-qr-code.ts b/e2e/bundle-check-app/fixtures/journey-client-qr-code.ts
new file mode 100644
index 0000000000..5aee099a81
--- /dev/null
+++ b/e2e/bundle-check-app/fixtures/journey-client-qr-code.ts
@@ -0,0 +1,13 @@
+import { journey } from '@forgerock/journey-client';
+import { QRCode } from '@forgerock/journey-client/qr-code';
+
+const client = await journey({
+ config: { serverConfig: { wellknown: 'https://example.com/.well-known/openid-configuration' } },
+});
+
+const step = await client.start();
+
+if (step.type === 'Step') {
+ const qrCode = QRCode.getQRCodeData(step);
+ console.log(qrCode);
+}
diff --git a/e2e/bundle-check-app/fixtures/journey-client-recovery-codes.ts b/e2e/bundle-check-app/fixtures/journey-client-recovery-codes.ts
new file mode 100644
index 0000000000..c8d1e909d9
--- /dev/null
+++ b/e2e/bundle-check-app/fixtures/journey-client-recovery-codes.ts
@@ -0,0 +1,13 @@
+import { journey } from '@forgerock/journey-client';
+import { RecoveryCodes } from '@forgerock/journey-client/recovery-codes';
+
+const client = await journey({
+ config: { serverConfig: { wellknown: 'https://example.com/.well-known/openid-configuration' } },
+});
+
+const step = await client.start();
+
+if (step.type === 'Step') {
+ const codes = RecoveryCodes.getCodes(step);
+ console.log(codes);
+}
diff --git a/e2e/bundle-check-app/fixtures/journey-client-webauthn.ts b/e2e/bundle-check-app/fixtures/journey-client-webauthn.ts
new file mode 100644
index 0000000000..2dcc16edbd
--- /dev/null
+++ b/e2e/bundle-check-app/fixtures/journey-client-webauthn.ts
@@ -0,0 +1,16 @@
+import { journey } from '@forgerock/journey-client';
+import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn';
+
+const client = await journey({
+ config: { serverConfig: { wellknown: 'https://example.com/.well-known/openid-configuration' } },
+});
+
+let step = await client.start();
+
+while (step.type === 'Step') {
+ const stepType = WebAuthn.getWebAuthnStepType(step);
+ if (stepType === WebAuthnStepType.Authenticate || stepType === WebAuthnStepType.Register) {
+ await WebAuthn.getWebAuthnOutcome(step);
+ }
+ step = await client.next(step);
+}
diff --git a/e2e/bundle-check-app/fixtures/journey-client.ts b/e2e/bundle-check-app/fixtures/journey-client.ts
new file mode 100644
index 0000000000..b484929f94
--- /dev/null
+++ b/e2e/bundle-check-app/fixtures/journey-client.ts
@@ -0,0 +1,15 @@
+import { journey } from '@forgerock/journey-client';
+
+const client = await journey({
+ config: { serverConfig: { wellknown: 'https://example.com/.well-known/openid-configuration' } },
+});
+
+let step = await client.start();
+
+while (step.type === 'Step') {
+ if (step.callbacks.some((cb) => cb.type === 'RedirectCallback')) {
+ await client.redirect(step);
+ break;
+ }
+ step = await client.next(step);
+}
diff --git a/e2e/bundle-check-app/fixtures/journey-full.ts b/e2e/bundle-check-app/fixtures/journey-full.ts
new file mode 100644
index 0000000000..04a0a8be54
--- /dev/null
+++ b/e2e/bundle-check-app/fixtures/journey-full.ts
@@ -0,0 +1,35 @@
+/**
+ * Realistic consumer pattern: journey authentication + WebAuthn + device fingerprinting.
+ * Mirrors what a real app would import for a complete login experience.
+ */
+import { journey } from '@forgerock/journey-client';
+import { WebAuthn } from '@forgerock/journey-client/webauthn';
+import { Device } from '@forgerock/journey-client/device';
+
+const config = {
+ serverConfig: { wellknown: 'https://example.com/.well-known/openid-configuration' },
+};
+
+// Collect device profile before authentication
+const device = new Device();
+const profile = await device.getProfile({ location: false, metadata: false });
+console.log('device profile collected', profile);
+
+const client = await journey({ config });
+let step = await client.start();
+
+while (step.type === 'Step') {
+ // Check if this step requires WebAuthn
+ const webAuthnType = WebAuthn.getWebAuthnStepType(step);
+ if (webAuthnType !== 'None') {
+ console.log('webauthn step type', webAuthnType);
+ }
+
+ step = await client.next(step);
+}
+
+if (step.type === 'LoginSuccess') {
+ console.log('authenticated', step.getSessionToken());
+} else if (step.type === 'LoginFailure') {
+ console.error('authentication failed', step);
+}
diff --git a/e2e/bundle-check-app/fixtures/oidc-client-logout.ts b/e2e/bundle-check-app/fixtures/oidc-client-logout.ts
new file mode 100644
index 0000000000..4088ee58f8
--- /dev/null
+++ b/e2e/bundle-check-app/fixtures/oidc-client-logout.ts
@@ -0,0 +1,22 @@
+import { oidc } from '@forgerock/oidc-client';
+
+const client = await oidc({
+ config: {
+ serverConfig: { wellknown: 'https://example.com/.well-known/openid-configuration' },
+ clientId: 'test-client',
+ redirectUri: 'https://example.com/callback',
+ scope: 'openid profile',
+ },
+});
+
+if ('error' in client) {
+ console.error(client.error);
+} else {
+ // Revoke tokens (server-side invalidation)
+ const revokeResult = await client.token.revoke();
+ console.log(revokeResult);
+
+ // Full logout (revoke + end session endpoint)
+ const logoutResult = await client.logout();
+ console.log(logoutResult);
+}
diff --git a/e2e/bundle-check-app/fixtures/oidc-client-token.ts b/e2e/bundle-check-app/fixtures/oidc-client-token.ts
new file mode 100644
index 0000000000..838275a6c1
--- /dev/null
+++ b/e2e/bundle-check-app/fixtures/oidc-client-token.ts
@@ -0,0 +1,30 @@
+import { oidc } from '@forgerock/oidc-client';
+
+const client = await oidc({
+ config: {
+ serverConfig: { wellknown: 'https://example.com/.well-known/openid-configuration' },
+ clientId: 'test-client',
+ redirectUri: 'https://example.com/callback',
+ scope: 'openid profile',
+ },
+});
+
+if ('error' in client) {
+ console.error(client.error);
+} else {
+ // Token exchange after authorization code callback
+ const tokens = await client.token.exchange('auth-code-123', 'state-abc');
+ if ('error' in tokens) {
+ console.error(tokens.error);
+ } else {
+ console.log(tokens);
+ }
+
+ // Retrieve stored tokens (with auto-renew if backgroundRenew is set)
+ const stored = await client.token.get({ backgroundRenew: false });
+ if ('error' in stored) {
+ console.error(stored.error);
+ } else {
+ console.log(stored);
+ }
+}
diff --git a/e2e/bundle-check-app/fixtures/oidc-client.ts b/e2e/bundle-check-app/fixtures/oidc-client.ts
new file mode 100644
index 0000000000..fc1d35ecaa
--- /dev/null
+++ b/e2e/bundle-check-app/fixtures/oidc-client.ts
@@ -0,0 +1,13 @@
+import { oidc } from '@forgerock/oidc-client';
+
+const client = await oidc({
+ config: {
+ serverConfig: { wellknown: 'https://example.com/.well-known/openid-configuration' },
+ clientId: 'test-client',
+ redirectUri: 'https://example.com/callback',
+ scope: 'openid profile',
+ },
+});
+
+const url = await client.getAuthorizationUrl();
+console.log(url);
diff --git a/e2e/bundle-check-app/fixtures/protect.ts b/e2e/bundle-check-app/fixtures/protect.ts
new file mode 100644
index 0000000000..3767335399
--- /dev/null
+++ b/e2e/bundle-check-app/fixtures/protect.ts
@@ -0,0 +1,15 @@
+import { protect } from '@forgerock/protect';
+
+const api = protect({
+ envId: 'example-env-id',
+ behavioralDataCollection: true,
+});
+
+const startResult = await api.start();
+
+if (startResult && 'error' in startResult) {
+ console.error(startResult.error);
+} else {
+ const data = await api.getData();
+ console.log(data);
+}
diff --git a/e2e/bundle-check-app/fixtures/redirect.ts b/e2e/bundle-check-app/fixtures/redirect.ts
new file mode 100644
index 0000000000..f9c3144631
--- /dev/null
+++ b/e2e/bundle-check-app/fixtures/redirect.ts
@@ -0,0 +1,11 @@
+import { journey } from '@forgerock/journey-client';
+
+const client = await journey({
+ config: { serverConfig: { wellknown: 'https://example.com/.well-known/openid-configuration' } },
+});
+
+const step = await client.start();
+
+if (step.type === 'Step') {
+ await client.redirect(step);
+}
diff --git a/e2e/bundle-check-app/package.json b/e2e/bundle-check-app/package.json
new file mode 100644
index 0000000000..e08c2d3d44
--- /dev/null
+++ b/e2e/bundle-check-app/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "@forgerock/bundle-check-app",
+ "version": "0.0.1",
+ "private": true,
+ "description": "Measures tree-shaken, gzip-level-9 bundle cost of journey-client features via isolated Rollup fixtures",
+ "type": "module",
+ "sideEffects": false,
+ "scripts": {
+ "bundle": "pnpm nx nxBundle bundle-check-app"
+ },
+ "dependencies": {
+ "@forgerock/davinci-client": "workspace:*",
+ "@forgerock/device-client": "workspace:*",
+ "@forgerock/journey-client": "workspace:*",
+ "@forgerock/oidc-client": "workspace:*",
+ "@forgerock/protect": "workspace:*",
+ "effect": "catalog:effect"
+ },
+ "devDependencies": {
+ "@rollup/plugin-node-resolve": "^16.0.0",
+ "@rollup/plugin-terser": "^1.0.0",
+ "rollup": "^4.59.0",
+ "rollup-plugin-esbuild": "^6.2.1",
+ "tsx": "^4.20.0"
+ },
+ "nx": {
+ "tags": ["scope:e2e"],
+ "targets": {
+ "nxBundle": {
+ "dependsOn": ["^nxBuild"],
+ "command": "tsx {projectRoot}/src/bundle.ts",
+ "inputs": [
+ "{projectRoot}/src/**/*.ts",
+ "{projectRoot}/fixtures/**/*.ts",
+ "{projectRoot}/bundle-feature-baseline.json",
+ "{workspaceRoot}/packages/journey-client/dist/**/*.js"
+ ],
+ "outputs": ["{projectRoot}/dist"]
+ }
+ }
+ }
+}
diff --git a/e2e/bundle-check-app/src/bundle.ts b/e2e/bundle-check-app/src/bundle.ts
new file mode 100644
index 0000000000..43fceadf5f
--- /dev/null
+++ b/e2e/bundle-check-app/src/bundle.ts
@@ -0,0 +1,164 @@
+/*
+ * Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+/**
+ * Measures tree-shaken, fully-minified bundle cost of each fixture in `../fixtures/`.
+ * Reports both minified (raw) size and gzip level-9 size — the two numbers that matter
+ * to a consumer: what they ship and what their users download.
+ *
+ * Mirrors the approach used by the Effect team:
+ * https://github.com/Effect-TS/effect-smol/blob/main/packages/tools/bundle/src/Rollup.ts
+ *
+ * Run via: pnpm nx nxBundle bundle-check-app
+ */
+
+import { createGzip } from 'node:zlib';
+import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
+import * as path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { rollup } from 'rollup';
+import { nodeResolve } from '@rollup/plugin-node-resolve';
+import terser from '@rollup/plugin-terser';
+import esbuild from 'rollup-plugin-esbuild';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const fixturesDir = path.resolve(__dirname, '../fixtures');
+const outDir = path.resolve(__dirname, '../dist');
+
+mkdirSync(outDir, { recursive: true });
+
+const fixtures = readdirSync(fixturesDir)
+ .filter((f) => f.endsWith('.ts'))
+ .sort();
+
+if (fixtures.length === 0) {
+ console.error('No fixtures found in', fixturesDir);
+ process.exit(1);
+}
+
+console.log(`\nMeasuring ${fixtures.length} fixture(s)…\n`);
+
+/** Gzip a string buffer at level 9 and return the compressed byte count. */
+function gzipSize(code: string): Promise {
+ return new Promise((resolve, reject) => {
+ const gz = createGzip({ level: 9 });
+ let total = 0;
+ gz.on('data', (chunk: Buffer) => {
+ total += chunk.length;
+ });
+ gz.on('end', () => resolve(total));
+ gz.on('error', reject);
+ gz.end(Buffer.from(code, 'utf8'));
+ });
+}
+
+function fmt(bytes: number): string {
+ return `${(bytes / 1000).toFixed(2)} kB`;
+}
+
+type Row = { name: string; minifiedBytes: number; gzipBytes: number };
+
+const rows: Row[] = [];
+
+for (const fixture of fixtures) {
+ const inputPath = path.join(fixturesDir, fixture);
+ const name = path.basename(fixture, '.ts');
+
+ const bundle = await rollup({
+ input: inputPath,
+ plugins: [
+ nodeResolve({ browser: true }),
+ esbuild({ target: 'esnext', format: 'esm', treeShaking: true }),
+ // @ts-ignore – NodeNext moduleResolution misidentifies the default export type for @rollup/plugin-terser
+ terser({ format: { comments: false }, compress: true, mangle: true }),
+ ],
+ onwarn: (warning, next) => {
+ if (warning.code === 'THIS_IS_UNDEFINED') return;
+ if (warning.code === 'CIRCULAR_DEPENDENCY') return;
+ next(warning);
+ },
+ });
+
+ const { output } = await bundle.generate({ format: 'esm' });
+ await bundle.close();
+
+ const code = output
+ .filter((chunk) => chunk.type === 'chunk')
+ .map((chunk) => chunk.code)
+ .join('');
+
+ // Write minified output for inspection
+ writeFileSync(path.join(outDir, `${name}.min.js`), code, 'utf8');
+
+ rows.push({
+ name: fixture,
+ minifiedBytes: Buffer.byteLength(code, 'utf8'),
+ gzipBytes: await gzipSize(code),
+ });
+}
+
+// --- Baseline JSON (raw bytes, used for PR comparison) ---
+type Baseline = Record;
+const baseline: Baseline = Object.fromEntries(
+ rows.map((r) => [r.name, { minifiedBytes: r.minifiedBytes, gzipBytes: r.gzipBytes }]),
+);
+writeFileSync(
+ path.join(outDir, 'bundle-feature-baseline.json'),
+ JSON.stringify(baseline, null, 2) + '\n',
+ 'utf8',
+);
+
+// --- Markdown table ---
+const pad = (s: string, w: number) => s.padStart(w);
+const padL = (s: string, w: number) => s.padEnd(w);
+
+// Load baseline for comparison if available
+let prevBaseline: Baseline = {};
+const prevBaselinePath = path.resolve(__dirname, '../bundle-feature-baseline.json');
+try {
+ prevBaseline = JSON.parse(readFileSync(prevBaselinePath, 'utf8')) as Baseline;
+} catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
+ console.warn('Failed to load previous baseline:', err);
+ }
+}
+
+function delta(current: number, prev: number | undefined): string {
+ if (prev === undefined) return ' 🆕';
+ const diff = current - prev;
+ if (diff === 0) return '';
+ const sign = diff > 0 ? '+' : '';
+ return ` (${sign}${fmt(diff)} ${diff > 0 ? '🔺' : '🔻'})`;
+}
+
+type TableRow = { name: string; minified: string; gzip: string };
+const tableRows: TableRow[] = rows.map((r) => {
+ const prev = prevBaseline[r.name];
+ return {
+ name: r.name,
+ minified: fmt(r.minifiedBytes) + delta(r.minifiedBytes, prev?.minifiedBytes),
+ gzip: fmt(r.gzipBytes) + delta(r.gzipBytes, prev?.gzipBytes),
+ };
+});
+
+const nameW = Math.max(...tableRows.map((r) => r.name.length)) + 2; // +2 for backticks
+const minW = Math.max('Minified'.length, ...tableRows.map((r) => r.minified.length));
+const gzipW = Math.max('gzip (lvl 9)'.length, ...tableRows.map((r) => r.gzip.length));
+
+const header = `| ${padL('Fixture', nameW)} | ${pad('Minified', minW)} | ${pad('gzip (lvl 9)', gzipW)} |`;
+const divider = `|:${'-'.repeat(nameW)}-|-${'-'.repeat(minW)}:|-${'-'.repeat(gzipW)}:|`;
+const mdRows = tableRows.map(
+ (r) => `| ${padL(`\`${r.name}\``, nameW)} | ${pad(r.minified, minW)} | ${pad(r.gzip, gzipW)} |`,
+);
+
+const table = [header, divider, ...mdRows].join('\n');
+
+console.log(table);
+console.log();
+
+// Write markdown report for CI consumption
+writeFileSync(path.join(outDir, 'bundle-feature-report.md'), table + '\n', 'utf8');
diff --git a/e2e/bundle-check-app/tsconfig.json b/e2e/bundle-check-app/tsconfig.json
new file mode 100644
index 0000000000..0582b61964
--- /dev/null
+++ b/e2e/bundle-check-app/tsconfig.json
@@ -0,0 +1,33 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "target": "ESNext",
+ "lib": ["ESNext", "DOM"],
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "noUnusedLocals": true,
+ "noImplicitReturns": true,
+ "outDir": "dist"
+ },
+ "include": ["src/**/*.ts"],
+ "references": [
+ {
+ "path": "../../packages/protect"
+ },
+ {
+ "path": "../../packages/oidc-client"
+ },
+ {
+ "path": "../../packages/journey-client"
+ },
+ {
+ "path": "../../packages/device-client"
+ },
+ {
+ "path": "../../packages/davinci-client"
+ }
+ ]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index babd5d7de7..6d1a31e9da 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -295,6 +295,43 @@ importers:
specifier: ^5.0.0
version: 5.0.5
+ e2e/bundle-check-app:
+ dependencies:
+ '@forgerock/davinci-client':
+ specifier: workspace:*
+ version: link:../../packages/davinci-client
+ '@forgerock/device-client':
+ specifier: workspace:*
+ version: link:../../packages/device-client
+ '@forgerock/journey-client':
+ specifier: workspace:*
+ version: link:../../packages/journey-client
+ '@forgerock/oidc-client':
+ specifier: workspace:*
+ version: link:../../packages/oidc-client
+ '@forgerock/protect':
+ specifier: workspace:*
+ version: link:../../packages/protect
+ effect:
+ specifier: catalog:effect
+ version: 3.20.0
+ devDependencies:
+ '@rollup/plugin-node-resolve':
+ specifier: ^16.0.0
+ version: 16.0.3(rollup@4.59.0)
+ '@rollup/plugin-terser':
+ specifier: ^1.0.0
+ version: 1.0.0(rollup@4.59.0)
+ rollup:
+ specifier: ^4.59.0
+ version: 4.59.0
+ rollup-plugin-esbuild:
+ specifier: ^6.2.1
+ version: 6.2.1(esbuild@0.27.2)(rollup@4.59.0)
+ tsx:
+ specifier: ^4.20.0
+ version: 4.21.0
+
e2e/davinci-app:
dependencies:
'@forgerock/davinci-client':
@@ -2843,6 +2880,33 @@ packages:
react-redux:
optional: true
+ '@rollup/plugin-node-resolve@16.0.3':
+ resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ rollup: ^4.59.0
+ peerDependenciesMeta:
+ rollup:
+ optional: true
+
+ '@rollup/plugin-terser@1.0.0':
+ resolution: {integrity: sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ rollup: ^4.59.0
+ peerDependenciesMeta:
+ rollup:
+ optional: true
+
+ '@rollup/pluginutils@5.3.0':
+ resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ rollup: ^4.59.0
+ peerDependenciesMeta:
+ rollup:
+ optional: true
+
'@rollup/rollup-android-arm-eabi@4.59.0':
resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
cpu: [arm]
@@ -3301,6 +3365,9 @@ packages:
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
+ '@types/resolve@1.20.2':
+ resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
+
'@types/responselike@1.0.0':
resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==}
@@ -5863,6 +5930,9 @@ packages:
resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
engines: {node: '>= 0.4'}
+ is-module@1.0.0:
+ resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==}
+
is-negative-zero@2.0.3:
resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
engines: {node: '>= 0.4'}
@@ -7356,6 +7426,13 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+ rollup-plugin-esbuild@6.2.1:
+ resolution: {integrity: sha512-jTNOMGoMRhs0JuueJrJqbW8tOwxumaWYq+V5i+PD+8ecSCVkuX27tGW7BXqDgoULQ55rO7IdNxPcnsWtshz3AA==}
+ engines: {node: '>=14.18.0'}
+ peerDependencies:
+ esbuild: '>=0.18.0'
+ rollup: ^4.59.0
+
rollup@4.59.0:
resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -7468,6 +7545,10 @@ packages:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
+ serialize-javascript@7.0.5:
+ resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==}
+ engines: {node: '>=20.0.0'}
+
serve-static@1.16.2:
resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==}
engines: {node: '>= 0.8.0'}
@@ -7554,6 +7635,10 @@ packages:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
+ smob@1.6.1:
+ resolution: {integrity: sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==}
+ engines: {node: '>=20.0.0'}
+
smol-toml@1.6.1:
resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
engines: {node: '>= 18'}
@@ -8184,6 +8269,10 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
+ unplugin-utils@0.2.5:
+ resolution: {integrity: sha512-gwXJnPRewT4rT7sBi/IvxKTjsms7jX7QIDLOClApuZwR49SXbrB1z2NLUZ+vDHyqCj/n58OzRRqaW+B8OZi8vg==}
+ engines: {node: '>=18.12.0'}
+
unrs-resolver@1.11.1:
resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==}
@@ -8223,6 +8312,7 @@ packages:
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
+ deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
v8-compile-cache-lib@3.0.1:
@@ -11153,6 +11243,32 @@ snapshots:
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
+ '@rollup/plugin-node-resolve@16.0.3(rollup@4.59.0)':
+ dependencies:
+ '@rollup/pluginutils': 5.3.0(rollup@4.59.0)
+ '@types/resolve': 1.20.2
+ deepmerge: 4.3.1
+ is-module: 1.0.0
+ resolve: 1.22.11
+ optionalDependencies:
+ rollup: 4.59.0
+
+ '@rollup/plugin-terser@1.0.0(rollup@4.59.0)':
+ dependencies:
+ serialize-javascript: 7.0.5
+ smob: 1.6.1
+ terser: 5.46.2
+ optionalDependencies:
+ rollup: 4.59.0
+
+ '@rollup/pluginutils@5.3.0(rollup@4.59.0)':
+ dependencies:
+ '@types/estree': 1.0.8
+ estree-walker: 2.0.2
+ picomatch: 4.0.4
+ optionalDependencies:
+ rollup: 4.59.0
+
'@rollup/rollup-android-arm-eabi@4.59.0':
optional: true
@@ -11579,6 +11695,8 @@ snapshots:
'@types/range-parser@1.2.7': {}
+ '@types/resolve@1.20.2': {}
+
'@types/responselike@1.0.0':
dependencies:
'@types/node': 24.9.2
@@ -14685,6 +14803,8 @@ snapshots:
is-map@2.0.3: {}
+ is-module@1.0.0: {}
+
is-negative-zero@2.0.3: {}
is-node-process@1.2.0: {}
@@ -16385,6 +16505,17 @@ snapshots:
reusify@1.1.0: {}
+ rollup-plugin-esbuild@6.2.1(esbuild@0.27.2)(rollup@4.59.0):
+ dependencies:
+ debug: 4.4.3
+ es-module-lexer: 1.7.0
+ esbuild: 0.27.2
+ get-tsconfig: 4.13.0
+ rollup: 4.59.0
+ unplugin-utils: 0.2.5
+ transitivePeerDependencies:
+ - supports-color
+
rollup@4.59.0:
dependencies:
'@types/estree': 1.0.8
@@ -16541,6 +16672,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ serialize-javascript@7.0.5: {}
+
serve-static@1.16.2:
dependencies:
encodeurl: 2.0.0
@@ -16651,6 +16784,8 @@ snapshots:
slash@3.0.0: {}
+ smob@1.6.1: {}
+
smol-toml@1.6.1: {}
sonic-boom@3.8.1:
@@ -17302,6 +17437,11 @@ snapshots:
unpipe@1.0.0: {}
+ unplugin-utils@0.2.5:
+ dependencies:
+ pathe: 2.0.3
+ picomatch: 4.0.4
+
unrs-resolver@1.11.1:
dependencies:
napi-postinstall: 0.3.4
diff --git a/tsconfig.json b/tsconfig.json
index c22692ddac..21a3509c49 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -84,6 +84,9 @@
},
{
"path": "./tools/api-report"
+ },
+ {
+ "path": "./e2e/bundle-check-app"
}
]
}