@servicetitan/startup 38.1.0 → 38.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/build.js +10 -1
  3. package/dist/cli/commands/build.js.map +1 -1
  4. package/dist/cli/commands/command.d.ts +4 -0
  5. package/dist/cli/commands/command.d.ts.map +1 -1
  6. package/dist/cli/commands/command.js +39 -0
  7. package/dist/cli/commands/command.js.map +1 -1
  8. package/dist/cli/commands/init.d.ts.map +1 -1
  9. package/dist/cli/commands/init.js +0 -1
  10. package/dist/cli/commands/init.js.map +1 -1
  11. package/dist/cli/commands/install.d.ts.map +1 -1
  12. package/dist/cli/commands/install.js +1 -17
  13. package/dist/cli/commands/install.js.map +1 -1
  14. package/dist/cli/commands/registry/command-registry.d.ts +0 -16
  15. package/dist/cli/commands/registry/command-registry.d.ts.map +1 -1
  16. package/dist/cli/commands/registry/install.d.ts +0 -16
  17. package/dist/cli/commands/registry/install.d.ts.map +1 -1
  18. package/dist/cli/commands/registry/install.js +1 -17
  19. package/dist/cli/commands/registry/install.js.map +1 -1
  20. package/dist/cli/commands/start.d.ts.map +1 -1
  21. package/dist/cli/commands/start.js +9 -1
  22. package/dist/cli/commands/start.js.map +1 -1
  23. package/dist/cli/utils/cli-git.d.ts +7 -0
  24. package/dist/cli/utils/cli-git.d.ts.map +1 -1
  25. package/dist/cli/utils/cli-git.js +30 -0
  26. package/dist/cli/utils/cli-git.js.map +1 -1
  27. package/dist/cli/utils/cli-os.d.ts +6 -1
  28. package/dist/cli/utils/cli-os.d.ts.map +1 -1
  29. package/dist/cli/utils/cli-os.js +22 -0
  30. package/dist/cli/utils/cli-os.js.map +1 -1
  31. package/dist/telemetry/index.d.ts +3 -0
  32. package/dist/telemetry/index.d.ts.map +1 -0
  33. package/dist/telemetry/index.js +21 -0
  34. package/dist/telemetry/index.js.map +1 -0
  35. package/dist/telemetry/telemetry.d.ts +15 -0
  36. package/dist/telemetry/telemetry.d.ts.map +1 -0
  37. package/dist/telemetry/telemetry.js +176 -0
  38. package/dist/telemetry/telemetry.js.map +1 -0
  39. package/dist/vite/config/create-filtering-logger.d.ts.map +1 -1
  40. package/dist/vite/config/create-filtering-logger.js +6 -2
  41. package/dist/vite/config/create-filtering-logger.js.map +1 -1
  42. package/dist/webpack/configs/optimization-config.js +13 -8
  43. package/dist/webpack/configs/optimization-config.js.map +1 -1
  44. package/package.json +17 -14
  45. package/src/cli/commands/__tests__/build.test.ts +49 -2
  46. package/src/cli/commands/__tests__/command.test.ts +112 -0
  47. package/src/cli/commands/__tests__/init.test.ts +1 -1
  48. package/src/cli/commands/__tests__/install.test.ts +5 -72
  49. package/src/cli/commands/__tests__/start.test.ts +43 -2
  50. package/src/cli/commands/build.ts +9 -1
  51. package/src/cli/commands/command.ts +50 -0
  52. package/src/cli/commands/init.ts +0 -1
  53. package/src/cli/commands/install.ts +5 -21
  54. package/src/cli/commands/registry/install.ts +1 -6
  55. package/src/cli/commands/start.ts +8 -1
  56. package/src/cli/utils/__tests__/cli-git.test.ts +54 -3
  57. package/src/cli/utils/__tests__/cli-os.test.ts +98 -2
  58. package/src/cli/utils/cli-git.ts +27 -1
  59. package/src/cli/utils/cli-os.ts +30 -0
  60. package/src/telemetry/__tests__/telemetry.test.ts +326 -0
  61. package/src/telemetry/index.ts +2 -0
  62. package/src/telemetry/telemetry.ts +134 -0
  63. package/src/vite/config/__tests__/create-filtering-logger.test.ts +3 -2
  64. package/src/vite/config/create-filtering-logger.ts +8 -2
  65. package/src/webpack/__tests__/create-webpack-config-shared-dependencies.test.ts +27 -1
  66. package/src/webpack/configs/optimization-config.ts +19 -8
