Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/widget-mangle-private.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": patch
---

The panel is about 240 B smaller gzipped (16.6 kB) and the single-file build about 300 B (18.62 kB): the minifier now also renames 55 more private class members, each checked to be used only inside its class.
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ ANTHROPIC_API_KEY=sk-... npx tsx src/examples/express-server.ts

`npx` downloads `tsx` the first time.

The widget has a size budget, gzipped: 5 kB for the script tag loader (`dist/chatcops.min.js`), 17 kB for the chat panel it loads when the chat opens (`dist/chatcops.panel.js`), 2 kB for the pre-chat form chunk, 19 kB for the single-file build (`dist/chatcops.full.min.js`) and 30 kB for the unminified ESM build. CI checks them with `pnpm size` after a build (limits in `.size-limit.json`).
The widget has a size budget, gzipped: 5 kB for the script tag loader (`dist/chatcops.min.js`), 17 kB for the chat panel it loads when the chat opens (`dist/chatcops.panel.js`), 2 kB for the pre-chat form chunk, 19 kB for the single-file build (`dist/chatcops.full.min.js`) and 30 kB for the unminified ESM build. CI checks them with `pnpm size` after a build (limits in `.size-limit.json`). To save bytes, the minifier renames the private class members listed in `packages/widget/mangle.ts`. A name can go on that list only if the code touches it through `this` and nowhere else, which `tests/mangle.test.ts` checks. A config field, a request body key or a DOM property renamed there would break only the CDN build.

### Browser tests

Expand Down
34 changes: 34 additions & 0 deletions packages/widget/mangle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Private class members the minifier renames to short names in the panel
* and single-file builds, to keep them in their size budgets. Renaming a
* property renames it everywhere in the bundle, so a name that is also a
* config field, a key in a request body, a stored field or a DOM property
* would break the build that sites load from the CDN, and only there.
* tests/mangle.test.ts checks every name in CHECKED.
*/

/** Names from before the check. Internal on both ends, and the e2e suite runs against the minified build. */
const EARLIER = [
'mounted', 'fab', 'panel', 'bubble', 'preChatFormEl', 'namespace', 'hydrated', 'isStreaming', 'destroyed',
'abortController', 'retryRequests', 'eventHandlers', 'containerEl', 'autoOpenTimer', 'unreadReplies',
'savedOverflow', 'shadow', 'unreadLabel', 'openLabel', 'closeLabel', 'badge', 'el', 'inline',
'messagesContainer', 'isVisible', 'typingEl', 'suggestionsEl', 'area', 'textarea', 'sendBtn', 'onSend', 'timerId',
];

/**
* Declared `private`, and only ever read or written as `this.<name>`: never
* an object key, never on another object, never in a string.
*/
export const CHECKED = [
'announcement', 'autoResize', 'cleanupEmptyActionContainers', 'clearError', 'createActionButton',
'createActionsContainer', 'createField', 'createMessageElement', 'createRequest', 'disclosureEl', 'endStream',
'ensureActionsContainer', 'errorElements', 'escapeHtml', 'fieldElements', 'findLatestRegeneratableAssistant',
'findMessage', 'findUserMessageForAssistant', 'getErrorMessage', 'handleKeydown', 'handlePreChatSubmit',
'handleRegenerate', 'handleRetry', 'handleSend', 'handleSubmit', 'isStale', 'isValidEmail', 'lockScroll',
'markFailed', 'nearBottom', 'pageData', 'panelLabels', 'renderDisclosure', 'renderHeader', 'renderMessageElement',
'renderStream', 'renderSuggestions', 'resolveContainer', 'resolveErrorType', 'restoreScroll', 'saveHistory',
'scrollToBottom', 'sendToServer', 'sessionKey', 'setHidden', 'showChatView', 'showError',
'showLatestRegenerateButton', 'streams', 'submitBtn', 'ttlMs', 'updateSubmitState', 'userDataSentWith', 'userFromSite',
];

export const MANGLED_PROPERTIES = new RegExp(`^(?:${[...EARLIER, ...CHECKED].join('|')})$`);
59 changes: 59 additions & 0 deletions packages/widget/tests/mangle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { CHECKED } from '../mangle.js';

