@corva/dc-platform-shared 0.67.0 → 0.68.1

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 (50) hide show
  1. package/CLI/components/App/App.js +1 -1
  2. package/CLI/components/App/components/AppBar/AppBar.js +1 -1
  3. package/CLI/hocs/effects/useProvideAuth/useProvideAuth.js +1 -1
  4. package/CLI/hocs/utils.js +1 -1
  5. package/CLI/hocs/withAppDataProviderHOC.js +1 -1
  6. package/CLI/hocs/withCLIAppSettingsHOC.js +1 -1
  7. package/CLI/hocs/withCorvaMagicHOC.js +1 -1
  8. package/CLI/hocs/withDevCenterRouterHOC.js +1 -1
  9. package/CLI/hocs/withGridLayoutHOC/withGridLayoutHOC.js +1 -1
  10. package/CLI/hocs/withPDFLayoutHOC.js +1 -1
  11. package/CLI/hocs/withReactRouterHOC.js +1 -1
  12. package/CLI/screens/LoginScreen/LoginScreen.js +1 -1
  13. package/CLI/screens/LoginScreen/components/Auth.js +1 -1
  14. package/CLI/screens/LoginScreen/components/AuthStep.js +1 -1
  15. package/CLI/screens/LoginScreen/components/EmailStep.js +1 -1
  16. package/CLI/screens/LoginScreen/components/SSOButton.js +1 -1
  17. package/CLI/screens/OAuthCallbackScreen/index.js +1 -1
  18. package/cjs/devcenter/webpack/DevServerEntryPoint.js +1 -1
  19. package/cjs/devcenter/webpack/DevServerIsolatedAppEntryPoint.js +1 -1
  20. package/cjs/devcenter/webpack/constants.js +1 -1
  21. package/cjs/devcenter/webpack/plugins/checkCorvaUIVersion.js +11 -1
  22. package/cjs/devcenter/webpack/utils/middlewares.js +1 -1
  23. package/cjs/devcenter/webpack/utils/serializeDotEnv.js +1 -1
  24. package/cjs/devcenter/webpack/validators/dependencies.js +1 -1
  25. package/cjs/devcenter/webpack/validators/index.js +1 -1
  26. package/cjs/devcenter/webpack/webpack.config.common.js +1 -1
  27. package/cjs/devcenter/webpack/webpack.config.dev.js +1 -1
  28. package/cjs/devcenter/webpack/webpack.config.prod.js +1 -1
  29. package/clients/auth0/index.js +1 -1
  30. package/components/GridLockButton/index.js +1 -1
  31. package/components/Login/components/Chip.js +1 -1
  32. package/components/Login/components/EmailField.js +1 -1
  33. package/components/Login/components/HiddenEmailField.js +1 -1
  34. package/components/Login/components/Login.js +1 -1
  35. package/components/Login/components/PasswordField.js +1 -1
  36. package/components/Login/components/StepFooter.js +1 -1
  37. package/components/Login/components/VerificationStep.js +1 -1
  38. package/package.json +23 -13
  39. package/utils/devcenter.js +1 -1
  40. package/utils/recaptcha.js +1 -1
  41. package/vite/corva-vite-config.mjs +858 -0
  42. package/_virtual/_commonjsHelpers.js +0 -1
  43. package/_virtual/toPrimitive.js +0 -1
  44. package/_virtual/toPropertyKey.js +0 -1
  45. package/_virtual/typeof.js +0 -1
  46. package/ext-cjs/@babel/runtime/helpers/defineProperty.js +0 -1
  47. package/ext-cjs/@babel/runtime/helpers/taggedTemplateLiteral.js +0 -1
  48. package/ext-cjs/@babel/runtime/helpers/toPrimitive.js +0 -1
  49. package/ext-cjs/@babel/runtime/helpers/toPropertyKey.js +0 -1
  50. package/ext-cjs/@babel/runtime/helpers/typeof.js +0 -1