@@ -1,4 +1,7 @@
1
- import { runCommandOutput } from './cli-os';
1
+ import path from 'node:path';
2
+ import { runCommandOutput, runCommandOutputAsync } from './cli-os';
3
+
4
+ const GIT_TIMEOUT_MS = 2000;
2
5
 
3
6
  export function gitGetBranch(): string {
4
7
  return runCommandOutput('git rev-parse --abbrev-ref HEAD').trim();
@@ -7,3 +10,26 @@ export function gitGetBranch(): string {
7
10
  export function gitGetCommitHash(): string {
8
11
  return runCommandOutput('git rev-parse --short HEAD').trim();
9
12
  }
13
+
14
+ export interface GitContext {
15
+ repo?: string;
16
+ branch?: string;
17
+ userEmail?: string;
18
+ userName?: string;
19
+ }
20
+
21
+ export async function gitGetContext(): Promise<GitContext> {
22
+ const options = { quiet: true, ignoreErrors: true, timeout: GIT_TIMEOUT_MS };
23
+ const [rev, userEmail, userName] = await Promise.all([
24
+ runCommandOutputAsync('git rev-parse --abbrev-ref HEAD --show-toplevel', options),
25
+ runCommandOutputAsync('git config user.email', options),
26
+ runCommandOutputAsync('git config user.name', options),
27
+ ]);
28
+ const [branch, root] = rev?.split('\n') ?? [];
29
+ return {
30
+ repo: root ? path.basename(root) : undefined,
31
+ branch,
32
+ userEmail,
33
+ userName,
34
+ };
35
+ }
@@ -1,10 +1,13 @@
1
1
  import {
2
+ exec,
2
3
  execFileSync,
4
+ ExecOptions,
3
5
  execSync,
4
6
  ExecSyncOptionsWithBufferEncoding,
5
7
  spawn,
6
8
  SpawnOptionsWithoutStdio,
7
9
  } from 'child_process';
10
+ import { promisify } from 'util';
8
11
  import { log } from '../../utils';
9
12
 
10
13
  type RunCommandOptions = SpawnOptionsWithoutStdio & { quiet?: boolean };
@@ -81,6 +84,33 @@ export function runCommandOutput(
81
84
  return result;
82
85
  }
83
86
 
87
+ type RunCommandOutputAsyncOptions = ExecOptions & { quiet?: boolean; ignoreErrors?: boolean };
88
+
89
+ export async function runCommandOutputAsync(
90
+ command: string,
91
+ { quiet, ignoreErrors, ...execOptions }: RunCommandOutputAsyncOptions = {}
92
+ ): Promise<string | undefined> {
93
+ if (!quiet) {
94
+ log.info(`Running: ${command}`);
95
+ }
96
+
97
+ try {
98
+ const { stdout } = await promisify(exec)(command, execOptions);
99
+ const result = stdout.toString().trim();
100
+
101
+ if (!quiet) {
102
+ log.info('command finished', result);
103
+ }
104
+
105
+ return result || undefined;
106
+ } catch (error) {
107
+ if (ignoreErrors) {
108
+ return undefined;
109
+ }
110
+ throw error;
111
+ }
112
+ }
113
+
84
114
  export function killProcessTree(pid: number, signal: NodeJS.Signals = 'SIGKILL') {
85
115
  try {
86
116
  if (process.platform === 'win32') {
@@ -0,0 +1,326 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+ import os from 'os';
5
+ import { gitGetContext } from '../../cli/utils/cli-git';
6
+ import { getStartupVersion } from '../../core';
7
+ import { Telemetry } from '../telemetry';
8
+
9
+ jest.mock('../../cli/utils/cli-git', () => ({ gitGetContext: jest.fn() }));
10
+ jest.mock('../../core', () => ({ getStartupVersion: jest.fn() }));
11
+ jest.mock('os', () => ({ ...jest.requireActual('os'), hostname: jest.fn() }));
12
+
13
+ jest.mock('@servicetitan/secrets', () => ({}), { virtual: true });
14
+
15
+ describe(`[startup] ${Telemetry.name}`, () => {
16
+ const OLD_ENV = process.env;
17
+ const token = 'pub-test-token';
18
+ const gitContext = {
19
+ repo: 'monolith',
20
+ branch: 'main',
21
+ userEmail: 'dev@st.com',
22
+ userName: 'dev',
23
+ };
24
+ const hostname = 'test-host';
25
+ const startupVersion = '38.0.0';
26
+ const intakeUrl = (clientToken: string) =>
27
+ `https://browser-intake-datadoghq.com/api/v2/logs?dd-api-key=${clientToken}&ddsource=frontend-platform`;
28
+ const secrets = jest.requireMock('@servicetitan/secrets') as {
29
+ datadogStartupClientToken?: string;
30
+ };
31
+
32
+ let fetchMock: jest.Mock;
33
+
34
+ beforeEach(() => {
35
+ jest.clearAllMocks();
36
+ process.env = {};
37
+ secrets.datadogStartupClientToken = token;
38
+ jest.mocked(gitGetContext).mockResolvedValue(gitContext);
39
+ jest.mocked(getStartupVersion).mockReturnValue(startupVersion);
40
+ jest.mocked(os.hostname).mockReturnValue(hostname);
41
+ fetchMock = jest.fn(() => Promise.resolve({ ok: true } as Response));
42
+ global.fetch = fetchMock as unknown as typeof fetch;
43
+ });
44
+
45
+ afterAll(() => {
46
+ process.env = OLD_ENV;
47
+ });
48
+
49
+ const sentBody = () => JSON.parse(fetchMock.mock.calls[0][1].body)[0];
50
+
51
+ const emit = async (act: (telemetry: Telemetry) => void): Promise<void> => {
52
+ const telemetry = new Telemetry();
53
+ act(telemetry);
54
+ await telemetry.flush();
55
+ };
56
+
57
+ describe('construction', () => {
58
+ const subject = () => new Telemetry();
59
+
60
+ test('does not spawn git', () => {
61
+ subject();
62
+
63
+ expect(gitGetContext).not.toHaveBeenCalled();
64
+ });
65
+ });
66
+
67
+ describe('info', () => {
68
+ let attributes: Record<string, string>;
69
+
70
+ beforeEach(() => (attributes = {}));
71
+
72
+ const subject = () => emit(telemetry => telemetry.info('command.started', attributes));
73
+
74
+ test('posts to the browser logs intake with the client token', async () => {
75
+ await subject();
76
+
77
+ expect(fetchMock).toHaveBeenCalledWith(
78
+ intakeUrl(token),
79
+ expect.objectContaining({
80
+ method: 'POST',
81
+ headers: { 'Content-Type': 'application/json' },
82
+ })
83
+ );
84
+ });
85
+
86
+ test('includes the base context', async () => {
87
+ await subject();
88
+
89
+ expect(sentBody()).toEqual(
90
+ expect.objectContaining({
91
+ service: 'startup',
92
+ repo: gitContext.repo,
93
+ branch: gitContext.branch,
94
+ startupVersion,
95
+ hostname,
96
+ os: process.platform,
97
+ node: process.versions.node,
98
+ message: 'command.started',
99
+ status: 'info',
100
+ date: expect.any(Number),
101
+ agent: false,
102
+ usr: { email: gitContext.userEmail, name: gitContext.userName },
103
+ })
104
+ );
105
+ });
106
+
107
+ describe('with event attributes', () => {
108
+ beforeEach(() => {
109
+ attributes.command = 'build';
110
+ attributes.bundler = 'vite';
111
+ });
112
+
113
+ test('merges them into the payload', async () => {
114
+ await subject();
115
+
116
+ expect(sentBody()).toEqual(expect.objectContaining(attributes));
117
+ });
118
+ });
119
+
120
+ describe('when AI_AGENT is set', () => {
121
+ beforeEach(() => (process.env.AI_AGENT = 'claude-code'));
122
+
123
+ test('sets the agent flag', async () => {
124
+ await subject();
125
+
126
+ expect(sentBody()).toEqual(expect.objectContaining({ agent: true }));
127
+ });
128
+ });
129
+
130
+ describe('when CURSOR_AGENT is set', () => {
131
+ beforeEach(() => (process.env.CURSOR_AGENT = '1'));
132
+
133
+ test('sets the agent flag', async () => {
134
+ await subject();
135
+
136
+ expect(sentBody()).toEqual(expect.objectContaining({ agent: true }));
137
+ });
138
+ });
139
+
140
+ describe('without a client token', () => {
141
+ beforeEach(() => (secrets.datadogStartupClientToken = undefined));
142
+
143
+ test('does not send anything', async () => {
144
+ await subject();
145
+
146
+ expect(fetchMock).not.toHaveBeenCalled();
147
+ });
148
+
149
+ test('does not collect git context', async () => {
150
+ await subject();
151
+
152
+ expect(gitGetContext).not.toHaveBeenCalled();
153
+ });
154
+ });
155
+
156
+ describe('with DATADOG_CLIENT_TOKEN set', () => {
157
+ beforeEach(() => (process.env.DATADOG_CLIENT_TOKEN = 'env-token'));
158
+
159
+ test('overrides the secrets token', async () => {
160
+ await subject();
161
+
162
+ expect(fetchMock).toHaveBeenCalledWith(
163
+ intakeUrl('env-token'),
164
+ expect.objectContaining({ method: 'POST' })
165
+ );
166
+ });
167
+ });
168
+
169
+ describe('when fetch throws', () => {
170
+ beforeEach(() => {
171
+ global.fetch = (() => {
172
+ throw new Error('boom');
173
+ }) as unknown as typeof fetch;
174
+ });
175
+
176
+ test('does not reject', async () => {
177
+ await expect(subject()).resolves.toBeUndefined();
178
+ });
179
+ });
180
+
181
+ describe('when base context gathering fails', () => {
182
+ beforeEach(() => {
183
+ jest.mocked(gitGetContext).mockRejectedValue(new Error('git blew up'));
184
+ });
185
+
186
+ test('still sends the event', async () => {
187
+ await subject();
188
+
189
+ expect(sentBody()).toEqual(expect.objectContaining({ message: 'command.started' }));
190
+ });
191
+ });
192
+ });
193
+
194
+ describe('warn', () => {
195
+ const subject = () => emit(telemetry => telemetry.warn('node.version.unsupported'));
196
+
197
+ test('sends with warn status', async () => {
198
+ await subject();
199
+
200
+ expect(sentBody()).toEqual(
201
+ expect.objectContaining({ message: 'node.version.unsupported', status: 'warn' })
202
+ );
203
+ });
204
+ });
205
+
206
+ describe('error', () => {
207
+ const message = 'compile blew up';
208
+
209
+ let error: unknown;
210
+
211
+ beforeEach(() => (error = new TypeError(message)));
212
+
213
+ const subject = () => emit(telemetry => telemetry.error('command.finished', error));
214
+
215
+ test('sends with error status, message, and kind', async () => {
216
+ await subject();
217
+
218
+ expect(sentBody()).toEqual(
219
+ expect.objectContaining({
220
+ message: 'command.finished',
221
+ status: 'error',
222
+ error: {
223
+ message,
224
+ kind: TypeError.name,
225
+ stack: expect.any(String),
226
+ },
227
+ })
228
+ );
229
+ });
230
+
231
+ describe('with a string', () => {
232
+ const stringError = 'boom';
233
+
234
+ beforeEach(() => (error = stringError));
235
+
236
+ test('uses it as the message', async () => {
237
+ await subject();
238
+
239
+ expect(sentBody()).toEqual(
240
+ expect.objectContaining({ error: { message: stringError } })
241
+ );
242
+ });
243
+ });
244
+
245
+ describe('with a non-Error object', () => {
246
+ const objectError = { code: 42 };
247
+
248
+ beforeEach(() => (error = objectError));
249
+
250
+ test('serializes it', async () => {
251
+ await subject();
252
+
253
+ expect(sentBody()).toEqual(
254
+ expect.objectContaining({ error: { message: JSON.stringify(objectError) } })
255
+ );
256
+ });
257
+ });
258
+
259
+ describe('with a circular value', () => {
260
+ beforeEach(() => {
261
+ const circular: Record<string, unknown> = {};
262
+ circular.self = circular;
263
+ error = circular;
264
+ });
265
+
266
+ test('falls back to a safe message', async () => {
267
+ await subject();
268
+
269
+ expect(sentBody()).toEqual(
270
+ expect.objectContaining({ error: { message: 'unserializable error value' } })
271
+ );
272
+ });
273
+ });
274
+ });
275
+
276
+ describe('flush', () => {
277
+ const subject = () => {
278
+ const telemetry = new Telemetry();
279
+ telemetry.info('command.started');
280
+ telemetry.info('command.finished');
281
+ return telemetry;
282
+ };
283
+
284
+ describe('with in-flight sends', () => {
285
+ let resolvers: (() => void)[];
286
+
287
+ beforeEach(() => {
288
+ resolvers = [];
289
+ fetchMock.mockImplementation(
290
+ () =>
291
+ new Promise<Response>(resolve => {
292
+ resolvers.push(() => resolve({ ok: true } as Response));
293
+ })
294
+ );
295
+ });
296
+
297
+ test('waits for all of them, not just the last', async () => {
298
+ let done = false;
299
+ const flushing = subject()
300
+ .flush()
301
+ .then(() => (done = true));
302
+
303
+ await new Promise(resolve => setImmediate(resolve));
304
+ expect(resolvers).toHaveLength(2);
305
+
306
+ resolvers[1]();
307
+ await Promise.resolve();
308
+ expect(done).toBe(false);
309
+
310
+ resolvers[0]();
311
+ await flushing;
312
+ expect(done).toBe(true);
313
+ });
314
+ });
315
+
316
+ describe('when a send rejects', () => {
317
+ beforeEach(() => {
318
+ fetchMock.mockRejectedValue(new Error('network down'));
319
+ });
320
+
321
+ test('resolves without rejecting', async () => {
322
+ await expect(subject().flush()).resolves.toBeUndefined();
323
+ });
324
+ });
325
+ });
326
+ });
@@ -0,0 +1,2 @@
1
+ export { Telemetry, telemetry } from './telemetry';
2
+ export type { AttributeValue, Attributes } from './telemetry';
@@ -0,0 +1,134 @@
1
+ import { isCI } from '@servicetitan/install';
2
+ import os from 'os';
3
+ import { gitGetContext } from '../cli/utils/cli-git';
4
+ import { getStartupVersion } from '../core';
5
+
6
+ const DDSOURCE = 'frontend-platform';
7
+ const SERVICE = 'startup';
8
+ const SITE = 'datadoghq.com';
9
+ const REQUEST_TIMEOUT_MS = 2000;
10
+
11
+ type Status = 'info' | 'warn' | 'error';
12
+ export type AttributeValue = string | number | boolean | undefined;
13
+ export type Attributes = Record<string, AttributeValue | Record<string, AttributeValue>>;
14
+
15
+ interface Context {
16
+ clientToken: string;
17
+ baseAttributes: Attributes;
18
+ }
19
+
20
+ function toError(error: unknown): { message: string; kind?: string; stack?: string } {
21
+ if (error instanceof Error) {
22
+ return { message: error.message, kind: error.name, stack: error.stack };
23
+ }
24
+ if (typeof error === 'string') {
25
+ return { message: error };
26
+ }
27
+ try {
28
+ return { message: JSON.stringify(error) };
29
+ } catch {
30
+ return { message: 'unserializable error value' };
31
+ }
32
+ }
33
+
34
+ function getClientToken(): string | undefined {
35
+ if (process.env.DATADOG_CLIENT_TOKEN) {
36
+ return process.env.DATADOG_CLIENT_TOKEN;
37
+ }
38
+ try {
39
+ const secrets = require('@servicetitan/secrets') as {
40
+ datadogStartupClientToken?: string;
41
+ };
42
+ return secrets.datadogStartupClientToken;
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ }
47
+
48
+ async function getBaseContext(): Promise<Attributes> {
49
+ const git = await gitGetContext();
50
+ return {
51
+ service: SERVICE,
52
+ repo: git.repo,
53
+ branch: git.branch,
54
+ startupVersion: getStartupVersion(),
55
+ ci: isCI(),
56
+ agent: Boolean(process.env.AI_AGENT ?? process.env.CURSOR_AGENT),
57
+ hostname: os.hostname(),
58
+ os: process.platform,
59
+ node: process.versions.node,
60
+ usr: { email: git.userEmail, name: git.userName },
61
+ };
62
+ }
63
+
64
+ async function resolveContext(): Promise<Context | undefined> {
65
+ const clientToken = getClientToken();
66
+ if (!clientToken) {
67
+ return undefined;
68
+ }
69
+ const baseAttributes = await getBaseContext().catch(() => ({}));
70
+ return { clientToken, baseAttributes };
71
+ }
72
+
73
+ export class Telemetry {
74
+ private initPromise?: Promise<Context | undefined>;
75
+ private readonly pendingRequests = new Set<Promise<unknown>>();
76
+
77
+ info(name: string, attributes: Attributes = {}): void {
78
+ this.event(name, 'info', attributes);
79
+ }
80
+
81
+ warn(name: string, attributes: Attributes = {}): void {
82
+ this.event(name, 'warn', attributes);
83
+ }
84
+
85
+ error(name: string, error: unknown, attributes: Attributes = {}): void {
86
+ this.event(name, 'error', { ...attributes, error: toError(error) });
87
+ }
88
+
89
+ async flush(): Promise<void> {
90
+ /*
91
+ * Promise.all, not allSettled: pending requests never reject, and swallowing
92
+ * here would leave failures asymmetric on whether flush ran (a race).
93
+ */
94
+ await Promise.all([...this.pendingRequests]);
95
+ }
96
+
97
+ private init(): Promise<Context | undefined> {
98
+ this.initPromise ??= resolveContext();
99
+ return this.initPromise;
100
+ }
101
+
102
+ private event(name: string, status: Status, attributes: Attributes): void {
103
+ try {
104
+ this.send({ message: name, status, date: Date.now(), ...attributes });
105
+ } catch {} // eslint-disable-line no-empty
106
+ }
107
+
108
+ private send(event: Attributes): void {
109
+ const request = this.init()
110
+ .then(context => {
111
+ if (!context) {
112
+ return;
113
+ }
114
+ const { clientToken, baseAttributes } = context;
115
+ const controller = new AbortController();
116
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
117
+ timer.unref();
118
+ const url =
119
+ `https://browser-intake-${SITE}/api/v2/logs` +
120
+ `?dd-api-key=${encodeURIComponent(clientToken)}&ddsource=${DDSOURCE}`;
121
+ return fetch(url, {
122
+ method: 'POST',
123
+ headers: { 'Content-Type': 'application/json' },
124
+ body: JSON.stringify([{ ...baseAttributes, ...event }]),
125
+ signal: controller.signal,
126
+ }).finally(() => clearTimeout(timer));
127
+ })
128
+ .catch(() => {})
129
+ .finally(() => this.pendingRequests.delete(request));
130
+ this.pendingRequests.add(request);
131
+ }
132
+ }
133
+
134
+ export const telemetry = new Telemetry();
@@ -1,4 +1,5 @@
1
1
  import { createLogger, Logger } from 'vite';
2
+ import { EMPTY_PREFIX } from '../../plugins/ignore-modules-plugin';
2
3
  import { createFilteringLogger } from '../create-filtering-logger';
3
4
 
4
5
  jest.mock('vite', () => ({ createLogger: jest.fn() }));
@@ -50,8 +51,8 @@ describe(createFilteringLogger.name, () => {
50
51
 
51
52
  describe('when message is an ignored-module import warning', () => {
52
53
  beforeEach(() => {
53
- message =
54
- "[IMPORT_IS_UNDEFINED] Import `DefaultPortalContext` will always be undefined because there is no matching export in '\\0IGNORE_EMPTY:@servicetitan/design-system'";
54
+ const renderedPrefix = EMPTY_PREFIX.replace('\0', '\\0');
55
+ message = `[IMPORT_IS_UNDEFINED] Import \`DefaultPortalContext\` will always be undefined because there is no matching export in '${renderedPrefix}@servicetitan/design-system'`;
55
56
  });
56
57
 
57
58
  itSuppressesMessage();
@@ -1,4 +1,5 @@
1
1
  import { createLogger, type Logger } from 'vite';
2
+ import { EMPTY_PREFIX } from '../plugins/ignore-modules-plugin';
2
3
 
3
4
  /*
4
5
  * Expected warnings when a host's HTML references the shared-bundle assets created by
@@ -13,10 +14,15 @@ const SHARED_ASSET_WARNINGS = [
13
14
  /*
14
15
  * Expected when code feature-detects an optional peer dependency (e.g. `Package.Export
15
16
  * === undefined`) and ignoreModulesPlugin has substituted an empty module for it. Matched
16
- * on the plugin's IGNORE_EMPTY marker so a genuinely undefined export elsewhere still warns.
17
+ * on the plugin's EMPTY_PREFIX marker so a genuinely undefined export elsewhere still warns.
18
+ * Rolldown renders the marker's leading NUL control character as the visible characters "\"
19
+ * and "0" when it prints a warning, so match that instead of the raw NUL, doubled here
20
+ * because the backslash is also a regex metacharacter.
17
21
  */
18
22
  const IGNORED_MODULE_WARNINGS = [
19
- /Import `\w+` will always be undefined because there is no matching export in '\\0IGNORE_EMPTY:[^']+'/,
23
+ new RegExp(
24
+ `Import \`\\w+\` will always be undefined because there is no matching export in '${EMPTY_PREFIX.replace('\0', '\\\\0')}[^']+'`
25
+ ),
20
26
  ];
21
27
 
22
28
  const SUPPRESSED_WARNINGS = [...SHARED_ASSET_WARNINGS, ...IGNORED_MODULE_WARNINGS];
@@ -11,7 +11,7 @@ import { WebpackAssetsManifest } from 'webpack-assets-manifest';
11
11
  import RemoveEmptyScriptsPlugin from 'webpack-remove-empty-scripts';
12
12
  import VirtualModulesPlugin from 'webpack-virtual-modules';
13
13
  import yargs from 'yargs';
14
- import { getLaunchDarklySdkVersion } from '../../core';
14
+ import { CHUNK_PATTERNS, getLaunchDarklySdkVersion } from '../../core';
15
15
  import {
16
16
  getFolders,
17
17
  getPackageData,
@@ -350,6 +350,32 @@ describe(`[startup] ${createWebpackConfig.name}`, () => {
350
350
  cacheGroups.servicetitan.priority ?? 0
351
351
  );
352
352
  });
353
+
354
+ describe.each(Object.keys(CHUNK_PATTERNS))(
355
+ 'optimization.splitChunks.cacheGroups.%s',
356
+ name => {
357
+ const cacheGroup = () =>
358
+ (subject().optimization!.splitChunks as any).cacheGroups[name];
359
+
360
+ test('excludes the design-system chunk', () => {
361
+ expect(cacheGroup().chunks({ name: 'design-system' })).toBe(false);
362
+ });
363
+
364
+ test('claims other chunks', () => {
365
+ expect(cacheGroup().chunks({ name: 'foo' })).toBe(true);
366
+ });
367
+
368
+ describe('when application does not share design system', () => {
369
+ beforeEach(() => {
370
+ jest.mocked(loadSharedDependencies).mockReturnValue({});
371
+ });
372
+
373
+ test('claims all chunks', () => {
374
+ expect(cacheGroup().chunks).toBe('all');
375
+ });
376
+ });
377
+ }
378
+ );
353
379
  });
354
380
 
355
381
  test('configures "output.chunkLoadingGlobal"', () => {
@@ -32,13 +32,16 @@ export function optimizationConfig(context: WebpackBuildContext, _: Overrides):
32
32
  }
33
33
 
34
34
  function hostConfig(optimization: ConfigWithCacheGroups, context: WebpackBuildContext) {
35
- const { isProduction } = context.build;
36
- const { isWebComponent } = context.package;
35
+ const { isProduction, emitExposedDependencies } = context.build;
36
+ const { isWebComponent, sharedDependencies } = context.package;
37
37
  if (isWebComponent || !isProduction) {
38
38
  return;
39
39
  }
40
40
 
41
- Object.assign(optimization.splitChunks.cacheGroups, vendorGroups());
41
+ const excludeDesignSystem =
42
+ emitExposedDependencies && !!sharedDependencies['@servicetitan/design-system'];
43
+
44
+ Object.assign(optimization.splitChunks.cacheGroups, vendorGroups(excludeDesignSystem));
42
45
  }
43
46
 
44
47
  function minimizeConfig(optimization: ConfigWithCacheGroups, context: WebpackBuildContext) {
@@ -115,27 +118,35 @@ function webComponentConfig(optimization: ConfigWithCacheGroups, context: Webpac
115
118
  });
116
119
  }
117
120
 
118
- function vendorGroups() {
121
+ function vendorGroups(excludeDesignSystem = false): Record<string, any> {
122
+ /*
123
+ * The design-system entry already gets its own chunk; excluding it here keeps
124
+ * its CSS from being absorbed into a vendor chunk.
125
+ */
126
+ const chunks: any = excludeDesignSystem
127
+ ? (chunk: { name?: string }) => chunk.name !== 'design-system'
128
+ : 'all';
129
+
119
130
  return {
120
131
  'servicetitan': {
121
132
  name: 'servicetitan',
122
133
  test: CHUNK_PATTERNS.servicetitan,
123
- chunks: 'all',
134
+ chunks,
124
135
  },
125
136
  'vendor-core': {
126
137
  name: 'vendor-core',
127
138
  test: CHUNK_PATTERNS['vendor-core'],
128
- chunks: 'all',
139
+ chunks,
129
140
  },
130
141
  'kendo': {
131
142
  name: 'kendo',
132
143
  test: CHUNK_PATTERNS.kendo,
133
- chunks: 'all',
144
+ chunks,
134
145
  },
135
146
  'vendor': {
136
147
  name: 'vendor',
137
148
  test: CHUNK_PATTERNS.vendor,
138
- chunks: 'all',
149
+ chunks,
139
150
  priority: -5,
140
151
  },
141
152
  };