@nckdv/translation-sdk 0.1.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.
- package/dist/index.cjs +104 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +54 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +101 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var translationCore = require('@nckdv/translation-core');
|
|
4
|
+
|
|
5
|
+
// src/client.ts
|
|
6
|
+
|
|
7
|
+
// src/types.ts
|
|
8
|
+
var TranslationApiError = class extends Error {
|
|
9
|
+
constructor(message, status) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.name = "TranslationApiError";
|
|
13
|
+
}
|
|
14
|
+
status;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/client.ts
|
|
18
|
+
var DEFAULT_CACHE_MS = 5 * 60 * 1e3;
|
|
19
|
+
function createClient(options) {
|
|
20
|
+
const {
|
|
21
|
+
apiUrl,
|
|
22
|
+
apiKey,
|
|
23
|
+
cacheMs = DEFAULT_CACHE_MS,
|
|
24
|
+
fetch: fetchImpl = globalThis.fetch
|
|
25
|
+
} = options;
|
|
26
|
+
if (!apiUrl) throw new Error("createClient: apiUrl is required.");
|
|
27
|
+
if (!apiKey) throw new Error("createClient: apiKey is required.");
|
|
28
|
+
if (typeof fetchImpl !== "function") {
|
|
29
|
+
throw new Error("createClient: no fetch available; pass one in options.");
|
|
30
|
+
}
|
|
31
|
+
const base = apiUrl.replace(/\/+$/, "");
|
|
32
|
+
const cache = /* @__PURE__ */ new Map();
|
|
33
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
34
|
+
async function request(path) {
|
|
35
|
+
const init = {
|
|
36
|
+
headers: { "x-api-key": apiKey },
|
|
37
|
+
cache: "no-store"
|
|
38
|
+
};
|
|
39
|
+
const response = await fetchImpl(`${base}${path}`, init);
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
throw new TranslationApiError(
|
|
42
|
+
response.status === 401 ? "Invalid or revoked API key." : `Translation API returned ${response.status}.`,
|
|
43
|
+
response.status
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return await response.json();
|
|
47
|
+
}
|
|
48
|
+
function fresh(language) {
|
|
49
|
+
const entry = cache.get(language);
|
|
50
|
+
if (!entry) return void 0;
|
|
51
|
+
if (entry.expiresAt <= Date.now()) {
|
|
52
|
+
cache.delete(language);
|
|
53
|
+
return void 0;
|
|
54
|
+
}
|
|
55
|
+
return entry.catalog;
|
|
56
|
+
}
|
|
57
|
+
function store(language, catalog) {
|
|
58
|
+
if (cacheMs > 0) {
|
|
59
|
+
cache.set(language, { catalog, expiresAt: Date.now() + cacheMs });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function getCatalog(language) {
|
|
63
|
+
const cached = fresh(language);
|
|
64
|
+
if (cached) return cached;
|
|
65
|
+
const existing = inFlight.get(language);
|
|
66
|
+
if (existing) return existing;
|
|
67
|
+
const pending = request(
|
|
68
|
+
`/api/catalog/${encodeURIComponent(language)}`
|
|
69
|
+
).then((catalog) => {
|
|
70
|
+
store(language, catalog);
|
|
71
|
+
return catalog;
|
|
72
|
+
}).finally(() => inFlight.delete(language));
|
|
73
|
+
inFlight.set(language, pending);
|
|
74
|
+
return pending;
|
|
75
|
+
}
|
|
76
|
+
async function getCatalogs() {
|
|
77
|
+
const catalogs = await request("/api/catalog");
|
|
78
|
+
for (const [language, catalog] of Object.entries(catalogs)) {
|
|
79
|
+
store(language, catalog);
|
|
80
|
+
}
|
|
81
|
+
return catalogs;
|
|
82
|
+
}
|
|
83
|
+
async function getTranslator(language) {
|
|
84
|
+
const messages = await getCatalog(language);
|
|
85
|
+
return translationCore.createTranslator({ language, messages });
|
|
86
|
+
}
|
|
87
|
+
async function prefetch(languages) {
|
|
88
|
+
if (!languages) {
|
|
89
|
+
await getCatalogs();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
await Promise.all(languages.map((language) => getCatalog(language)));
|
|
93
|
+
}
|
|
94
|
+
function invalidate(language) {
|
|
95
|
+
if (language) cache.delete(language);
|
|
96
|
+
else cache.clear();
|
|
97
|
+
}
|
|
98
|
+
return { getCatalog, getCatalogs, getTranslator, prefetch, invalidate };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
exports.TranslationApiError = TranslationApiError;
|
|
102
|
+
exports.createClient = createClient;
|
|
103
|
+
//# sourceMappingURL=index.cjs.map
|
|
104
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +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"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { InterpolationValues } from '@nckdv/translation-core';
|
|
2
|
+
|
|
3
|
+
/** A flat catalog for one language, exactly as the API serves it. */
|
|
4
|
+
type Catalog = Record<string, string>;
|
|
5
|
+
type ClientOptions = {
|
|
6
|
+
/** Where the platform is served from, e.g. `https://translate.example.com`. */
|
|
7
|
+
apiUrl: string;
|
|
8
|
+
/**
|
|
9
|
+
* A project API key. It names its own project, so nothing else identifies
|
|
10
|
+
* the project — that is the whole point of the credential.
|
|
11
|
+
*
|
|
12
|
+
* **Server-side only.** A key is a bearer credential for every catalog in
|
|
13
|
+
* the project; shipping one to a browser publishes all of them.
|
|
14
|
+
*/
|
|
15
|
+
apiKey: string;
|
|
16
|
+
/**
|
|
17
|
+
* How long a fetched catalog is reused, in milliseconds. Default 5 minutes.
|
|
18
|
+
*
|
|
19
|
+
* `0` disables caching, which is what a long-lived editor preview wants and
|
|
20
|
+
* what a request-scoped server render does not.
|
|
21
|
+
*/
|
|
22
|
+
cacheMs?: number;
|
|
23
|
+
/** Swappable for tests and for runtimes with their own fetch. */
|
|
24
|
+
fetch?: typeof globalThis.fetch;
|
|
25
|
+
};
|
|
26
|
+
type Translate = (key: string, values?: InterpolationValues) => string;
|
|
27
|
+
type Client = {
|
|
28
|
+
/** One language's catalog, from cache when it is still fresh. */
|
|
29
|
+
getCatalog(language: string): Promise<Catalog>;
|
|
30
|
+
/** Every language the project has, in one request. */
|
|
31
|
+
getCatalogs(): Promise<Record<string, Catalog>>;
|
|
32
|
+
/** A translate function bound to one language. */
|
|
33
|
+
getTranslator(language: string): Promise<Translate>;
|
|
34
|
+
/** Warms the cache — call at start-up so the first render does not wait. */
|
|
35
|
+
prefetch(languages?: string[]): Promise<void>;
|
|
36
|
+
/** Drops cached catalogs, so the next read goes to the server. */
|
|
37
|
+
invalidate(language?: string): void;
|
|
38
|
+
};
|
|
39
|
+
/** Thrown for any answer that is not a catalog. */
|
|
40
|
+
declare class TranslationApiError extends Error {
|
|
41
|
+
readonly status: number;
|
|
42
|
+
constructor(message: string, status: number);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A client for one project's translations.
|
|
47
|
+
*
|
|
48
|
+
* The key identifies the project, so nothing here takes a project id — a
|
|
49
|
+
* deployment holds one credential rather than a credential and a uuid it
|
|
50
|
+
* cannot verify by eye.
|
|
51
|
+
*/
|
|
52
|
+
declare function createClient(options: ClientOptions): Client;
|
|
53
|
+
|
|
54
|
+
export { type Catalog, type Client, type ClientOptions, type Translate, TranslationApiError, createClient };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { InterpolationValues } from '@nckdv/translation-core';
|
|
2
|
+
|
|
3
|
+
/** A flat catalog for one language, exactly as the API serves it. */
|
|
4
|
+
type Catalog = Record<string, string>;
|
|
5
|
+
type ClientOptions = {
|
|
6
|
+
/** Where the platform is served from, e.g. `https://translate.example.com`. */
|
|
7
|
+
apiUrl: string;
|
|
8
|
+
/**
|
|
9
|
+
* A project API key. It names its own project, so nothing else identifies
|
|
10
|
+
* the project — that is the whole point of the credential.
|
|
11
|
+
*
|
|
12
|
+
* **Server-side only.** A key is a bearer credential for every catalog in
|
|
13
|
+
* the project; shipping one to a browser publishes all of them.
|
|
14
|
+
*/
|
|
15
|
+
apiKey: string;
|
|
16
|
+
/**
|
|
17
|
+
* How long a fetched catalog is reused, in milliseconds. Default 5 minutes.
|
|
18
|
+
*
|
|
19
|
+
* `0` disables caching, which is what a long-lived editor preview wants and
|
|
20
|
+
* what a request-scoped server render does not.
|
|
21
|
+
*/
|
|
22
|
+
cacheMs?: number;
|
|
23
|
+
/** Swappable for tests and for runtimes with their own fetch. */
|
|
24
|
+
fetch?: typeof globalThis.fetch;
|
|
25
|
+
};
|
|
26
|
+
type Translate = (key: string, values?: InterpolationValues) => string;
|
|
27
|
+
type Client = {
|
|
28
|
+
/** One language's catalog, from cache when it is still fresh. */
|
|
29
|
+
getCatalog(language: string): Promise<Catalog>;
|
|
30
|
+
/** Every language the project has, in one request. */
|
|
31
|
+
getCatalogs(): Promise<Record<string, Catalog>>;
|
|
32
|
+
/** A translate function bound to one language. */
|
|
33
|
+
getTranslator(language: string): Promise<Translate>;
|
|
34
|
+
/** Warms the cache — call at start-up so the first render does not wait. */
|
|
35
|
+
prefetch(languages?: string[]): Promise<void>;
|
|
36
|
+
/** Drops cached catalogs, so the next read goes to the server. */
|
|
37
|
+
invalidate(language?: string): void;
|
|
38
|
+
};
|
|
39
|
+
/** Thrown for any answer that is not a catalog. */
|
|
40
|
+
declare class TranslationApiError extends Error {
|
|
41
|
+
readonly status: number;
|
|
42
|
+
constructor(message: string, status: number);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A client for one project's translations.
|
|
47
|
+
*
|
|
48
|
+
* The key identifies the project, so nothing here takes a project id — a
|
|
49
|
+
* deployment holds one credential rather than a credential and a uuid it
|
|
50
|
+
* cannot verify by eye.
|
|
51
|
+
*/
|
|
52
|
+
declare function createClient(options: ClientOptions): Client;
|
|
53
|
+
|
|
54
|
+
export { type Catalog, type Client, type ClientOptions, type Translate, TranslationApiError, createClient };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createTranslator } from '@nckdv/translation-core';
|
|
2
|
+
|
|
3
|
+
// src/client.ts
|
|
4
|
+
|
|
5
|
+
// src/types.ts
|
|
6
|
+
var TranslationApiError = class extends Error {
|
|
7
|
+
constructor(message, status) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.name = "TranslationApiError";
|
|
11
|
+
}
|
|
12
|
+
status;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// src/client.ts
|
|
16
|
+
var DEFAULT_CACHE_MS = 5 * 60 * 1e3;
|
|
17
|
+
function createClient(options) {
|
|
18
|
+
const {
|
|
19
|
+
apiUrl,
|
|
20
|
+
apiKey,
|
|
21
|
+
cacheMs = DEFAULT_CACHE_MS,
|
|
22
|
+
fetch: fetchImpl = globalThis.fetch
|
|
23
|
+
} = options;
|
|
24
|
+
if (!apiUrl) throw new Error("createClient: apiUrl is required.");
|
|
25
|
+
if (!apiKey) throw new Error("createClient: apiKey is required.");
|
|
26
|
+
if (typeof fetchImpl !== "function") {
|
|
27
|
+
throw new Error("createClient: no fetch available; pass one in options.");
|
|
28
|
+
}
|
|
29
|
+
const base = apiUrl.replace(/\/+$/, "");
|
|
30
|
+
const cache = /* @__PURE__ */ new Map();
|
|
31
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
32
|
+
async function request(path) {
|
|
33
|
+
const init = {
|
|
34
|
+
headers: { "x-api-key": apiKey },
|
|
35
|
+
cache: "no-store"
|
|
36
|
+
};
|
|
37
|
+
const response = await fetchImpl(`${base}${path}`, init);
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
throw new TranslationApiError(
|
|
40
|
+
response.status === 401 ? "Invalid or revoked API key." : `Translation API returned ${response.status}.`,
|
|
41
|
+
response.status
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return await response.json();
|
|
45
|
+
}
|
|
46
|
+
function fresh(language) {
|
|
47
|
+
const entry = cache.get(language);
|
|
48
|
+
if (!entry) return void 0;
|
|
49
|
+
if (entry.expiresAt <= Date.now()) {
|
|
50
|
+
cache.delete(language);
|
|
51
|
+
return void 0;
|
|
52
|
+
}
|
|
53
|
+
return entry.catalog;
|
|
54
|
+
}
|
|
55
|
+
function store(language, catalog) {
|
|
56
|
+
if (cacheMs > 0) {
|
|
57
|
+
cache.set(language, { catalog, expiresAt: Date.now() + cacheMs });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function getCatalog(language) {
|
|
61
|
+
const cached = fresh(language);
|
|
62
|
+
if (cached) return cached;
|
|
63
|
+
const existing = inFlight.get(language);
|
|
64
|
+
if (existing) return existing;
|
|
65
|
+
const pending = request(
|
|
66
|
+
`/api/catalog/${encodeURIComponent(language)}`
|
|
67
|
+
).then((catalog) => {
|
|
68
|
+
store(language, catalog);
|
|
69
|
+
return catalog;
|
|
70
|
+
}).finally(() => inFlight.delete(language));
|
|
71
|
+
inFlight.set(language, pending);
|
|
72
|
+
return pending;
|
|
73
|
+
}
|
|
74
|
+
async function getCatalogs() {
|
|
75
|
+
const catalogs = await request("/api/catalog");
|
|
76
|
+
for (const [language, catalog] of Object.entries(catalogs)) {
|
|
77
|
+
store(language, catalog);
|
|
78
|
+
}
|
|
79
|
+
return catalogs;
|
|
80
|
+
}
|
|
81
|
+
async function getTranslator(language) {
|
|
82
|
+
const messages = await getCatalog(language);
|
|
83
|
+
return createTranslator({ language, messages });
|
|
84
|
+
}
|
|
85
|
+
async function prefetch(languages) {
|
|
86
|
+
if (!languages) {
|
|
87
|
+
await getCatalogs();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
await Promise.all(languages.map((language) => getCatalog(language)));
|
|
91
|
+
}
|
|
92
|
+
function invalidate(language) {
|
|
93
|
+
if (language) cache.delete(language);
|
|
94
|
+
else cache.clear();
|
|
95
|
+
}
|
|
96
|
+
return { getCatalog, getCatalogs, getTranslator, prefetch, invalidate };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export { TranslationApiError, createClient };
|
|
100
|
+
//# sourceMappingURL=index.js.map
|
|
101
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nckdv/translation-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.cjs",
|
|
6
|
+
"module": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"require": "./dist/index.cjs"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup",
|
|
20
|
+
"dev": "tsup --watch",
|
|
21
|
+
"typecheck": "tsc --noEmit"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@nckdv/translation-core": "^0.1.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"tsup": "^8.3.5",
|
|
28
|
+
"typescript": "^5.7.2"
|
|
29
|
+
},
|
|
30
|
+
"description": "Client SDK for the nck-translation API: catalog fetching, caching and single-flight.",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/NiklasErath/nck-translation.git",
|
|
38
|
+
"directory": "packages/sdk"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/NiklasErath/nck-translation#readme",
|
|
41
|
+
"keywords": [
|
|
42
|
+
"i18n",
|
|
43
|
+
"translation",
|
|
44
|
+
"sdk",
|
|
45
|
+
"internationalization",
|
|
46
|
+
"localization"
|
|
47
|
+
]
|
|
48
|
+
}
|