// The minifier renames these everywhere in the bundle. Each one has to be a
// private member that the code only touches through `this`, or the CDN build
// breaks where the unit tests (which run on src) can't see it.

const root = join(__dirname, '..');
const files: string[] = [];
const walk = (dir: string) => {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) walk(path);
else if (name.endsWith('.ts')) files.push(path);
}
};
walk(join(root, 'src'));
const source = files.map((file) => readFileSync(file, 'utf8')).join('\n');
const api = readFileSync(join(root, '../../api/widget.api.md'), 'utf8');
const domPrototypes = [
window,
Document.prototype,
ShadowRoot.prototype,
HTMLElement.prototype,
HTMLButtonElement.prototype,
HTMLTextAreaElement.prototype,
HTMLInputElement.prototype,
HTMLFormElement.prototype,
HTMLSelectElement.prototype,
Storage.prototype,
Response.prototype,
AbortController.prototype,
];

describe.each(CHECKED)('the mangled property %s', (name) => {
it('is declared private', () => {
expect(source).toMatch(new RegExp(`^\\s+private (?:readonly |async )*${name}\\b`, 'm'));
});

it('is only used through this', () => {
const elsewhere = source.match(new RegExp(`.{0,30}(?<!\\bthis)\\.${name}\\b.{0,10}`));
expect(elsewhere?.[0]).toBeUndefined();
});

it('is never an object key, a shorthand property or a string', () => {
const lines = source.split('\n').filter((line) => !/^\s+private\b/.test(line) && !/^\s*(\/\/|\*)/.test(line));
const keyed = lines.find((line) => new RegExp(`(^|[^.\\w])${name}\\??\\s*:`).test(line.replace(/'[^']*'|"[^"]*"|`[^`]*`/g, '')));
expect(keyed).toBeUndefined();
expect(source.replaceAll(`this.${name}`, '')).not.toMatch(new RegExp(`[{,]\\s*${name}\\s*[,}]`));
expect(source).not.toMatch(new RegExp(`['"\`]${name}['"\`]`));
});

it('is not part of the public API or a DOM name', () => {
expect(api).not.toMatch(new RegExp(`\\b${name}\\b`));
expect(domPrototypes.filter((proto) => name in proto)).toEqual([]);
});
});
3 changes: 2 additions & 1 deletion packages/widget/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs';
import { defineConfig } from 'vite';
import { resolve } from 'path';
import { MANGLED_PROPERTIES } from './mangle';

const { version } = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf8')) as { version: string };

Expand All @@ -21,7 +22,7 @@ export default defineConfig({
compress: { passes: 2 },
mangle: {
properties: {
regex: /^(?:mounted|fab|panel|bubble|preChatFormEl|namespace|hydrated|isStreaming|destroyed|abortController|retryRequests|eventHandlers|containerEl|autoOpenTimer|unreadReplies|savedOverflow|shadow|unreadLabel|openLabel|closeLabel|badge|el|inline|messagesContainer|isVisible|typingEl|suggestionsEl|area|textarea|sendBtn|onSend|timerId)$/,
regex: MANGLED_PROPERTIES,
},
},
},
Expand Down
3 changes: 2 additions & 1 deletion packages/widget/vite.panel.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { defineConfig } from 'vite';
import { resolve } from 'node:path';
import { MANGLED_PROPERTIES } from './mangle';

// One translation per file in src/locales (de.ts, pt.ts, ...), each built to
// dist/locales/<code>.js for the loader to fetch next to the panel.
Expand All @@ -27,7 +28,7 @@ export default defineConfig({
compress: { passes: 2 },
mangle: {
properties: {
regex: /^(?:mounted|fab|panel|bubble|preChatFormEl|namespace|hydrated|isStreaming|destroyed|abortController|retryRequests|eventHandlers|containerEl|autoOpenTimer|unreadReplies|savedOverflow|shadow|unreadLabel|openLabel|closeLabel|badge|el|inline|messagesContainer|isVisible|typingEl|suggestionsEl|area|textarea|sendBtn|onSend|timerId)$/,
regex: MANGLED_PROPERTIES,
},
},
},
Expand Down
Loading