@@ -0,0 +1,858 @@
1
+ // The Vite equivalent of getWebpackConfig: one shared build for every DC app, extended per app
2
+ // through `overrides`. Ships unbundled - it is a Node-side config Vite loads as ESM, so rollup
3
+ // must not touch it.
4
+ import { randomUUID } from 'node:crypto';
5
+ import { builtinModules, createRequire } from 'node:module';
6
+ import { existsSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
7
+ import { join, relative, resolve } from 'node:path';
8
+
9
+ import dotenv from 'dotenv';
10
+ import { expand } from 'dotenv-expand';
11
+ import react from '@vitejs/plugin-react';
12
+ import license from 'rollup-plugin-license';
13
+ import { viteStaticCopy } from 'vite-plugin-static-copy';
14
+ import presetEnv from 'postcss-preset-env';
15
+ import { defineConfig, mergeConfig } from 'vitest/config';
16
+
17
+ const require = createRequire(import.meta.url);
18
+
19
+ const {
20
+ CORVA_API_ENVS,
21
+ CORVA_API_ENV_VARIABLES,
22
+ CORVA_GLOBAL_DEPENDENCIES,
23
+ } = require('../cjs/devcenter/webpack/constants.js');
24
+ const validateDependencies = require('../cjs/devcenter/webpack/validators/index.js');
25
+ const { updateDotEnv } = require('../cjs/devcenter/webpack/utils/serializeDotEnv.js');
26
+
27
+ // this file ships unbundled at <package>/vite/, so the package root is one level up and
28
+ // @corva/ui sits next to it in the app's node_modules/@corva
29
+ const PLATFORM_SHARED_DIR = join(import.meta.dirname, '..');
30
+ // NOTE: These imports are always bundled, never taken from the platform globals
31
+ const SKIP_IN_EXTERNALS = ['react/jsx-runtime', 'highcharts/'];
32
+
33
+ // webpack's css-loader appended a per-build uuid to every local class name, so two DC apps sharing a
34
+ // dashboard could not overwrite each other's styles. Vite hashes path + name + source instead, which
35
+ // is identical for two apps scaffolded from the same template.
36
+ const BUILD_ID = randomUUID().slice(-5);
37
+
38
+ const isLegalIdentifier = value => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value);
39
+
40
+ const toLegalIdentifier = value => {
41
+ const identifier = value.replace(/[^A-Za-z0-9_$]/g, '_');
42
+
43
+ return isLegalIdentifier(identifier) ? identifier : `_${identifier}`;
44
+ };
45
+
46
+ const toGlobalSegmentAccess = segment =>
47
+ /^\d+$/.test(segment) ? `[${segment}]` : `[${JSON.stringify(segment)}]`;
48
+
49
+ const getSegmentedGlobalAssignmentPrefix = appIdentifier => {
50
+ const [rootSegment, ...segments] = appIdentifier.split('.');
51
+ const rootAccessor = `this[${JSON.stringify(rootSegment)}]`;
52
+
53
+ if (segments.length === 0) {
54
+ return `${rootAccessor}=`;
55
+ }
56
+
57
+ let currentAccessor = rootAccessor;
58
+ const initializers = [`${rootAccessor}=${rootAccessor}||{}`];
59
+
60
+ segments.slice(0, -1).forEach(segment => {
61
+ currentAccessor += toGlobalSegmentAccess(segment);
62
+ initializers.push(`${currentAccessor}=${currentAccessor}||{}`);
63
+ });
64
+
65
+ currentAccessor += toGlobalSegmentAccess(segments[segments.length - 1]);
66
+
67
+ return `${initializers.join(',')},${currentAccessor}=`;
68
+ };
69
+
70
+ const getGlobalAssignmentPrefixes = globalName =>
71
+ [
72
+ ...new Set([
73
+ isLegalIdentifier(globalName) ? `var ${globalName}=` : '',
74
+ isLegalIdentifier(globalName)
75
+ ? `this.${globalName}=`
76
+ : `this[${JSON.stringify(globalName)}]=`,
77
+ `this[${JSON.stringify(globalName)}]=`,
78
+ getSegmentedGlobalAssignmentPrefix(globalName),
79
+ ]),
80
+ ].filter(Boolean);
81
+
82
+ const normalizeIifeGlobalAssignment = (jsContent, appIdentifier, emittedGlobalName) => {
83
+ const normalizedAssignment = `this[${JSON.stringify(appIdentifier)}]=`;
84
+
85
+ if (jsContent.includes(normalizedAssignment)) {
86
+ return jsContent;
87
+ }
88
+
89
+ const assignmentPrefix = getGlobalAssignmentPrefixes(emittedGlobalName).find(prefix =>
90
+ jsContent.includes(prefix),
91
+ );
92
+
93
+ if (assignmentPrefix) {
94
+ return jsContent.replace(assignmentPrefix, normalizedAssignment);
95
+ }
96
+
97
+ // Rolldown doesn't emit a global assignment for IIFE output — inject one.
98
+ // Terser rewrites `(function(){})()` → `!function(){}()` when the result is discarded.
99
+ // Replacing `!` with the assignment (`this["name"]=function(){}()`) is valid JS
100
+ // because after `=` the `function` keyword is an expression, not a declaration.
101
+ if (jsContent.trimStart().startsWith('!function')) {
102
+ return jsContent.replace('!function', `${normalizedAssignment}function`);
103
+ }
104
+
105
+ return normalizedAssignment + jsContent;
106
+ };
107
+
108
+ // webpack's `output.publicPath: 'auto'` read the bundle's own <script> src once, at bundle
109
+ // init, and served every emitted asset from next to it. Same expression, same moment.
110
+ const ASSET_BASE_EXPR =
111
+ '(function(){var d=document,s=d.currentScript;if(!s||s.tagName.toUpperCase()!=="SCRIPT"){' +
112
+ 'var a=d.getElementsByTagName("script");s=a[a.length-1];}' +
113
+ 'return((s&&s.src)||d.baseURI).replace(/[^/]*(?:[?#].*)?$/,"");})()';
114
+
115
+ // url() in the extracted CSS carries this marker instead of a path (see renderBuiltUrl); the
116
+ // marker is spliced out into a concatenation with the base, so the injected stylesheet points
117
+ // at the CDN directory rather than at the host document's.
118
+ const ASSET_MARKER = '__CORVA_ASSET__';
119
+
120
+ const createCssInjection = cssContent =>
121
+ `(function(){var B=${ASSET_BASE_EXPR};var s=document.createElement('style');s.textContent=${JSON.stringify(
122
+ cssContent,
123
+ ).replaceAll(ASSET_MARKER, '"+B+"')};document.head.appendChild(s)})();`;
124
+
125
+ // The platform loads a single file, so the emitted stylesheets are folded back into it.
126
+ const finalizeJsBundle = ({ outDir, cssFileNames, appIdentifier, emittedGlobalName }) => {
127
+ const jsPath = join(outDir, 'app.js');
128
+ const css = cssFileNames
129
+ .map(fileName => readFileSync(join(outDir, fileName), 'utf-8'))
130
+ .join('');
131
+ // normalise first: the fallbacks read the head of the bundle, so a stylesheet
132
+ // sitting in front of it would take the global assignment for itself
133
+ const jsContent = normalizeIifeGlobalAssignment(
134
+ readFileSync(jsPath, 'utf-8'),
135
+ appIdentifier,
136
+ emittedGlobalName,
137
+ );
138
+
139
+ writeFileSync(jsPath, css ? createCssInjection(css) + jsContent : jsContent);
140
+
141
+ cssFileNames.forEach(fileName => unlinkSync(join(outDir, fileName)));
142
+ };
143
+
144
+ // webpack emitted binaries as files (`asset/resource`, publicPath resolved at runtime) and
145
+ // inlined only SVGs under svg-url-loader's 10 KB limit. Vite's library mode inlines every asset
146
+ // whatever `assetsInlineLimit` says — `?no-inline` is the one thing it still honours, so the
147
+ // same split is restored by tagging the id.
148
+ const EMITTED_ASSET_RE = /\.(png|jpe?g|gif|webp|avif|woff2?|eot|ttf|otf|mp4|webm|ogg|mp3|wav)$/i;
149
+ const SVG_INLINE_LIMIT = 10000;
150
+
151
+ const emitAssetsAsFilesPlugin = () => {
152
+ const resolving = new Set();
153
+
154
+ return {
155
+ name: 'corva-emit-assets-as-files',
156
+ // before vite:resolve, which would otherwise resolve the asset id first and never let this
157
+ // hook see it
158
+ enforce: 'pre',
159
+ async resolveId(source, importer, options) {
160
+ if (source.includes('?') || (!EMITTED_ASSET_RE.test(source) && !/\.svg$/i.test(source))) {
161
+ return null;
162
+ }
163
+
164
+ const key = `${importer}\u0000${source}`;
165
+
166
+ if (resolving.has(key)) {
167
+ return null;
168
+ }
169
+
170
+ resolving.add(key);
171
+
172
+ try {
173
+ const resolved = await this.resolve(source, importer, options);
174
+
175
+ if (!resolved || resolved.external) {
176
+ return null;
177
+ }
178
+
179
+ if (/\.svg$/i.test(resolved.id) && statSync(resolved.id).size < SVG_INLINE_LIMIT) {
180
+ return null;
181
+ }
182
+
183
+ return `${resolved.id}?no-inline`;
184
+ } finally {
185
+ resolving.delete(key);
186
+ }
187
+ },
188
+ };
189
+ };
190
+
191
+ // Every DC app on a dashboard renders into the same document, which is why webpack's css-loader
192
+ // ran with `modules` on for every stylesheet: nothing an app shipped could leak onto its
193
+ // neighbours. Vite decides by filename, so a plain `.css` would quietly become global. Refuse it
194
+ // instead — the selectors that really have to be global still go through `:global(...)`, exactly
195
+ // as they did under webpack.
196
+ const UNSCOPED_CSS_RE = /(?<!\.module)\.(css|scss|sass)(\?|$)/;
197
+ const STYLESHEET_IMPORTER_RE = /\.(css|scss|sass)(\?|$)/;
198
+
199
+ const rejectUnscopedCssPlugin = pwd => {
200
+ const resolving = new Set();
201
+
202
+ return {
203
+ name: 'corva-reject-unscoped-css',
204
+ enforce: 'pre',
205
+ async resolveId(source, importer, options) {
206
+ // no importer: an entry. Stylesheet importer: a sass/postcss partial, which is not a module
207
+ // in either build system.
208
+ if (!importer || !UNSCOPED_CSS_RE.test(source) || STYLESHEET_IMPORTER_RE.test(importer)) {
209
+ return null;
210
+ }
211
+
212
+ const key = `${importer}\u0000${source}`;
213
+
214
+ if (resolving.has(key)) {
215
+ return null;
216
+ }
217
+
218
+ resolving.add(key);
219
+
220
+ let resolved;
221
+
222
+ try {
223
+ resolved = await this.resolve(source, importer, options);
224
+ } finally {
225
+ resolving.delete(key);
226
+ }
227
+
228
+ // a dependency's own stylesheet is its business, and it was never scoped to this app
229
+ if (!resolved || resolved.external || resolved.id.includes('node_modules')) {
230
+ return null;
231
+ }
232
+
233
+ throw new Error(
234
+ `${relative(pwd, resolved.id)} is imported as a plain stylesheet by ${relative(pwd, importer)}.\n` +
235
+ 'Please use *.module.css — global styles may leak into other platform elements, and ' +
236
+ 'that surfaces later as weird bugs nobody traces back to this file.\n' +
237
+ 'If you really do want a global style and know what you are doing, keep the .module.css ' +
238
+ 'file and wrap that one selector:\n' +
239
+ ' :global(.my-global-class) { color: red; }',
240
+ );
241
+ },
242
+ };
243
+ };
244
+
245
+ const getCorvaApiEnvVariables = () => {
246
+ const apiEnv = process.env.CORVA_API_ENV || CORVA_API_ENVS.production;
247
+
248
+ if (Object.hasOwn(CORVA_API_ENVS, apiEnv)) {
249
+ return CORVA_API_ENV_VARIABLES[apiEnv];
250
+ }
251
+
252
+ console.error(`CORVA_API_ENV should be one of [${Object.keys(CORVA_API_ENVS).join(', ')}]`);
253
+
254
+ return process.exit(1);
255
+ };
256
+
257
+ // react-refresh can only swap React components, and a hot update of anything else reaches the
258
+ // screen only when a component happens to re-read it while rendering. Measured on this app: all 7
259
+ // component edits applied live, while all 7 edits to a module behind a memo boundary or consumed
260
+ // while the app boots (a template default, a colour table, getStateColor) left the old value on
261
+ // screen with `hmr update` in the terminal and no hint that it was stale. Nothing in the module
262
+ // graph tells the two apart, so the honest rule is: components fast-refresh, every other module
263
+ // under src reloads the page. A developer never has to guess whether an edit arrived.
264
+ const COMPONENT_FILE_RE = /\.[jt]sx$/;
265
+ const SCRIPT_FILE_RE = /\.[cm]?[jt]s$/;
266
+
267
+ const reloadOnNonComponentEditPlugin = pwd => ({
268
+ name: 'corva-reload-on-non-component-edit',
269
+ handleHotUpdate({ file, server }) {
270
+ if (!file.startsWith(join(pwd, 'src')) || COMPONENT_FILE_RE.test(file) || !SCRIPT_FILE_RE.test(file)) {
271
+ return undefined;
272
+ }
273
+
274
+ server.config.logger.info(`page reload ${relative(pwd, file)} (not a component)`, { timestamp: true });
275
+ server.ws.send({ type: 'full-reload' });
276
+
277
+ return [];
278
+ },
279
+ });
280
+
281
+ // webpack tapped this after every compile: it warns when the installed @corva/ui is behind the
282
+ // one published on npm. It is written as a webpack plugin, so it gets the single hook it taps,
283
+ // and it is required here rather than at the top of the file because it reads @corva/ui's
284
+ // package.json off the cwd as it loads, which has nothing to do with a build or a test run.
285
+ const corvaUiVersionPlugin = () => ({
286
+ name: 'corva-ui-version',
287
+ configureServer() {
288
+ const checkCorvaUiVersion = require('../cjs/devcenter/webpack/plugins/checkCorvaUIVersion.js');
289
+ let warnWhenOutdated;
290
+
291
+ checkCorvaUiVersion({
292
+ hooks: { afterCompile: { tapPromise: (name, hook) => { warnWhenOutdated = hook; } } },
293
+ });
294
+
295
+ warnWhenOutdated({ getLogger: () => console });
296
+ },
297
+ });
298
+
299
+ // webpack's dev server ran react-error-overlay (through @pmmmwh/react-refresh-webpack-plugin),
300
+ // which put every uncaught error and rejection on the screen. Vite's overlay only shows what
301
+ // its own pipeline throws, so a platform call rejecting mid-render left the DevCenter sitting
302
+ // on its loader with nothing but a console entry.
303
+ // The auth branch is the one rejection that must not stop at an overlay: @corva/ui redirects to
304
+ // /login itself for anything that goes through apiCore, but the DevCenter's first call is
305
+ // jsonApi.getCurrentUser, whose 401 leaves useProvideAuth through an uncaught rejection, so a
306
+ // session that is missing or expired never reaches the login screen.
307
+ const RUNTIME_ERRORS_ID = 'virtual:corva-dev-runtime-errors';
308
+
309
+ const AUTH_RECOVERY_MODULE = '@corva/ui/clients/api/AuthRecovery';
310
+
311
+ const RUNTIME_ERRORS_SOURCE = `
312
+ import { AuthRecovery } from '${AUTH_RECOVERY_MODULE}';
313
+
314
+ const show = error => {
315
+ document.querySelectorAll('vite-error-overlay').forEach(overlay => overlay.close());
316
+ document.body.appendChild(
317
+ new (customElements.get('vite-error-overlay'))({
318
+ message: error?.message ?? String(error),
319
+ stack: error?.stack ?? '',
320
+ }),
321
+ );
322
+ };
323
+
324
+ window.addEventListener('error', event => show(event.error ?? event.message));
325
+
326
+ window.addEventListener('unhandledrejection', event => {
327
+ const authRecovery = AuthRecovery.getInstance();
328
+
329
+ if (authRecovery.isRefreshableAuthError(event.reason)) {
330
+ authRecovery.handleAuthFailure(event.reason);
331
+ return;
332
+ }
333
+
334
+ show(event.reason);
335
+ });
336
+ `;
337
+
338
+ const runtimeErrorsPlugin = () => ({
339
+ name: 'corva-dev-runtime-errors',
340
+ resolveId(id) {
341
+ return id === RUNTIME_ERRORS_ID ? `\0${RUNTIME_ERRORS_ID}` : null;
342
+ },
343
+ load(id) {
344
+ return id === `\0${RUNTIME_ERRORS_ID}` ? RUNTIME_ERRORS_SOURCE : null;
345
+ },
346
+ transformIndexHtml: () => [
347
+ {
348
+ tag: 'script',
349
+ attrs: { type: 'module', src: `/@id/__x00__${RUNTIME_ERRORS_ID}` },
350
+ injectTo: 'body',
351
+ },
352
+ ],
353
+ });
354
+
355
+ // webpack's dev config added this entry point whenever the app depends on
356
+ // mapbox-gl: it reads the access token off @corva/ui and publishes the
357
+ // configured instance as window['mapbox-gl'].
358
+ // A virtual module rather than a direct node_modules URL: mapbox-gl's dist is
359
+ // CJS, so it only imports cleanly once Vite's resolver and dep optimizer see it.
360
+ const MAPBOX_DEV_SETUP_ID = 'virtual:corva-mapbox-dev-setup';
361
+
362
+ const mapboxDevSetupPlugin = () => ({
363
+ name: 'corva-mapbox-dev-setup',
364
+ resolveId(id) {
365
+ return id === MAPBOX_DEV_SETUP_ID ? `\0${MAPBOX_DEV_SETUP_ID}` : null;
366
+ },
367
+ load(id) {
368
+ return id === `\0${MAPBOX_DEV_SETUP_ID}`
369
+ ? "import '@corva/dc-platform-shared/cjs/devcenter/webpack/MapboxGlDevSetupEntryPoint.js';"
370
+ : null;
371
+ },
372
+ transformIndexHtml: () => [
373
+ {
374
+ tag: 'script',
375
+ attrs: { type: 'module', src: `/@id/__x00__${MAPBOX_DEV_SETUP_ID}` },
376
+ injectTo: 'body',
377
+ },
378
+ ],
379
+ });
380
+
381
+ // The DevCenter dev page edits the app's .env through this endpoint.
382
+ const dotEnvEndpointPlugin = appDirectory => ({
383
+ name: 'corva-dotenv-endpoint',
384
+ configureServer(server) {
385
+ server.middlewares.use('/dotenv', (req, res, next) => {
386
+ if (req.method !== 'PATCH') {
387
+ return next();
388
+ }
389
+
390
+ const envPath = resolve(appDirectory, '.env');
391
+ const chunks = [];
392
+
393
+ req.on('data', chunk => chunks.push(chunk));
394
+ req.on('end', () => {
395
+ let update;
396
+
397
+ // webpack's dev server parsed the body with express.json(), which answers
398
+ // 400 on a malformed one; parsing it here would take the server down
399
+ try {
400
+ update = JSON.parse(Buffer.concat(chunks));
401
+ } catch {
402
+ res.statusCode = 400;
403
+ res.end();
404
+
405
+ return;
406
+ }
407
+
408
+ const currentEnv = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : '';
409
+
410
+ writeFileSync(envPath, updateDotEnv(currentEnv, update));
411
+ res.statusCode = 200;
412
+ res.end();
413
+ });
414
+ });
415
+ },
416
+ });
417
+
418
+ // Vitest runs its dependency optimizer with `noDiscovery: true`, so a dependency is pre-bundled
419
+ // only if it is listed. Unlisted, every test file re-evaluates that dependency's entire module
420
+ // graph in its own isolated context — @corva/ui alone is 2022 ESM files — and a 169-file suite
421
+ // takes 105 s where jest took 21 s (jest required one CJS module tree per worker). Pre-bundling
422
+ // the dependencies the app imports brings the same suite to 23 s.
423
+ const BARE_IMPORT_RE =
424
+ /(?:import|export)[^'"\n]*?from\s*['"]([^'"]+)['"]|\bimport\(\s*['"]([^'"]+)['"]\s*\)|\brequire\(\s*['"]([^'"]+)['"]\s*\)/g;
425
+ const NAMESPACE_IMPORT_RE = /import\s*\*\s*as\s+([A-Za-z_$][\w$]*)\s*from\s*['"]([^'"]+)['"]/g;
426
+ // react and the test renderers stay external whatever their package says: a copy inside the
427
+ // pre-bundle is a second React instance, and the two cannot see each other's hooks or renderer
428
+ // registry ("Could not auto-detect a React renderer")
429
+ const TEST_EXTERNAL_DEP_RE = /^(react|react-dom|react-test-renderer)($|\/)|^@testing-library\//;
430
+
431
+ const packageRoot = specifier =>
432
+ (specifier.startsWith('@') ? specifier.split('/').slice(0, 2) : [specifier.split('/')[0]]).join('/');
433
+
434
+ const readPackageJson = (pwd, specifier) => {
435
+ const manifest = join(pwd, 'node_modules', packageRoot(specifier), 'package.json');
436
+
437
+ return existsSync(manifest) ? JSON.parse(readFileSync(manifest, 'utf-8')) : null;
438
+ };
439
+
440
+ // Only a dependency vite pre-bundles as ESM can be included: a CJS one reaches the app through
441
+ // vite's interop, which changes what a `vi.mock` factory has to return and whether `new Dep()`
442
+ // still constructs. This is the same decision vite records as `needsInterop`, read off the
443
+ // package the way vite resolves its entry: `exports`/`type` win, then `module`, and a package
444
+ // with a real `main` and nothing else is CJS.
445
+ const isEsmPackage = (pwd, specifier) => {
446
+ const packageJson = readPackageJson(pwd, specifier);
447
+
448
+ if (!packageJson) {
449
+ return false;
450
+ }
451
+
452
+ if (packageJson.type === 'module' || JSON.stringify(packageJson.exports || {}).includes('"import"')) {
453
+ return true;
454
+ }
455
+
456
+ const main = packageJson.main && join(pwd, 'node_modules', packageRoot(specifier), packageJson.main);
457
+
458
+ return Boolean(packageJson.module) && !(main && (existsSync(main) || existsSync(`${main}.js`) || existsSync(join(main, 'index.js'))));
459
+ };
460
+
461
+ // the optimizer needs an entry file to bundle; 85 of @corva/ui's component folders ship only
462
+ // index.d.ts, and `@corva/ui`'s own `main` points at a directory that is not published
463
+ const hasEntryFile = (pwd, appRequire, specifier) => {
464
+ try {
465
+ return /\.(js|mjs|cjs)$/.test(appRequire.resolve(specifier));
466
+ } catch {
467
+ const base = join(pwd, 'node_modules', specifier);
468
+
469
+ return existsSync(`${base}.js`) || existsSync(join(base, 'index.js'));
470
+ }
471
+ };
472
+
473
+ const scanAppImports = pwd => {
474
+ const specifiers = new Set();
475
+ const spied = new Set();
476
+
477
+ const collect = dir => {
478
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
479
+ const full = join(dir, entry.name);
480
+
481
+ if (entry.isDirectory()) {
482
+ collect(full);
483
+ } else if (/\.(js|jsx|ts|tsx|mjs|cjs)$/.test(entry.name)) {
484
+ const code = readFileSync(full, 'utf-8');
485
+
486
+ for (const match of code.matchAll(BARE_IMPORT_RE)) {
487
+ specifiers.add(match[1] || match[2] || match[3]);
488
+ }
489
+
490
+ // `import * as utils from 'x'` + `vi.spyOn(utils, 'y')`: a pre-bundled module hands the
491
+ // test a real, sealed ESM namespace and the spy throws
492
+ for (const match of code.matchAll(NAMESPACE_IMPORT_RE)) {
493
+ if (new RegExp(`spyOn\\(\\s*${match[1]}\\s*,`).test(code)) {
494
+ spied.add(match[2]);
495
+ }
496
+ }
497
+ }
498
+ }
499
+ };
500
+
501
+ collect(join(pwd, 'src'));
502
+
503
+ return { specifiers: [...specifiers], spied };
504
+ };
505
+
506
+ // The dev server pre-bundles whatever it discovers while serving a request, and the app's entry
507
+ // is behind a dynamic import in the DevCenter harness, so discovery only starts once the browser
508
+ // asks for the app: it then bundles the dependencies mid-session and force-reloads the page
509
+ // ("optimized dependencies changed. reloading"). Listing them up front makes the first load one
510
+ // pass instead of two — 4.6 s to a rendered app becomes 2.0 s on a 169-file app, cold cache.
511
+ // Interop does not matter here the way it does for tests, so this list is everything importable.
512
+ const getDevOptimizeDeps = (pwd, { specifiers }) => {
513
+ const appRequire = createRequire(join(pwd, 'package.json'));
514
+
515
+ return specifiers
516
+ .filter(specifier => /^@?[\w.-]/.test(specifier) && !specifier.startsWith('@/'))
517
+ .filter(specifier => !builtinModules.includes(specifier.replace(/^node:/, '')))
518
+ .filter(specifier => hasEntryFile(pwd, appRequire, specifier))
519
+ .sort();
520
+ };
521
+
522
+ const getTestOptimizeDeps = (pwd, { specifiers, spied }) => {
523
+ const appRequire = createRequire(join(pwd, 'package.json'));
524
+
525
+ return specifiers
526
+ .filter(specifier => /^@?[\w.-]/.test(specifier) && !specifier.startsWith('@/'))
527
+ .filter(specifier => !builtinModules.includes(specifier.replace(/^node:/, '')))
528
+ .filter(specifier => !TEST_EXTERNAL_DEP_RE.test(specifier))
529
+ .filter(specifier => !spied.has(specifier))
530
+ .filter(specifier => isEsmPackage(pwd, specifier) && hasEntryFile(pwd, appRequire, specifier))
531
+ .sort();
532
+ };
533
+
534
+ const DEP_CSS_ID = '\0corva-test-dep-css';
535
+ const NODE_BUILTIN_PREFIX = '\0corva-test-node-builtin:';
536
+
537
+ const testDepBundlePlugin = pwd => {
538
+ const appRequire = createRequire(join(pwd, 'package.json'));
539
+
540
+ return {
541
+ name: 'corva-test-dep-bundle',
542
+ resolveId(source, importer) {
543
+ // the shim below imports the real builtin
544
+ if (importer?.startsWith(NODE_BUILTIN_PREFIX)) {
545
+ return { id: source, external: true };
546
+ }
547
+
548
+ // React 17 publishes no exports map, so Node cannot resolve a bare `react/jsx-runtime`
549
+ // left in a pre-bundled chunk
550
+ if (source === 'react/jsx-runtime') {
551
+ return appRequire.resolve(source);
552
+ }
553
+
554
+ // jest mapped every stylesheet to a stub object; Node cannot load a .css file at all
555
+ if (/\.(css|scss|sass|less)(\?|$)/.test(source)) {
556
+ return DEP_CSS_ID;
557
+ }
558
+
559
+ // these tests run in Node, so a dependency that reads a builtin (debug -> tty,
560
+ // fflate -> worker_threads) gets the real one instead of the browser stub that throws
561
+ const builtin = source.replace(/^node:/, '');
562
+
563
+ return builtinModules.includes(builtin) ? `${NODE_BUILTIN_PREFIX}${builtin}` : null;
564
+ },
565
+ load(id) {
566
+ if (id === DEP_CSS_ID) {
567
+ return 'export default {};';
568
+ }
569
+
570
+ if (id.startsWith(NODE_BUILTIN_PREFIX)) {
571
+ const builtin = id.slice(NODE_BUILTIN_PREFIX.length);
572
+
573
+ return `export * from 'node:${builtin}';\nexport { default } from 'node:${builtin}';`;
574
+ }
575
+
576
+ return null;
577
+ },
578
+ };
579
+ };
580
+
581
+ export const createViteConfig = ({ pwd, overrides = {} }) => {
582
+ expand(dotenv.config({ path: resolve(pwd, '.env'), quiet: true }));
583
+
584
+ const packageJson = JSON.parse(readFileSync(resolve(pwd, 'package.json'), 'utf-8'));
585
+ const manifestJson = JSON.parse(readFileSync(resolve(pwd, 'manifest.json'), 'utf-8'));
586
+
587
+ const appKey = manifestJson.application.key;
588
+ const version = packageJson.version;
589
+ const appIdentifier = `${appKey.replace(/\./g, '_')}-${version}`;
590
+ const appIifeName = toLegalIdentifier(appIdentifier);
591
+
592
+ const globalDependencies = Object.keys(CORVA_GLOBAL_DEPENDENCIES);
593
+ const externalPatterns = globalDependencies.map(
594
+ libName => new RegExp(`(^${libName}$|^${libName}/)([^]+)?`),
595
+ );
596
+
597
+ const isExternal = source =>
598
+ // a stylesheet cannot be read off a window global: webpack bundled `mapbox-gl/dist/mapbox-gl.css`
599
+ // and so does this, and `this["mapbox-gl"].dist["mapbox-gl.css"]` is not even valid JS
600
+ !source.endsWith('.css') &&
601
+ !SKIP_IN_EXTERNALS.some(skipped => source.startsWith(skipped)) &&
602
+ externalPatterns.some(pattern => pattern.test(source));
603
+
604
+ // Package names are not valid JS identifiers, so every global is read off `this`.
605
+ // The name has to match on a package boundary, as isExternal does: matching on a
606
+ // bare prefix maps react-dom/client onto react and emits `this["react"]-dom.client`.
607
+ const toGlobal = id => {
608
+ const dep = globalDependencies.find(libName => id === libName || id.startsWith(`${libName}/`));
609
+
610
+ if (!dep) {
611
+ throw new Error(`Unknown dependency: ${id}`);
612
+ }
613
+
614
+ if (dep === id) {
615
+ return `this["${dep}"].default || this["${dep}"]`;
616
+ }
617
+
618
+ return `this["${dep}"]${id
619
+ .slice(dep.length)
620
+ .split('/')
621
+ .filter(Boolean)
622
+ .map(segment => `.${segment}`)
623
+ .join('')}`;
624
+ };
625
+
626
+ const getAlias = () => {
627
+ // jest resolved '~' to src for both templates; keep it in the build too
628
+ const defaultAlias = { '~': resolve(pwd, 'src') };
629
+ const tsconfigPath = resolve(pwd, 'tsconfig.json');
630
+
631
+ if (!existsSync(tsconfigPath)) {
632
+ return defaultAlias;
633
+ }
634
+
635
+ // tsconfig.json is JSONC, which JSON.parse rejects; a comment in it must not
636
+ // take the build down
637
+ let compilerOptions;
638
+
639
+ try {
640
+ ({ compilerOptions } = JSON.parse(readFileSync(tsconfigPath, 'utf-8')));
641
+ } catch {
642
+ console.warn(`Could not read compilerOptions.paths from ${tsconfigPath}; only '~' is aliased.`);
643
+
644
+ return defaultAlias;
645
+ }
646
+
647
+ return {
648
+ ...defaultAlias,
649
+ ...Object.fromEntries(
650
+ Object.entries(compilerOptions?.paths || {}).map(([key, [value]]) => [
651
+ key.replace('/*', ''),
652
+ resolve(pwd, value.replace('/*', '')),
653
+ ]),
654
+ ),
655
+ };
656
+ };
657
+
658
+ const appImports = scanAppImports(pwd);
659
+
660
+ const resolveConfig = ({ command }) => {
661
+ validateDependencies({ packageJson });
662
+
663
+ const config = {
664
+ root: pwd,
665
+ optimizeDeps: {
666
+ // the harness module's own import belongs in this pass: discovered later, it becomes a
667
+ // second pre-bundled chunk in which AuthRecovery and apiCore's circular import
668
+ // initialises the wrong way round and apiCore's top-level getInstance() throws
669
+ include: [...getDevOptimizeDeps(pwd, appImports), AUTH_RECOVERY_MODULE],
670
+ },
671
+ server: {
672
+ host: process.env.HOST || 'app.local.corva.ai',
673
+ port: Number(process.env.PORT) || 8080,
674
+ },
675
+ css: {
676
+ modules: {
677
+ localsConvention: 'camelCase',
678
+ generateScopedName: `[name]--[local]__[hash:base64:10]__${BUILD_ID}`,
679
+ },
680
+ // webpack's postcss-loader ran preset-env with no options, i.e. stage 2; a narrower
681
+ // stage silently stops transpiling CSS features the app already relies on
682
+ postcss: { plugins: [presetEnv()] },
683
+ },
684
+ plugins: [react(), dotEnvEndpointPlugin(pwd), rejectUnscopedCssPlugin(pwd)],
685
+ // webpack ran with `resolve.symlinks: false`; a yarn-linked @corva/ui has to keep
686
+ // resolving through the link, not through its real path
687
+ resolve: { alias: getAlias(), preserveSymlinks: true },
688
+ test: {
689
+ watch: false,
690
+ globals: true,
691
+ // jest ran with resetMocks: true; vitest defaults it to false
692
+ mockReset: true,
693
+ // measured on a 169-file app before the pre-bundle below: threads 105 s, the default
694
+ // forks pool 114 s, vmThreads 950 s
695
+ pool: 'threads',
696
+ deps: {
697
+ optimizer: {
698
+ client: {
699
+ enabled: true,
700
+ include: getTestOptimizeDeps(pwd, appImports),
701
+ rolldownOptions: { plugins: [testDepBundlePlugin(pwd)] },
702
+ },
703
+ },
704
+ },
705
+ environment: 'jsdom',
706
+ setupFiles: ['./vitest.setup.js'],
707
+ globalSetup: ['./vitest.global-setup.js'],
708
+ coverage: {
709
+ provider: 'v8',
710
+ include: ['src/**/*.{js,jsx,ts,tsx}'],
711
+ thresholds: { branches: 60, functions: 60, lines: 60, statements: 60 },
712
+ },
713
+ },
714
+ };
715
+
716
+ if (command === 'serve') {
717
+ return {
718
+ ...config,
719
+ define: {
720
+ // webpack's node.global shim made `global` the real global object; `{}` would hand
721
+ // every `global.x` read a fresh empty object and make `global.x = …` a syntax error
722
+ global: 'globalThis',
723
+ // Webpack's EnvironmentPlugin exposed the whole environment, so apps read
724
+ // their own REACT_APP_* vars straight off process.env.
725
+ 'process.env': {
726
+ ...process.env,
727
+ DEV_CENTER_APP_PATH: '',
728
+ NODE_ENV: 'development',
729
+ REACT_APP_ENVIRONMENT: process.env.REACT_APP_ENVIRONMENT || 'development',
730
+ ...getCorvaApiEnvVariables(),
731
+ },
732
+ },
733
+ plugins: [
734
+ ...config.plugins,
735
+ ...(packageJson.dependencies?.['mapbox-gl'] ? [mapboxDevSetupPlugin()] : []),
736
+ runtimeErrorsPlugin(),
737
+ corvaUiVersionPlugin(),
738
+ reloadOnNonComponentEditPlugin(pwd),
739
+ // webpack's dev config copied @corva/ui's and @corva/dc-platform-shared's assets
740
+ // into static/media, where both packages request them by hash name (the loader gif
741
+ // of every DevCenter screen is one). stripBase drops the node_modules path the
742
+ // plugin would otherwise keep, which put them out of the platform's reach.
743
+ viteStaticCopy({
744
+ watch: { reloadPageOnChange: false },
745
+ targets: [
746
+ {
747
+ src: `${PLATFORM_SHARED_DIR}/assets/*`,
748
+ dest: 'static/media',
749
+ rename: { stripBase: true },
750
+ },
751
+ {
752
+ src: `${join(PLATFORM_SHARED_DIR, '../ui/assets')}/*`,
753
+ dest: 'static/media',
754
+ rename: { stripBase: true },
755
+ },
756
+ ],
757
+ }),
758
+ ],
759
+ };
760
+ }
761
+
762
+ return {
763
+ ...config,
764
+ define: {
765
+ // webpack's prod config ran `EnvironmentPlugin(process.env)`: one textual replacement
766
+ // per key, so only the keys the source actually reads reach the bundle. Without it an
767
+ // app reading e.g. process.env.CORVA_API_ENV silently compiles to undefined.
768
+ ...Object.fromEntries(
769
+ Object.entries(process.env)
770
+ .filter(([key]) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key))
771
+ .map(([key, value]) => [`process.env.${key}`, JSON.stringify(value)]),
772
+ ),
773
+ 'process.env': { NODE_ENV: 'production' },
774
+ 'process.env.NODE_ENV': '"production"',
775
+ global: 'globalThis',
776
+ },
777
+ plugins: [
778
+ ...config.plugins,
779
+ emitAssetsAsFilesPlugin(),
780
+ viteStaticCopy({ targets: [{ src: 'manifest.json', dest: '.' }] }),
781
+ {
782
+ name: 'corva-single-file-bundle',
783
+ writeBundle(_options, bundle) {
784
+ finalizeJsBundle({
785
+ outDir: resolve(pwd, 'dist'),
786
+ cssFileNames: Object.keys(bundle).filter(fileName => fileName.endsWith('.css')),
787
+ appIdentifier,
788
+ emittedGlobalName: appIifeName,
789
+ });
790
+ },
791
+ },
792
+ ],
793
+ // an asset URL has to be resolved at runtime, from the bundle's own location: the same
794
+ // app.js is served from a different package directory for every version
795
+ experimental: {
796
+ renderBuiltUrl: (filename, { hostType }) =>
797
+ (hostType === 'css'
798
+ ? `${ASSET_MARKER}${filename}`
799
+ : { runtime: `__CORVA_ASSET_BASE__+${JSON.stringify(filename)}` }),
800
+ },
801
+ build: {
802
+ outDir: 'dist',
803
+ emptyOutDir: true,
804
+ copyPublicDir: false,
805
+ sourcemap: false,
806
+ cssCodeSplit: false,
807
+ minify: 'terser',
808
+ terserOptions: {
809
+ format: { comments: false },
810
+ },
811
+ // lib mode plus named exports keeps the IIFE compatible with the platform
812
+ // loader, which reads `this["appKey-version"].default.component`.
813
+ lib: {
814
+ entry: resolve(pwd, 'src/index.js'),
815
+ name: appIifeName,
816
+ fileName: () => 'app.js',
817
+ formats: ['iife'],
818
+ },
819
+ rolldownOptions: {
820
+ external: isExternal,
821
+ moduleTypes: { '.cjs': 'js' },
822
+ output: {
823
+ format: 'iife',
824
+ name: appIifeName,
825
+ entryFileNames: 'app.js',
826
+ // flat next to app.js, the way webpack's default assetModuleFilename emitted them
827
+ assetFileNames: '[hash][extname]',
828
+ globals: toGlobal,
829
+ exports: 'named',
830
+ // Rolldown doesn't rewrite `require()` inside bundled CJS modules (e.g.
831
+ // react-is) to their external global references. This shim resolves them
832
+ // to the globals the IIFE receives as params.
833
+ intro:
834
+ `var require=function(id){var g=globalThis[id];return g&&g.default||g;};` +
835
+ `var __CORVA_ASSET_BASE__=${ASSET_BASE_EXPR};`,
836
+ },
837
+ plugins: [
838
+ {
839
+ // Rolldown rejects `typeof require` as a define key (not an identifier),
840
+ // so the CJS-detection branches are stripped by transform instead.
841
+ name: 'replace-typeof-require',
842
+ transform(code) {
843
+ if (!code.includes('typeof require')) return null;
844
+
845
+ return { code: code.replaceAll('typeof require', '"undefined"'), map: null };
846
+ },
847
+ },
848
+ license({
849
+ thirdParty: { output: resolve(pwd, 'dist/app.js.LICENSE.txt') },
850
+ }),
851
+ ],
852
+ },
853
+ },
854
+ };
855
+ };
856
+
857
+ return defineConfig(env => mergeConfig(resolveConfig(env), overrides));
858
+ };