@rekhu/core 0.1.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 +19 -0
- package/README.md +47 -1
- package/package.json +1 -1
- package/src/index.js +152 -38
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
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.
|
|
16
|
+
## 0.2.0 · 6 November 2026
|
|
17
|
+
|
|
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`.
|
|
19
|
+
- **ai_enhance:** `seshat().enhance(picture, modes, options, extra)` improves one picture without a check:
|
|
20
|
+
- the modes are upscale to a print size, extend, denoise, sharpen, restore, remove_background, replace_background, remove_object, vectorize and colour_correct;
|
|
21
|
+
- large pictures run as a job: `poll(jobId)`, or a webhook checked with `verifyWebhook()`;
|
|
22
|
+
- `enhancePreview(fileId)` gives a before / after picture, and `upload()` keeps a mask or background picture.
|
|
23
|
+
|
|
5
24
|
## 0.1.0 · 6 November 2026
|
|
6
25
|
|
|
7
26
|
- **Rekhu Core for Node.js.** Rekhu's knowers in a Node.js back end, with the app's own tenants (`tenant`), languages (`locale`), limits a day per tenant and user (`limits`, `limitsFor`), its own price per call (`price`) and every call kept in a ledger (`memoryLedger`, `fileLedger`, or the app's own) and passed to `onUsage`.
|
package/README.md
CHANGED
|
@@ -54,6 +54,50 @@ 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
|
+
|
|
65
|
+
## Seshat AI: one picture improved (ai_enhance)
|
|
66
|
+
|
|
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.
|
|
68
|
+
|
|
69
|
+
| Mode | What it does | Options |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `upscale` | More pixels, up to 4×: to a resolution at a print size | `ppi`, `width_mm`, `height_mm`, or `factor` |
|
|
72
|
+
| `extend` | Grows the picture beyond its edges (bleed); a little per side on CPU | `all_mm`, or `top_mm` `right_mm` `bottom_mm` `left_mm` (or `_px`) |
|
|
73
|
+
| `denoise`, `sharpen` | Cleans JPEG artefacts and noise; crisper edges | `strength`, `amount` |
|
|
74
|
+
| `restore` | An old or scanned photo: scratches filled, noise and faded colour corrected | |
|
|
75
|
+
| `remove_background` | Transparent PNG, or a solid colour | `background: '#ffffff'` |
|
|
76
|
+
| `replace_background` | A new background from a picture you send | `background_file_id` (from `upload`) |
|
|
77
|
+
| `remove_object` | Fills an object or blemish from its surroundings | `region: {x, y, w, h}`, or `mask_file_id` |
|
|
78
|
+
| `vectorize` | A logo or line drawing as SVG. A photo is refused (`not_vectorizable`) | |
|
|
79
|
+
| `colour_correct` | Exposure and white balance | |
|
|
80
|
+
|
|
81
|
+
Every answer says what it invented in `warnings` (an edge, a filled area, a reconstructed detail), with a `confidence` from 0 to 1. RGB stays RGB, and CMYK stays CMYK unless the mode needs otherwise. Files are kept a day and never used for training. A large picture runs as a job: poll it, or give a webhook URL. The webhook is signed `x-rekhu-signature: t=…,v1=…` with the `webhook_secret` the call answered. Each mode is priced on its own, and `meta.usage` says per item what it cost.
|
|
82
|
+
|
|
83
|
+
Limits:
|
|
84
|
+
- at most 40 megapixels in and 120 megapixels out;
|
|
85
|
+
- upscaling at most 4×;
|
|
86
|
+
- extending at most 12 % of a side on CPU;
|
|
87
|
+
- removing at most a quarter of the picture.
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
const s = rekhu.seshat({ tenant: { id: tenant.uuid, name: tenant.name } });
|
|
91
|
+
await s.modules(); // { modules: { preflight: false, ai_enhance: true }, modes: [...] }
|
|
92
|
+
const r = await s.enhance(photoBuffer, ['denoise', 'upscale'], { ppi: 300, width_mm: 100, height_mm: 70 }, { name: 'photo.png', webhookUrl: 'https://shop.example.com/hooks/rekhu' });
|
|
93
|
+
const { image } = await s.enhancePreview(r.file_id); // before | after, a data: URL
|
|
94
|
+
const png = await s.download(r.file_id); // a Buffer
|
|
95
|
+
await s.poll('j_…'); // for a job
|
|
96
|
+
|
|
97
|
+
import { verifyWebhook } from '@rekhu/core';
|
|
98
|
+
if (!verifyWebhook(rawBody, req.headers['x-rekhu-signature'], storedSecret)) return res.status(401).end();
|
|
99
|
+
```
|
|
100
|
+
|
|
57
101
|
## Rekhu Machines
|
|
58
102
|
|
|
59
103
|
```js
|
|
@@ -99,5 +143,7 @@ The knowers and this package are the same in both.
|
|
|
99
143
|
|
|
100
144
|
## Errors
|
|
101
145
|
|
|
102
|
-
- `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.
|
|
103
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,10 +6,26 @@
|
|
|
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, 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';
|
|
9
13
|
import { memoryLedger, fileLedger, usage } from './ledger.js';
|
|
10
14
|
|
|
11
15
|
export { memoryLedger, fileLedger, usage };
|
|
12
|
-
export const VERSION = '0.
|
|
16
|
+
export const VERSION = '0.3.0';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether a webhook Rekhu sent is genuine: the x-rekhu-signature header (t=…,v1=…) over the raw body, with the
|
|
20
|
+
* webhook_secret the call answered, and not older than `tolerance` seconds.
|
|
21
|
+
*/
|
|
22
|
+
export function verifyWebhook(body, signature, secret, tolerance = 300) {
|
|
23
|
+
const m = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(String(signature || ''));
|
|
24
|
+
if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > tolerance) return false;
|
|
25
|
+
const expected = createHmac('sha256', secret).update(`${m[1]}.${body}`).digest();
|
|
26
|
+
const given = Buffer.from(m[2], 'hex');
|
|
27
|
+
return given.length === expected.length && timingSafeEqual(given, expected);
|
|
28
|
+
}
|
|
13
29
|
|
|
14
30
|
/** A knower could not answer: Rekhu's status (0 when the call was not made) and its error type. */
|
|
15
31
|
export class RekhuError extends Error {
|
|
@@ -21,6 +37,39 @@ export class RekhuError extends Error {
|
|
|
21
37
|
}
|
|
22
38
|
}
|
|
23
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
|
+
|
|
24
73
|
/** One of the app's own limits a day is reached (scope: tenant or user); nothing was sent to Rekhu. */
|
|
25
74
|
export class LimitReached extends RekhuError {
|
|
26
75
|
constructor(message, scope) {
|
|
@@ -64,7 +113,7 @@ export function createRekhu(options = {}) {
|
|
|
64
113
|
|
|
65
114
|
function key() {
|
|
66
115
|
const k = parseKey(config.key);
|
|
67
|
-
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');
|
|
68
117
|
return k;
|
|
69
118
|
}
|
|
70
119
|
|
|
@@ -72,7 +121,7 @@ export function createRekhu(options = {}) {
|
|
|
72
121
|
if (named) return { id: String(named.id), name: String(named.name ?? named.id) };
|
|
73
122
|
if (config.tenancy !== 'multi') return { id: config.appId, name: config.appName };
|
|
74
123
|
const t = resolvers.tenant && user ? await resolvers.tenant(user) : null;
|
|
75
|
-
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) };
|
|
76
125
|
if (t !== null && t !== undefined && t !== '' && typeof t !== 'object') return { id: String(t), name: String(t) };
|
|
77
126
|
return null;
|
|
78
127
|
}
|
|
@@ -96,32 +145,55 @@ export function createRekhu(options = {}) {
|
|
|
96
145
|
|
|
97
146
|
async function failed(response) {
|
|
98
147
|
const error = await response.json().catch(() => ({}));
|
|
99
|
-
return
|
|
148
|
+
return errorFor(response.status, error?.error?.type || 'api_error', error?.error?.message || `Rekhu answered ${response.status}`);
|
|
100
149
|
}
|
|
101
150
|
|
|
102
|
-
/**
|
|
103
|
-
|
|
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) {
|
|
104
176
|
const k = key();
|
|
105
|
-
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');
|
|
106
177
|
const tenant = await tenantOf(user, named);
|
|
107
|
-
if (config.tenancy === 'multi' && !tenant && action !== 'models') 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');
|
|
108
178
|
const who = userId ?? (user ? (options.userId ? options.userId(user) : user.id) : null);
|
|
109
|
-
|
|
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);
|
|
110
188
|
const body = ['check', 'explain'].includes(action) && input.language === undefined ? { ...input, language: await localeOf(user) } : input;
|
|
111
|
-
|
|
189
|
+
if (!FREE.includes(action)) headers['idempotency-key'] = randomUUID();
|
|
112
190
|
const started = Date.now();
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
: 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) });
|
|
118
|
-
} catch (error) {
|
|
119
|
-
if (error instanceof RekhuError) throw error;
|
|
120
|
-
throw new RekhuError('Rekhu could not be reached; try again in a moment.', 503, 'connection_error');
|
|
121
|
-
}
|
|
122
|
-
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) })));
|
|
123
195
|
const answer = await response.json();
|
|
124
|
-
if (action
|
|
196
|
+
if (FREE.includes(action) || answer.meta?.replayed) return answer;
|
|
125
197
|
const meta = answer.meta || {};
|
|
126
198
|
const row = {
|
|
127
199
|
tenant_id: tenant?.id ?? null, tenant_name: tenant?.name ?? null, user_id: who != null ? String(who) : null, knower, action, ref: answer.ref ?? null,
|
|
@@ -133,21 +205,33 @@ export function createRekhu(options = {}) {
|
|
|
133
205
|
return answer;
|
|
134
206
|
}
|
|
135
207
|
|
|
136
|
-
async function feedback(knower, ref, outcome, correction = null) {
|
|
137
|
-
const
|
|
138
|
-
const response = await config.fetch(`${platform()}/v1/knowers/${knower}/feedback`, { method: 'POST', headers: { 'content-type': 'application/json'
|
|
139
|
-
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 } : {}) }) }));
|
|
140
211
|
return response.json();
|
|
141
212
|
}
|
|
142
213
|
|
|
143
|
-
async function
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
if (!response.ok) throw await failed(response);
|
|
147
|
-
return Buffer.from(await response.arrayBuffer());
|
|
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) }));
|
|
148
217
|
}
|
|
149
218
|
|
|
150
|
-
|
|
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);
|
|
232
|
+
}
|
|
233
|
+
|
|
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) });
|
|
151
235
|
|
|
152
236
|
/** Rekhu Machines: printing and finishing machines, sent as a Rekhu machine profile (see the README). */
|
|
153
237
|
const machines = (who = {}) => {
|
|
@@ -163,20 +247,50 @@ export function createRekhu(options = {}) {
|
|
|
163
247
|
};
|
|
164
248
|
};
|
|
165
249
|
|
|
166
|
-
|
|
250
|
+
const bytesOf = async (file) => (file instanceof Uint8Array ? file : file instanceof ArrayBuffer ? new Uint8Array(file) : typeof file?.arrayBuffer === 'function' ? new Uint8Array(await file.arrayBuffer()) : null);
|
|
251
|
+
|
|
252
|
+
/** Seshat: a print file against the order it is for; fix, preview, download. With ai_enhance: one picture improved. */
|
|
167
253
|
const seshat = (who = {}) => {
|
|
168
254
|
const k = knowerFor('seshat')(who);
|
|
255
|
+
const file = async (f, name, what) => {
|
|
256
|
+
if (typeof f === 'string' && existsSync(f)) return { name: name || f.split(/[\\/]/).pop(), path: f };
|
|
257
|
+
if (!name) throw new RekhuError(`Give the file's name: ${what}(bytes, { name: 'flyer.pdf' }).`, 0, 'invalid_request_error');
|
|
258
|
+
const bytes = await bytesOf(f);
|
|
259
|
+
if (!bytes) throw new RekhuError('The file is a Buffer, Uint8Array, ArrayBuffer or Blob.', 0, 'invalid_request_error');
|
|
260
|
+
return { name, bytes };
|
|
261
|
+
};
|
|
169
262
|
return {
|
|
170
263
|
...k,
|
|
171
|
-
check: async (
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
264
|
+
check: async (f, { name, order = {}, explain = false } = {}) => k.call('check', { order, ...(explain ? { explain: true } : {}) }, { file: await file(f, name, 'check') }),
|
|
265
|
+
/** Which modules this application may use (preflight, ai_enhance) and the modes. Not charged. */
|
|
266
|
+
modules: () => k.call('modules', {}),
|
|
267
|
+
/** A file kept for a day (a mask, a background picture): its file_id. Not charged. */
|
|
268
|
+
upload: async (f, { name } = {}) => k.call('upload', {}, { file: await file(f, name, 'upload') }),
|
|
269
|
+
/**
|
|
270
|
+
* ai_enhance: one picture (bytes with a name, or a file_id) through the modes, in order. options: ppi, width_mm,
|
|
271
|
+
* height_mm, factor, all_mm…, background, region; extra: async, webhookUrl, maskFileId, backgroundFileId.
|
|
272
|
+
* A large picture answers { status: 'queued', job_id }: poll() it, or verify the webhook with verifyWebhook().
|
|
273
|
+
*/
|
|
274
|
+
enhance: async (picture, modes, options = {}, extra = {}) => {
|
|
275
|
+
const input = {
|
|
276
|
+
mode: [].concat(modes), options,
|
|
277
|
+
...(extra.async ? { async: true } : {}), ...(extra.webhookUrl ? { webhook_url: extra.webhookUrl } : {}),
|
|
278
|
+
...(extra.maskFileId ? { mask_file_id: extra.maskFileId } : {}), ...(extra.backgroundFileId ? { background_file_id: extra.backgroundFileId } : {}),
|
|
279
|
+
};
|
|
280
|
+
if (typeof picture === 'string' && /^f_[a-z0-9]{24}$/.test(picture)) return k.call('enhance', { ...input, file_id: picture });
|
|
281
|
+
return k.call('enhance', input, { file: await file(picture, extra.name, 'enhance') });
|
|
176
282
|
},
|
|
283
|
+
enhancePreview: (fileId, { longest = 900 } = {}) => k.call('enhance_preview', { file_id: fileId, longest }),
|
|
284
|
+
poll: (jobId) => k.call('poll', { job_id: jobId }),
|
|
177
285
|
fix: (fileId, fixes = 'auto', order = {}) => k.call('fix', { file_id: fileId, fixes, order }),
|
|
178
286
|
preview: (fileId, { page = 1, longest = 1200 } = {}) => k.call('preview', { file_id: fileId, page, longest }),
|
|
179
|
-
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', {}),
|
|
180
294
|
};
|
|
181
295
|
};
|
|
182
296
|
|