uvpdftool 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 UV ResearchLabs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,54 @@
1
- # Temporary Holding Version
1
+ # uvpdftool
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The [UV pdfTool API](https://uvpdftool.com/developers) from Node.js 18 and later: compress,
4
+ convert, OCR and check PDFs on UV pdfTool's servers. No dependencies; ES modules; types
5
+ included.
6
+
7
+ ```sh
8
+ npm install uvpdftool
9
+ ```
10
+
11
+ ```js
12
+ import { readFile, writeFile } from 'node:fs/promises';
13
+ import { UvPdfTool, JobFailedError } from 'uvpdftool';
14
+
15
+ const client = new UvPdfTool({ apiKey: process.env.UVPDF_KEY });
16
+
17
+ // Creates the job, uploads the file, starts it and waits for it.
18
+ const job = await client.run({
19
+ tool: 'compress',
20
+ options: { method: 'ghostscript', preset: 'ebook' },
21
+ files: [
22
+ { name: 'report.pdf', data: await readFile('report.pdf'), contentType: 'application/pdf' },
23
+ ],
24
+ });
25
+
26
+ for (const output of job.outputs) {
27
+ await writeFile(output.name, await client.download(output));
28
+ }
29
+ ```
30
+
31
+ Make a key on the [API keys page](https://uvpdftool.com/developers/keys); a test key is free
32
+ and marks its results. Keep the key on your server, never in a browser or an app.
33
+
34
+ ## What it does
35
+
36
+ | Call | Does |
37
+ | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
38
+ | `new UvPdfTool({ apiKey, baseUrl? })` | The client. |
39
+ | `client.run({ tool, options?, files, retentionHours?, onProgress?, timeoutMs? })` | Create, upload, start, wait; resolves with the finished job or throws `JobFailedError`. |
40
+ | `client.download(output)` | The bytes of one output of a finished job. |
41
+ | `client.jobs.create(request)` | `POST /jobs`: the job and an upload link per file. |
42
+ | `client.jobs.upload(upload, data)` | `PUT` the bytes to one link. |
43
+ | `client.jobs.start(id)`, `.get(id)`, `.cancel(id)` | The three job calls. |
44
+ | `client.jobs.wait(id, { intervalMs?, timeoutMs?, onProgress?, signal? })` | Polls until the job ends. |
45
+ | `client.tools()`, `client.account()` | `GET /tools`, `GET /account`. |
46
+ | `verifyWebhook(rawBody, signatureHeader, secret)` | True when a webhook delivery is genuine and fresh. |
47
+
48
+ A refused request throws `ApiError` (`status`, `code`, `message`, `details`,
49
+ `retryAfterSeconds`); a job that fails or is cancelled throws `JobFailedError` (`job`,
50
+ with `job.error`). A file is `{ name, data, contentType }` where `data` is a `Buffer`,
51
+ `Uint8Array`, `ArrayBuffer`, `Blob` or a `ReadableStream` with `size`.
52
+
53
+ The tools, their options and the errors are documented at
54
+ <https://uvpdftool.com/developers>.
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The UV pdfTool API from Node.js 18 and later, with nothing to install besides this
3
+ * package: create a job, upload the files, start it, wait for it, download the result, or
4
+ * do all of that with one call. Documentation: https://uvpdftool.com/developers
5
+ */
6
+ export declare const DEFAULT_BASE_URL = "https://uvpdftool.com/api/v1";
7
+ export type JobState = 'created' | 'queued' | 'running' | 'done' | 'failed' | 'cancelled';
8
+ export interface JobError {
9
+ code: string;
10
+ message: string;
11
+ }
12
+ export interface JobOutput {
13
+ name: string;
14
+ size: number;
15
+ contentType: string;
16
+ /** A download link, present once the job is done; good for fifteen minutes. */
17
+ url?: string;
18
+ }
19
+ export interface Job {
20
+ id: string;
21
+ tool: string;
22
+ mode: 'live' | 'test';
23
+ state: JobState;
24
+ progress: number | null;
25
+ error: JobError | null;
26
+ outputs: JobOutput[];
27
+ createdAt: string;
28
+ expiresAt: string;
29
+ }
30
+ export interface Upload {
31
+ name: string;
32
+ method: 'PUT';
33
+ url: string;
34
+ headers: Record<string, string>;
35
+ }
36
+ export interface FileDeclaration {
37
+ name: string;
38
+ size: number;
39
+ contentType: string;
40
+ }
41
+ /** A file to send: its bytes (or a stream of them, with the size), its name and type. */
42
+ export interface FileInput {
43
+ name: string;
44
+ data: Uint8Array | ArrayBuffer | Blob | ReadableStream<Uint8Array>;
45
+ contentType: string;
46
+ /** Needed for a stream; read from the bytes otherwise. */
47
+ size?: number;
48
+ }
49
+ export interface CreateJobRequest {
50
+ tool: string;
51
+ options?: Record<string, unknown>;
52
+ files: FileDeclaration[];
53
+ /** How long the files and results are kept once the job has run, 1 to 24 hours (default 2). */
54
+ retentionHours?: number;
55
+ }
56
+ export interface RunRequest {
57
+ tool: string;
58
+ options?: Record<string, unknown>;
59
+ files: FileInput[];
60
+ retentionHours?: number;
61
+ /** Called with the job's progress, 0 to 1, while it runs. */
62
+ onProgress?: (fraction: number, job: Job) => void;
63
+ /** Give up waiting after this long; the job itself goes on. */
64
+ timeoutMs?: number;
65
+ }
66
+ export interface WaitOptions {
67
+ /** Between two reads of the job; default 2 seconds, growing to 5 after a minute. */
68
+ intervalMs?: number;
69
+ timeoutMs?: number;
70
+ onProgress?: (fraction: number, job: Job) => void;
71
+ signal?: AbortSignal;
72
+ }
73
+ export interface ToolInfo {
74
+ id: string;
75
+ accepts: string[];
76
+ produces: string;
77
+ maxFiles: number;
78
+ maxBytes: {
79
+ live: number;
80
+ test: number;
81
+ };
82
+ timeoutMinutes: number;
83
+ options: Record<string, unknown>;
84
+ }
85
+ export interface Account {
86
+ key: {
87
+ name: string;
88
+ mode: 'live' | 'test';
89
+ prefix: string;
90
+ scopes: string[];
91
+ };
92
+ tasks: number;
93
+ test: {
94
+ left: number;
95
+ perDay: number;
96
+ };
97
+ }
98
+ export interface ClientOptions {
99
+ /** uvp_live_… or uvp_test_… */
100
+ apiKey: string;
101
+ /** Another API address, for tests. */
102
+ baseUrl?: string;
103
+ /** Another fetch, for tests or a proxy. */
104
+ fetch?: typeof fetch;
105
+ /** Sent as the User-Agent after the library's own name. */
106
+ userAgent?: string;
107
+ }
108
+ /** A refused request: the status, the code to switch on, the message to show. */
109
+ export declare class ApiError extends Error {
110
+ readonly status: number;
111
+ readonly code: string;
112
+ readonly details: Record<string, unknown>;
113
+ /** From the Retry-After header of a 429, in seconds. */
114
+ readonly retryAfterSeconds: number | null;
115
+ constructor(status: number, code: string, message: string, details?: Record<string, unknown>, retryAfterSeconds?: number | null);
116
+ }
117
+ /** A job that ended failed or cancelled; `job.error` says why. */
118
+ export declare class JobFailedError extends Error {
119
+ readonly job: Job;
120
+ constructor(job: Job);
121
+ }
122
+ export declare class UvPdfTool {
123
+ readonly jobs: {
124
+ create: (request: CreateJobRequest) => Promise<{
125
+ job: Job;
126
+ uploads: Upload[];
127
+ }>;
128
+ upload: (upload: Upload, data: FileInput['data']) => Promise<void>;
129
+ start: (id: string) => Promise<Job>;
130
+ get: (id: string) => Promise<Job>;
131
+ cancel: (id: string) => Promise<Job>;
132
+ wait: (id: string, options?: WaitOptions) => Promise<Job>;
133
+ };
134
+ private readonly apiKey;
135
+ private readonly baseUrl;
136
+ private readonly fetchFn;
137
+ private readonly userAgent;
138
+ constructor(options: ClientOptions);
139
+ /** The tools the API offers, with their limits and options. */
140
+ tools(): Promise<ToolInfo[]>;
141
+ /** The key in use, the API tasks left, and today's test jobs. */
142
+ account(): Promise<Account>;
143
+ /**
144
+ * Creates the job, uploads every file, starts it and waits for it. Resolves with the
145
+ * finished job, whose outputs carry download links; throws JobFailedError otherwise.
146
+ */
147
+ run(request: RunRequest): Promise<Job>;
148
+ /** The bytes of one output of a finished job. */
149
+ download(output: JobOutput): Promise<Uint8Array>;
150
+ private wait;
151
+ /** PUTs the bytes to the upload link, as they are, trying three times on a passing fault. */
152
+ private upload;
153
+ private request;
154
+ }
155
+ /**
156
+ * True when a webhook body came from UV pdfTool, unchanged, within `toleranceSeconds` of
157
+ * now. Check the raw body as it arrived, before parsing it.
158
+ */
159
+ export declare function verifyWebhook(rawBody: string | Uint8Array, signatureHeader: string, secret: string, toleranceSeconds?: number, now?: number): boolean;
package/dist/index.js ADDED
@@ -0,0 +1,240 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ /**
3
+ * The UV pdfTool API from Node.js 18 and later, with nothing to install besides this
4
+ * package: create a job, upload the files, start it, wait for it, download the result, or
5
+ * do all of that with one call. Documentation: https://uvpdftool.com/developers
6
+ */
7
+ export const DEFAULT_BASE_URL = 'https://uvpdftool.com/api/v1';
8
+ /** A refused request: the status, the code to switch on, the message to show. */
9
+ export class ApiError extends Error {
10
+ status;
11
+ code;
12
+ details;
13
+ /** From the Retry-After header of a 429, in seconds. */
14
+ retryAfterSeconds;
15
+ constructor(status, code, message, details = {}, retryAfterSeconds = null) {
16
+ super(`${code}: ${message}`);
17
+ this.name = 'ApiError';
18
+ this.status = status;
19
+ this.code = code;
20
+ this.details = details;
21
+ this.retryAfterSeconds = retryAfterSeconds;
22
+ }
23
+ }
24
+ /** A job that ended failed or cancelled; `job.error` says why. */
25
+ export class JobFailedError extends Error {
26
+ job;
27
+ constructor(job) {
28
+ super(job.error
29
+ ? `${job.error.code}: ${job.error.message}`
30
+ : `The job ended ${job.state} without a result.`);
31
+ this.name = 'JobFailedError';
32
+ this.job = job;
33
+ }
34
+ }
35
+ const ENDED = ['done', 'failed', 'cancelled'];
36
+ const VERSION = '0.1.0';
37
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
38
+ const timer = setTimeout(resolve, ms);
39
+ signal?.addEventListener('abort', () => {
40
+ clearTimeout(timer);
41
+ reject(signal.reason instanceof Error ? signal.reason : new Error('Aborted'));
42
+ }, { once: true });
43
+ });
44
+ function sizeOf(file) {
45
+ if (file.data instanceof Uint8Array || file.data instanceof ArrayBuffer) {
46
+ return file.data.byteLength;
47
+ }
48
+ if (file.data instanceof Blob)
49
+ return file.data.size;
50
+ if (file.size === undefined)
51
+ throw new TypeError(`${file.name}: a stream needs its size.`);
52
+ return file.size;
53
+ }
54
+ function bodyOf(data) {
55
+ if (data instanceof Uint8Array) {
56
+ // A view over a plain buffer goes as it is; one over a shared buffer is copied, as fetch asks.
57
+ return data.buffer instanceof ArrayBuffer
58
+ ? data
59
+ : new Uint8Array(data);
60
+ }
61
+ return data;
62
+ }
63
+ export class UvPdfTool {
64
+ jobs;
65
+ apiKey;
66
+ baseUrl;
67
+ fetchFn;
68
+ userAgent;
69
+ constructor(options) {
70
+ if (!options.apiKey)
71
+ throw new TypeError('An API key is needed: uvp_live_… or uvp_test_…');
72
+ this.apiKey = options.apiKey;
73
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/u, '');
74
+ this.fetchFn = options.fetch ?? globalThis.fetch;
75
+ this.userAgent = `uvpdftool-node/${VERSION}${options.userAgent ? ` ${options.userAgent}` : ''}`;
76
+ this.jobs = {
77
+ create: (request) => this.request('POST', '/jobs', request),
78
+ upload: (upload, data) => this.upload(upload, data),
79
+ start: async (id) => (await this.request('POST', `/jobs/${id}/start`)).job,
80
+ get: async (id) => (await this.request('GET', `/jobs/${id}`)).job,
81
+ cancel: async (id) => (await this.request('DELETE', `/jobs/${id}`)).job,
82
+ wait: (id, options) => this.wait(id, options),
83
+ };
84
+ }
85
+ /** The tools the API offers, with their limits and options. */
86
+ async tools() {
87
+ return (await this.request('GET', '/tools')).tools;
88
+ }
89
+ /** The key in use, the API tasks left, and today's test jobs. */
90
+ account() {
91
+ return this.request('GET', '/account');
92
+ }
93
+ /**
94
+ * Creates the job, uploads every file, starts it and waits for it. Resolves with the
95
+ * finished job, whose outputs carry download links; throws JobFailedError otherwise.
96
+ */
97
+ async run(request) {
98
+ const files = request.files.map((file) => ({
99
+ name: file.name,
100
+ size: sizeOf(file),
101
+ contentType: file.contentType,
102
+ }));
103
+ const { job, uploads } = await this.jobs.create({
104
+ tool: request.tool,
105
+ ...(request.options ? { options: request.options } : {}),
106
+ files,
107
+ ...(request.retentionHours !== undefined ? { retentionHours: request.retentionHours } : {}),
108
+ });
109
+ try {
110
+ await Promise.all(uploads.map((upload, index) => {
111
+ const file = request.files[index];
112
+ if (!file)
113
+ throw new Error(`No file for upload ${upload.name}`);
114
+ return this.upload(upload, file.data);
115
+ }));
116
+ await this.jobs.start(job.id);
117
+ }
118
+ catch (error) {
119
+ // A job without its files would wait twenty minutes for them; end it now, task returned.
120
+ await this.jobs.cancel(job.id).catch(() => undefined);
121
+ throw error;
122
+ }
123
+ return this.wait(job.id, {
124
+ ...(request.onProgress ? { onProgress: request.onProgress } : {}),
125
+ ...(request.timeoutMs !== undefined ? { timeoutMs: request.timeoutMs } : {}),
126
+ });
127
+ }
128
+ /** The bytes of one output of a finished job. */
129
+ async download(output) {
130
+ if (!output.url)
131
+ throw new TypeError(`${output.name} has no download link yet.`);
132
+ const response = await this.fetchFn(output.url);
133
+ if (!response.ok) {
134
+ throw new ApiError(response.status, 'DOWNLOAD_FAILED', `The download answered ${String(response.status)}.`);
135
+ }
136
+ return new Uint8Array(await response.arrayBuffer());
137
+ }
138
+ async wait(id, options = {}) {
139
+ const started = Date.now();
140
+ let interval = options.intervalMs ?? 2000;
141
+ let last = -1;
142
+ for (;;) {
143
+ const job = await this.jobs.get(id);
144
+ if (job.progress !== null && job.progress !== last && options.onProgress) {
145
+ last = job.progress;
146
+ options.onProgress(job.progress, job);
147
+ }
148
+ if (job.state === 'done')
149
+ return job;
150
+ if (ENDED.includes(job.state))
151
+ throw new JobFailedError(job);
152
+ const waited = Date.now() - started;
153
+ if (options.timeoutMs !== undefined && waited > options.timeoutMs) {
154
+ throw new Error(`Still ${job.state} after ${String(Math.round(waited / 1000))} s.`);
155
+ }
156
+ if (options.intervalMs === undefined && waited > 60_000)
157
+ interval = 5000;
158
+ await sleep(interval, options.signal);
159
+ }
160
+ }
161
+ /** PUTs the bytes to the upload link, as they are, trying three times on a passing fault. */
162
+ async upload(upload, data) {
163
+ const stream = data instanceof ReadableStream;
164
+ for (let attempt = 1;; attempt += 1) {
165
+ try {
166
+ const response = await this.fetchFn(upload.url, {
167
+ method: upload.method,
168
+ headers: upload.headers,
169
+ body: bodyOf(data),
170
+ ...(stream ? { duplex: 'half' } : {}),
171
+ });
172
+ if (response.ok)
173
+ return;
174
+ if (response.status < 500 || attempt === 3 || stream) {
175
+ throw new ApiError(response.status, 'UPLOAD_FAILED', `The upload of ${upload.name} answered ${String(response.status)}.`);
176
+ }
177
+ }
178
+ catch (error) {
179
+ if (error instanceof ApiError || attempt === 3 || stream)
180
+ throw error;
181
+ }
182
+ await sleep(500 * attempt);
183
+ }
184
+ }
185
+ async request(method, path, body) {
186
+ for (let attempt = 1;; attempt += 1) {
187
+ const response = await this.fetchFn(`${this.baseUrl}${path}`, {
188
+ method,
189
+ headers: {
190
+ Authorization: `Bearer ${this.apiKey}`,
191
+ Accept: 'application/json',
192
+ 'User-Agent': this.userAgent,
193
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
194
+ },
195
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
196
+ });
197
+ const text = await response.text();
198
+ let parsed;
199
+ try {
200
+ parsed = text ? JSON.parse(text) : null;
201
+ }
202
+ catch {
203
+ parsed = null;
204
+ }
205
+ if (response.ok)
206
+ return parsed;
207
+ const problem = parsed?.error;
208
+ const retryAfter = Number(response.headers.get('retry-after'));
209
+ const error = new ApiError(response.status, problem?.code ?? 'HTTP_ERROR', problem?.message ?? `The API answered ${String(response.status)}.`, problem?.details ?? {}, Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : null);
210
+ // One minute's worth of requests spent: wait what the API asks, once, then go on.
211
+ if (error.code === 'TOO_MANY_REQUESTS' && attempt === 1 && error.retryAfterSeconds !== null) {
212
+ await sleep(Math.min(error.retryAfterSeconds, 60) * 1000);
213
+ continue;
214
+ }
215
+ throw error;
216
+ }
217
+ }
218
+ }
219
+ /**
220
+ * True when a webhook body came from UV pdfTool, unchanged, within `toleranceSeconds` of
221
+ * now. Check the raw body as it arrived, before parsing it.
222
+ */
223
+ export function verifyWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300, now = Date.now()) {
224
+ const parts = new Map();
225
+ for (const part of signatureHeader.split(',')) {
226
+ const at = part.indexOf('=');
227
+ if (at > 0)
228
+ parts.set(part.slice(0, at).trim(), part.slice(at + 1).trim());
229
+ }
230
+ const t = Number(parts.get('t'));
231
+ if (!Number.isInteger(t) || Math.abs(now / 1000 - t) > toleranceSeconds)
232
+ return false;
233
+ const body = typeof rawBody === 'string' ? Buffer.from(rawBody, 'utf8') : Buffer.from(rawBody);
234
+ const expected = createHmac('sha256', secret)
235
+ .update(`${String(t)}.`)
236
+ .update(body)
237
+ .digest('hex');
238
+ const given = parts.get('v1') ?? '';
239
+ return (given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected)));
240
+ }
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "uvpdftool",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "The UV pdfTool API from Node.js: compress, convert, OCR and check PDFs on UV pdfTool's servers.",
5
+ "license": "MIT",
6
+ "author": "UV ResearchLabs",
7
+ "homepage": "https://uvpdftool.com/developers",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/uvresearchlab/UV-pdfTool",
11
+ "directory": "packages/sdk-node"
12
+ },
13
+ "keywords": [
14
+ "pdf",
15
+ "compress",
16
+ "ocr",
17
+ "convert",
18
+ "pdfa",
19
+ "api",
20
+ "uvpdftool"
21
+ ],
22
+ "type": "module",
23
+ "sideEffects": false,
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "types": "./dist/index.d.ts",
35
+ "files": [
36
+ "dist",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "scripts": {
41
+ "build": "tsc -p tsconfig.build.json",
42
+ "test": "vitest run",
43
+ "lint": "eslint .",
44
+ "typecheck": "tsc -p tsconfig.json"
45
+ },
46
+ "devDependencies": {
47
+ "@eslint/js": "^10.0.1",
48
+ "@types/node": "^24.13.6",
49
+ "eslint": "^10.11.0",
50
+ "typescript": "~6.0.3",
51
+ "typescript-eslint": "^8.70.0",
52
+ "vitest": "^4.1.12"
53
+ }
54
+ }