@edgekits/astro-i18n 0.1.7 → 0.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 (50) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +98 -27
  3. package/dist/integration/index.d.ts +25 -4
  4. package/dist/integration/index.js +35 -9
  5. package/dist/integration/vite-plugin.d.ts +1 -1
  6. package/dist/types.d.ts +7 -55
  7. package/dist/types.js +1 -44
  8. package/package.json +7 -17
  9. package/src/components/LanguageSwitcher.astro +89 -91
  10. package/src/config.entry.ts +5 -36
  11. package/src/constants.ts +5 -3
  12. package/src/cookie-storage.ts +19 -25
  13. package/src/fetcher.ts +3 -304
  14. package/src/format.ts +3 -107
  15. package/src/instance.ts +44 -0
  16. package/src/integration/index.ts +38 -9
  17. package/src/integration/vite-plugin.ts +1 -1
  18. package/src/middleware/i18n.ts +232 -114
  19. package/src/resolve-locale.ts +7 -39
  20. package/src/runtime.ts +2 -2
  21. package/src/schema.ts +3 -11
  22. package/src/translations-keys.ts +7 -38
  23. package/src/types.ts +16 -116
  24. package/dist/cli/generate.d.ts +0 -50
  25. package/dist/cli/generate.js +0 -160
  26. package/dist/cli/hashes.d.ts +0 -19
  27. package/dist/cli/hashes.js +0 -42
  28. package/dist/cli/index.d.ts +0 -2
  29. package/dist/cli/index.js +0 -368
  30. package/dist/cli/load-config.d.ts +0 -7
  31. package/dist/cli/load-config.js +0 -19
  32. package/dist/cli/purge.d.ts +0 -29
  33. package/dist/cli/purge.js +0 -94
  34. package/dist/cli/wrangler.d.ts +0 -10
  35. package/dist/cli/wrangler.js +0 -71
  36. package/dist/key-format.d.ts +0 -8
  37. package/dist/key-format.js +0 -15
  38. package/src/accept-language.ts +0 -56
  39. package/src/assemble.ts +0 -29
  40. package/src/cli/generate.ts +0 -205
  41. package/src/cli/hashes.ts +0 -66
  42. package/src/cli/index.ts +0 -467
  43. package/src/cli/load-config.ts +0 -26
  44. package/src/cli/purge.ts +0 -122
  45. package/src/cli/wrangler.ts +0 -96
  46. package/src/deep-merge.ts +0 -38
  47. package/src/key-format.ts +0 -17
  48. package/src/locale-fallback.ts +0 -58
  49. package/src/markdown-path.ts +0 -31
  50. package/src/parse-boolean.ts +0 -20
