@hyperspan/framework 2.0.0-alpha.15 → 2.0.0-alpha.16

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/framework",
3
- "version": "2.0.0-alpha.15",
3
+ "version": "2.0.0-alpha.16",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "type": "module",
6
6
  "main": "src/server.ts",
@@ -104,7 +104,7 @@
104
104
  "vitest": "^4.1.10"
105
105
  },
106
106
  "dependencies": {
107
- "@hyperspan/html": "^2.0.0-alpha.15",
107
+ "@hyperspan/html": "^2.0.0-alpha.16",
108
108
  "debug": "^4.4.3",
109
109
  "isbot": "^5.1.32",
110
110
  "zod": "^4.4.3"
@@ -1,9 +1,10 @@
1
1
  import { describe, expect, test, beforeEach } from 'vitest';
2
- import { mkdtempSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { mkdtempSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import { render } from '@hyperspan/html';
7
+ import { assetHash } from '../utils';
7
8
  import { setAssetManifest } from './manifest';
8
9
  import {
9
10
  buildClientJS,
@@ -12,7 +13,6 @@ import {
12
13
  getClientJSEntries,
13
14
  registerPathAliases,
14
15
  resetClientJSEntriesForTests,
15
- stableClientSourceKey,
16
16
  streamingClient,
17
17
  } from './js';
18
18
 
@@ -21,7 +21,7 @@ describe('buildClientJS', () => {
21
21
 
22
22
  beforeEach(() => {
23
23
  resetClientJSEntriesForTests();
24
- setAssetManifest({ imports: {}, css: {}, clients: {}, clientSources: {} });
24
+ setAssetManifest({ imports: {}, css: {}, clients: {} });
25
25
  const dir = mkdtempSync(join(tmpdir(), 'hs-client-'));
26
26
  clientFile = join(dir, 'picker.ts');
27
27
  writeFileSync(
@@ -57,7 +57,6 @@ describe('buildClientJS', () => {
57
57
  imports: { [result.esmName]: hashed },
58
58
  css: {},
59
59
  clients: {},
60
- clientSources: {},
61
60
  });
62
61
  expect(result.publicPath).toBe(hashed);
63
62
  });
@@ -96,7 +95,7 @@ describe('buildClientJS', () => {
96
95
  expect(tag).toContain('({ mountPicker }) => mountPicker()');
97
96
  });
98
97
 
99
- test('package specifiers and import.meta.resolve of the same file share one hash', async () => {
98
+ test('package specifiers hash the same wherever the package is installed', async () => {
100
99
  const spec = '@hyperspan/framework/client/_hs/hyperspan-streaming.client.ts';
101
100
  const first = await buildClientJS(spec, { type: 'iife' });
102
101
  resetClientJSEntriesForTests();
@@ -106,21 +105,42 @@ describe('buildClientJS', () => {
106
105
  expect(existsSync(getClientJSEntries()[0].absPath)).toBe(true);
107
106
  });
108
107
 
109
- test('import.meta.resolve at the call site registers a real file', async () => {
108
+ // workerd rejects `import.meta.resolve` after bundling, which crashed Worker startup.
109
+ test('builtins register without resolving a path at module load', () => {
110
+ const src = readFileSync(new URL('./js.ts', import.meta.url), 'utf8');
111
+ const registrations = src.slice(src.indexOf('export const streamingClient'));
112
+
113
+ expect(registrations).toContain(
114
+ "'@hyperspan/framework/client/_hs/hyperspan-streaming.client.ts'"
115
+ );
116
+ expect(registrations).toContain(
117
+ "'@hyperspan/framework/client/_hs/hyperspan-actions.client.ts'"
118
+ );
119
+ expect(registrations).not.toContain('import.meta.resolve');
120
+ });
121
+
122
+ // Valid on Node/Bun, which can resolve paths at runtime. Flagged as a path identity
123
+ // so a Worker build can reject it instead of serving a 404 for a script in dist/.
124
+ test('import.meta.resolve at the call site registers a real file, flagged as a path identity', async () => {
110
125
  const result = await buildClientJS(import.meta.resolve('./_hs/hyperspan-streaming.client.ts'), {
111
126
  type: 'iife',
112
127
  });
113
128
 
114
- expect(result.esmName).toBe(streamingClient.esmName);
115
129
  expect(result.publicPath).toMatch(/^\/_hs\/js\/client-[0-9a-f]{16}\.js$/);
116
130
  expect(getClientJSEntries()[0].type).toBe('iife');
117
131
  expect(existsSync(getClientJSEntries()[0].absPath)).toBe(true);
118
132
  expect(getClientJSEntries()[0].absPath).toMatch(/hyperspan-streaming\.client\.ts$/);
133
+ expect(getClientJSEntries()[0].identityFromPath).toBe(true);
134
+ });
135
+
136
+ test('specifier identities are not flagged as path identities', async () => {
137
+ await buildClientJS('@hyperspan/framework/client/_hs/hyperspan-actions.client.ts');
138
+ expect(getClientJSEntries()[0].identityFromPath).toBe(false);
119
139
  });
120
140
 
121
- test('relative paths must be resolved at the call site', async () => {
122
- await expect(buildClientJS('../client/picker.ts')).rejects.toThrow(/import\.meta\.resolve/);
123
- await expect(buildClientJS('./picker.ts')).rejects.toThrow(/import\.meta\.resolve/);
141
+ test('relative paths are rejected in favor of an alias', async () => {
142
+ await expect(buildClientJS('../client/picker.ts')).rejects.toThrow(/tsconfig alias/);
143
+ await expect(buildClientJS('./picker.ts')).rejects.toThrow(/tsconfig alias/);
124
144
  });
125
145
 
126
146
  test('type iife renders a classic script tag', async () => {
@@ -175,19 +195,16 @@ describe('buildClientJS', () => {
175
195
  expect(second.esmName).toBe(result.esmName);
176
196
  });
177
197
 
178
- test('missing files use a build-time hash from clientSources', async () => {
179
- const ghost = '/definitely-missing-hyperspan-root/app/client/gone.ts';
180
- const builtName = 'client-from-build0123';
181
- setAssetManifest({
182
- imports: { [builtName]: `/_hs/js/${builtName}.js` },
183
- css: {},
184
- clients: {},
185
- clientSources: { [stableClientSourceKey(ghost)]: builtName },
186
- });
198
+ // A Worker has no filesystem: registration must not read the source to get a URL.
199
+ test('missing source files still resolve to the built URL', async () => {
200
+ const spec = 'app/client/gone.ts';
201
+ const esmName = `client-${assetHash(spec)}`;
202
+ const hashed = `/_hs/js/${esmName}-a1b2c3d4.js`;
203
+ setAssetManifest({ imports: { [esmName]: hashed }, css: {}, clients: {} });
187
204
 
188
- const result = await buildClientJS(ghost);
189
- expect(result.esmName).toBe(builtName);
190
- expect(result.publicPath).toBe(`/_hs/js/${builtName}.js`);
205
+ const result = await buildClientJS(spec);
206
+ expect(result.esmName).toBe(esmName);
207
+ expect(result.publicPath).toBe(hashed);
191
208
  });
192
209
  });
193
210
 
package/src/client/js.ts CHANGED
@@ -3,7 +3,7 @@ import { isAbsolute, join, relative } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { html } from '@hyperspan/html';
5
5
  import { assetHash as assetHashFn } from '../utils';
6
- import { getAssetManifest, getImportMap, registerImport, resolveImport } from './manifest';
6
+ import { getImportMap, registerImport, resolveImport } from './manifest';
7
7
  import type { Hyperspan as HS } from '../types';
8
8
 
9
9
  export { registerImport, resolveImport, getImportMap } from './manifest';
@@ -21,10 +21,17 @@ export type BuildClientJSOptions = {
21
21
  export type ClientJSEntry = {
22
22
  /** Original path/specifier passed to `buildClientJS`. */
23
23
  modulePath: string;
24
+ /** Resolved file path. Lazy — only a build/dev tool with a filesystem reads this. */
24
25
  absPath: string;
25
- /** Identities used to look up a stable import-map key when the source file is gone (Workers). */
26
- sourceKeys: string[];
27
- /** Identity hash of the resolved path (not file contents). */
26
+ /** Runtime-stable identity of the module. Same value on Vite, Node, and Workers. */
27
+ identityKey: string;
28
+ /**
29
+ * Identity came from a filesystem path rather than a logical specifier. Such an
30
+ * identity only matches at runtime on a runtime that can resolve paths, so it is
31
+ * not portable to a Worker.
32
+ */
33
+ identityFromPath: boolean;
34
+ /** Identity hash of `identityKey` (not of file contents). */
28
35
  assetHash: string;
29
36
  esmName: string;
30
37
  publicPath: string;
@@ -33,7 +40,13 @@ export type ClientJSEntry = {
33
40
  type: ClientJSType;
34
41
  };
35
42
 
43
+ export type ClientJSPathIdentity = {
44
+ modulePath: string;
45
+ identityKey: string;
46
+ };
47
+
36
48
  const CLIENT_JS_REGISTRY = Symbol.for('@hyperspan/client-js-entries');
49
+ const CLIENT_JS_PATH_IDENTITIES = Symbol.for('@hyperspan/client-js-path-identities');
37
50
  const PATH_ALIASES = Symbol.for('@hyperspan/path-aliases');
38
51
 
39
52
  function getPathAliases(): Record<string, string> {
@@ -63,6 +76,23 @@ function resolveWithAliases(specifier: string): string | undefined {
63
76
  return undefined;
64
77
  }
65
78
 
79
+ /**
80
+ * Client scripts registered from a path rather than a logical specifier, kept outside
81
+ * the registry. A path identity can coincide with the equivalent project-relative
82
+ * specifier (`file:///proj/app/client/x.ts` and `app/client/x.ts` share one identity),
83
+ * so the registry entry may be replaced by whichever form registered last. Deploy
84
+ * checks need the record regardless of that order.
85
+ */
86
+ export function getPathIdentityClientJS(): ClientJSPathIdentity[] {
87
+ const globalPathIdentities = globalThis as {
88
+ [CLIENT_JS_PATH_IDENTITIES]?: ClientJSPathIdentity[];
89
+ };
90
+ if (!globalPathIdentities[CLIENT_JS_PATH_IDENTITIES]) {
91
+ globalPathIdentities[CLIENT_JS_PATH_IDENTITIES] = [];
92
+ }
93
+ return globalPathIdentities[CLIENT_JS_PATH_IDENTITIES];
94
+ }
95
+
66
96
  function getClientJSRegistry(): Map<string, ClientJSEntry> {
67
97
  const globalRegistry = globalThis as {
68
98
  [CLIENT_JS_REGISTRY]?: Map<string, ClientJSEntry>;
@@ -102,9 +132,9 @@ export const JS_IMPORT_MAP = {
102
132
  };
103
133
 
104
134
  /**
105
- * File URLs from `import.meta.resolve` must not use a cwd-relative pnpm path —
106
- * that changes between install layouts and Workers. Prefer the path after the
107
- * last `node_modules/` so published packages stay stable.
135
+ * File paths must not become a cwd-relative pnpm path — that changes between
136
+ * install layouts and Workers. Prefer the path after the last `node_modules/`
137
+ * so published packages stay stable.
108
138
  */
109
139
  export function stableClientSourceKey(absPath: string): string {
110
140
  const normalized = absPath.replace(/\\/g, '/');
@@ -146,24 +176,27 @@ function clientPublicPath(esmName: string): string {
146
176
  }
147
177
 
148
178
  /**
149
- * Resolve to a real file path. The import-map key hashes `stableClientSourceKey(absPath)`
150
- * so `import.meta.resolve()`, aliases, and package specifiers of the same file share
151
- * one key. Vite/esbuild attach a content hash to the emitted filename; `publicPath`
152
- * reads that from the asset manifest. Relative `./` / `../` paths error — resolve
153
- * them at the call site.
179
+ * Identity of a client module, computed with string math only — no filesystem and
180
+ * no `import.meta.resolve`. Workers can therefore build the same identity (and so
181
+ * the same public URL) that Vite used at build time.
182
+ *
183
+ * A logical specifier (package export, tsconfig alias, project-root path) is its
184
+ * own identity. File URLs and absolute paths fall back to a path identity.
154
185
  */
155
- function resolveClientAbsPath(modulePath: string): string {
156
- if (modulePath.startsWith('file://') || isAbsolute(modulePath)) {
157
- return toFilePath(modulePath);
186
+ function clientIdentityKey(specifier: string): string {
187
+ if (specifier.startsWith('file://') || isAbsolute(specifier)) {
188
+ return stableClientSourceKey(toFilePath(specifier));
158
189
  }
190
+ return specifier;
191
+ }
159
192
 
160
- const specifier = modulePath.replace(/\\/g, '/');
161
- if (specifier.startsWith('.')) {
162
- throw new Error(
163
- `[Hyperspan] buildClientJS(${JSON.stringify(modulePath)}) got a relative path. ` +
164
- `Use import.meta.resolve(${JSON.stringify(modulePath)}) at the call site, ` +
165
- `or a tsconfig alias like '~/app/client/file.ts'.`
166
- );
193
+ /**
194
+ * Resolve an identity to a real file path. Only build/dev tooling calls this, so
195
+ * `import.meta.resolve` never runs during module load on a Worker.
196
+ */
197
+ function resolveClientAbsPath(specifier: string): string {
198
+ if (specifier.startsWith('file://') || isAbsolute(specifier)) {
199
+ return toFilePath(specifier);
167
200
  }
168
201
 
169
202
  const aliased = resolveWithAliases(specifier);
@@ -171,7 +204,9 @@ function resolveClientAbsPath(modulePath: string): string {
171
204
  return aliased;
172
205
  }
173
206
 
174
- return tryResolveToFile(specifier) ?? join(process.cwd(), specifier);
207
+ const cwd =
208
+ typeof process !== 'undefined' && typeof process.cwd === 'function' ? process.cwd() : '';
209
+ return tryResolveToFile(specifier) ?? (cwd ? join(cwd, specifier) : specifier);
175
210
  }
176
211
 
177
212
  function readClientSource(absPath: string): string | undefined {
@@ -185,121 +220,105 @@ function readClientSource(absPath: string): string | undefined {
185
220
  return undefined;
186
221
  }
187
222
 
188
- function clientSourceKeys(modulePath: string, absPath: string): string[] {
189
- const keys = new Set<string>([stableClientSourceKey(absPath), modulePath.replace(/\\/g, '/')]);
190
- if (modulePath.startsWith('file://')) {
191
- keys.add(toFilePath(modulePath));
192
- }
193
- return [...keys];
194
- }
195
-
196
- function resolveEsmName(sourceKeys: string[], fallbackHash: string): string {
197
- const sources = getAssetManifest().clientSources;
198
- if (sources) {
199
- for (const key of sourceKeys) {
200
- if (sources[key]) {
201
- return sources[key];
202
- }
203
- }
204
- }
205
- return `client-${fallbackHash}`;
206
- }
207
-
208
223
  export function getClientJSEntries(): ClientJSEntry[] {
209
224
  return [...getClientJSRegistry().values()];
210
225
  }
211
226
 
212
227
  export function getClientJSEntryByEsmName(esmName: string): ClientJSEntry | undefined {
213
- for (const entry of getClientJSRegistry().values()) {
214
- if (currentEsmName(entry) === esmName) {
215
- return entry;
216
- }
217
- }
218
- return undefined;
219
- }
220
-
221
- export function getClientJSSourceMap(): Record<string, string> {
222
- const map: Record<string, string> = {};
223
- for (const entry of getClientJSEntries()) {
224
- const esmName = currentEsmName(entry);
225
- for (const key of entry.sourceKeys) {
226
- map[key] = esmName;
227
- }
228
- }
229
- return map;
228
+ return getClientJSRegistry().get(esmName);
230
229
  }
231
230
 
232
231
  export function resetClientJSEntriesForTests(): void {
233
232
  getClientJSRegistry().clear();
233
+ getPathIdentityClientJS().length = 0;
234
234
  const aliases = getPathAliases();
235
235
  for (const key of Object.keys(aliases)) {
236
236
  delete aliases[key];
237
237
  }
238
238
  }
239
239
 
240
- function currentEsmName(entry: ClientJSEntry): string {
241
- return resolveEsmName(entry.sourceKeys, entry.assetHash);
242
- }
243
-
244
240
  function registerClientJS(
245
241
  modulePathResolved: string,
246
242
  options: BuildClientJSOptions = {}
247
243
  ): HS.ClientJSBuildResult {
248
244
  const type = options.type ?? 'module';
249
- const absPath = resolveClientAbsPath(modulePathResolved);
250
- const sourceKeys = clientSourceKeys(modulePathResolved, absPath);
251
- const identityKey = stableClientSourceKey(absPath);
245
+ const specifier = modulePathResolved.replace(/\\/g, '/');
246
+ if (specifier.startsWith('.')) {
247
+ throw new Error(
248
+ `[Hyperspan] buildClientJS(${JSON.stringify(modulePathResolved)}) got a relative path, ` +
249
+ `which has no stable identity across dev, build, and deploy. Use a tsconfig alias ` +
250
+ `like '~/app/client/file.ts' — the build resolves it, and every runtime (including ` +
251
+ `Workers, which cannot resolve paths) matches it in the asset manifest.`
252
+ );
253
+ }
254
+
255
+ const identityKey = clientIdentityKey(specifier);
256
+ const identityFromPath = identityKey !== specifier;
252
257
  const hash = assetHashFn(identityKey);
253
- const source = readClientSource(absPath);
254
-
255
- let exports = '* as _module';
256
- let fnArgs = '_module';
257
- if (source) {
258
- const discovered = discoverClientExports(source);
259
- exports = discovered.exports;
260
- fnArgs = discovered.fnArgs;
258
+ const esmName = `client-${hash}`;
259
+
260
+ if (identityFromPath) {
261
+ getPathIdentityClientJS().push({ modulePath: modulePathResolved, identityKey });
262
+ }
263
+
264
+ let absPath: string | undefined;
265
+ let discovered: { exports: string; fnArgs: string } | undefined;
266
+
267
+ // Both are lazy: a Worker renders script tags without a filesystem, and
268
+ // resolving there would need `import.meta.resolve`, which workerd may reject.
269
+ function currentAbsPath(): string {
270
+ return (absPath ??= resolveClientAbsPath(specifier));
271
+ }
272
+ function currentExports(): { exports: string; fnArgs: string } {
273
+ if (!discovered) {
274
+ const source = readClientSource(currentAbsPath());
275
+ discovered = source
276
+ ? discoverClientExports(source)
277
+ : { exports: '* as _module', fnArgs: '_module' };
278
+ }
279
+ return discovered;
261
280
  }
262
281
 
263
282
  const entry: ClientJSEntry = {
264
283
  modulePath: modulePathResolved,
265
- absPath,
266
- sourceKeys,
267
- assetHash: hash,
268
- get esmName() {
269
- return currentEsmName(entry);
284
+ get absPath() {
285
+ return currentAbsPath();
270
286
  },
287
+ identityKey,
288
+ identityFromPath,
289
+ assetHash: hash,
290
+ esmName,
271
291
  get publicPath() {
272
- return clientPublicPath(currentEsmName(entry));
292
+ return clientPublicPath(esmName);
293
+ },
294
+ get exports() {
295
+ return currentExports().exports;
296
+ },
297
+ get fnArgs() {
298
+ return currentExports().fnArgs;
273
299
  },
274
- exports,
275
- fnArgs,
276
300
  type,
277
301
  };
278
- getClientJSRegistry().set(identityKey, entry);
279
- registerImport(entry.esmName, entry.publicPath);
302
+ getClientJSRegistry().set(esmName, entry);
303
+ registerImport(esmName, entry.publicPath);
280
304
 
281
305
  return {
282
- get assetHash() {
283
- return currentEsmName(entry).replace(/^client-/, '');
284
- },
285
- get esmName() {
286
- return currentEsmName(entry);
287
- },
306
+ assetHash: hash,
307
+ esmName,
288
308
  get publicPath() {
289
- return clientPublicPath(currentEsmName(entry));
309
+ return clientPublicPath(esmName);
290
310
  },
291
311
  renderScriptTag: (loadScript) => {
292
- const esmName = currentEsmName(entry);
293
- const tagHash = esmName.replace(/^client-/, '');
294
312
  if (type === 'iife') {
295
313
  return html`<script src="${clientPublicPath(esmName)}"></script>`;
296
314
  }
297
315
 
298
316
  const t = typeof loadScript;
317
+ const { exports, fnArgs } = currentExports();
299
318
 
300
319
  if (t === 'string') {
301
320
  return html`
302
- <script type="module" data-source-id="${tagHash}">
321
+ <script type="module" data-source-id="${hash}">
303
322
  import ${exports} from '${esmName}';
304
323
  (${html.raw(loadScript as string)})(${fnArgs});
305
324
  </script>
@@ -307,7 +326,7 @@ function registerClientJS(
307
326
  }
308
327
  if (t === 'function') {
309
328
  return html`
310
- <script type="module" data-source-id="${tagHash}">
329
+ <script type="module" data-source-id="${hash}">
311
330
  import ${exports} from '${esmName}';
312
331
  (${html.raw(functionToString(loadScript))})(${fnArgs});
313
332
  </script>
@@ -315,7 +334,7 @@ function registerClientJS(
315
334
  }
316
335
 
317
336
  return html`
318
- <script type="module" data-source-id="${tagHash}">
337
+ <script type="module" data-source-id="${hash}">
319
338
  import '${esmName}';
320
339
  </script>
321
340
  `;
@@ -325,9 +344,12 @@ function registerClientJS(
325
344
 
326
345
  /**
327
346
  * Register a client JS module for Vite to bundle.
328
- * Prefer `import.meta.resolve('./file.ts')`, a tsconfig alias, or a project-root path.
329
- * The import-map key is a stable identity of the resolved file. Vite/esbuild content-hash
330
- * the emitted filename; `publicPath` uses that URL from the asset manifest when present.
347
+ * Prefer a package export, tsconfig alias, or project-root path
348
+ * (`@scope/pkg/file.ts`, `~/app/client/foo.ts`, `app/client/foo.ts`) — those need no
349
+ * filesystem lookup, so Vite, Node, and Workers all derive the same public URL.
350
+ * Vite/esbuild content-hash the emitted filename; `publicPath` reads that URL from
351
+ * the asset manifest. Relative `./` / `../` paths must be resolved at the call site
352
+ * with `import.meta.resolve('./file.ts')`.
331
353
  */
332
354
  export async function buildClientJS(
333
355
  modulePathResolved: string,
@@ -336,12 +358,14 @@ export async function buildClientJS(
336
358
  return registerClientJS(modulePathResolved, options);
337
359
  }
338
360
 
361
+ // Registered through the same public API an app uses. Package-export specifiers keep
362
+ // module load free of `import.meta.resolve`, which workerd rejects after bundling.
339
363
  export const streamingClient = registerClientJS(
340
- import.meta.resolve('./_hs/hyperspan-streaming.client.ts'),
364
+ '@hyperspan/framework/client/_hs/hyperspan-streaming.client.ts',
341
365
  { type: 'iife' }
342
366
  );
343
367
  export const actionsClient = registerClientJS(
344
- import.meta.resolve('./_hs/hyperspan-actions.client.ts')
368
+ '@hyperspan/framework/client/_hs/hyperspan-actions.client.ts'
345
369
  );
346
370
 
347
371
  /**
@@ -4,15 +4,12 @@ export type AssetManifest = {
4
4
  imports: Record<string, string>;
5
5
  css: Record<string, string[]>;
6
6
  clients: Record<string, string>;
7
- /** Resolved path / original specifier → import-map key (`client-{id}`). */
8
- clientSources?: Record<string, string>;
9
7
  };
10
8
 
11
9
  let _manifest: AssetManifest = {
12
10
  imports: {},
13
11
  css: {},
14
12
  clients: {},
15
- clientSources: {},
16
13
  };
17
14
 
18
15
  /**
@@ -23,7 +20,6 @@ export function setAssetManifest(manifest: AssetManifest): void {
23
20
  imports: manifest.imports ?? {},
24
21
  css: manifest.css ?? {},
25
22
  clients: manifest.clients ?? {},
26
- clientSources: { ...(manifest.clientSources ?? {}) },
27
23
  };
28
24
  }
29
25