uvpdftool 0.0.0-stage → 0.2.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,75 @@
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.3 and later, and from the
4
+ command line: compress, convert, OCR and check PDFs on UV pdfTool's servers. No dependencies; ES
5
+ modules; types 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 command line
54
+
55
+ The package installs `uvpdftool`, which uploads the files, runs the tool and saves the results
56
+ beside them:
57
+
58
+ ```sh
59
+ npm install -g uvpdftool
60
+ export UVPDFTOOL_API_KEY=uvp_test_...
61
+
62
+ uvpdftool compress report.pdf --set method=ghostscript --set preset=ebook
63
+ uvpdftool office-to-pdf proposal.docx contract.docx -o pdfs
64
+ uvpdftool ocr scan.pdf --set 'languages=["eng","hin"]' --json
65
+ uvpdftool tools # what each tool takes, and its options
66
+ uvpdftool account # the key in use, and the API tasks left
67
+ ```
68
+
69
+ The key comes from `--key`, `UVPDFTOOL_API_KEY` or `UVPDF_KEY`. Each `--set` is one option of
70
+ the tool, read as JSON where it can be; `--options '{...}'` gives them all at once. A result never
71
+ replaces a file of the same name unless `--force` is given; a number is added instead. The exit
72
+ code is 0 when done, 1 when the API refused or the job failed, and 2 when the command was wrong.
73
+
74
+ The tools, their options and the errors are documented at
75
+ <https://uvpdftool.com/developers>.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { main } from './command.js';
3
+ // The command line itself: what it does is in command.ts, where the tests reach it.
4
+ void main(process.argv.slice(2), {
5
+ env: process.env,
6
+ cwd: process.cwd(),
7
+ stdout: (text) => process.stdout.write(text),
8
+ stderr: (text) => process.stderr.write(text),
9
+ }).then((code) => {
10
+ process.exitCode = code;
11
+ });
@@ -0,0 +1,13 @@
1
+ /** One `--set name=value`: the value as JSON where it parses, as text otherwise. */
2
+ export declare function parseSetting(setting: string): [string, unknown];
3
+ export interface Io {
4
+ env: Record<string, string | undefined>;
5
+ cwd: string;
6
+ stdout: (text: string) => void;
7
+ stderr: (text: string) => void;
8
+ /** Another fetch, for tests. */
9
+ fetch?: typeof fetch;
10
+ }
11
+ /** Runs the command line; resolves with the exit code: 0 done, 1 refused or failed, 2 misused. */
12
+ export { contentTypeOf, safeName } from './files.js';
13
+ export declare function main(argv: readonly string[], io: Io): Promise<number>;
@@ -0,0 +1,211 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { mkdir, stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { Readable } from 'node:stream';
5
+ import { parseArgs } from 'node:util';
6
+ import { contentTypeOf, safeName, saveResult } from './files.js';
7
+ import { ApiError, JobFailedError, UvPdfTool, VERSION } from './index.js';
8
+ /**
9
+ * The UV pdfTool API from the command line: `uvpdftool <tool> <file>...` uploads the files,
10
+ * runs the tool and saves what it made; `uvpdftool tools` and `uvpdftool account` answer the
11
+ * two questions a script asks first. Nothing but Node itself and this package.
12
+ */
13
+ const HELP = `uvpdftool ${VERSION}: the UV pdfTool API from the command line.
14
+
15
+ Usage:
16
+ uvpdftool <tool> <file>... [options] Run a tool on the files and save the results
17
+ uvpdftool tools The tools, what they take and their options
18
+ uvpdftool account The key in use and the tasks left
19
+
20
+ Options:
21
+ -k, --key <key> The API key; or set UVPDFTOOL_API_KEY (or UVPDF_KEY)
22
+ -o, --out <folder> Where to save the results (default: the current folder)
23
+ -s, --set <name=value> One option of the tool; repeat for more. Values are read as
24
+ JSON where they can be: --set preset=ebook --set linearize=true
25
+ --options <json> All the tool's options as one JSON object
26
+ --keep <hours> Keep the files on the server this long after the job, 1 to 24
27
+ --force Overwrite files of the same name instead of adding a number
28
+ --json Print the finished job as JSON
29
+ -q, --quiet No progress on standard error
30
+ -h, --help This help
31
+ -v, --version The version
32
+
33
+ Examples:
34
+ uvpdftool compress report.pdf --set method=ghostscript --set preset=ebook
35
+ uvpdftool office-to-pdf proposal.docx -o pdfs
36
+ uvpdftool ocr scan.pdf --set 'languages=["eng","hin"]'
37
+
38
+ Tools, options and errors: https://uvpdftool.com/developers
39
+ `;
40
+ /** One `--set name=value`: the value as JSON where it parses, as text otherwise. */
41
+ export function parseSetting(setting) {
42
+ const at = setting.indexOf('=');
43
+ if (at < 1)
44
+ throw new UsageError(`--set needs name=value, not "${setting}".`);
45
+ const name = setting.slice(0, at).trim();
46
+ const raw = setting.slice(at + 1);
47
+ try {
48
+ return [name, JSON.parse(raw)];
49
+ }
50
+ catch {
51
+ return [name, raw];
52
+ }
53
+ }
54
+ class UsageError extends Error {
55
+ }
56
+ /** Runs the command line; resolves with the exit code: 0 done, 1 refused or failed, 2 misused. */
57
+ export { contentTypeOf, safeName } from './files.js';
58
+ export async function main(argv, io) {
59
+ let parsed;
60
+ try {
61
+ parsed = parseArgs({
62
+ args: [...argv],
63
+ allowPositionals: true,
64
+ options: {
65
+ key: { type: 'string', short: 'k' },
66
+ out: { type: 'string', short: 'o' },
67
+ set: { type: 'string', short: 's', multiple: true },
68
+ options: { type: 'string' },
69
+ keep: { type: 'string' },
70
+ force: { type: 'boolean' },
71
+ json: { type: 'boolean' },
72
+ quiet: { type: 'boolean', short: 'q' },
73
+ help: { type: 'boolean', short: 'h' },
74
+ version: { type: 'boolean', short: 'v' },
75
+ 'base-url': { type: 'string' },
76
+ },
77
+ });
78
+ }
79
+ catch (error) {
80
+ io.stderr(`${error instanceof Error ? error.message : String(error)}\n\n${HELP}`);
81
+ return 2;
82
+ }
83
+ const { values, positionals } = parsed;
84
+ if (values.version) {
85
+ io.stdout(`${VERSION}\n`);
86
+ return 0;
87
+ }
88
+ const [command, ...files] = positionals;
89
+ if (values.help || command === undefined || command === 'help') {
90
+ io.stdout(HELP);
91
+ return command === undefined && !values.help ? 2 : 0;
92
+ }
93
+ const apiKey = values.key ?? io.env.UVPDFTOOL_API_KEY ?? io.env.UVPDF_KEY ?? '';
94
+ if (apiKey === '') {
95
+ io.stderr('An API key is needed: --key, or UVPDFTOOL_API_KEY. Make one at https://uvpdftool.com/developers/keys\n');
96
+ return 2;
97
+ }
98
+ const client = new UvPdfTool({
99
+ apiKey,
100
+ userAgent: 'uvpdftool-cli',
101
+ ...(values['base-url'] ? { baseUrl: values['base-url'] } : {}),
102
+ ...(io.fetch ? { fetch: io.fetch } : {}),
103
+ });
104
+ try {
105
+ if (command === 'tools') {
106
+ const tools = await client.tools();
107
+ if (values.json)
108
+ io.stdout(`${JSON.stringify(tools, null, 2)}\n`);
109
+ else {
110
+ for (const tool of tools) {
111
+ io.stdout(`${tool.id}\n takes ${tool.accepts.join(', ')}; up to ${String(tool.maxFiles)} files\n`);
112
+ const names = Object.keys(tool.options.properties ?? {});
113
+ if (names.length > 0)
114
+ io.stdout(` options: ${names.join(', ')}\n`);
115
+ }
116
+ }
117
+ return 0;
118
+ }
119
+ if (command === 'account') {
120
+ const account = await client.account();
121
+ if (values.json)
122
+ io.stdout(`${JSON.stringify(account, null, 2)}\n`);
123
+ else {
124
+ io.stdout(`${account.key.name} (${account.key.mode}, ${account.key.prefix}…)\n` +
125
+ `API tasks left: ${String(account.tasks)}\n` +
126
+ `Test jobs left today: ${String(account.test.left)} of ${String(account.test.perDay)}\n`);
127
+ }
128
+ return 0;
129
+ }
130
+ if (files.length === 0) {
131
+ // A tool that takes no files (web-to-pdf) runs on its options alone.
132
+ const known = (await client.tools()).find((tool) => tool.id === command);
133
+ if (!known || known.accepts.length > 0) {
134
+ throw new UsageError(`Name the files for ${command}.`);
135
+ }
136
+ }
137
+ const options = {};
138
+ if (values.options !== undefined) {
139
+ let given;
140
+ try {
141
+ given = JSON.parse(values.options);
142
+ }
143
+ catch {
144
+ throw new UsageError('--options must be a JSON object.');
145
+ }
146
+ if (typeof given !== 'object' || given === null || Array.isArray(given)) {
147
+ throw new UsageError('--options must be a JSON object.');
148
+ }
149
+ Object.assign(options, given);
150
+ }
151
+ for (const setting of values.set ?? []) {
152
+ const [name, value] = parseSetting(setting);
153
+ options[name] = value;
154
+ }
155
+ const keep = values.keep === undefined ? undefined : Number(values.keep);
156
+ if (keep !== undefined && (!Number.isInteger(keep) || keep < 1 || keep > 24)) {
157
+ throw new UsageError('--keep takes a whole number of hours, 1 to 24.');
158
+ }
159
+ const inputs = [];
160
+ for (const file of files) {
161
+ const full = path.resolve(io.cwd, file);
162
+ const info = await stat(full).catch(() => null);
163
+ if (!info?.isFile())
164
+ throw new UsageError(`${file}: no such file.`);
165
+ inputs.push({
166
+ name: path.basename(full),
167
+ contentType: contentTypeOf(full),
168
+ size: info.size,
169
+ data: Readable.toWeb(createReadStream(full)),
170
+ });
171
+ }
172
+ const quiet = values.quiet === true;
173
+ if (!quiet)
174
+ io.stderr(`${command}: uploading ${String(inputs.length)} file(s)…\n`);
175
+ const job = await client.run({
176
+ tool: command,
177
+ files: inputs,
178
+ ...(Object.keys(options).length > 0 ? { options } : {}),
179
+ ...(keep !== undefined ? { retentionHours: keep } : {}),
180
+ onProgress: (fraction) => {
181
+ if (!quiet)
182
+ io.stderr(`${command}: ${String(Math.round(fraction * 100))}%\n`);
183
+ },
184
+ });
185
+ const folder = path.resolve(io.cwd, values.out ?? '.');
186
+ await mkdir(folder, { recursive: true });
187
+ const saved = [];
188
+ for (const [index, output] of job.outputs.entries()) {
189
+ const data = await client.download(output);
190
+ saved.push(await saveResult(folder, safeName(output.name, index), data, values.force === true));
191
+ }
192
+ if (values.json)
193
+ io.stdout(`${JSON.stringify({ job, saved }, null, 2)}\n`);
194
+ else
195
+ for (const file of saved)
196
+ io.stdout(`${file}\n`);
197
+ return 0;
198
+ }
199
+ catch (error) {
200
+ if (error instanceof UsageError) {
201
+ io.stderr(`${error.message}\n`);
202
+ return 2;
203
+ }
204
+ if (error instanceof JobFailedError || error instanceof ApiError) {
205
+ io.stderr(`${error.message}\n`);
206
+ return 1;
207
+ }
208
+ io.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
209
+ return 1;
210
+ }
211
+ }
@@ -0,0 +1,14 @@
1
+ export declare const contentTypeOf: (file: string) => string;
2
+ /**
3
+ * A result's name as a file in the output folder: its last part only, with the characters no
4
+ * system allows in a name replaced, so a name from the server can never reach another folder.
5
+ */
6
+ export declare function safeName(name: string, index: number): string;
7
+ /** A free path for `name` in `folder`: the name itself, or with " (2)", " (3)"… before the extension. */
8
+ export declare function freePath(folder: string, name: string, force: boolean): Promise<string>;
9
+ /**
10
+ * Saves a result as `name` in `folder`, under a free name unless `force`, and says where. The file
11
+ * is created only if nothing is there, so a result saved at the same moment, or a link put at the
12
+ * name, is never written over or through: the next free name is taken instead.
13
+ */
14
+ export declare function saveResult(folder: string, name: string, data: Uint8Array, force: boolean): Promise<string>;
package/dist/files.js ADDED
@@ -0,0 +1,77 @@
1
+ import { lstat, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ /**
4
+ * Local files and the API: the content type to declare for a file, and a safe, free place to
5
+ * save a result, for the command line and for the MCP server alike.
6
+ */
7
+ /** Content types by file extension, for what the tools take. */
8
+ const TYPES = {
9
+ '.pdf': 'application/pdf',
10
+ '.doc': 'application/msword',
11
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
12
+ '.xls': 'application/vnd.ms-excel',
13
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
14
+ '.ppt': 'application/vnd.ms-powerpoint',
15
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
16
+ '.odt': 'application/vnd.oasis.opendocument.text',
17
+ '.ods': 'application/vnd.oasis.opendocument.spreadsheet',
18
+ '.odp': 'application/vnd.oasis.opendocument.presentation',
19
+ '.rtf': 'application/rtf',
20
+ '.txt': 'text/plain',
21
+ '.csv': 'text/csv',
22
+ '.html': 'text/html',
23
+ '.htm': 'text/html',
24
+ '.md': 'text/markdown',
25
+ '.markdown': 'text/markdown',
26
+ '.epub': 'application/epub+zip',
27
+ '.png': 'image/png',
28
+ '.jpg': 'image/jpeg',
29
+ '.jpeg': 'image/jpeg',
30
+ };
31
+ export const contentTypeOf = (file) => TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream';
32
+ const RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/iu;
33
+ /**
34
+ * A result's name as a file in the output folder: its last part only, with the characters no
35
+ * system allows in a name replaced, so a name from the server can never reach another folder.
36
+ */
37
+ export function safeName(name, index) {
38
+ const last = name.split(/[\\/]/u).pop() ?? '';
39
+ // eslint-disable-next-line no-control-regex -- control characters are what is being removed
40
+ const cleaned = last.replace(/[<>:"|?*\u0000-\u001f]/gu, '_').replace(/^[.\s]+|[.\s]+$/gu, '');
41
+ if (cleaned === '')
42
+ return `result-${String(index + 1)}`;
43
+ return RESERVED.test(cleaned) ? `_${cleaned}` : cleaned;
44
+ }
45
+ // lstat: a link at the name counts as taken, even one that leads nowhere.
46
+ const exists = async (file) => lstat(file).then(() => true, () => false);
47
+ /** A free path for `name` in `folder`: the name itself, or with " (2)", " (3)"… before the extension. */
48
+ export async function freePath(folder, name, force) {
49
+ const first = path.join(folder, name);
50
+ if (force || !(await exists(first)))
51
+ return first;
52
+ const extension = path.extname(name);
53
+ const stem = name.slice(0, name.length - extension.length);
54
+ for (let count = 2;; count += 1) {
55
+ const candidate = path.join(folder, `${stem} (${String(count)})${extension}`);
56
+ if (!(await exists(candidate)))
57
+ return candidate;
58
+ }
59
+ }
60
+ /**
61
+ * Saves a result as `name` in `folder`, under a free name unless `force`, and says where. The file
62
+ * is created only if nothing is there, so a result saved at the same moment, or a link put at the
63
+ * name, is never written over or through: the next free name is taken instead.
64
+ */
65
+ export async function saveResult(folder, name, data, force) {
66
+ for (;;) {
67
+ const target = await freePath(folder, name, force);
68
+ try {
69
+ await writeFile(target, data, { flag: force ? 'w' : 'wx' });
70
+ return target;
71
+ }
72
+ catch (error) {
73
+ if (force || error.code !== 'EEXIST')
74
+ throw error;
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,161 @@
1
+ export { contentTypeOf, freePath, safeName, saveResult } from './files.js';
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 declare const DEFAULT_BASE_URL = "https://uvpdftool.com/api/v1";
8
+ export type JobState = 'created' | 'queued' | 'running' | 'done' | 'failed' | 'cancelled';
9
+ export interface JobError {
10
+ code: string;
11
+ message: string;
12
+ }
13
+ export interface JobOutput {
14
+ name: string;
15
+ size: number;
16
+ contentType: string;
17
+ /** A download link, present once the job is done; good for fifteen minutes. */
18
+ url?: string;
19
+ }
20
+ export interface Job {
21
+ id: string;
22
+ tool: string;
23
+ mode: 'live' | 'test';
24
+ state: JobState;
25
+ progress: number | null;
26
+ error: JobError | null;
27
+ outputs: JobOutput[];
28
+ createdAt: string;
29
+ expiresAt: string;
30
+ }
31
+ export interface Upload {
32
+ name: string;
33
+ method: 'PUT';
34
+ url: string;
35
+ headers: Record<string, string>;
36
+ }
37
+ export interface FileDeclaration {
38
+ name: string;
39
+ size: number;
40
+ contentType: string;
41
+ }
42
+ /** A file to send: its bytes (or a stream of them, with the size), its name and type. */
43
+ export interface FileInput {
44
+ name: string;
45
+ data: Uint8Array | ArrayBuffer | Blob | ReadableStream<Uint8Array>;
46
+ contentType: string;
47
+ /** Needed for a stream; read from the bytes otherwise. */
48
+ size?: number;
49
+ }
50
+ export interface CreateJobRequest {
51
+ tool: string;
52
+ options?: Record<string, unknown>;
53
+ files: FileDeclaration[];
54
+ /** How long the files and results are kept once the job has run, 1 to 24 hours (default 2). */
55
+ retentionHours?: number;
56
+ }
57
+ export interface RunRequest {
58
+ tool: string;
59
+ options?: Record<string, unknown>;
60
+ files: FileInput[];
61
+ retentionHours?: number;
62
+ /** Called with the job's progress, 0 to 1, while it runs. */
63
+ onProgress?: (fraction: number, job: Job) => void;
64
+ /** Give up waiting after this long; the job itself goes on. */
65
+ timeoutMs?: number;
66
+ }
67
+ export interface WaitOptions {
68
+ /** Between two reads of the job; default 2 seconds, growing to 5 after a minute. */
69
+ intervalMs?: number;
70
+ timeoutMs?: number;
71
+ onProgress?: (fraction: number, job: Job) => void;
72
+ signal?: AbortSignal;
73
+ }
74
+ export interface ToolInfo {
75
+ id: string;
76
+ accepts: string[];
77
+ produces: string;
78
+ maxFiles: number;
79
+ maxBytes: {
80
+ live: number;
81
+ test: number;
82
+ };
83
+ timeoutMinutes: number;
84
+ options: Record<string, unknown>;
85
+ }
86
+ export interface Account {
87
+ key: {
88
+ name: string;
89
+ mode: 'live' | 'test';
90
+ prefix: string;
91
+ scopes: string[];
92
+ };
93
+ tasks: number;
94
+ test: {
95
+ left: number;
96
+ perDay: number;
97
+ };
98
+ }
99
+ export interface ClientOptions {
100
+ /** uvp_live_… or uvp_test_… */
101
+ apiKey: string;
102
+ /** Another API address, for tests. */
103
+ baseUrl?: string;
104
+ /** Another fetch, for tests or a proxy. */
105
+ fetch?: typeof fetch;
106
+ /** Sent as the User-Agent after the library's own name. */
107
+ userAgent?: string;
108
+ }
109
+ /** A refused request: the status, the code to switch on, the message to show. */
110
+ export declare class ApiError extends Error {
111
+ readonly status: number;
112
+ readonly code: string;
113
+ readonly details: Record<string, unknown>;
114
+ /** From the Retry-After header of a 429, in seconds. */
115
+ readonly retryAfterSeconds: number | null;
116
+ constructor(status: number, code: string, message: string, details?: Record<string, unknown>, retryAfterSeconds?: number | null);
117
+ }
118
+ /** A job that ended failed or cancelled; `job.error` says why. */
119
+ export declare class JobFailedError extends Error {
120
+ readonly job: Job;
121
+ constructor(job: Job);
122
+ }
123
+ export declare const VERSION = "0.2.0";
124
+ export declare class UvPdfTool {
125
+ readonly jobs: {
126
+ create: (request: CreateJobRequest) => Promise<{
127
+ job: Job;
128
+ uploads: Upload[];
129
+ }>;
130
+ upload: (upload: Upload, data: FileInput['data']) => Promise<void>;
131
+ start: (id: string) => Promise<Job>;
132
+ get: (id: string) => Promise<Job>;
133
+ cancel: (id: string) => Promise<Job>;
134
+ wait: (id: string, options?: WaitOptions) => Promise<Job>;
135
+ };
136
+ private readonly apiKey;
137
+ private readonly baseUrl;
138
+ private readonly fetchFn;
139
+ private readonly userAgent;
140
+ constructor(options: ClientOptions);
141
+ /** The tools the API offers, with their limits and options. */
142
+ tools(): Promise<ToolInfo[]>;
143
+ /** The key in use, the API tasks left, and today's test jobs. */
144
+ account(): Promise<Account>;
145
+ /**
146
+ * Creates the job, uploads every file, starts it and waits for it. Resolves with the
147
+ * finished job, whose outputs carry download links; throws JobFailedError otherwise.
148
+ */
149
+ run(request: RunRequest): Promise<Job>;
150
+ /** The bytes of one output of a finished job. */
151
+ download(output: JobOutput): Promise<Uint8Array>;
152
+ private wait;
153
+ /** PUTs the bytes to the upload link, as they are, trying three times on a passing fault. */
154
+ private upload;
155
+ private request;
156
+ }
157
+ /**
158
+ * True when a webhook body came from UV pdfTool, unchanged, within `toleranceSeconds` of
159
+ * now. Check the raw body as it arrived, before parsing it.
160
+ */
161
+ export declare function verifyWebhook(rawBody: string | Uint8Array, signatureHeader: string, secret: string, toleranceSeconds?: number, now?: number): boolean;
package/dist/index.js ADDED
@@ -0,0 +1,242 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ export { contentTypeOf, freePath, safeName, saveResult } from './files.js';
3
+ /**
4
+ * The UV pdfTool API from Node.js 18 and later, with nothing to install besides this
5
+ * package: create a job, upload the files, start it, wait for it, download the result, or
6
+ * do all of that with one call. Documentation: https://uvpdftool.com/developers
7
+ */
8
+ export const DEFAULT_BASE_URL = 'https://uvpdftool.com/api/v1';
9
+ /** A refused request: the status, the code to switch on, the message to show. */
10
+ export class ApiError extends Error {
11
+ status;
12
+ code;
13
+ details;
14
+ /** From the Retry-After header of a 429, in seconds. */
15
+ retryAfterSeconds;
16
+ constructor(status, code, message, details = {}, retryAfterSeconds = null) {
17
+ super(`${code}: ${message}`);
18
+ this.name = 'ApiError';
19
+ this.status = status;
20
+ this.code = code;
21
+ this.details = details;
22
+ this.retryAfterSeconds = retryAfterSeconds;
23
+ }
24
+ }
25
+ /** A job that ended failed or cancelled; `job.error` says why. */
26
+ export class JobFailedError extends Error {
27
+ job;
28
+ constructor(job) {
29
+ super(job.error
30
+ ? `${job.error.code}: ${job.error.message}`
31
+ : `The job ended ${job.state} without a result.`);
32
+ this.name = 'JobFailedError';
33
+ this.job = job;
34
+ }
35
+ }
36
+ const ENDED = ['done', 'failed', 'cancelled'];
37
+ export const VERSION = '0.2.0';
38
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
39
+ const timer = setTimeout(resolve, ms);
40
+ signal?.addEventListener('abort', () => {
41
+ clearTimeout(timer);
42
+ reject(signal.reason instanceof Error ? signal.reason : new Error('Aborted'));
43
+ }, { once: true });
44
+ });
45
+ function sizeOf(file) {
46
+ if (file.data instanceof Uint8Array || file.data instanceof ArrayBuffer) {
47
+ return file.data.byteLength;
48
+ }
49
+ if (file.data instanceof Blob)
50
+ return file.data.size;
51
+ if (file.size === undefined)
52
+ throw new TypeError(`${file.name}: a stream needs its size.`);
53
+ return file.size;
54
+ }
55
+ function bodyOf(data) {
56
+ if (data instanceof Uint8Array) {
57
+ // A view over a plain buffer goes as it is; one over a shared buffer is copied, as fetch asks.
58
+ return data.buffer instanceof ArrayBuffer
59
+ ? data
60
+ : new Uint8Array(data);
61
+ }
62
+ return data;
63
+ }
64
+ export class UvPdfTool {
65
+ jobs;
66
+ apiKey;
67
+ baseUrl;
68
+ fetchFn;
69
+ userAgent;
70
+ constructor(options) {
71
+ if (!options.apiKey)
72
+ throw new TypeError('An API key is needed: uvp_live_… or uvp_test_…');
73
+ this.apiKey = options.apiKey;
74
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/u, '');
75
+ this.fetchFn = options.fetch ?? globalThis.fetch;
76
+ this.userAgent = `uvpdftool-node/${VERSION}${options.userAgent ? ` ${options.userAgent}` : ''}`;
77
+ this.jobs = {
78
+ create: (request) => this.request('POST', '/jobs', request),
79
+ upload: (upload, data) => this.upload(upload, data),
80
+ start: async (id) => (await this.request('POST', `/jobs/${id}/start`)).job,
81
+ get: async (id) => (await this.request('GET', `/jobs/${id}`)).job,
82
+ cancel: async (id) => (await this.request('DELETE', `/jobs/${id}`)).job,
83
+ wait: (id, options) => this.wait(id, options),
84
+ };
85
+ }
86
+ /** The tools the API offers, with their limits and options. */
87
+ async tools() {
88
+ return (await this.request('GET', '/tools')).tools;
89
+ }
90
+ /** The key in use, the API tasks left, and today's test jobs. */
91
+ account() {
92
+ return this.request('GET', '/account');
93
+ }
94
+ /**
95
+ * Creates the job, uploads every file, starts it and waits for it. Resolves with the
96
+ * finished job, whose outputs carry download links; throws JobFailedError otherwise.
97
+ */
98
+ async run(request) {
99
+ const files = request.files.map((file) => ({
100
+ name: file.name,
101
+ size: sizeOf(file),
102
+ contentType: file.contentType,
103
+ }));
104
+ const { job, uploads } = await this.jobs.create({
105
+ tool: request.tool,
106
+ ...(request.options ? { options: request.options } : {}),
107
+ files,
108
+ ...(request.retentionHours !== undefined ? { retentionHours: request.retentionHours } : {}),
109
+ });
110
+ try {
111
+ await Promise.all(uploads.map((upload, index) => {
112
+ const file = request.files[index];
113
+ if (!file)
114
+ throw new Error(`No file for upload ${upload.name}`);
115
+ return this.upload(upload, file.data);
116
+ }));
117
+ await this.jobs.start(job.id);
118
+ }
119
+ catch (error) {
120
+ // A job without its files would wait twenty minutes for them; end it now, task returned.
121
+ await this.jobs.cancel(job.id).catch(() => undefined);
122
+ throw error;
123
+ }
124
+ return this.wait(job.id, {
125
+ ...(request.onProgress ? { onProgress: request.onProgress } : {}),
126
+ ...(request.timeoutMs !== undefined ? { timeoutMs: request.timeoutMs } : {}),
127
+ });
128
+ }
129
+ /** The bytes of one output of a finished job. */
130
+ async download(output) {
131
+ if (!output.url)
132
+ throw new TypeError(`${output.name} has no download link yet.`);
133
+ const response = await this.fetchFn(output.url);
134
+ if (!response.ok) {
135
+ throw new ApiError(response.status, 'DOWNLOAD_FAILED', `The download answered ${String(response.status)}.`);
136
+ }
137
+ return new Uint8Array(await response.arrayBuffer());
138
+ }
139
+ async wait(id, options = {}) {
140
+ const started = Date.now();
141
+ let interval = options.intervalMs ?? 2000;
142
+ let last = -1;
143
+ for (;;) {
144
+ const job = await this.jobs.get(id);
145
+ if (job.progress !== null && job.progress !== last && options.onProgress) {
146
+ last = job.progress;
147
+ options.onProgress(job.progress, job);
148
+ }
149
+ if (job.state === 'done')
150
+ return job;
151
+ if (ENDED.includes(job.state))
152
+ throw new JobFailedError(job);
153
+ const waited = Date.now() - started;
154
+ if (options.timeoutMs !== undefined && waited > options.timeoutMs) {
155
+ throw new Error(`Still ${job.state} after ${String(Math.round(waited / 1000))} s.`);
156
+ }
157
+ if (options.intervalMs === undefined && waited > 60_000)
158
+ interval = 5000;
159
+ await sleep(interval, options.signal);
160
+ }
161
+ }
162
+ /** PUTs the bytes to the upload link, as they are, trying three times on a passing fault. */
163
+ async upload(upload, data) {
164
+ const stream = data instanceof ReadableStream;
165
+ for (let attempt = 1;; attempt += 1) {
166
+ try {
167
+ const response = await this.fetchFn(upload.url, {
168
+ method: upload.method,
169
+ headers: upload.headers,
170
+ body: bodyOf(data),
171
+ ...(stream ? { duplex: 'half' } : {}),
172
+ });
173
+ if (response.ok)
174
+ return;
175
+ if (response.status < 500 || attempt === 3 || stream) {
176
+ throw new ApiError(response.status, 'UPLOAD_FAILED', `The upload of ${upload.name} answered ${String(response.status)}.`);
177
+ }
178
+ }
179
+ catch (error) {
180
+ if (error instanceof ApiError || attempt === 3 || stream)
181
+ throw error;
182
+ }
183
+ await sleep(500 * attempt);
184
+ }
185
+ }
186
+ async request(method, path, body) {
187
+ for (let attempt = 1;; attempt += 1) {
188
+ const response = await this.fetchFn(`${this.baseUrl}${path}`, {
189
+ method,
190
+ headers: {
191
+ Authorization: `Bearer ${this.apiKey}`,
192
+ Accept: 'application/json',
193
+ 'User-Agent': this.userAgent,
194
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
195
+ },
196
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
197
+ });
198
+ const text = await response.text();
199
+ let parsed;
200
+ try {
201
+ parsed = text ? JSON.parse(text) : null;
202
+ }
203
+ catch {
204
+ parsed = null;
205
+ }
206
+ if (response.ok)
207
+ return parsed;
208
+ const problem = parsed?.error;
209
+ const retryAfter = Number(response.headers.get('retry-after'));
210
+ 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);
211
+ // One minute's worth of requests spent: wait what the API asks, once, then go on.
212
+ if (error.code === 'TOO_MANY_REQUESTS' && attempt === 1 && error.retryAfterSeconds !== null) {
213
+ await sleep(Math.min(error.retryAfterSeconds, 60) * 1000);
214
+ continue;
215
+ }
216
+ throw error;
217
+ }
218
+ }
219
+ }
220
+ /**
221
+ * True when a webhook body came from UV pdfTool, unchanged, within `toleranceSeconds` of
222
+ * now. Check the raw body as it arrived, before parsing it.
223
+ */
224
+ export function verifyWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300, now = Date.now()) {
225
+ const parts = new Map();
226
+ for (const part of signatureHeader.split(',')) {
227
+ const at = part.indexOf('=');
228
+ if (at > 0)
229
+ parts.set(part.slice(0, at).trim(), part.slice(at + 1).trim());
230
+ }
231
+ const t = Number(parts.get('t'));
232
+ if (!Number.isInteger(t) || Math.abs(now / 1000 - t) > toleranceSeconds)
233
+ return false;
234
+ const body = typeof rawBody === 'string' ? Buffer.from(rawBody, 'utf8') : Buffer.from(rawBody);
235
+ const expected = createHmac('sha256', secret)
236
+ .update(`${String(t)}.`)
237
+ .update(body)
238
+ .digest('hex');
239
+ const given = parts.get('v1') ?? '';
240
+ // Hex only: then both are the same length in bytes, as timingSafeEqual needs.
241
+ return (/^[0-9a-f]{64}$/u.test(given) && timingSafeEqual(Buffer.from(given), Buffer.from(expected)));
242
+ }
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "uvpdftool",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "The UV pdfTool API from Node.js and the command line: 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
+ "cli",
21
+ "uvpdftool"
22
+ ],
23
+ "type": "module",
24
+ "sideEffects": false,
25
+ "engines": {
26
+ "node": ">=18.3"
27
+ },
28
+ "exports": {
29
+ ".": {
30
+ "@pdftool/source": "./src/index.ts",
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ },
34
+ "./package.json": "./package.json"
35
+ },
36
+ "types": "./dist/index.d.ts",
37
+ "bin": {
38
+ "uvpdftool": "./dist/cli.js"
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "README.md",
43
+ "LICENSE"
44
+ ],
45
+ "devDependencies": {
46
+ "@eslint/js": "^10.0.1",
47
+ "@types/node": "^24.13.6",
48
+ "eslint": "^10.11.0",
49
+ "typescript": "~6.0.3",
50
+ "typescript-eslint": "^8.70.0",
51
+ "vitest": "^4.1.12"
52
+ },
53
+ "scripts": {
54
+ "build": "tsc -p tsconfig.build.json",
55
+ "test": "vitest run",
56
+ "lint": "eslint .",
57
+ "typecheck": "tsc -p tsconfig.json"
58
+ }
6
59
  }