@rekhu/core 0.2.0 → 0.3.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/CHANGELOG.md +11 -0
- package/README.md +11 -1
- package/package.json +1 -1
- package/src/index.js +111 -34
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@rekhu/core`. Versions follow [semantic versioning](https://semver.org).
|
|
4
4
|
|
|
5
|
+
|
|
6
|
+
## 0.3.0 · 9 November 2026
|
|
7
|
+
|
|
8
|
+
In parity with rekhu/core 0.3.0 for Laravel:
|
|
9
|
+
|
|
10
|
+
- **Typed errors** under `RekhuError`: `InvalidKey`, `ModuleNotEnabled`, `RegionUnavailable`, `CreditExhausted`, `AppPaused`, `FileNotFound`, `InvalidRequest`, `RateLimited`, `Unavailable`, and `errorFor()`.
|
|
11
|
+
- **Retries without double charges:** a dropped connection, 429, 502 or 503 is tried again up to three times; charged calls carry an `idempotency-key`, and a replayed answer is not kept in the ledger twice.
|
|
12
|
+
- **Large files streamed** from a path; `downloadTo(fileId, path)`; `remove(fileId)`.
|
|
13
|
+
- **File names in any language** arrive whole.
|
|
14
|
+
- **Seshat:** `codes()`, structured `options` on `ask` findings, unknown fixes refused and not charged, side effects in `applied`, `resolved`.
|
|
15
|
+
- `download()` and `feedback()` send the tenant and user; `knowers` is optional; no account offer in error messages.
|
|
5
16
|
## 0.2.0 · 6 November 2026
|
|
6
17
|
|
|
7
18
|
- **Seshat in modules:** `seshat().modules()` says which modules this application may use (`preflight`, `ai_enhance`); a call to a module that is off is refused with `module_not_enabled`.
|
package/README.md
CHANGED
|
@@ -54,6 +54,14 @@ It fixes:
|
|
|
54
54
|
- CMYK colours and embedded fonts;
|
|
55
55
|
- pictures, made into print PDFs.
|
|
56
56
|
|
|
57
|
+
**Every code, decision and fix in one list:** `await seshat.codes()` (free) answers the versioned list to map your own tool's names against. Every `ask` finding carries `options` (`upload_new`, `print_as_is`, `fix:<fix>[:<option>]`), so you can offer the choice without `explain`.
|
|
58
|
+
|
|
59
|
+
**Fixes only, your own check:** `upload()` the file (free), then `fix(fileId, [{ fix: 'add_bleed' }, { fix: 'convert_cmyk' }], order)`. An unknown fix or option is refused (`InvalidRequest`) and not charged. `applied` lists every change, side effects too (`side_effect_of`), and `resolved` the codes the file no longer has.
|
|
60
|
+
|
|
61
|
+
**Large files:** pass a path instead of a Buffer and the file is streamed; `downloadTo(fileId, path)` writes the result straight to disk; `remove(fileId)` deletes a kept file at once. File names keep every character.
|
|
62
|
+
|
|
63
|
+
**What it costs:** `meta.usage` has one line per charged item: the check (`seshat`) and the explanation (`seshat_explain`). A refused call, or a fix with nothing to do, costs nothing.
|
|
64
|
+
|
|
57
65
|
## Seshat AI: one picture improved (ai_enhance)
|
|
58
66
|
|
|
59
67
|
For a platform with its own preflight that only wants Seshat's image work, or next to the preflight. The module is switched on per application by Rekhu; `modules()` tells which ones this key may use.
|
|
@@ -135,5 +143,7 @@ The knowers and this package are the same in both.
|
|
|
135
143
|
|
|
136
144
|
## Errors
|
|
137
145
|
|
|
138
|
-
- `RekhuError`
|
|
146
|
+
- Every refusal is a `RekhuError` with `status` and `type`, and its own class: `InvalidKey` (401, also a key from another Rekhu platform), `ModuleNotEnabled` and `RegionUnavailable` (403), `CreditExhausted` and `AppPaused` (402), `FileNotFound` (404), `InvalidRequest` (422, not charged), `RateLimited` and `Unavailable` (after retries).
|
|
147
|
+
- A dropped connection, 429, 502 or 503 is tried again up to three times. Charged calls carry an `idempotency-key`, so a retry is never charged twice (`meta.replayed`).
|
|
148
|
+
- `knowers` (or `REKHU_KNOWERS`) is optional: empty means every knower the key allows.
|
|
139
149
|
- `LimitReached` (429) carries `scope: 'tenant' | 'user'`; nothing is sent to Rekhu.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rekhu/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Rekhu Core for Node.js: Rekhu's knowers in your app (Seshat for print files, Rekhu Machines for printing machines), with your own tenants, languages, limits and a usage ledger per customer.",
|
|
5
5
|
"keywords": ["ai", "print", "printing", "preflight", "pdf", "machines", "multi-tenant", "rekhu"],
|
|
6
6
|
"homepage": "https://rekhu.com",
|
package/src/index.js
CHANGED
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
// await rekhu.machines({ user }).identify('Heidelberg Speedmaster SM 74-4 with coater');
|
|
7
7
|
// await rekhu.seshat({ user }).check(pdfBuffer, { name: 'flyer.pdf', order: { width_mm: 148, height_mm: 210 } });
|
|
8
8
|
|
|
9
|
-
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
9
|
+
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
10
|
+
import { createWriteStream, existsSync, openAsBlob } from 'node:fs';
|
|
11
|
+
import { Readable } from 'node:stream';
|
|
12
|
+
import { pipeline } from 'node:stream/promises';
|
|
10
13
|
import { memoryLedger, fileLedger, usage } from './ledger.js';
|
|
11
14
|
|
|
12
15
|
export { memoryLedger, fileLedger, usage };
|
|
13
|
-
export const VERSION = '0.
|
|
16
|
+
export const VERSION = '0.3.0';
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* Whether a webhook Rekhu sent is genuine: the x-rekhu-signature header (t=…,v1=…) over the raw body, with the
|
|
@@ -34,6 +37,39 @@ export class RekhuError extends Error {
|
|
|
34
37
|
}
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
/** The key is missing, revoked, or belongs to another Rekhu platform (401). */
|
|
41
|
+
export class InvalidKey extends RekhuError {}
|
|
42
|
+
/** The knower's module is not switched on for this application (403 module_not_enabled). */
|
|
43
|
+
export class ModuleNotEnabled extends RekhuError {}
|
|
44
|
+
/** The account processes data in Europe only and this call has no engine in the EU (403 region_unavailable). */
|
|
45
|
+
export class RegionUnavailable extends RekhuError {}
|
|
46
|
+
/** The Rekhu account's credit is used up (402). */
|
|
47
|
+
export class CreditExhausted extends RekhuError {}
|
|
48
|
+
/** The application is paused or at its monthly limit in the Rekhu account (402 app_limit). */
|
|
49
|
+
export class AppPaused extends RekhuError {}
|
|
50
|
+
/** A kept file is past its day, removed, or of another account (404). */
|
|
51
|
+
export class FileNotFound extends RekhuError {}
|
|
52
|
+
/** Refused before anything ran, with what was wrong (422); nothing was charged. */
|
|
53
|
+
export class InvalidRequest extends RekhuError {}
|
|
54
|
+
/** Too many calls at once, after the client tried again (429). */
|
|
55
|
+
export class RateLimited extends RekhuError {}
|
|
56
|
+
/** Rekhu or a service behind it could not answer, after the client tried again (502, 503, 529). */
|
|
57
|
+
export class Unavailable extends RekhuError {}
|
|
58
|
+
|
|
59
|
+
/** The typed error for an answer from Rekhu: catch the class you handle, or RekhuError for all of them. */
|
|
60
|
+
export function errorFor(status, type, message) {
|
|
61
|
+
const Type = status === 401 ? InvalidKey : type === 'module_not_enabled' ? ModuleNotEnabled : type === 'region_unavailable' ? RegionUnavailable
|
|
62
|
+
: type === 'app_limit' ? AppPaused : status === 402 ? CreditExhausted : status === 404 ? FileNotFound : status === 422 || status === 400 ? InvalidRequest
|
|
63
|
+
: status === 429 ? RateLimited : status >= 500 ? Unavailable : RekhuError;
|
|
64
|
+
const error = new Type(message, status, type);
|
|
65
|
+
error.name = Type.name;
|
|
66
|
+
return error;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const FREE = ['models', 'modules', 'codes', 'upload', 'poll', 'enhance_preview'];
|
|
70
|
+
const RETRY = [429, 502, 503, 529];
|
|
71
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
72
|
+
|
|
37
73
|
/** One of the app's own limits a day is reached (scope: tenant or user); nothing was sent to Rekhu. */
|
|
38
74
|
export class LimitReached extends RekhuError {
|
|
39
75
|
constructor(message, scope) {
|
|
@@ -77,7 +113,7 @@ export function createRekhu(options = {}) {
|
|
|
77
113
|
|
|
78
114
|
function key() {
|
|
79
115
|
const k = parseKey(config.key);
|
|
80
|
-
if (!k) throw new
|
|
116
|
+
if (!k) throw new InvalidKey('REKHU_APP_KEY is missing or not a Rekhu app key (agk_…): put the app key from your Rekhu account (Projects → the app → Connection) in the environment.', 0, 'configuration_error');
|
|
81
117
|
return k;
|
|
82
118
|
}
|
|
83
119
|
|
|
@@ -85,7 +121,7 @@ export function createRekhu(options = {}) {
|
|
|
85
121
|
if (named) return { id: String(named.id), name: String(named.name ?? named.id) };
|
|
86
122
|
if (config.tenancy !== 'multi') return { id: config.appId, name: config.appName };
|
|
87
123
|
const t = resolvers.tenant && user ? await resolvers.tenant(user) : null;
|
|
88
|
-
if (t && typeof t === 'object' && t.id !== undefined && t.id !== null && t.id !== '') return { id: String(t.id), name: String(t.name ?? t.id)
|
|
124
|
+
if (t && typeof t === 'object' && t.id !== undefined && t.id !== null && t.id !== '') return { ...t, id: String(t.id), name: String(t.name ?? t.id) };
|
|
89
125
|
if (t !== null && t !== undefined && t !== '' && typeof t !== 'object') return { id: String(t), name: String(t) };
|
|
90
126
|
return null;
|
|
91
127
|
}
|
|
@@ -109,33 +145,55 @@ export function createRekhu(options = {}) {
|
|
|
109
145
|
|
|
110
146
|
async function failed(response) {
|
|
111
147
|
const error = await response.json().catch(() => ({}));
|
|
112
|
-
return
|
|
148
|
+
return errorFor(response.status, error?.error?.type || 'api_error', error?.error?.message || `Rekhu answered ${response.status}`);
|
|
113
149
|
}
|
|
114
150
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
151
|
+
/**
|
|
152
|
+
* One request, tried again on a dropped connection or a 429, 502, 503 or 529, waiting a little longer each time (and
|
|
153
|
+
* as long as retry-after asks, up to 5 seconds); any other failure is the typed error for it.
|
|
154
|
+
*/
|
|
155
|
+
async function send(make) {
|
|
156
|
+
for (let attempt = 1; ; attempt++) {
|
|
157
|
+
let response;
|
|
158
|
+
try {
|
|
159
|
+
response = await make();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (error instanceof RekhuError) throw error;
|
|
162
|
+
if (attempt >= 3) throw new Unavailable('Rekhu could not be reached; try again in a moment.', 503, 'connection_error');
|
|
163
|
+
await pause(250 * 2 ** attempt);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (response.ok) return response;
|
|
167
|
+
if (attempt < 3 && RETRY.includes(response.status)) {
|
|
168
|
+
await pause(Math.min(5000, Number(response.headers?.get?.('retry-after')) * 1000 || 250 * 2 ** attempt));
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
throw await failed(response);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function headersFor(user, userId, named) {
|
|
117
176
|
const k = key();
|
|
118
|
-
if (!config.knowers.includes(knower)) throw new RekhuError(`The knower ${knower} is not installed in this app: add it to knowers (or REKHU_KNOWERS).`, 0, 'configuration_error');
|
|
119
177
|
const tenant = await tenantOf(user, named);
|
|
120
|
-
if (config.tenancy === 'multi' && !tenant && !['models', 'modules'].includes(action)) throw new RekhuError('This app is multi-tenant, and no tenant was found for the user: give createRekhu a tenant function, or pass a tenant.', 0, 'configuration_error');
|
|
121
178
|
const who = userId ?? (user ? (options.userId ? options.userId(user) : user.id) : null);
|
|
122
|
-
|
|
179
|
+
return { tenant, who, headers: { accept: 'application/json', 'x-api-key': k.key, 'x-agent': k.agent, 'x-tenant': String(tenant?.id ?? config.appId), 'x-tenant-name': String(tenant?.name ?? config.appName), ...(who != null ? { 'x-user': String(who) } : {}) } };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** One call to a knower for a user (or a named tenant): the app's limits, the call, the ledger and onUsage. */
|
|
183
|
+
async function call(knower, action, input = {}, { user = null, userId = null, tenant: named = null, file = null } = {}) {
|
|
184
|
+
if (config.knowers.length && !config.knowers.includes(knower)) throw new RekhuError(`The knower ${knower} is not in this app's knowers (REKHU_KNOWERS): add it, or leave the list empty to use every knower the key allows.`, 0, 'configuration_error');
|
|
185
|
+
const { tenant, who, headers } = await headersFor(user, userId, named);
|
|
186
|
+
if (config.tenancy === 'multi' && !tenant && !FREE.includes(action)) throw new RekhuError('This app is multi-tenant, and no tenant was found for the user: give createRekhu a tenant function, or pass a tenant.', 0, 'configuration_error');
|
|
187
|
+
if (!FREE.includes(action)) await checkLimits(tenant, who, knower);
|
|
123
188
|
const body = ['check', 'explain'].includes(action) && input.language === undefined ? { ...input, language: await localeOf(user) } : input;
|
|
124
|
-
|
|
189
|
+
if (!FREE.includes(action)) headers['idempotency-key'] = randomUUID();
|
|
125
190
|
const started = Date.now();
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
: await config.fetch(`${platform()}/v1/knowers/${knower}/${action}`, { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(config.timeout) });
|
|
131
|
-
} catch (error) {
|
|
132
|
-
if (error instanceof RekhuError) throw error;
|
|
133
|
-
throw new RekhuError('Rekhu could not be reached; try again in a moment.', 503, 'connection_error');
|
|
134
|
-
}
|
|
135
|
-
if (!response.ok) throw await failed(response);
|
|
191
|
+
const url = `${platform()}/v1/knowers/${knower}/${action}`;
|
|
192
|
+
const response = await send(async () => (file
|
|
193
|
+
? config.fetch(url, { method: 'POST', headers: { ...headers, 'content-type': 'application/octet-stream', 'x-file-name': encodeURIComponent(file.name), 'x-knower-input': JSON.stringify({ ...body, name: file.name }) }, body: file.path ? await openAsBlob(file.path) : file.bytes, signal: AbortSignal.timeout(config.timeout) })
|
|
194
|
+
: config.fetch(url, { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(config.timeout) })));
|
|
136
195
|
const answer = await response.json();
|
|
137
|
-
|
|
138
|
-
if (['models', 'modules', 'upload', 'poll', 'enhance_preview'].includes(action)) return answer;
|
|
196
|
+
if (FREE.includes(action) || answer.meta?.replayed) return answer;
|
|
139
197
|
const meta = answer.meta || {};
|
|
140
198
|
const row = {
|
|
141
199
|
tenant_id: tenant?.id ?? null, tenant_name: tenant?.name ?? null, user_id: who != null ? String(who) : null, knower, action, ref: answer.ref ?? null,
|
|
@@ -147,21 +205,33 @@ export function createRekhu(options = {}) {
|
|
|
147
205
|
return answer;
|
|
148
206
|
}
|
|
149
207
|
|
|
150
|
-
async function feedback(knower, ref, outcome, correction = null) {
|
|
151
|
-
const
|
|
152
|
-
const response = await config.fetch(`${platform()}/v1/knowers/${knower}/feedback`, { method: 'POST', headers: { 'content-type': 'application/json'
|
|
153
|
-
if (!response.ok) throw await failed(response);
|
|
208
|
+
async function feedback(knower, ref, outcome, correction = null, who = {}) {
|
|
209
|
+
const { headers } = await headersFor(who.user ?? null, who.userId ?? null, who.tenant ?? null);
|
|
210
|
+
const response = await send(() => config.fetch(`${platform()}/v1/knowers/${knower}/feedback`, { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify({ ref, outcome, ...(correction ? { correction } : {}) }) }));
|
|
154
211
|
return response.json();
|
|
155
212
|
}
|
|
156
213
|
|
|
157
|
-
async function
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
214
|
+
async function fileRequest(knower, fileId, method, who = {}) {
|
|
215
|
+
const { headers } = await headersFor(who.user ?? null, who.userId ?? null, who.tenant ?? null);
|
|
216
|
+
return send(() => config.fetch(`${platform()}/v1/knowers/${knower}/files/${encodeURIComponent(fileId)}`, { method, headers, signal: AbortSignal.timeout(config.timeout) }));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function download(knower, fileId, who = {}) {
|
|
220
|
+
return Buffer.from(await (await fileRequest(knower, fileId, 'GET', who)).arrayBuffer());
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A result written straight to a path, without holding a large file in memory. */
|
|
224
|
+
async function downloadTo(knower, fileId, path, who = {}) {
|
|
225
|
+
const response = await fileRequest(knower, fileId, 'GET', who);
|
|
226
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(path));
|
|
227
|
+
return path;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function remove(knower, fileId, who = {}) {
|
|
231
|
+
await fileRequest(knower, fileId, 'DELETE', who);
|
|
162
232
|
}
|
|
163
233
|
|
|
164
|
-
const knowerFor = (name) => (who = {}) => ({ call: (action, input, extra = {}) => call(name, action, input, { ...who, ...extra }), feedback: (ref, outcome, correction) => feedback(name, ref, outcome, correction) });
|
|
234
|
+
const knowerFor = (name) => (who = {}) => ({ call: (action, input, extra = {}) => call(name, action, input, { ...who, ...extra }), feedback: (ref, outcome, correction) => feedback(name, ref, outcome, correction, who) });
|
|
165
235
|
|
|
166
236
|
/** Rekhu Machines: printing and finishing machines, sent as a Rekhu machine profile (see the README). */
|
|
167
237
|
const machines = (who = {}) => {
|
|
@@ -183,6 +253,7 @@ export function createRekhu(options = {}) {
|
|
|
183
253
|
const seshat = (who = {}) => {
|
|
184
254
|
const k = knowerFor('seshat')(who);
|
|
185
255
|
const file = async (f, name, what) => {
|
|
256
|
+
if (typeof f === 'string' && existsSync(f)) return { name: name || f.split(/[\\/]/).pop(), path: f };
|
|
186
257
|
if (!name) throw new RekhuError(`Give the file's name: ${what}(bytes, { name: 'flyer.pdf' }).`, 0, 'invalid_request_error');
|
|
187
258
|
const bytes = await bytesOf(f);
|
|
188
259
|
if (!bytes) throw new RekhuError('The file is a Buffer, Uint8Array, ArrayBuffer or Blob.', 0, 'invalid_request_error');
|
|
@@ -213,7 +284,13 @@ export function createRekhu(options = {}) {
|
|
|
213
284
|
poll: (jobId) => k.call('poll', { job_id: jobId }),
|
|
214
285
|
fix: (fileId, fixes = 'auto', order = {}) => k.call('fix', { file_id: fileId, fixes, order }),
|
|
215
286
|
preview: (fileId, { page = 1, longest = 1200 } = {}) => k.call('preview', { file_id: fileId, page, longest }),
|
|
216
|
-
download: (fileId) => download('seshat', fileId),
|
|
287
|
+
download: (fileId) => download('seshat', fileId, who),
|
|
288
|
+
/** The same, written straight to a path: for large print files. */
|
|
289
|
+
downloadTo: (fileId, path) => downloadTo('seshat', fileId, path, who),
|
|
290
|
+
/** A kept file removed at once: a customer took it out of their order. */
|
|
291
|
+
remove: (fileId) => remove('seshat', fileId, who),
|
|
292
|
+
/** Every finding code, decision and fix with its options, versioned: the list to map against. Not charged. */
|
|
293
|
+
codes: () => k.call('codes', {}),
|
|
217
294
|
};
|
|
218
295
|
};
|
|
219
296
|
|