fimo-vite 2.4.0-staging.2 → 2.4.0-staging.4

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.
@@ -5,7 +5,19 @@ interface LabelBundle {
5
5
  locales: string[];
6
6
  updatedAt: string | null;
7
7
  }
8
+ interface LabelBundleRead {
9
+ bundle: LabelBundle;
10
+ ok: boolean;
11
+ }
8
12
  export default function translationsPlugin(): Plugin;
9
- export declare function loadLabelBundle(rootDir: string): Promise<LabelBundle>;
13
+ /**
14
+ * Reads every configured locale dictionary from the tenant API.
15
+ *
16
+ * `ok` is true only when every locale request returned an authoritative
17
+ * dictionary — an empty dictionary from a 200 response counts, a rejected or
18
+ * non-ok read does not. Failed locales keep the dictionary from `previous`,
19
+ * so a transient outage never replaces valid labels with empty data.
20
+ */
21
+ export declare function loadLabelBundle(rootDir: string, previous?: LabelBundle): Promise<LabelBundleRead>;
10
22
  export {};
11
23
  //# sourceMappingURL=translations.d.ts.map
@@ -25,17 +25,50 @@ export default function translationsPlugin() {
25
25
  let refreshTimer;
26
26
  let pollTimer;
27
27
  let eventConnectionAttempt = 0;
28
+ let subscribedAttempt = null;
28
29
  let closed = false;
29
- const refreshLabels = async () => {
30
- bundle = await loadLabelBundle(rootDir);
31
- return bundle;
30
+ let refreshQueue = Promise.resolve();
31
+ // `closeBundle` also fires between build-watch rebuilds, which must keep
32
+ // reading labels; only a torn-down dev server retires the queue.
33
+ const retired = () => closed && command === 'serve';
34
+ // Reads run one at a time: a read started before an event still resolves
35
+ // first, so it can never overwrite the fresher bundle the event asked for.
36
+ const refreshLabels = (invalidateIfChanged = false) => {
37
+ const refresh = refreshQueue.then(async () => {
38
+ if (retired()) {
39
+ return { bundle, ok: false };
40
+ }
41
+ const before = serializeBundle(bundle);
42
+ const next = await loadLabelBundle(rootDir, bundle);
43
+ if (retired()) {
44
+ return { bundle, ok: false };
45
+ }
46
+ bundle = next.bundle;
47
+ if (invalidateIfChanged && serializeBundle(bundle) !== before) {
48
+ invalidateLabels();
49
+ }
50
+ return next;
51
+ });
52
+ refreshQueue = refresh.then(() => undefined, () => undefined);
53
+ return refresh;
32
54
  };
33
- const refreshLabelsAndInvalidateIfChanged = async () => {
34
- const before = serializeBundle(bundle);
35
- const next = await refreshLabels();
36
- if (serializeBundle(next) !== before) {
37
- invalidateLabels();
38
- }
55
+ // A failed read must not retire the poll: it can hide a label change
56
+ // committed while the subscription was still connecting. Only a snapshot
57
+ // every locale reported as authoritative, applied while the attempt that
58
+ // asked for it is still the subscribed one, proves events now cover
59
+ // further changes.
60
+ const reconcileLabels = (attempt) => {
61
+ void refreshLabels(true)
62
+ .then((next) => {
63
+ if (next.ok &&
64
+ !closed &&
65
+ attempt !== null &&
66
+ attempt === eventConnectionAttempt &&
67
+ attempt === subscribedAttempt) {
68
+ stopFallbackPolling();
69
+ }
70
+ })
71
+ .catch(() => undefined);
39
72
  };
40
73
  const invalidateLabels = () => {
41
74
  if (!devServer) {
@@ -72,7 +105,7 @@ export default function translationsPlugin() {
72
105
  if (closed) {
73
106
  return;
74
107
  }
75
- void refreshLabelsAndInvalidateIfChanged().catch(() => undefined);
108
+ void refreshLabels(true).catch(() => undefined);
76
109
  }, 150);
77
110
  };
78
111
  const startFallbackPolling = () => {
@@ -80,7 +113,7 @@ export default function translationsPlugin() {
80
113
  return;
81
114
  }
82
115
  pollTimer = setInterval(() => {
83
- void refreshLabelsAndInvalidateIfChanged().catch(() => undefined);
116
+ reconcileLabels(subscribedAttempt);
84
117
  }, FALLBACK_POLL_INTERVAL_MS);
85
118
  };
86
119
  const stopFallbackPolling = () => {
@@ -115,19 +148,21 @@ export default function translationsPlugin() {
115
148
  await connectLabelEvents(rootDir, scheduleRefresh, sockets, {
116
149
  isActive: () => !closed && attempt === eventConnectionAttempt,
117
150
  onConnected: () => {
118
- if (closed || attempt !== eventConnectionAttempt) {
151
+ if (closed || attempt !== eventConnectionAttempt || subscribedAttempt === attempt) {
119
152
  return;
120
153
  }
154
+ subscribedAttempt = attempt;
121
155
  if (reconnectTimer) {
122
156
  clearTimeout(reconnectTimer);
123
157
  reconnectTimer = undefined;
124
158
  }
125
- stopFallbackPolling();
159
+ reconcileLabels(attempt);
126
160
  },
127
161
  onDisconnected: () => {
128
162
  if (closed || attempt !== eventConnectionAttempt) {
129
163
  return;
130
164
  }
165
+ subscribedAttempt = null;
131
166
  scheduleReconnect();
132
167
  },
133
168
  });
@@ -166,7 +201,7 @@ export default function translationsPlugin() {
166
201
  devServer = server;
167
202
  closed = false;
168
203
  server.watcher.add([resolveFimoConfigPath(rootDir), join(rootDir, '.env'), join(rootDir, '.env.local')]);
169
- void refreshLabelsAndInvalidateIfChanged().catch(() => undefined);
204
+ void refreshLabels(true).catch(() => undefined);
170
205
  startFallbackPolling();
171
206
  void connectEvents();
172
207
  },
@@ -180,7 +215,7 @@ export default function translationsPlugin() {
180
215
  if (id !== RESOLVED_ID) {
181
216
  return null;
182
217
  }
183
- const current = command === 'serve' ? bundle : await refreshLabels();
218
+ const current = command === 'serve' ? bundle : (await refreshLabels()).bundle;
184
219
  const dictionaries = JSON.stringify(current.dictionaries);
185
220
  const defaultLabels = JSON.stringify(current.dictionaries[current.defaultLocale] ?? {});
186
221
  return [
@@ -384,7 +419,15 @@ function labelUpdatePayload(bundle) {
384
419
  signature: labelSignature(bundle),
385
420
  };
386
421
  }
387
- export async function loadLabelBundle(rootDir) {
422
+ /**
423
+ * Reads every configured locale dictionary from the tenant API.
424
+ *
425
+ * `ok` is true only when every locale request returned an authoritative
426
+ * dictionary — an empty dictionary from a 200 response counts, a rejected or
427
+ * non-ok read does not. Failed locales keep the dictionary from `previous`,
428
+ * so a transient outage never replaces valid labels with empty data.
429
+ */
430
+ export async function loadLabelBundle(rootDir, previous) {
388
431
  const config = getConfigServer(rootDir);
389
432
  const defaultLocale = getFimoDefaultLocale(config);
390
433
  const locales = getFimoLocales(config);
@@ -392,52 +435,54 @@ export async function loadLabelBundle(rootDir) {
392
435
  const apiUrl = env.VITE_API_URL;
393
436
  if (!apiUrl) {
394
437
  return {
395
- dictionaries: Object.fromEntries(locales.map((locale) => [locale, {}])),
396
- defaultLocale,
397
- locales,
398
- updatedAt: null,
438
+ bundle: {
439
+ dictionaries: Object.fromEntries(locales.map((locale) => [locale, {}])),
440
+ defaultLocale,
441
+ locales,
442
+ updatedAt: null,
443
+ },
444
+ ok: true,
399
445
  };
400
446
  }
401
- try {
402
- const loaded = await Promise.all(locales.map((locale) => fetchLabelDictionary(apiUrl, locale).catch(() => ({ locale, labels: {}, updatedAt: null }))));
403
- const updatedAtValues = loaded
404
- .map((entry) => entry.updatedAt)
405
- .filter((value) => typeof value === 'string')
406
- .sort();
407
- const updatedAt = updatedAtValues[updatedAtValues.length - 1];
408
- return {
409
- dictionaries: Object.fromEntries(loaded.map((entry) => [entry.locale, entry.labels])),
410
- defaultLocale,
411
- locales,
412
- updatedAt: updatedAt ?? null,
413
- };
447
+ const loaded = await Promise.all(locales.map(async (locale) => {
448
+ try {
449
+ return { locale, ok: true, ...(await fetchLabelDictionary(apiUrl, locale)) };
450
+ }
451
+ catch {
452
+ return { locale, ok: false };
453
+ }
454
+ }));
455
+ const ok = loaded.every((entry) => entry.ok);
456
+ const updatedAtValues = loaded.flatMap((entry) => entry.ok && typeof entry.updatedAt === 'string' ? [entry.updatedAt] : []);
457
+ if (!ok && typeof previous?.updatedAt === 'string') {
458
+ updatedAtValues.push(previous.updatedAt);
414
459
  }
415
- catch {
416
- return {
417
- dictionaries: Object.fromEntries(locales.map((locale) => [locale, {}])),
460
+ updatedAtValues.sort();
461
+ return {
462
+ bundle: {
463
+ dictionaries: Object.fromEntries(loaded.map((entry) => [entry.locale, entry.ok ? entry.labels : (previous?.dictionaries[entry.locale] ?? {})])),
418
464
  defaultLocale,
419
465
  locales,
420
- updatedAt: null,
421
- };
422
- }
466
+ updatedAt: updatedAtValues[updatedAtValues.length - 1] ?? null,
467
+ },
468
+ ok,
469
+ };
423
470
  }
424
471
  async function fetchLabelDictionary(apiUrl, locale) {
425
472
  const url = new URL('/labels', apiUrl);
426
473
  url.searchParams.set('locale', locale);
427
474
  const response = await fetchWithTimeout(url, LABEL_FETCH_TIMEOUT_MS);
428
475
  if (!response.ok) {
429
- return { locale, labels: {}, updatedAt: null };
476
+ throw new Error(`Failed to load labels for locale "${locale}": ${response.status}`);
430
477
  }
431
478
  const body = (await response.json());
432
479
  if (Array.isArray(body.data)) {
433
480
  return {
434
- locale,
435
481
  labels: Object.fromEntries(body.data.map((row) => [row.key, row.value ?? row.defaultValue ?? ''])),
436
482
  updatedAt: null,
437
483
  };
438
484
  }
439
485
  return {
440
- locale,
441
486
  labels: body.data?.labels ?? {},
442
487
  updatedAt: body.data?.updatedAt ?? null,
443
488
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fimo-vite",
3
- "version": "2.4.0-staging.2",
3
+ "version": "2.4.0-staging.4",
4
4
  "description": "Framework-neutral Fimo Vite plugin. Pairs with the fimo package at the same version.",
5
5
  "files": [
6
6
  "dist/",
@@ -31,15 +31,15 @@
31
31
  "oxc-parser": "^0.118.0"
32
32
  },
33
33
  "devDependencies": {
34
- "@fimo/tsconfig": "2.4.0-staging.2",
34
+ "@fimo/tsconfig": "2.4.0-staging.4",
35
35
  "@types/node": "22.18.6",
36
- "fimo": "2.4.0-staging.2",
36
+ "fimo": "2.4.0-staging.4",
37
37
  "typescript": "7.0.2",
38
38
  "vite": "8.2.2",
39
39
  "vitest": "5.0.0"
40
40
  },
41
41
  "peerDependencies": {
42
- "fimo": "2.4.0-staging.2",
42
+ "fimo": "2.4.0-staging.4",
43
43
  "vite": "^7.0.0 || ^8.0.0"
44
44
  },
45
45
  "engines": {