@nckdv/translation-sdk 0.1.0 → 0.2.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.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # `@nckdv/translation-sdk`
2
+
3
+ The API client for the nck-translation platform: fetch and cache a project's
4
+ catalogs, translate with them, and push updates back. Built on
5
+ `@nckdv/translation-core`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @nckdv/translation-sdk
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { createClient } from "@nckdv/translation-sdk";
17
+
18
+ const client = createClient({
19
+ apiUrl: "https://translate.example.com",
20
+ apiKey: process.env.NCK_TRANSLATION_API_KEY, // server-side only
21
+ });
22
+
23
+ // read
24
+ const t = await client.getTranslator("de");
25
+ t("home.title"); // "Willkommen bei Acme"
26
+
27
+ // push (needs a write-scoped key)
28
+ await client.push("en", { "home.title": "Welcome to Acme" });
29
+ ```
30
+
31
+ ## Notes
32
+
33
+ - **Server-side only.** The API key is a bearer credential for every catalog in
34
+ its project — never ship it to a browser.
35
+ - Catalogs are **cached in memory** (~5 min; `cacheMs`, `0` disables) and
36
+ **single-flighted**, so many concurrent reads of one language share one
37
+ request. A failure is never cached — the next read retries.
38
+ - `push()` merges a catalog into one language; a conflict throws a typed
39
+ `CatalogPushConflictError`. For the usual workflow, prefer
40
+ `@nckdv/translation-cli` (`sync` + `push`).
41
+
42
+ ## API
43
+
44
+ `createClient(options)` returns: `getCatalog` · `getCatalogs` · `getTranslator`
45
+ · `prefetch` · `push` · `invalidate`.
46
+
47
+ ## Family
48
+
49
+ - `@nckdv/translation-core` — the translate engine.
50
+ - `@nckdv/translation-cli` — `sync` / `push` from the command line.
51
+ - `@nckdv/translation-react` / `@nckdv/translation-nextjs` — React & Next.js
52
+ bindings.
package/dist/index.cjs CHANGED
@@ -6,12 +6,22 @@ var translationCore = require('@nckdv/translation-core');
6
6
 
7
7
  // src/types.ts
8
8
  var TranslationApiError = class extends Error {
9
- constructor(message, status) {
9
+ constructor(message, status, body) {
10
10
  super(message);
11
11
  this.status = status;
12
+ this.body = body;
12
13
  this.name = "TranslationApiError";
13
14
  }
14
15
  status;
16
+ body;
17
+ };
18
+ var CatalogPushConflictError = class extends TranslationApiError {
19
+ constructor(message, conflicts, body) {
20
+ super(message, 409, body);
21
+ this.conflicts = conflicts;
22
+ this.name = "CatalogPushConflictError";
23
+ }
24
+ conflicts;
15
25
  };
16
26
 
17
27
  // src/client.ts
@@ -31,19 +41,38 @@ function createClient(options) {
31
41
  const base = apiUrl.replace(/\/+$/, "");
32
42
  const cache = /* @__PURE__ */ new Map();
33
43
  const inFlight = /* @__PURE__ */ new Map();
34
- async function request(path) {
44
+ async function request(path, options2 = {}) {
35
45
  const init = {
36
- headers: { "x-api-key": apiKey },
46
+ ...options2,
47
+ headers: {
48
+ ...options2.headers,
49
+ "x-api-key": apiKey
50
+ },
37
51
  cache: "no-store"
38
52
  };
39
53
  const response = await fetchImpl(`${base}${path}`, init);
54
+ let body;
55
+ try {
56
+ body = await response.json();
57
+ } catch {
58
+ body = void 0;
59
+ }
40
60
  if (!response.ok) {
61
+ const errorMessage = body && typeof body === "object" && "error" in body && typeof body.error === "string" ? body.error : void 0;
62
+ if (response.status === 409 && body && typeof body === "object" && "conflicts" in body && Array.isArray(body.conflicts) && body.conflicts.every((key) => typeof key === "string")) {
63
+ throw new CatalogPushConflictError(
64
+ errorMessage ?? "Catalog conflicts with existing values.",
65
+ body.conflicts,
66
+ body
67
+ );
68
+ }
41
69
  throw new TranslationApiError(
42
- response.status === 401 ? "Invalid or revoked API key." : `Translation API returned ${response.status}.`,
43
- response.status
70
+ response.status === 401 ? "Invalid or revoked API key." : errorMessage ?? `Translation API returned ${response.status}.`,
71
+ response.status,
72
+ body
44
73
  );
45
74
  }
46
- return await response.json();
75
+ return body;
47
76
  }
48
77
  function fresh(language) {
49
78
  const entry = cache.get(language);
@@ -91,13 +120,30 @@ function createClient(options) {
91
120
  }
92
121
  await Promise.all(languages.map((language) => getCatalog(language)));
93
122
  }
123
+ async function push(language, catalog, options2 = {}) {
124
+ const parameters = new URLSearchParams({
125
+ mode: options2.mode ?? "no-force"
126
+ });
127
+ if (options2.force) parameters.set("force", "true");
128
+ const result = await request(
129
+ `/api/catalog/${encodeURIComponent(language)}?${parameters}`,
130
+ {
131
+ method: "PUT",
132
+ headers: { "content-type": "application/json" },
133
+ body: JSON.stringify(catalog)
134
+ }
135
+ );
136
+ invalidate(language);
137
+ return result;
138
+ }
94
139
  function invalidate(language) {
95
140
  if (language) cache.delete(language);
96
141
  else cache.clear();
97
142
  }
98
- return { getCatalog, getCatalogs, getTranslator, prefetch, invalidate };
143
+ return { getCatalog, getCatalogs, getTranslator, prefetch, push, invalidate };
99
144
  }
100
145
 
146
+ exports.CatalogPushConflictError = CatalogPushConflictError;
101
147
  exports.TranslationApiError = TranslationApiError;
102
148
  exports.createClient = createClient;
103
149
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts","../src/client.ts"],"names":["createTranslator"],"mappings":"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","file":"index.cjs","sourcesContent":["import type { InterpolationValues } from \"@nckdv/translation-core\";\n\n/** A flat catalog for one language, exactly as the API serves it. */\nexport type Catalog = Record<string, string>;\n\nexport type ClientOptions = {\n /** Where the platform is served from, e.g. `https://translate.example.com`. */\n apiUrl: string;\n /**\n * A project API key. It names its own project, so nothing else identifies\n * the project — that is the whole point of the credential.\n *\n * **Server-side only.** A key is a bearer credential for every catalog in\n * the project; shipping one to a browser publishes all of them.\n */\n apiKey: string;\n /**\n * How long a fetched catalog is reused, in milliseconds. Default 5 minutes.\n *\n * `0` disables caching, which is what a long-lived editor preview wants and\n * what a request-scoped server render does not.\n */\n cacheMs?: number;\n /** Swappable for tests and for runtimes with their own fetch. */\n fetch?: typeof globalThis.fetch;\n};\n\nexport type Translate = (key: string, values?: InterpolationValues) => string;\n\nexport type Client = {\n /** One language's catalog, from cache when it is still fresh. */\n getCatalog(language: string): Promise<Catalog>;\n /** Every language the project has, in one request. */\n getCatalogs(): Promise<Record<string, Catalog>>;\n /** A translate function bound to one language. */\n getTranslator(language: string): Promise<Translate>;\n /** Warms the cache — call at start-up so the first render does not wait. */\n prefetch(languages?: string[]): Promise<void>;\n /** Drops cached catalogs, so the next read goes to the server. */\n invalidate(language?: string): void;\n};\n\n/** Thrown for any answer that is not a catalog. */\nexport class TranslationApiError extends Error {\n constructor(\n message: string,\n readonly status: number,\n ) {\n super(message);\n this.name = \"TranslationApiError\";\n }\n}\n","import { createTranslator } from \"@nckdv/translation-core\";\nimport {\n TranslationApiError,\n type Catalog,\n type Client,\n type ClientOptions,\n type Translate,\n} from \"./types.js\";\n\n/** Five minutes: long enough to matter, short enough that a fix lands soon. */\nconst DEFAULT_CACHE_MS = 5 * 60 * 1000;\n\ntype Entry = {\n catalog: Catalog;\n /** When this entry stops being reusable. */\n expiresAt: number;\n};\n\n/**\n * A client for one project's translations.\n *\n * The key identifies the project, so nothing here takes a project id — a\n * deployment holds one credential rather than a credential and a uuid it\n * cannot verify by eye.\n */\nexport function createClient(options: ClientOptions): Client {\n const {\n apiUrl,\n apiKey,\n cacheMs = DEFAULT_CACHE_MS,\n fetch: fetchImpl = globalThis.fetch,\n } = options;\n\n if (!apiUrl) throw new Error(\"createClient: apiUrl is required.\");\n if (!apiKey) throw new Error(\"createClient: apiKey is required.\");\n if (typeof fetchImpl !== \"function\") {\n throw new Error(\"createClient: no fetch available; pass one in options.\");\n }\n\n const base = apiUrl.replace(/\\/+$/, \"\");\n const cache = new Map<string, Entry>();\n\n /**\n * In-flight requests, keyed the same way as the cache.\n *\n * Without this, a cold start that renders ten pages at once makes ten\n * identical requests for the same catalog. They are shared instead, and the\n * entry is removed as soon as it settles so a failure is retried rather than\n * remembered.\n */\n const inFlight = new Map<string, Promise<Catalog>>();\n\n async function request<T>(path: string): Promise<T> {\n // `cache` is not in Node's own `RequestInit`, but Next.js patches global\n // fetch and caches by default — and a framework quietly holding on to a\n // credentialled response is a correctness bug, not an optimisation. So the\n // field is set through a widened type rather than dropped: this client's\n // own cache is the one with the policy.\n const init: RequestInit & { cache?: string } = {\n headers: { \"x-api-key\": apiKey },\n cache: \"no-store\",\n };\n\n const response = await fetchImpl(`${base}${path}`, init);\n\n if (!response.ok) {\n throw new TranslationApiError(\n response.status === 401\n ? \"Invalid or revoked API key.\"\n : `Translation API returned ${response.status}.`,\n response.status,\n );\n }\n return (await response.json()) as T;\n }\n\n function fresh(language: string): Catalog | undefined {\n const entry = cache.get(language);\n if (!entry) return undefined;\n if (entry.expiresAt <= Date.now()) {\n cache.delete(language);\n return undefined;\n }\n return entry.catalog;\n }\n\n function store(language: string, catalog: Catalog) {\n // A zero TTL means \"do not cache\" rather than \"expire immediately\", so the\n // entry is simply never written.\n if (cacheMs > 0) {\n cache.set(language, { catalog, expiresAt: Date.now() + cacheMs });\n }\n }\n\n async function getCatalog(language: string): Promise<Catalog> {\n const cached = fresh(language);\n if (cached) return cached;\n\n const existing = inFlight.get(language);\n if (existing) return existing;\n\n const pending = request<Catalog>(\n `/api/catalog/${encodeURIComponent(language)}`,\n )\n .then((catalog) => {\n store(language, catalog);\n return catalog;\n })\n .finally(() => inFlight.delete(language));\n\n inFlight.set(language, pending);\n return pending;\n }\n\n async function getCatalogs(): Promise<Record<string, Catalog>> {\n const catalogs = await request<Record<string, Catalog>>(\"/api/catalog\");\n for (const [language, catalog] of Object.entries(catalogs)) {\n store(language, catalog);\n }\n return catalogs;\n }\n\n async function getTranslator(language: string): Promise<Translate> {\n const messages = await getCatalog(language);\n return createTranslator({ language, messages });\n }\n\n async function prefetch(languages?: string[]): Promise<void> {\n if (!languages) {\n await getCatalogs();\n return;\n }\n await Promise.all(languages.map((language) => getCatalog(language)));\n }\n\n function invalidate(language?: string): void {\n if (language) cache.delete(language);\n else cache.clear();\n }\n\n return { getCatalog, getCatalogs, getTranslator, prefetch, invalidate };\n}\n"]}
1
+ {"version":3,"sources":["../src/types.ts","../src/client.ts"],"names":["options","createTranslator"],"mappings":"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","file":"index.cjs","sourcesContent":["import type { InterpolationValues } from \"@nckdv/translation-core\";\n\n/** A flat catalog for one language, exactly as the API serves it. */\nexport type Catalog = Record<string, string>;\n\nexport type ClientOptions = {\n /** Where the platform is served from, e.g. `https://translate.example.com`. */\n apiUrl: string;\n /**\n * A project API key. It names its own project, so nothing else identifies\n * the project — that is the whole point of the credential.\n *\n * **Server-side only.** A key is a bearer credential for every catalog in\n * the project; shipping one to a browser publishes all of them.\n */\n apiKey: string;\n /**\n * How long a fetched catalog is reused, in milliseconds. Default 5 minutes.\n *\n * `0` disables caching, which is what a long-lived editor preview wants and\n * what a request-scoped server render does not.\n */\n cacheMs?: number;\n /** Swappable for tests and for runtimes with their own fetch. */\n fetch?: typeof globalThis.fetch;\n};\n\nexport type Translate = (key: string, values?: InterpolationValues) => string;\n\nexport type CatalogPushMode = \"no-force\" | \"override\" | \"keep\";\n\nexport type CatalogPushOptions = {\n /** Existing-value policy. Defaults to the atomic `no-force` mode. */\n mode?: CatalogPushMode;\n /** Required before a non-source language can be written. */\n force?: boolean;\n};\n\nexport type CatalogPushResult = {\n created: number;\n updated: number;\n unchanged: number;\n skipped: number;\n /** Existing target translations newly marked as needing review. */\n outdated: number;\n};\n\nexport type Client = {\n /** One language's catalog, from cache when it is still fresh. */\n getCatalog(language: string): Promise<Catalog>;\n /** Every language the project has, in one request. */\n getCatalogs(): Promise<Record<string, Catalog>>;\n /** A translate function bound to one language. */\n getTranslator(language: string): Promise<Translate>;\n /** Warms the cache — call at start-up so the first render does not wait. */\n prefetch(languages?: string[]): Promise<void>;\n /** Atomically merges a local catalog into one server language. */\n push(\n language: string,\n catalog: Catalog,\n options?: CatalogPushOptions,\n ): Promise<CatalogPushResult>;\n /** Drops cached catalogs, so the next read goes to the server. */\n invalidate(language?: string): void;\n};\n\n/** Thrown for any answer that is not a catalog. */\nexport class TranslationApiError extends Error {\n constructor(\n message: string,\n readonly status: number,\n readonly body?: unknown,\n ) {\n super(message);\n this.name = \"TranslationApiError\";\n }\n}\n\n/** A no-force push found existing keys with different values. */\nexport class CatalogPushConflictError extends TranslationApiError {\n constructor(\n message: string,\n readonly conflicts: string[],\n body?: unknown,\n ) {\n super(message, 409, body);\n this.name = \"CatalogPushConflictError\";\n }\n}\n","import { createTranslator } from \"@nckdv/translation-core\";\nimport {\n CatalogPushConflictError,\n TranslationApiError,\n type Catalog,\n type CatalogPushOptions,\n type CatalogPushResult,\n type Client,\n type ClientOptions,\n type Translate,\n} from \"./types.js\";\n\n/** Five minutes: long enough to matter, short enough that a fix lands soon. */\nconst DEFAULT_CACHE_MS = 5 * 60 * 1000;\n\ntype Entry = {\n catalog: Catalog;\n /** When this entry stops being reusable. */\n expiresAt: number;\n};\n\n/**\n * A client for one project's translations.\n *\n * The key identifies the project, so nothing here takes a project id — a\n * deployment holds one credential rather than a credential and a uuid it\n * cannot verify by eye.\n */\nexport function createClient(options: ClientOptions): Client {\n const {\n apiUrl,\n apiKey,\n cacheMs = DEFAULT_CACHE_MS,\n fetch: fetchImpl = globalThis.fetch,\n } = options;\n\n if (!apiUrl) throw new Error(\"createClient: apiUrl is required.\");\n if (!apiKey) throw new Error(\"createClient: apiKey is required.\");\n if (typeof fetchImpl !== \"function\") {\n throw new Error(\"createClient: no fetch available; pass one in options.\");\n }\n\n const base = apiUrl.replace(/\\/+$/, \"\");\n const cache = new Map<string, Entry>();\n\n /**\n * In-flight requests, keyed the same way as the cache.\n *\n * Without this, a cold start that renders ten pages at once makes ten\n * identical requests for the same catalog. They are shared instead, and the\n * entry is removed as soon as it settles so a failure is retried rather than\n * remembered.\n */\n const inFlight = new Map<string, Promise<Catalog>>();\n\n async function request<T>(\n path: string,\n options: RequestInit = {},\n ): Promise<T> {\n // `cache` is not in Node's own `RequestInit`, but Next.js patches global\n // fetch and caches by default — and a framework quietly holding on to a\n // credentialled response is a correctness bug, not an optimisation. So the\n // field is set through a widened type rather than dropped: this client's\n // own cache is the one with the policy.\n const init: RequestInit & { cache?: string } = {\n ...options,\n headers: {\n ...(options.headers as Record<string, string> | undefined),\n \"x-api-key\": apiKey,\n },\n cache: \"no-store\",\n };\n\n const response = await fetchImpl(`${base}${path}`, init);\n let body: unknown;\n try {\n body = await response.json();\n } catch {\n body = undefined;\n }\n\n if (!response.ok) {\n const errorMessage =\n body &&\n typeof body === \"object\" &&\n \"error\" in body &&\n typeof body.error === \"string\"\n ? body.error\n : undefined;\n if (\n response.status === 409 &&\n body &&\n typeof body === \"object\" &&\n \"conflicts\" in body &&\n Array.isArray(body.conflicts) &&\n body.conflicts.every((key) => typeof key === \"string\")\n ) {\n throw new CatalogPushConflictError(\n errorMessage ?? \"Catalog conflicts with existing values.\",\n body.conflicts,\n body,\n );\n }\n throw new TranslationApiError(\n response.status === 401\n ? \"Invalid or revoked API key.\"\n : (errorMessage ?? `Translation API returned ${response.status}.`),\n response.status,\n body,\n );\n }\n return body as T;\n }\n\n function fresh(language: string): Catalog | undefined {\n const entry = cache.get(language);\n if (!entry) return undefined;\n if (entry.expiresAt <= Date.now()) {\n cache.delete(language);\n return undefined;\n }\n return entry.catalog;\n }\n\n function store(language: string, catalog: Catalog) {\n // A zero TTL means \"do not cache\" rather than \"expire immediately\", so the\n // entry is simply never written.\n if (cacheMs > 0) {\n cache.set(language, { catalog, expiresAt: Date.now() + cacheMs });\n }\n }\n\n async function getCatalog(language: string): Promise<Catalog> {\n const cached = fresh(language);\n if (cached) return cached;\n\n const existing = inFlight.get(language);\n if (existing) return existing;\n\n const pending = request<Catalog>(\n `/api/catalog/${encodeURIComponent(language)}`,\n )\n .then((catalog) => {\n store(language, catalog);\n return catalog;\n })\n .finally(() => inFlight.delete(language));\n\n inFlight.set(language, pending);\n return pending;\n }\n\n async function getCatalogs(): Promise<Record<string, Catalog>> {\n const catalogs = await request<Record<string, Catalog>>(\"/api/catalog\");\n for (const [language, catalog] of Object.entries(catalogs)) {\n store(language, catalog);\n }\n return catalogs;\n }\n\n async function getTranslator(language: string): Promise<Translate> {\n const messages = await getCatalog(language);\n return createTranslator({ language, messages });\n }\n\n async function prefetch(languages?: string[]): Promise<void> {\n if (!languages) {\n await getCatalogs();\n return;\n }\n await Promise.all(languages.map((language) => getCatalog(language)));\n }\n\n async function push(\n language: string,\n catalog: Catalog,\n options: CatalogPushOptions = {},\n ): Promise<CatalogPushResult> {\n const parameters = new URLSearchParams({\n mode: options.mode ?? \"no-force\",\n });\n if (options.force) parameters.set(\"force\", \"true\");\n\n const result = await request<CatalogPushResult>(\n `/api/catalog/${encodeURIComponent(language)}?${parameters}`,\n {\n method: \"PUT\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(catalog),\n },\n );\n invalidate(language);\n return result;\n }\n\n function invalidate(language?: string): void {\n if (language) cache.delete(language);\n else cache.clear();\n }\n\n return { getCatalog, getCatalogs, getTranslator, prefetch, push, invalidate };\n}\n"]}
package/dist/index.d.cts CHANGED
@@ -24,6 +24,21 @@ type ClientOptions = {
24
24
  fetch?: typeof globalThis.fetch;
25
25
  };