package/dist/cli/index.js DELETED
@@ -1,368 +0,0 @@
1
- #!/usr/bin/env node
2
- import { execFileSync } from 'node:child_process';
3
- import fs from 'node:fs';
4
- import path from 'node:path';
5
- import { DEFAULT_GENERATED_DIR, DEFAULT_LOCALES_DIR } from "../types.js";
6
- import { assertValidNames, buildBulkData, buildFallbacksContent, buildRuntimeConstantsContent, buildTypesContent, localesWithTranslations, schemaSourceLocale, } from "./generate.js";
7
- import { diffHashes, purgeUrlsFor } from "./hashes.js";
8
- import { CONFIG_FILE, loadI18nConfig } from "./load-config.js";
9
- import { purgeTranslationsCache } from "./purge.js";
10
- import { getKvNamespaceId, getWranglerVar, KV_BINDING, resolveWranglerBin } from "./wrangler.js";
11
- const HELP = `Usage: edgekits-i18n <command> [--fallbacks]
12
-
13
- Reads ${CONFIG_FILE} and <localesDir>/<locale>/<namespace>.json from the current directory.
14
-
15
- Commands:
16
- bundle Write i18n-data.json, src/i18n.generated.d.ts and <generatedDir>/runtime-constants.ts.
17
- No side effects. --fallbacks also writes <generatedDir>/fallbacks.generated.ts.
18
- seed bundle + fallbacks, then push to LOCAL KV (wrangler --local).
19
- .i18n-hashes.json is left untouched: it records production, not local KV.
20
- migrate bundle + fallbacks, then push to production KV, purge the changed edge-cache
21
- entries, and write .i18n-hashes.json. Refuses to run while .i18n-hashes.json
22
- has uncommitted changes.
23
- `;
24
- const COMMANDS = ['bundle', 'seed', 'migrate'];
25
- const ROOT = process.cwd();
26
- // Fixed outputs at the project root.
27
- const OUTPUT_DATA = path.join(ROOT, 'i18n-data.json');
28
- const OUTPUT_TYPES = path.join(ROOT, 'src', 'i18n.generated.d.ts');
29
- // Stores per-namespace content hashes from the last production migrate.
30
- // Used to detect which namespaces actually changed and need cache purging.
31
- // TRACKED in git: the committed copy is the production purge baseline.
32
- // Absence means "first run", all namespaces treated as changed.
33
- const HASHES_FILE = path.join(ROOT, '.i18n-hashes.json');
34
- const KV_NAMESPACE_ID = /^[0-9a-f]{32}$/i;
35
- await main(process.argv.slice(2));
36
- async function main(args) {
37
- const command = args.find((arg) => !arg.startsWith('-'));
38
- if (!command || args.includes('--help') || args.includes('-h')) {
39
- console.log(HELP);
40
- return;
41
- }
42
- if (!COMMANDS.includes(command)) {
43
- console.error(`[i18n] Unknown command "${command}".\n`);
44
- console.error(HELP);
45
- process.exit(1);
46
- }
47
- await run(command, args);
48
- }
49
- async function run(command, args) {
50
- const shouldDeploy = command !== 'bundle';
51
- const isLocal = command === 'seed';
52
- const shouldGenerateFallbacks = shouldDeploy || args.includes('--fallbacks') || process.env.I18N_GENERATE_FALLBACKS === 'true';
53
- if (command === 'migrate') {
54
- assertCleanPurgeBaseline();
55
- }
56
- const config = await loadI18nConfig(ROOT);
57
- const defaultLocale = config.defaultLocale;
58
- const localesDir = path.join(ROOT, config.localesDir ?? DEFAULT_LOCALES_DIR);
59
- const generatedDir = path.join(ROOT, config.generatedDir ?? DEFAULT_GENERATED_DIR);
60
- const outputRuntimeConstants = path.join(generatedDir, 'runtime-constants.ts');
61
- const outputFallbacks = path.join(generatedDir, 'fallbacks.generated.ts');
62
- // Read before writing anything, so a missing binding fails the run up front.
63
- const kvId = shouldDeploy ? getKvNamespaceId(ROOT, KV_BINDING) : undefined;
64
- // The id becomes a command-line argument (deploy()); only a real namespace id's shape
65
- // may get there.
66
- if (kvId !== undefined && !KV_NAMESPACE_ID.test(kvId)) {
67
- console.error(`[i18n] The ${KV_BINDING} id in wrangler.jsonc, "${kvId}", is not a KV namespace id (32 hex characters).`);
68
- process.exit(1);
69
- }
70
- // The project's wrangler, found before anything is written.
71
- let wranglerBin;
72
- if (shouldDeploy) {
73
- try {
74
- wranglerBin = resolveWranglerBin(ROOT);
75
- }
76
- catch (err) {
77
- console.error(`[i18n] ${err instanceof Error ? err.message : String(err)}`);
78
- process.exit(1);
79
- }
80
- }
81
- // -------------------------------------------------------------------------
82
- // 1. Collect translations from <localesDir>/<locale>/<namespace>.json
83
- // -------------------------------------------------------------------------
84
- const collected = collectTranslations(localesDir);
85
- // The schema is built here too, so an untypeable value (a mixed array) stops the run
86
- // before anything is written.
87
- let typesContent;
88
- try {
89
- assertValidNames(collected, config.languages);
90
- typesContent = buildTypesContent(collected, defaultLocale);
91
- }
92
- catch (err) {
93
- console.error(`[i18n] ${err instanceof Error ? err.message : String(err)}`);
94
- process.exit(1);
95
- }
96
- const locales = localesWithTranslations(collected);
97
- if (locales.length === 0) {
98
- console.warn(`[i18n] No locales with JSON files found in ${localesDir}. Generating empty artifacts.`);
99
- }
100
- // -------------------------------------------------------------------------
101
- // 2. Compute per-namespace content hashes and detect changes
102
- // -------------------------------------------------------------------------
103
- // Load previous hashes (absent on first run — all namespaces treated as new)
104
- const previousHashes = fs.existsSync(HASHES_FILE)
105
- ? JSON.parse(fs.readFileSync(HASHES_FILE, 'utf8'))
106
- : {};
107
- const { currentHashes, changedKeys } = diffHashes(collected, previousHashes);
108
- if (changedKeys.length > 0) {
109
- console.log(`[i18n] Changed namespaces (${changedKeys.length}):`);
110
- changedKeys.forEach((k) => console.log(` - ${k}`));
111
- }
112
- else {
113
- console.log('[i18n] No translation changes detected.');
114
- }
115
- // -------------------------------------------------------------------------
116
- // 3. Write KV bulk data, types and runtime constants
117
- // -------------------------------------------------------------------------
118
- fs.writeFileSync(OUTPUT_DATA, JSON.stringify(buildBulkData(collected, config.keyPrefix), null, 2) + '\n', 'utf8');
119
- const source = schemaSourceLocale(collected, defaultLocale);
120
- if (source && source !== defaultLocale) {
121
- console.warn(`[i18n] defaultLocale="${defaultLocale}" has no translation files. ` +
122
- `Using "${source}" as the source for I18n.Schema.`);
123
- }
124
- writeFile(OUTPUT_TYPES, typesContent);
125
- writeFile(outputRuntimeConstants, buildRuntimeConstantsContent(collected, defaultLocale));
126
- // -------------------------------------------------------------------------
127
- // 4. Fallbacks (seed, migrate, or bundle --fallbacks)
128
- // -------------------------------------------------------------------------
129
- let wroteFallbacks = false;
130
- if (shouldGenerateFallbacks) {
131
- const fallbacksContent = buildFallbacksContent(collected, defaultLocale);
132
- if (fallbacksContent === null) {
133
- console.warn(`[i18n] No translations found for defaultLocale="${defaultLocale}". ` + 'Skipping fallback generation.');
134
- }
135
- else {
136
- writeFile(outputFallbacks, fallbacksContent);
137
- wroteFallbacks = true;
138
- }
139
- }
140
- else {
141
- console.log('[i18n] Skipping fallback generation. Enable with I18N_GENERATE_FALLBACKS=true or --fallbacks.');
142
- }
143
- const outputs = [OUTPUT_DATA, OUTPUT_TYPES, outputRuntimeConstants, wroteFallbacks ? outputFallbacks : null].filter(Boolean);
144
- console.log('Generated:\n' + outputs.map((p) => `- ${path.relative(ROOT, p).split(path.sep).join('/')}`).join('\n'));
145
- // -------------------------------------------------------------------------
146
- // 5. Push to KV + purge changed cache entries via Cloudflare API
147
- // seed → local KV only, no purge, hashes untouched (a warm local cache under
148
- // `astro dev` is the consumer's to drop: `I18N_CACHE=off`, restart)
149
- // migrate → remote KV + Cloudflare Purge API, hashes written on success
150
- // -------------------------------------------------------------------------
151
- if (!shouldDeploy) {
152
- console.log('[i18n] Skipping KV deploy (bundle). Use `edgekits-i18n seed` or `edgekits-i18n migrate`.');
153
- return;
154
- }
155
- await deploy({ config, kvId: kvId, wranglerBin: wranglerBin, isLocal, changedKeys, currentHashes });
156
- }
157
- function collectTranslations(localesDir) {
158
- if (!fs.existsSync(localesDir)) {
159
- console.error(`Locales directory not found: ${localesDir}`);
160
- process.exit(1);
161
- }
162
- const localeDirs = fs
163
- .readdirSync(localesDir, { withFileTypes: true })
164
- .filter((d) => d.isDirectory())
165
- .map((d) => d.name)
166
- .filter((locale) => {
167
- const fullPath = path.join(localesDir, locale);
168
- const files = fs.readdirSync(fullPath, { withFileTypes: true });
169
- return files.some((f) => f.isFile() && f.name.endsWith('.json'));
170
- });
171
- const collected = {};
172
- for (const locale of localeDirs) {
173
- const localeDir = path.join(localesDir, locale);
174
- const files = fs
175
- .readdirSync(localeDir, { withFileTypes: true })
176
- .filter((f) => f.isFile() && f.name.endsWith('.json'));
177
- for (const file of files) {
178
- const namespace = path.basename(file.name, '.json');
179
- const raw = fs.readFileSync(path.join(localeDir, file.name), 'utf8');
180
- const json = JSON.parse(raw);
181
- if (!collected[locale])
182
- collected[locale] = {};
183
- collected[locale][namespace] = json;
184
- }
185
- }
186
- return collected;
187
- }
188
- function writeFile(file, content) {
189
- fs.mkdirSync(path.dirname(file), { recursive: true });
190
- fs.writeFileSync(file, content, 'utf8');
191
- }
192
- async function deploy({ config, kvId, wranglerBin, isLocal, changedKeys, currentHashes, }) {
193
- const kvFlag = isLocal ? '--local' : '--remote';
194
- const target = isLocal ? 'local' : 'remote';
195
- // Push all translations to KV
196
- console.log(`[i18n] Pushing translations to ${target} KV...`);
197
- try {
198
- // wrangler's JavaScript entry, run by this Node binary: no shell on any platform
199
- // (no `.cmd` shim on Windows), and the arguments are constants plus the shape-checked id.
200
- execFileSync(process.execPath, [wranglerBin, 'kv', 'bulk', 'put', 'i18n-data.json', kvFlag, `--namespace-id=${kvId}`], {
201
- cwd: ROOT,
202
- stdio: 'inherit',
203
- });
204
- console.log(`[i18n] ✅ KV updated (${target}).`);
205
- }
206
- catch (err) {
207
- console.error(`[i18n] ❌ Failed to push to ${target} KV.`, err);
208
- process.exit(1);
209
- }
210
- if (isLocal) {
211
- // Local KV: no purge (a warm local cache under `astro dev` is the consumer's to drop),
212
- // and the hashes describe production only.
213
- console.log('[i18n] .i18n-hashes.json left untouched (local seed).');
214
- return;
215
- }
216
- // ---------------------------------------------------------------------------
217
- // Graceful recovery for the hash file:
218
- //
219
- // We update .i18n-hashes.json ONLY when one of these holds:
220
- // - No changes: nothing needed purging to begin with.
221
- // - A successful purge of all changed URLs.
222
- //
223
- // If a purge was attempted but failed (network error, rate limit
224
- // saturation, API error, missing credentials), we DO NOT write the file.
225
- // This way, the next `edgekits-i18n migrate` will see the same diff as this
226
- // one and re-attempt the invalidation automatically. No manual intervention,
227
- // no silent cache staleness.
228
- // ---------------------------------------------------------------------------
229
- let shouldWriteHashes = false;
230
- if (changedKeys.length === 0) {
231
- // Nothing changed — nothing to purge, safe to update hashes.
232
- //
233
- // Said out loud rather than passed over: a silent no-op on a deploy path is
234
- // what made the 2026-09-12 staleness take an afternoon to find. Legitimate
235
- // outcome, so not an error — but the operator has to be able to see it, and
236
- // to see WHICH half of the job was skipped.
237
- console.log('[i18n] Nothing changed against the recorded baseline, so NO cache entry was purged.\n' +
238
- ' The bulk put above is unconditional, so production KV did receive every value;\n' +
239
- ' it is the edge cache that was left alone, and it holds translations for a year.\n' +
240
- ' If you expected a change here, .i18n-hashes.json is describing a state that\n' +
241
- ' production is not actually in.');
242
- shouldWriteHashes = true;
243
- }
244
- else {
245
- // Changes — the purge must succeed before we update hashes.
246
- loadDevVars();
247
- const purgeUrls = purgeUrlsFor(changedKeys, config.keyPrefix);
248
- const zoneId = getWranglerVar(ROOT, 'CLOUDFLARE_ZONE_ID') || process.env.CLOUDFLARE_ZONE_ID;
249
- const apiToken = process.env.CLOUDFLARE_CACHEPURGE_API_TOKEN;
250
- if (!zoneId) {
251
- console.warn('[i18n] ⚠️ CLOUDFLARE_ZONE_ID not found in wrangler.jsonc vars or environment. ' +
252
- 'Skipping cache purge — hash file NOT updated. Re-run `edgekits-i18n migrate` after setting the variable.');
253
- }
254
- else if (!apiToken) {
255
- console.warn('[i18n] ⚠️ CLOUDFLARE_CACHEPURGE_API_TOKEN not set in environment. ' +
256
- 'Skipping cache purge — hash file NOT updated. Re-run `edgekits-i18n migrate` after setting the secret.');
257
- }
258
- else {
259
- console.log(`[i18n] Purging ${purgeUrls.length} cache entries via Cloudflare API...`);
260
- const purgeSuccess = await purgeTranslationsCache(zoneId, apiToken, purgeUrls);
261
- if (purgeSuccess) {
262
- console.log('[i18n] ✅ Cache purge completed.');
263
- shouldWriteHashes = true;
264
- }
265
- else {
266
- console.error('[i18n] ❌ Cache purge failed — hash file NOT updated. ' +
267
- 'Re-run `edgekits-i18n migrate` to retry the same invalidation.');
268
- // Non-fatal for KV: translations are already pushed. Cache will
269
- // converge on the next successful migrate.
270
- }
271
- }
272
- }
273
- if (shouldWriteHashes) {
274
- fs.writeFileSync(HASHES_FILE, JSON.stringify(currentHashes, null, 2) + '\n', 'utf8');
275
- console.log('[i18n] ✅ .i18n-hashes.json updated.');
276
- }
277
- }
278
- /**
279
- * Load .dev.vars into process.env. wrangler dev reads .dev.vars for the Worker
280
- * runtime, but Node does not — so it is parsed here. Never overwrites a variable
281
- * already set in the real environment.
282
- */
283
- function loadDevVars() {
284
- const devVarsPath = path.join(ROOT, '.dev.vars');
285
- if (!fs.existsSync(devVarsPath))
286
- return;
287
- const lines = fs.readFileSync(devVarsPath, 'utf8').split('\n');
288
- for (const line of lines) {
289
- const trimmed = line.trim();
290
- // Skip empty lines and comments
291
- if (!trimmed || trimmed.startsWith('#'))
292
- continue;
293
- const eqIndex = trimmed.indexOf('=');
294
- if (eqIndex === -1)
295
- continue;
296
- const key = trimmed.slice(0, eqIndex).trim();
297
- const value = trimmed.slice(eqIndex + 1).trim();
298
- if (key && !(key in process.env)) {
299
- process.env[key] = value;
300
- }
301
- }
302
- }
303
- // ---------------------------------------------------------------------------
304
- // Precondition (migrate only): the purge baseline must be clean
305
- //
306
- // `.i18n-hashes.json` records what PRODUCTION KV already holds, and the diff
307
- // against it is the ONLY thing that decides which cache entries get purged.
308
- //
309
- // Migrate against a modified copy and the diff comes out wrong. Note what that
310
- // does and does not break: the bulk put is unconditional, so every value still
311
- // reaches production KV. What is skipped is the PURGE — and translations are
312
- // served `s-maxage=31536000, immutable` (see fetcher.ts), so the stale edge copy
313
- // never ages out. A purge is the only thing that ends it. The run then writes a
314
- // baseline claiming production is current, and exits 0.
315
- //
316
- // The live page that results is neither the old one nor the new one: keys that
317
- // exist only in the shipped fallbacks render beside keys still served stale
318
- // from the edge. That reached production on 2026-09-12, and nothing failed
319
- // loudly — it was caught by somebody reading the live page. Hence a
320
- // precondition that refuses to run, rather than a warning nobody sees.
321
- //
322
- // `seed` never writes this file, so it is exempt and never dirties it.
323
- // ---------------------------------------------------------------------------
324
- /**
325
- * Abort if the purge baseline carries uncommitted changes.
326
- *
327
- * Outside a git work tree, or with no git on PATH, this warns and proceeds: a
328
- * guard on the deploy path must not be the reason the CLI cannot run in CI
329
- * or from a tarball.
330
- */
331
- function assertCleanPurgeBaseline() {
332
- const relativePath = path.relative(ROOT, HASHES_FILE).split(path.sep).join('/');
333
- let status;
334
- try {
335
- // Porcelain v1 is a stable format, and `--` scopes it to the one path, so a
336
- // dirty working tree elsewhere (the usual state) is not this guard's business.
337
- status = execFileSync('git', ['status', '--porcelain', '--', relativePath], {
338
- cwd: ROOT,
339
- encoding: 'utf8',
340
- stdio: ['ignore', 'pipe', 'pipe'],
341
- });
342
- }
343
- catch (err) {
344
- console.warn(`[i18n] ⚠️ Could not check ${relativePath} against git (${err instanceof Error ? err.message.split('\n')[0] : String(err)}).\n` +
345
- ' Proceeding WITHOUT the purge-baseline check. If that file carries hashes\n' +
346
- ' that do not describe production, this run will skip the cache purge and say nothing.');
347
- return;
348
- }
349
- if (status.trim() === '')
350
- return;
351
- console.error(`\n[i18n] ❌ ${relativePath} has uncommitted changes. Refusing to migrate.\n` +
352
- `\n git status --porcelain: ${status.trim()}\n` +
353
- '\n That file is the purge baseline: the record of what production KV already holds.\n' +
354
- ' Migrating against a modified copy makes the change diff come out wrong. Every value\n' +
355
- ' is still pushed to KV, but the cache purge is driven by that diff, so stale edge\n' +
356
- ' entries survive — and translations are cached immutable for a year, so they do not\n' +
357
- ' age out on their own. The run would exit 0 as though it had worked.\n' +
358
- '\n Two things dirty this file, and they need opposite fixes:\n' +
359
- '\n 1. Something other than migrate wrote it (a hand edit, or a pre-package local\n' +
360
- ' seed script). Those hashes lie about production. Discard them, then migrate:\n' +
361
- ' git checkout .i18n-hashes.json && npx edgekits-i18n migrate\n' +
362
- '\n 2. A previous `edgekits-i18n migrate` wrote it. Those hashes describe production\n' +
363
- ' and are correct. Commit them; migrating again is unnecessary:\n' +
364
- ' git add .i18n-hashes.json && git commit\n' +
365
- '\n The CLI cannot tell the two apart — the file looks identical either way. If you\n' +
366
- ' are unsure, the last i18n command you ran is the answer.\n');
367
- process.exit(1);
368
- }
@@ -1,7 +0,0 @@
1
- import { type I18nConfig } from '../types.ts';
2
- export declare const CONFIG_FILE = "edgekits-i18n.config.ts";
3
- /**
4
- * Load the project's `edgekits-i18n.config.ts` from `root` — the same file its
5
- * `astro.config` passes to the integration — and validate it.
6
- */
7
- export declare function loadI18nConfig(root: string): Promise<I18nConfig>;
@@ -1,19 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { createJiti } from 'jiti';
4
- import { defineI18nConfig } from "../types.js";
5
- export const CONFIG_FILE = 'edgekits-i18n.config.ts';
6
- /**
7
- * Load the project's `edgekits-i18n.config.ts` from `root` — the same file its
8
- * `astro.config` passes to the integration — and validate it.
9
- */
10
- export async function loadI18nConfig(root) {
11
- const configPath = path.join(root, CONFIG_FILE);
12
- if (!fs.existsSync(configPath)) {
13
- console.error(`[i18n] ${CONFIG_FILE} not found in ${root}`);
14
- process.exit(1);
15
- }
16
- const jiti = createJiti(import.meta.url);
17
- const config = await jiti.import(configPath, { default: true });
18
- return defineI18nConfig(config);
19
- }
@@ -1,29 +0,0 @@
1
- /**
2
- * Purge a list of URLs from Cloudflare's edge cache via the Purge API.
3
- *
4
- * Handles Cloudflare's rate limits automatically:
5
- * - Max 100 URLs per request (max operations per request)
6
- * - Max 800 URLs per second on Free plan (8 chunks of 100)
7
- * - HTTP 429: up to MAX_RETRIES retries per chunk, waiting `Retry-After` seconds when
8
- * the response sends it, otherwise 2 s, 4 s, 8 s… (every wait capped at
9
- * MAX_RETRY_WAIT_MS); then the chunk counts as failed.
10
- *
11
- * Reference: https://developers.cloudflare.com/cache/how-to/purge-cache/
12
- *
13
- * `deps` replaces `fetch` and the wait in tests.
14
- *
15
- * @returns true if all chunks succeeded, false if any chunk failed
16
- */
17
- export declare function purgeTranslationsCache(zoneId: string, apiToken: string, urls: string[], deps?: PurgeDeps): Promise<boolean>;
18
- export type PurgeDeps = {
19
- fetch?: (input: string, init: RequestInit) => Promise<Response>;
20
- sleep?: (ms: number) => Promise<void>;
21
- };
22
- export declare const MAX_RETRIES = 5;
23
- export declare const MAX_RETRY_WAIT_MS = 30000;
24
- /**
25
- * The wait before 429 retry number `attempt` (1-based): `Retry-After` in seconds when
26
- * the header holds a non-negative number, otherwise 2 s doubled per attempt. Either way
27
- * capped at MAX_RETRY_WAIT_MS, so a large header cannot stall the run.
28
- */
29
- export declare function retryWaitMs(retryAfter: string | null, attempt: number): number;
package/dist/cli/purge.js DELETED
@@ -1,94 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // Cloudflare Cache Purge API
3
- // ---------------------------------------------------------------------------
4
- /**
5
- * Purge a list of URLs from Cloudflare's edge cache via the Purge API.
6
- *
7
- * Handles Cloudflare's rate limits automatically:
8
- * - Max 100 URLs per request (max operations per request)
9
- * - Max 800 URLs per second on Free plan (8 chunks of 100)
10
- * - HTTP 429: up to MAX_RETRIES retries per chunk, waiting `Retry-After` seconds when
11
- * the response sends it, otherwise 2 s, 4 s, 8 s… (every wait capped at
12
- * MAX_RETRY_WAIT_MS); then the chunk counts as failed.
13
- *
14
- * Reference: https://developers.cloudflare.com/cache/how-to/purge-cache/
15
- *
16
- * `deps` replaces `fetch` and the wait in tests.
17
- *
18
- * @returns true if all chunks succeeded, false if any chunk failed
19
- */
20
- export async function purgeTranslationsCache(zoneId, apiToken, urls, deps = {}) {
21
- if (!urls.length)
22
- return true;
23
- const { fetch: fetchFn = fetch, sleep: wait = sleep } = deps;
24
- // Cloudflare limit: max 100 URLs per single purge request
25
- const CHUNK_SIZE = 100;
26
- // Cloudflare Free plan limit: 800 URLs/sec = 8 chunks of 100 per second
27
- // Pro and above: 1500 URLs/sec — CHUNK_SIZE * MAX_CHUNKS_PER_SEC covers Free plan
28
- const MAX_CHUNKS_PER_SEC = 8;
29
- console.log(`[i18n] Purging cache for ${urls.length} URL(s)...`);
30
- // 429 retries of the current chunk; reset when a chunk goes through.
31
- let retries = 0;
32
- for (let i = 0; i < urls.length; i += CHUNK_SIZE) {
33
- const chunk = urls.slice(i, i + CHUNK_SIZE);
34
- const currentChunkIndex = Math.floor(i / CHUNK_SIZE) + 1;
35
- try {
36
- const response = await fetchFn(`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`, {
37
- method: 'POST',
38
- headers: {
39
- Authorization: `Bearer ${apiToken}`,
40
- 'Content-Type': 'application/json',
41
- },
42
- body: JSON.stringify({ files: chunk }),
43
- });
44
- // Rate limit hit — wait and retry the same chunk, a bounded number of times
45
- if (response.status === 429) {
46
- if (retries >= MAX_RETRIES) {
47
- console.error(`[i18n] [Chunk ${currentChunkIndex}] Still rate-limited after ${MAX_RETRIES} retries; ` +
48
- `giving up on ${chunk.length} URL(s):\n` +
49
- chunk.map((url) => ` - ${url}`).join('\n'));
50
- return false;
51
- }
52
- retries++;
53
- const waitMs = retryWaitMs(response.headers.get('retry-after'), retries);
54
- console.warn(`[i18n] [Chunk ${currentChunkIndex}] Rate limit hit, retry ${retries}/${MAX_RETRIES} in ${waitMs}ms...`);
55
- await wait(waitMs);
56
- i -= CHUNK_SIZE; // retry same chunk
57
- continue;
58
- }
59
- if (!response.ok) {
60
- const errorData = await response.text();
61
- console.error(`[i18n] [Chunk ${currentChunkIndex}] Cloudflare API error:`, errorData);
62
- return false;
63
- }
64
- retries = 0;
65
- console.log(`[i18n] [Chunk ${currentChunkIndex}] Purged ${chunk.length} URL(s).`);
66
- // After sending MAX_CHUNKS_PER_SEC chunks (= 800 URLs), pause for 1 second
67
- // to stay within the Free plan rate limit before the next batch
68
- if (currentChunkIndex % MAX_CHUNKS_PER_SEC === 0 && i + CHUNK_SIZE < urls.length) {
69
- console.log(`[i18n] Reached 800 URL/sec limit. Waiting 1000ms before next batch...`);
70
- await wait(1000);
71
- }
72
- }
73
- catch (error) {
74
- console.error(`[i18n] [Chunk ${currentChunkIndex}] Network error calling Cloudflare API:`, error);
75
- return false;
76
- }
77
- }
78
- return true;
79
- }
80
- export const MAX_RETRIES = 5;
81
- export const MAX_RETRY_WAIT_MS = 30_000;
82
- /**
83
- * The wait before 429 retry number `attempt` (1-based): `Retry-After` in seconds when
84
- * the header holds a non-negative number, otherwise 2 s doubled per attempt. Either way
85
- * capped at MAX_RETRY_WAIT_MS, so a large header cannot stall the run.
86
- */
87
- export function retryWaitMs(retryAfter, attempt) {
88
- const seconds = retryAfter === null || retryAfter.trim() === '' ? NaN : Number(retryAfter);
89
- const ms = Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : 2000 * 2 ** (attempt - 1);
90
- return Math.min(ms, MAX_RETRY_WAIT_MS);
91
- }
92
- function sleep(ms) {
93
- return new Promise((resolve) => setTimeout(resolve, ms));
94
- }
@@ -1,10 +0,0 @@
1
- export declare const KV_BINDING = "TRANSLATIONS";
2
- export declare function getKvNamespaceId(root: string, binding: string): string;
3
- export declare function getWranglerVar(root: string, key: string): string | undefined;
4
- /**
5
- * The absolute path of the project's wrangler entry (`bin.wrangler` in its package.json),
6
- * resolved from `root` as the project itself would resolve it. `wrangler/package.json` is
7
- * an exported subpath; `bin/` is not, hence the detour through the manifest. Throws when
8
- * the project has no wrangler or its manifest declares no `bin.wrangler`.
9
- */
10
- export declare function resolveWranglerBin(root: string): string;
@@ -1,71 +0,0 @@
1
- import fs from 'node:fs';
2
- import { createRequire } from 'node:module';
3
- import path from 'node:path';
4
- import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser';
5
- // ---------------------------------------------------------------------------
6
- // Wrangler config reader
7
- // Parses wrangler.jsonc to extract KV namespace IDs and vars by key.
8
- // Single source of truth — no hardcoded IDs or zone IDs in package.json.
9
- // ---------------------------------------------------------------------------
10
- export const KV_BINDING = 'TRANSLATIONS';
11
- function readWranglerConfig(root) {
12
- const wranglerConfigPath = path.join(root, 'wrangler.jsonc');
13
- if (!fs.existsSync(wranglerConfigPath)) {
14
- console.error(`[i18n] wrangler.jsonc not found at ${wranglerConfigPath}`);
15
- process.exit(1);
16
- }
17
- const raw = fs.readFileSync(wranglerConfigPath, 'utf8');
18
- const errors = [];
19
- const config = parseJsonc(raw, errors, { allowTrailingComma: true });
20
- if (!config || typeof config !== 'object') {
21
- console.error('[i18n] Failed to parse wrangler.jsonc — result is empty.');
22
- for (const err of errors) {
23
- const snippet = raw.slice(Math.max(0, err.offset - 10), err.offset + 20);
24
- console.error(` ${printParseErrorCode(err.error)} at offset ${err.offset}: ...${JSON.stringify(snippet)}...`);
25
- }
26
- process.exit(1);
27
- }
28
- return config;
29
- }
30
- export function getKvNamespaceId(root, binding) {
31
- const config = readWranglerConfig(root);
32
- const kvNamespaces = config.kv_namespaces;
33
- const entry = kvNamespaces?.find((kv) => kv.binding === binding);
34
- if (!entry) {
35
- console.error(`[i18n] KV binding "${binding}" not found in wrangler.jsonc`);
36
- process.exit(1);
37
- }
38
- return entry.id;
39
- }
40
- export function getWranglerVar(root, key) {
41
- const config = readWranglerConfig(root);
42
- const vars = config.vars;
43
- return vars?.[key];
44
- }
45
- // ---------------------------------------------------------------------------
46
- // Wrangler binary
47
- // The CLI runs wrangler's JavaScript entry with the current Node binary, never through
48
- // a shell (no `.cmd` shim on Windows, nothing for a shell to interpret).
49
- // ---------------------------------------------------------------------------
50
- /**
51
- * The absolute path of the project's wrangler entry (`bin.wrangler` in its package.json),
52
- * resolved from `root` as the project itself would resolve it. `wrangler/package.json` is
53
- * an exported subpath; `bin/` is not, hence the detour through the manifest. Throws when
54
- * the project has no wrangler or its manifest declares no `bin.wrangler`.
55
- */
56
- export function resolveWranglerBin(root) {
57
- const requireFromRoot = createRequire(path.join(root, 'package.json'));
58
- let manifestPath;
59
- try {
60
- manifestPath = requireFromRoot.resolve('wrangler/package.json');
61
- }
62
- catch {
63
- throw new Error(`wrangler is not installed in ${root}. The CLI runs the project's own wrangler: install it there (npm i -D wrangler).`);
64
- }
65
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
66
- const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.wrangler;
67
- if (!bin) {
68
- throw new Error(`wrangler at ${path.dirname(manifestPath)} declares no bin.wrangler in its package.json.`);
69
- }
70
- return path.join(path.dirname(manifestPath), bin);
71
- }
@@ -1,8 +0,0 @@
1
- /**
2
- * The translation KV key and edge-cache URL formats, as pure functions of the key
3
- * prefix. Shared by the runtime (`translations-keys.ts`, prefix from the virtual config)
4
- * and the CLI (prefix from the loaded config), so the key a value is stored under and
5
- * the URL a purge targets can never drift apart. Pinned by `test/i18n-keys.test.ts`.
6
- */
7
- export declare function formatTranslationKvKey(keyPrefix: string, locale: string, ns: string): string;
8
- export declare function formatTranslationCacheUrl(keyPrefix: string, locale: string, ns: string): string;
@@ -1,15 +0,0 @@
1
- /**
2
- * The translation KV key and edge-cache URL formats, as pure functions of the key
3
- * prefix. Shared by the runtime (`translations-keys.ts`, prefix from the virtual config)
4
- * and the CLI (prefix from the loaded config), so the key a value is stored under and
5
- * the URL a purge targets can never drift apart. Pinned by `test/i18n-keys.test.ts`.
6
- */
7
- // Format: <keyPrefix>:<namespace>:<locale>
8
- export function formatTranslationKvKey(keyPrefix, locale, ns) {
9
- return `${keyPrefix}:${ns}:${locale}`;
10
- }
11
- // Format: https://<keyPrefix>/<encodeURIComponent(i18n:<locale>:<namespace>)>
12
- export function formatTranslationCacheUrl(keyPrefix, locale, ns) {
13
- const cacheId = `i18n:${locale}:${ns}`;
14
- return `https://${keyPrefix}/${encodeURIComponent(cacheId)}`;
15
- }