26
26
  type Translate = (key: string, values?: InterpolationValues) => string;
27
+ type CatalogPushMode = "no-force" | "override" | "keep";
28
+ type CatalogPushOptions = {
29
+ /** Existing-value policy. Defaults to the atomic `no-force` mode. */
30
+ mode?: CatalogPushMode;
31
+ /** Required before a non-source language can be written. */
32
+ force?: boolean;
33
+ };
34
+ type CatalogPushResult = {
35
+ created: number;
36
+ updated: number;
37
+ unchanged: number;
38
+ skipped: number;
39
+ /** Existing target translations newly marked as needing review. */
40
+ outdated: number;
41
+ };
27
42
  type Client = {
28
43
  /** One language's catalog, from cache when it is still fresh. */
29
44
  getCatalog(language: string): Promise<Catalog>;
@@ -33,13 +48,21 @@ type Client = {
33
48
  getTranslator(language: string): Promise<Translate>;
34
49
  /** Warms the cache — call at start-up so the first render does not wait. */
35
50
  prefetch(languages?: string[]): Promise<void>;
51
+ /** Atomically merges a local catalog into one server language. */
52
+ push(language: string, catalog: Catalog, options?: CatalogPushOptions): Promise<CatalogPushResult>;
36
53
  /** Drops cached catalogs, so the next read goes to the server. */
37
54
  invalidate(language?: string): void;
38
55
  };
39
56
  /** Thrown for any answer that is not a catalog. */
40
57
  declare class TranslationApiError extends Error {
41
58
  readonly status: number;
42
- constructor(message: string, status: number);
59
+ readonly body?: unknown | undefined;
60
+ constructor(message: string, status: number, body?: unknown | undefined);
61
+ }
62
+ /** A no-force push found existing keys with different values. */
63
+ declare class CatalogPushConflictError extends TranslationApiError {
64
+ readonly conflicts: string[];
65
+ constructor(message: string, conflicts: string[], body?: unknown);
43
66
  }
44
67
 
45
68
  /**
@@ -51,4 +74,4 @@ declare class TranslationApiError extends Error {
51
74
  */
52
75
  declare function createClient(options: ClientOptions): Client;
53
76
 
54
- export { type Catalog, type Client, type ClientOptions, type Translate, TranslationApiError, createClient };
77
+ export { type Catalog, CatalogPushConflictError, type CatalogPushMode, type CatalogPushOptions, type CatalogPushResult, type Client, type ClientOptions, type Translate, TranslationApiError, createClient };
package/dist/index.d.ts CHANGED
@@ -24,6 +24,21 @@ type ClientOptions = {
24
24
  fetch?: typeof globalThis.fetch;
25
25
  };
26
26
  type Translate = (key: string, values?: InterpolationValues) => string;
27
+ type CatalogPushMode = "no-force" | "override" | "keep";
28
+ type CatalogPushOptions = {
29
+ /** Existing-value policy. Defaults to the atomic `no-force` mode. */
30
+ mode?: CatalogPushMode;
31
+ /** Required before a non-source language can be written. */
32
+ force?: boolean;
33
+ };
34
+ type CatalogPushResult = {
35
+ created: number;
36
+ updated: number;
37
+ unchanged: number;
38
+ skipped: number;
39
+ /** Existing target translations newly marked as needing review. */
40
+ outdated: number;
41
+ };
27
42
  type Client = {
28
43
  /** One language's catalog, from cache when it is still fresh. */
29
44
  getCatalog(language: string): Promise<Catalog>;
@@ -33,13 +48,21 @@ type Client = {
33
48
  getTranslator(language: string): Promise<Translate>;
34
49
  /** Warms the cache — call at start-up so the first render does not wait. */
35
50
  prefetch(languages?: string[]): Promise<void>;
51
+ /** Atomically merges a local catalog into one server language. */
52
+ push(language: string, catalog: Catalog, options?: CatalogPushOptions): Promise<CatalogPushResult>;
36
53
  /** Drops cached catalogs, so the next read goes to the server. */
37
54
  invalidate(language?: string): void;
38
55
  };
39
56
  /** Thrown for any answer that is not a catalog. */
40
57
  declare class TranslationApiError extends Error {
41
58
  readonly status: number;
42
- constructor(message: string, status: number);
59
+ readonly body?: unknown | undefined;
60
+ constructor(message: string, status: number, body?: unknown | undefined);
61
+ }
62
+ /** A no-force push found existing keys with different values. */
63
+ declare class CatalogPushConflictError extends TranslationApiError {
64
+ readonly conflicts: string[];
65
+ constructor(message: string, conflicts: string[], body?: unknown);
43
66
  }
44
67
 
45
68
  /**
@@ -51,4 +74,4 @@ declare class TranslationApiError extends Error {
51
74
  */
52
75
  declare function createClient(options: ClientOptions): Client;
53
76
 
54
- export { type Catalog, type Client, type ClientOptions, type Translate, TranslationApiError, createClient };
77
+ export { type Catalog, CatalogPushConflictError, type CatalogPushMode, type CatalogPushOptions, type CatalogPushResult, type Client, type ClientOptions, type Translate, TranslationApiError, createClient };
package/dist/index.js CHANGED
@@ -4,12 +4,22 @@ import { createTranslator } from '@nckdv/translation-core';
4
4
 
5
5
  // src/types.ts
6
6
  var TranslationApiError = class extends Error {
7
- constructor(message, status) {
7
+ constructor(message, status, body) {
8
8
  super(message);
9
9
  this.status = status;
10
+ this.body = body;
10
11
  this.name = "TranslationApiError";
11
12
  }
12
13
  status;
14
+ body;
15
+ };
16
+ var CatalogPushConflictError = class extends TranslationApiError {
17
+ constructor(message, conflicts, body) {
18
+ super(message, 409, body);
19
+ this.conflicts = conflicts;
20
+ this.name = "CatalogPushConflictError";
21
+ }
22
+ conflicts;
13
23
  };
14
24
 
15
25
  // src/client.ts
@@ -29,19 +39,38 @@ function createClient(options) {
29
39
  const base = apiUrl.replace(/\/+$/, "");
30
40
  const cache = /* @__PURE__ */ new Map();
31
41
  const inFlight = /* @__PURE__ */ new Map();
32
- async function request(path) {
42
+ async function request(path, options2 = {}) {
33
43
  const init = {
34
- headers: { "x-api-key": apiKey },
44
+ ...options2,
45
+ headers: {
46
+ ...options2.headers,
47
+ "x-api-key": apiKey
48
+ },
35
49
  cache: "no-store"
36
50
  };
37
51
  const response = await fetchImpl(`${base}${path}`, init);
52
+ let body;
53
+ try {
54
+ body = await response.json();
55
+ } catch {
56
+ body = void 0;
57
+ }
38
58
  if (!response.ok) {
59
+ const errorMessage = body && typeof body === "object" && "error" in body && typeof body.error === "string" ? body.error : void 0;
60
+ if (response.status === 409 && body && typeof body === "object" && "conflicts" in body && Array.isArray(body.conflicts) && body.conflicts.every((key) => typeof key === "string")) {
61
+ throw new CatalogPushConflictError(
62
+ errorMessage ?? "Catalog conflicts with existing values.",
63
+ body.conflicts,
64
+ body
65
+ );
66
+ }
39
67
  throw new TranslationApiError(
40
- response.status === 401 ? "Invalid or revoked API key." : `Translation API returned ${response.status}.`,
41
- response.status
68
+ response.status === 401 ? "Invalid or revoked API key." : errorMessage ?? `Translation API returned ${response.status}.`,
69
+ response.status,
70
+ body
42
71
  );
43
72
  }
44
- return await response.json();
73
+ return body;
45
74
  }
46
75
  function fresh(language) {
47
76
  const entry = cache.get(language);
@@ -89,13 +118,29 @@ function createClient(options) {
89
118
  }
90
119
  await Promise.all(languages.map((language) => getCatalog(language)));
91
120
  }
121
+ async function push(language, catalog, options2 = {}) {
122
+ const parameters = new URLSearchParams({
123
+ mode: options2.mode ?? "no-force"
124
+ });
125
+ if (options2.force) parameters.set("force", "true");
126
+ const result = await request(
127
+ `/api/catalog/${encodeURIComponent(language)}?${parameters}`,
128
+ {
129
+ method: "PUT",
130
+ headers: { "content-type": "application/json" },
131
+ body: JSON.stringify(catalog)
132
+ }
133
+ );
134
+ invalidate(language);
135
+ return result;
136
+ }
92
137
  function invalidate(language) {
93
138
  if (language) cache.delete(language);
94
139
  else cache.clear();
95
140
  }
96
- return { getCatalog, getCatalogs, getTranslator, prefetch, invalidate };
141
+ return { getCatalog, getCatalogs, getTranslator, prefetch, push, invalidate };
97
142
  }
98
143
 
99
- export { TranslationApiError, createClient };
144
+ export { CatalogPushConflictError, TranslationApiError, createClient };
100
145
  //# sourceMappingURL=index.js.map
101
146
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts","../src/client.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type { InterpolationValues } from \"@nckdv/translation-core\";\n\n/** A flat catalog for one language, exactly as the API serves it. */\nexport type Catalog = Record<string, string>;\n\nexport type ClientOptions = {\n /** Where the platform is served from, e.g. `https://translate.example.com`. */\n apiUrl: string;\n /**\n * A project API key. It names its own project, so nothing else identifies\n * the project — that is the whole point of the credential.\n *\n * **Server-side only.** A key is a bearer credential for every catalog in\n * the project; shipping one to a browser publishes all of them.\n */\n apiKey: string;\n /**\n * How long a fetched catalog is reused, in milliseconds. Default 5 minutes.\n *\n * `0` disables caching, which is what a long-lived editor preview wants and\n * what a request-scoped server render does not.\n */\n cacheMs?: number;\n /** Swappable for tests and for runtimes with their own fetch. */\n fetch?: typeof globalThis.fetch;\n};\n\nexport type Translate = (key: string, values?: InterpolationValues) => string;\n\nexport type Client = {\n /** One language's catalog, from cache when it is still fresh. */\n getCatalog(language: string): Promise<Catalog>;\n /** Every language the project has, in one request. */\n getCatalogs(): Promise<Record<string, Catalog>>;\n /** A translate function bound to one language. */\n getTranslator(language: string): Promise<Translate>;\n /** Warms the cache — call at start-up so the first render does not wait. */\n prefetch(languages?: string[]): Promise<void>;\n /** Drops cached catalogs, so the next read goes to the server. */\n invalidate(language?: string): void;\n};\n\n/** Thrown for any answer that is not a catalog. */\nexport class TranslationApiError extends Error {\n constructor(\n message: string,\n readonly status: number,\n ) {\n super(message);\n this.name = \"TranslationApiError\";\n }\n}\n","import { createTranslator } from \"@nckdv/translation-core\";\nimport {\n TranslationApiError,\n type Catalog,\n type Client,\n type ClientOptions,\n type Translate,\n} from \"./types.js\";\n\n/** Five minutes: long enough to matter, short enough that a fix lands soon. */\nconst DEFAULT_CACHE_MS = 5 * 60 * 1000;\n\ntype Entry = {\n catalog: Catalog;\n /** When this entry stops being reusable. */\n expiresAt: number;\n};\n\n/**\n * A client for one project's translations.\n *\n * The key identifies the project, so nothing here takes a project id — a\n * deployment holds one credential rather than a credential and a uuid it\n * cannot verify by eye.\n */\nexport function createClient(options: ClientOptions): Client {\n const {\n apiUrl,\n apiKey,\n cacheMs = DEFAULT_CACHE_MS,\n fetch: fetchImpl = globalThis.fetch,\n } = options;\n\n if (!apiUrl) throw new Error(\"createClient: apiUrl is required.\");\n if (!apiKey) throw new Error(\"createClient: apiKey is required.\");\n if (typeof fetchImpl !== \"function\") {\n throw new Error(\"createClient: no fetch available; pass one in options.\");\n }\n\n const base = apiUrl.replace(/\\/+$/, \"\");\n const cache = new Map<string, Entry>();\n\n /**\n * In-flight requests, keyed the same way as the cache.\n *\n * Without this, a cold start that renders ten pages at once makes ten\n * identical requests for the same catalog. They are shared instead, and the\n * entry is removed as soon as it settles so a failure is retried rather than\n * remembered.\n */\n const inFlight = new Map<string, Promise<Catalog>>();\n\n async function request<T>(path: string): Promise<T> {\n // `cache` is not in Node's own `RequestInit`, but Next.js patches global\n // fetch and caches by default — and a framework quietly holding on to a\n // credentialled response is a correctness bug, not an optimisation. So the\n // field is set through a widened type rather than dropped: this client's\n // own cache is the one with the policy.\n const init: RequestInit & { cache?: string } = {\n headers: { \"x-api-key\": apiKey },\n cache: \"no-store\",\n };\n\n const response = await fetchImpl(`${base}${path}`, init);\n\n if (!response.ok) {\n throw new TranslationApiError(\n response.status === 401\n ? \"Invalid or revoked API key.\"\n : `Translation API returned ${response.status}.`,\n response.status,\n );\n }\n return (await response.json()) as T;\n }\n\n function fresh(language: string): Catalog | undefined {\n const entry = cache.get(language);\n if (!entry) return undefined;\n if (entry.expiresAt <= Date.now()) {\n cache.delete(language);\n return undefined;\n }\n return entry.catalog;\n }\n\n function store(language: string, catalog: Catalog) {\n // A zero TTL means \"do not cache\" rather than \"expire immediately\", so the\n // entry is simply never written.\n if (cacheMs > 0) {\n cache.set(language, { catalog, expiresAt: Date.now() + cacheMs });\n }\n }\n\n async function getCatalog(language: string): Promise<Catalog> {\n const cached = fresh(language);\n if (cached) return cached;\n\n const existing = inFlight.get(language);\n if (existing) return existing;\n\n const pending = request<Catalog>(\n `/api/catalog/${encodeURIComponent(language)}`,\n )\n .then((catalog) => {\n store(language, catalog);\n return catalog;\n })\n .finally(() => inFlight.delete(language));\n\n inFlight.set(language, pending);\n return pending;\n }\n\n async function getCatalogs(): Promise<Record<string, Catalog>> {\n const catalogs = await request<Record<string, Catalog>>(\"/api/catalog\");\n for (const [language, catalog] of Object.entries(catalogs)) {\n store(language, catalog);\n }\n return catalogs;\n }\n\n async function getTranslator(language: string): Promise<Translate> {\n const messages = await getCatalog(language);\n return createTranslator({ language, messages });\n }\n\n async function prefetch(languages?: string[]): Promise<void> {\n if (!languages) {\n await getCatalogs();\n return;\n }\n await Promise.all(languages.map((language) => getCatalog(language)));\n }\n\n function invalidate(language?: string): void {\n if (language) cache.delete(language);\n else cache.clear();\n }\n\n return { getCatalog, getCatalogs, getTranslator, prefetch, invalidate };\n}\n"]}
1
+ {"version":3,"sources":["../src/types.ts","../src/client.ts"],"names":["options"],"mappings":";;;;;AAmEO,IAAM,mBAAA,GAAN,cAAkC,KAAA,CAAM;AAAA,EAC7C,WAAA,CACE,OAAA,EACS,MAAA,EACA,IAAA,EACT;AACA,IAAA,KAAA,CAAM,OAAO,CAAA;AAHJ,IAAA,IAAA,CAAA,MAAA,GAAA,MAAA;AACA,IAAA,IAAA,CAAA,IAAA,GAAA,IAAA;AAGT,IAAA,IAAA,CAAK,IAAA,GAAO,qBAAA;AAAA,EACd;AAAA,EALW,MAAA;AAAA,EACA,IAAA;AAKb;AAGO,IAAM,wBAAA,GAAN,cAAuC,mBAAA,CAAoB;AAAA,EAChE,WAAA,CACE,OAAA,EACS,SAAA,EACT,IAAA,EACA;AACA,IAAA,KAAA,CAAM,OAAA,EAAS,KAAK,IAAI,CAAA;AAHf,IAAA,IAAA,CAAA,SAAA,GAAA,SAAA;AAIT,IAAA,IAAA,CAAK,IAAA,GAAO,0BAAA;AAAA,EACd;AAAA,EALW,SAAA;AAMb;;;AC3EA,IAAM,gBAAA,GAAmB,IAAI,EAAA,GAAK,GAAA;AAe3B,SAAS,aAAa,OAAA,EAAgC;AAC3D,EAAA,MAAM;AAAA,IACJ,MAAA;AAAA,IACA,MAAA;AAAA,IACA,OAAA,GAAU,gBAAA;AAAA,IACV,KAAA,EAAO,YAAY,UAAA,CAAW;AAAA,GAChC,GAAI,OAAA;AAEJ,EAAA,IAAI,CAAC,MAAA,EAAQ,MAAM,IAAI,MAAM,mCAAmC,CAAA;AAChE,EAAA,IAAI,CAAC,MAAA,EAAQ,MAAM,IAAI,MAAM,mCAAmC,CAAA;AAChE,EAAA,IAAI,OAAO,cAAc,UAAA,EAAY;AACnC,IAAA,MAAM,IAAI,MAAM,wDAAwD,CAAA;AAAA,EAC1E;AAEA,EAAA,MAAM,IAAA,GAAO,MAAA,CAAO,OAAA,CAAQ,MAAA,EAAQ,EAAE,CAAA;AACtC,EAAA,MAAM,KAAA,uBAAY,GAAA,EAAmB;AAUrC,EAAA,MAAM,QAAA,uBAAe,GAAA,EAA8B;AAEnD,EAAA,eAAe,OAAA,CACb,IAAA,EACAA,QAAAA,GAAuB,EAAC,EACZ;AAMZ,IAAA,MAAM,IAAA,GAAyC;AAAA,MAC7C,GAAGA,QAAAA;AAAA,MACH,OAAA,EAAS;AAAA,QACP,GAAIA,QAAAA,CAAQ,OAAA;AAAA,QACZ,WAAA,EAAa;AAAA,OACf;AAAA,MACA,KAAA,EAAO;AAAA,KACT;AAEA,IAAA,MAAM,QAAA,GAAW,MAAM,SAAA,CAAU,CAAA,EAAG,IAAI,CAAA,EAAG,IAAI,IAAI,IAAI,CAAA;AACvD,IAAA,IAAI,IAAA;AACJ,IAAA,IAAI;AACF,MAAA,IAAA,GAAO,MAAM,SAAS,IAAA,EAAK;AAAA,IAC7B,CAAA,CAAA,MAAQ;AACN,MAAA,IAAA,GAAO,MAAA;AAAA,IACT;AAEA,IAAA,IAAI,CAAC,SAAS,EAAA,EAAI;AAChB,MAAA,MAAM,YAAA,GACJ,IAAA,IACA,OAAO,IAAA,KAAS,QAAA,IAChB,OAAA,IAAW,IAAA,IACX,OAAO,IAAA,CAAK,KAAA,KAAU,QAAA,GAClB,IAAA,CAAK,KAAA,GACL,MAAA;AACN,MAAA,IACE,QAAA,CAAS,WAAW,GAAA,IACpB,IAAA,IACA,OAAO,IAAA,KAAS,QAAA,IAChB,WAAA,IAAe,IAAA,IACf,KAAA,CAAM,OAAA,CAAQ,KAAK,SAAS,CAAA,IAC5B,KAAK,SAAA,CAAU,KAAA,CAAM,CAAC,GAAA,KAAQ,OAAO,GAAA,KAAQ,QAAQ,CAAA,EACrD;AACA,QAAA,MAAM,IAAI,wBAAA;AAAA,UACR,YAAA,IAAgB,yCAAA;AAAA,UAChB,IAAA,CAAK,SAAA;AAAA,UACL;AAAA,SACF;AAAA,MACF;AACA,MAAA,MAAM,IAAI,mBAAA;AAAA,QACR,SAAS,MAAA,KAAW,GAAA,GAChB,gCACC,YAAA,IAAgB,CAAA,yBAAA,EAA4B,SAAS,MAAM,CAAA,CAAA,CAAA;AAAA,QAChE,QAAA,CAAS,MAAA;AAAA,QACT;AAAA,OACF;AAAA,IACF;AACA,IAAA,OAAO,IAAA;AAAA,EACT;AAEA,EAAA,SAAS,MAAM,QAAA,EAAuC;AACpD,IAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,GAAA,CAAI,QAAQ,CAAA;AAChC,IAAA,IAAI,CAAC,OAAO,OAAO,MAAA;AACnB,IAAA,IAAI,KAAA,CAAM,SAAA,IAAa,IAAA,CAAK,GAAA,EAAI,EAAG;AACjC,MAAA,KAAA,CAAM,OAAO,QAAQ,CAAA;AACrB,MAAA,OAAO,MAAA;AAAA,IACT;AACA,IAAA,OAAO,KAAA,CAAM,OAAA;AAAA,EACf;AAEA,EAAA,SAAS,KAAA,CAAM,UAAkB,OAAA,EAAkB;AAGjD,IAAA,IAAI,UAAU,CAAA,EAAG;AACf,MAAA,KAAA,CAAM,GAAA,CAAI,UAAU,EAAE,OAAA,EAAS,WAAW,IAAA,CAAK,GAAA,EAAI,GAAI,OAAA,EAAS,CAAA;AAAA,IAClE;AAAA,EACF;AAEA,EAAA,eAAe,WAAW,QAAA,EAAoC;AAC5D,IAAA,MAAM,MAAA,GAAS,MAAM,QAAQ,CAAA;AAC7B,IAAA,IAAI,QAAQ,OAAO,MAAA;AAEnB,IAAA,MAAM,QAAA,GAAW,QAAA,CAAS,GAAA,CAAI,QAAQ,CAAA;AACtC,IAAA,IAAI,UAAU,OAAO,QAAA;AAErB,IAAA,MAAM,OAAA,GAAU,OAAA;AAAA,MACd,CAAA,aAAA,EAAgB,kBAAA,CAAmB,QAAQ,CAAC,CAAA;AAAA,KAC9C,CACG,IAAA,CAAK,CAAC,OAAA,KAAY;AACjB,MAAA,KAAA,CAAM,UAAU,OAAO,CAAA;AACvB,MAAA,OAAO,OAAA;AAAA,IACT,CAAC,CAAA,CACA,OAAA,CAAQ,MAAM,QAAA,CAAS,MAAA,CAAO,QAAQ,CAAC,CAAA;AAE1C,IAAA,QAAA,CAAS,GAAA,CAAI,UAAU,OAAO,CAAA;AAC9B,IAAA,OAAO,OAAA;AAAA,EACT;AAEA,EAAA,eAAe,WAAA,GAAgD;AAC7D,IAAA,MAAM,QAAA,GAAW,MAAM,OAAA,CAAiC,cAAc,CAAA;AACtE,IAAA,KAAA,MAAW,CAAC,QAAA,EAAU,OAAO,KAAK,MAAA,CAAO,OAAA,CAAQ,QAAQ,CAAA,EAAG;AAC1D,MAAA,KAAA,CAAM,UAAU,OAAO,CAAA;AAAA,IACzB;AACA,IAAA,OAAO,QAAA;AAAA,EACT;AAEA,EAAA,eAAe,cAAc,QAAA,EAAsC;AACjE,IAAA,MAAM,QAAA,GAAW,MAAM,UAAA,CAAW,QAAQ,CAAA;AAC1C,IAAA,OAAO,gBAAA,CAAiB,EAAE,QAAA,EAAU,QAAA,EAAU,CAAA;AAAA,EAChD;AAEA,EAAA,eAAe,SAAS,SAAA,EAAqC;AAC3D,IAAA,IAAI,CAAC,SAAA,EAAW;AACd,MAAA,MAAM,WAAA,EAAY;AAClB,MAAA;AAAA,IACF;AACA,IAAA,MAAM,OAAA,CAAQ,IAAI,SAAA,CAAU,GAAA,CAAI,CAAC,QAAA,KAAa,UAAA,CAAW,QAAQ,CAAC,CAAC,CAAA;AAAA,EACrE;AAEA,EAAA,eAAe,IAAA,CACb,QAAA,EACA,OAAA,EACAA,QAAAA,GAA8B,EAAC,EACH;AAC5B,IAAA,MAAM,UAAA,GAAa,IAAI,eAAA,CAAgB;AAAA,MACrC,IAAA,EAAMA,SAAQ,IAAA,IAAQ;AAAA,KACvB,CAAA;AACD,IAAA,IAAIA,QAAAA,CAAQ,KAAA,EAAO,UAAA,CAAW,GAAA,CAAI,SAAS,MAAM,CAAA;AAEjD,IAAA,MAAM,SAAS,MAAM,OAAA;AAAA,MACnB,CAAA,aAAA,EAAgB,kBAAA,CAAmB,QAAQ,CAAC,IAAI,UAAU,CAAA,CAAA;AAAA,MAC1D;AAAA,QACE,MAAA,EAAQ,KAAA;AAAA,QACR,OAAA,EAAS,EAAE,cAAA,EAAgB,kBAAA,EAAmB;AAAA,QAC9C,IAAA,EAAM,IAAA,CAAK,SAAA,CAAU,OAAO;AAAA;AAC9B,KACF;AACA,IAAA,UAAA,CAAW,QAAQ,CAAA;AACnB,IAAA,OAAO,MAAA;AAAA,EACT;AAEA,EAAA,SAAS,WAAW,QAAA,EAAyB;AAC3C,IAAA,IAAI,QAAA,EAAU,KAAA,CAAM,MAAA,CAAO,QAAQ,CAAA;AAAA,eACxB,KAAA,EAAM;AAAA,EACnB;AAEA,EAAA,OAAO,EAAE,UAAA,EAAY,WAAA,EAAa,aAAA,EAAe,QAAA,EAAU,MAAM,UAAA,EAAW;AAC9E","file":"index.js","sourcesContent":["import type { InterpolationValues } from \"@nckdv/translation-core\";\n\n/** A flat catalog for one language, exactly as the API serves it. */\nexport type Catalog = Record<string, string>;\n\nexport type ClientOptions = {\n /** Where the platform is served from, e.g. `https://translate.example.com`. */\n apiUrl: string;\n /**\n * A project API key. It names its own project, so nothing else identifies\n * the project — that is the whole point of the credential.\n *\n * **Server-side only.** A key is a bearer credential for every catalog in\n * the project; shipping one to a browser publishes all of them.\n */\n apiKey: string;\n /**\n * How long a fetched catalog is reused, in milliseconds. Default 5 minutes.\n *\n * `0` disables caching, which is what a long-lived editor preview wants and\n * what a request-scoped server render does not.\n */\n cacheMs?: number;\n /** Swappable for tests and for runtimes with their own fetch. */\n fetch?: typeof globalThis.fetch;\n};\n\nexport type Translate = (key: string, values?: InterpolationValues) => string;\n\nexport type CatalogPushMode = \"no-force\" | \"override\" | \"keep\";\n\nexport type CatalogPushOptions = {\n /** Existing-value policy. Defaults to the atomic `no-force` mode. */\n mode?: CatalogPushMode;\n /** Required before a non-source language can be written. */\n force?: boolean;\n};\n\nexport type CatalogPushResult = {\n created: number;\n updated: number;\n unchanged: number;\n skipped: number;\n /** Existing target translations newly marked as needing review. */\n outdated: number;\n};\n\nexport type Client = {\n /** One language's catalog, from cache when it is still fresh. */\n getCatalog(language: string): Promise<Catalog>;\n /** Every language the project has, in one request. */\n getCatalogs(): Promise<Record<string, Catalog>>;\n /** A translate function bound to one language. */\n getTranslator(language: string): Promise<Translate>;\n /** Warms the cache — call at start-up so the first render does not wait. */\n prefetch(languages?: string[]): Promise<void>;\n /** Atomically merges a local catalog into one server language. */\n push(\n language: string,\n catalog: Catalog,\n options?: CatalogPushOptions,\n ): Promise<CatalogPushResult>;\n /** Drops cached catalogs, so the next read goes to the server. */\n invalidate(language?: string): void;\n};\n\n/** Thrown for any answer that is not a catalog. */\nexport class TranslationApiError extends Error {\n constructor(\n message: string,\n readonly status: number,\n readonly body?: unknown,\n ) {\n super(message);\n this.name = \"TranslationApiError\";\n }\n}\n\n/** A no-force push found existing keys with different values. */\nexport class CatalogPushConflictError extends TranslationApiError {\n constructor(\n message: string,\n readonly conflicts: string[],\n body?: unknown,\n ) {\n super(message, 409, body);\n this.name = \"CatalogPushConflictError\";\n }\n}\n","import { createTranslator } from \"@nckdv/translation-core\";\nimport {\n CatalogPushConflictError,\n TranslationApiError,\n type Catalog,\n type CatalogPushOptions,\n type CatalogPushResult,\n type Client,\n type ClientOptions,\n type Translate,\n} from \"./types.js\";\n\n/** Five minutes: long enough to matter, short enough that a fix lands soon. */\nconst DEFAULT_CACHE_MS = 5 * 60 * 1000;\n\ntype Entry = {\n catalog: Catalog;\n /** When this entry stops being reusable. */\n expiresAt: number;\n};\n\n/**\n * A client for one project's translations.\n *\n * The key identifies the project, so nothing here takes a project id — a\n * deployment holds one credential rather than a credential and a uuid it\n * cannot verify by eye.\n */\nexport function createClient(options: ClientOptions): Client {\n const {\n apiUrl,\n apiKey,\n cacheMs = DEFAULT_CACHE_MS,\n fetch: fetchImpl = globalThis.fetch,\n } = options;\n\n if (!apiUrl) throw new Error(\"createClient: apiUrl is required.\");\n if (!apiKey) throw new Error(\"createClient: apiKey is required.\");\n if (typeof fetchImpl !== \"function\") {\n throw new Error(\"createClient: no fetch available; pass one in options.\");\n }\n\n const base = apiUrl.replace(/\\/+$/, \"\");\n const cache = new Map<string, Entry>();\n\n /**\n * In-flight requests, keyed the same way as the cache.\n *\n * Without this, a cold start that renders ten pages at once makes ten\n * identical requests for the same catalog. They are shared instead, and the\n * entry is removed as soon as it settles so a failure is retried rather than\n * remembered.\n */\n const inFlight = new Map<string, Promise<Catalog>>();\n\n async function request<T>(\n path: string,\n options: RequestInit = {},\n ): Promise<T> {\n // `cache` is not in Node's own `RequestInit`, but Next.js patches global\n // fetch and caches by default — and a framework quietly holding on to a\n // credentialled response is a correctness bug, not an optimisation. So the\n // field is set through a widened type rather than dropped: this client's\n // own cache is the one with the policy.\n const init: RequestInit & { cache?: string } = {\n ...options,\n headers: {\n ...(options.headers as Record<string, string> | undefined),\n \"x-api-key\": apiKey,\n },\n cache: \"no-store\",\n };\n\n const response = await fetchImpl(`${base}${path}`, init);\n let body: unknown;\n try {\n body = await response.json();\n } catch {\n body = undefined;\n }\n\n if (!response.ok) {\n const errorMessage =\n body &&\n typeof body === \"object\" &&\n \"error\" in body &&\n typeof body.error === \"string\"\n ? body.error\n : undefined;\n if (\n response.status === 409 &&\n body &&\n typeof body === \"object\" &&\n \"conflicts\" in body &&\n Array.isArray(body.conflicts) &&\n body.conflicts.every((key) => typeof key === \"string\")\n ) {\n throw new CatalogPushConflictError(\n errorMessage ?? \"Catalog conflicts with existing values.\",\n body.conflicts,\n body,\n );\n }\n throw new TranslationApiError(\n response.status === 401\n ? \"Invalid or revoked API key.\"\n : (errorMessage ?? `Translation API returned ${response.status}.`),\n response.status,\n body,\n );\n }\n return body as T;\n }\n\n function fresh(language: string): Catalog | undefined {\n const entry = cache.get(language);\n if (!entry) return undefined;\n if (entry.expiresAt <= Date.now()) {\n cache.delete(language);\n return undefined;\n }\n return entry.catalog;\n }\n\n function store(language: string, catalog: Catalog) {\n // A zero TTL means \"do not cache\" rather than \"expire immediately\", so the\n // entry is simply never written.\n if (cacheMs > 0) {\n cache.set(language, { catalog, expiresAt: Date.now() + cacheMs });\n }\n }\n\n async function getCatalog(language: string): Promise<Catalog> {\n const cached = fresh(language);\n if (cached) return cached;\n\n const existing = inFlight.get(language);\n if (existing) return existing;\n\n const pending = request<Catalog>(\n `/api/catalog/${encodeURIComponent(language)}`,\n )\n .then((catalog) => {\n store(language, catalog);\n return catalog;\n })\n .finally(() => inFlight.delete(language));\n\n inFlight.set(language, pending);\n return pending;\n }\n\n async function getCatalogs(): Promise<Record<string, Catalog>> {\n const catalogs = await request<Record<string, Catalog>>(\"/api/catalog\");\n for (const [language, catalog] of Object.entries(catalogs)) {\n store(language, catalog);\n }\n return catalogs;\n }\n\n async function getTranslator(language: string): Promise<Translate> {\n const messages = await getCatalog(language);\n return createTranslator({ language, messages });\n }\n\n async function prefetch(languages?: string[]): Promise<void> {\n if (!languages) {\n await getCatalogs();\n return;\n }\n await Promise.all(languages.map((language) => getCatalog(language)));\n }\n\n async function push(\n language: string,\n catalog: Catalog,\n options: CatalogPushOptions = {},\n ): Promise<CatalogPushResult> {\n const parameters = new URLSearchParams({\n mode: options.mode ?? \"no-force\",\n });\n if (options.force) parameters.set(\"force\", \"true\");\n\n const result = await request<CatalogPushResult>(\n `/api/catalog/${encodeURIComponent(language)}?${parameters}`,\n {\n method: \"PUT\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(catalog),\n },\n );\n invalidate(language);\n return result;\n }\n\n function invalidate(language?: string): void {\n if (language) cache.delete(language);\n else cache.clear();\n }\n\n return { getCatalog, getCatalogs, getTranslator, prefetch, push, invalidate };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nckdv/translation-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
@@ -21,7 +21,7 @@
21
21
  "typecheck": "tsc --noEmit"
22
22
  },
23
23
  "dependencies": {
24
- "@nckdv/translation-core": "^0.1.0"
24
+ "@nckdv/translation-core": "^0.2.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "tsup": "^8.3.5",