@ironfang/renderwolf 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/README.md +64 -0
- package/dist/client.d.ts +114 -0
- package/dist/client.js +257 -0
- package/dist/errors.d.ts +20 -0
- package/dist/errors.js +24 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/types.d.ts +243 -0
- package/dist/types.js +1 -0
- package/dist/webhook.d.ts +30 -0
- package/dist/webhook.js +32 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# @ironfang/renderwolf
|
|
2
|
+
|
|
3
|
+
The official client for the [Renderwolf](https://ironfang.uk/renderwolf) API:
|
|
4
|
+
screenshots, PDFs, QR codes, template images, clips and site previews from a
|
|
5
|
+
URL or from HTML you send. Zero dependencies; Node 20 and later.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @ironfang/renderwolf
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { Renderwolf } from '@ironfang/renderwolf';
|
|
13
|
+
|
|
14
|
+
const rw = new Renderwolf({ apiKey: process.env.IRONFANG_API_KEY! });
|
|
15
|
+
|
|
16
|
+
const shot = await rw.screenshot({ url: 'https://example.com', full_page: true });
|
|
17
|
+
await shot.saveTo('page.png');
|
|
18
|
+
console.log(shot.credits, shot.renderMs, shot.requestId);
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Every render returns the bytes plus what the API reports about them: content
|
|
22
|
+
type, credits charged (zero on a cache hit), render time and the request id
|
|
23
|
+
to quote to support.
|
|
24
|
+
|
|
25
|
+
## Durable jobs
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
const job = await rw.jobs.submit(
|
|
29
|
+
{ kind: 'pdf', request: { html: invoiceHtml, paper_format: 'a4' } },
|
|
30
|
+
{ idempotencyKey: `invoice-${invoice.id}` },
|
|
31
|
+
);
|
|
32
|
+
const done = await rw.jobs.wait(job.id);
|
|
33
|
+
if (done.status === 'succeeded') {
|
|
34
|
+
const pdf = await rw.jobs.result(job.id);
|
|
35
|
+
await pdf.saveTo(`invoice-${invoice.id}.pdf`);
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Verifying a webhook
|
|
40
|
+
|
|
41
|
+
Run the check over the raw request body, before parsing it.
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { verifyWebhook } from '@ironfang/renderwolf';
|
|
45
|
+
|
|
46
|
+
const ok = await verifyWebhook({
|
|
47
|
+
secret: process.env.RENDERWOLF_WEBHOOK_SECRET!,
|
|
48
|
+
timestamp: req.headers['renderwolf-timestamp'],
|
|
49
|
+
signature: req.headers['renderwolf-signature'],
|
|
50
|
+
payload: rawBody,
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Errors
|
|
55
|
+
|
|
56
|
+
Failures throw `RenderwolfError` with `status`, the API's `code`
|
|
57
|
+
(`quota_exhausted`, `render_failed`, ...) and `requestId`. Synchronous renders
|
|
58
|
+
are never retried by the client: a render that timed out may already have
|
|
59
|
+
been charged, and retrying it blind doubles the bill.
|
|
60
|
+
|
|
61
|
+
## Keys
|
|
62
|
+
|
|
63
|
+
Keep the key on a server. For anything a browser or an email needs to fetch
|
|
64
|
+
directly, mint a signed URL with `rw.sign(...)` instead.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { ClipRequest, Destination, DestinationDraft, ImageRequest, Job, JobSubmission, PdfRequest, QrRequest, RequestFilter, RequestRecord, ScreenshotRequest, SignRequest, SignedUrl, SitePreviewRequest, Usage } from './types.js';
|
|
2
|
+
export declare const VERSION = "0.1.0";
|
|
3
|
+
export interface ClientOptions {
|
|
4
|
+
/** Your `rw_live_...` key. Never ship this to a browser. */
|
|
5
|
+
apiKey: string;
|
|
6
|
+
/** Defaults to https://api.ironfang.uk/renderwolf. */
|
|
7
|
+
baseUrl?: string;
|
|
8
|
+
/** Per-request timeout. Renders can legitimately take tens of seconds; default 90s. */
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
/** A fetch implementation, for tests or unusual runtimes. */
|
|
11
|
+
fetch?: typeof fetch;
|
|
12
|
+
}
|
|
13
|
+
export interface RequestOptions {
|
|
14
|
+
/** Cancel the request. A synchronous render that has started may still be charged. */
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
/** Your own correlation id, echoed as X-Request-ID and kept in request history. */
|
|
17
|
+
requestId?: string;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
}
|
|
20
|
+
/** A finished synchronous render with the facts the API reports about it. */
|
|
21
|
+
export interface RenderResult {
|
|
22
|
+
bytes: Uint8Array;
|
|
23
|
+
contentType: string;
|
|
24
|
+
/** Credits this render cost. Zero when served from cache. */
|
|
25
|
+
credits: number;
|
|
26
|
+
renderMs: number;
|
|
27
|
+
cached: boolean;
|
|
28
|
+
/** The id to quote to support. */
|
|
29
|
+
requestId: string;
|
|
30
|
+
/** Every response header, for the ones not surfaced above. */
|
|
31
|
+
headers: Headers;
|
|
32
|
+
/** Write the bytes to a file. Node only; throws elsewhere. */
|
|
33
|
+
saveTo(path: string): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
/** A site preview: the poster frame and the scrolling video. */
|
|
36
|
+
export interface SitePreviewResult {
|
|
37
|
+
poster: Uint8Array;
|
|
38
|
+
video: Uint8Array;
|
|
39
|
+
credits: number;
|
|
40
|
+
renderMs: number;
|
|
41
|
+
outputSeconds: number;
|
|
42
|
+
cached: boolean;
|
|
43
|
+
requestId: string;
|
|
44
|
+
headers: Headers;
|
|
45
|
+
}
|
|
46
|
+
export interface WaitOptions extends RequestOptions {
|
|
47
|
+
/** How often to poll. Default 2s. */
|
|
48
|
+
pollMs?: number;
|
|
49
|
+
/** Give up after this long. Default 10 minutes. */
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
}
|
|
52
|
+
export declare class Renderwolf {
|
|
53
|
+
private readonly apiKey;
|
|
54
|
+
private readonly baseUrl;
|
|
55
|
+
private readonly timeoutMs;
|
|
56
|
+
private readonly fetchImpl;
|
|
57
|
+
constructor(opts: ClientOptions);
|
|
58
|
+
screenshot(req: ScreenshotRequest, opts?: RequestOptions): Promise<RenderResult>;
|
|
59
|
+
pdf(req: PdfRequest, opts?: RequestOptions): Promise<RenderResult>;
|
|
60
|
+
qr(req: QrRequest, opts?: RequestOptions): Promise<RenderResult>;
|
|
61
|
+
/** Render one of your stored templates with variables filled in. */
|
|
62
|
+
image(templateId: string, req?: ImageRequest, opts?: RequestOptions): Promise<RenderResult>;
|
|
63
|
+
clip(req: ClipRequest, opts?: RequestOptions): Promise<RenderResult>;
|
|
64
|
+
sitePreview(req: SitePreviewRequest, opts?: RequestOptions): Promise<SitePreviewResult>;
|
|
65
|
+
private render;
|
|
66
|
+
readonly jobs: {
|
|
67
|
+
/**
|
|
68
|
+
* Submit a durable job. Pass `idempotencyKey` to make a retry of the same
|
|
69
|
+
* submission return the same job instead of a second one.
|
|
70
|
+
*/
|
|
71
|
+
submit: (sub: JobSubmission, opts?: RequestOptions & {
|
|
72
|
+
idempotencyKey?: string;
|
|
73
|
+
}) => Promise<Job>;
|
|
74
|
+
get: (id: string, opts?: RequestOptions) => Promise<Job>;
|
|
75
|
+
list: (params?: {
|
|
76
|
+
status?: string;
|
|
77
|
+
cursor?: string;
|
|
78
|
+
limit?: number;
|
|
79
|
+
}, opts?: RequestOptions) => Promise<{
|
|
80
|
+
jobs: Job[];
|
|
81
|
+
next_cursor?: string;
|
|
82
|
+
}>;
|
|
83
|
+
cancel: (id: string, opts?: RequestOptions) => Promise<Job>;
|
|
84
|
+
/** Download a succeeded job's result. */
|
|
85
|
+
result: (id: string, opts?: RequestOptions) => Promise<RenderResult>;
|
|
86
|
+
/** Poll until the job is terminal. Resolves with the job whatever the outcome; check `status`. */
|
|
87
|
+
wait: (id: string, opts?: WaitOptions) => Promise<Job>;
|
|
88
|
+
};
|
|
89
|
+
readonly destinations: {
|
|
90
|
+
/**
|
|
91
|
+
* Register where finished jobs go. A webhook destination's
|
|
92
|
+
* `signing_secret` is returned here once and never again; keep it.
|
|
93
|
+
*/
|
|
94
|
+
create: (dest: DestinationDraft, opts?: RequestOptions) => Promise<Destination>;
|
|
95
|
+
list: (opts?: RequestOptions) => Promise<{
|
|
96
|
+
destinations: Destination[];
|
|
97
|
+
}>;
|
|
98
|
+
/** Reach the destination now; a failure is reported in the body, not thrown. */
|
|
99
|
+
test: (id: string, opts?: RequestOptions) => Promise<{
|
|
100
|
+
ok: boolean;
|
|
101
|
+
error?: string;
|
|
102
|
+
}>;
|
|
103
|
+
remove: (id: string, opts?: RequestOptions) => Promise<void>;
|
|
104
|
+
};
|
|
105
|
+
/** Mint a signed render URL that an <img> or an email can fetch directly. */
|
|
106
|
+
sign(req: SignRequest, opts?: RequestOptions): Promise<SignedUrl>;
|
|
107
|
+
usage(opts?: RequestOptions): Promise<Usage>;
|
|
108
|
+
/** Request history for the last seven days. */
|
|
109
|
+
requests(filter?: RequestFilter, opts?: RequestOptions): Promise<{
|
|
110
|
+
requests: RequestRecord[];
|
|
111
|
+
retention_days: number;
|
|
112
|
+
}>;
|
|
113
|
+
private send;
|
|
114
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { RenderwolfError } from './errors.js';
|
|
2
|
+
export const VERSION = '0.1.0';
|
|
3
|
+
export class Renderwolf {
|
|
4
|
+
apiKey;
|
|
5
|
+
baseUrl;
|
|
6
|
+
timeoutMs;
|
|
7
|
+
fetchImpl;
|
|
8
|
+
constructor(opts) {
|
|
9
|
+
if (!opts.apiKey) {
|
|
10
|
+
throw new RenderwolfError('an API key is required', { status: 0, code: 'missing_api_key' });
|
|
11
|
+
}
|
|
12
|
+
this.apiKey = opts.apiKey;
|
|
13
|
+
this.baseUrl = (opts.baseUrl ?? 'https://api.ironfang.uk/renderwolf').replace(/\/+$/, '');
|
|
14
|
+
this.timeoutMs = opts.timeoutMs ?? 90_000;
|
|
15
|
+
this.fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
16
|
+
}
|
|
17
|
+
/* ------------------------------ renders ------------------------------ */
|
|
18
|
+
screenshot(req, opts = {}) {
|
|
19
|
+
return this.render('/v1/screenshot', req, opts);
|
|
20
|
+
}
|
|
21
|
+
pdf(req, opts = {}) {
|
|
22
|
+
return this.render('/v1/pdf', req, opts);
|
|
23
|
+
}
|
|
24
|
+
qr(req, opts = {}) {
|
|
25
|
+
return this.render('/v1/qr', req, opts);
|
|
26
|
+
}
|
|
27
|
+
/** Render one of your stored templates with variables filled in. */
|
|
28
|
+
image(templateId, req = {}, opts = {}) {
|
|
29
|
+
return this.render(`/v1/image/${encodeURIComponent(templateId)}`, req, opts);
|
|
30
|
+
}
|
|
31
|
+
clip(req, opts = {}) {
|
|
32
|
+
return this.render('/v1/video', req, opts);
|
|
33
|
+
}
|
|
34
|
+
async sitePreview(req, opts = {}) {
|
|
35
|
+
const resp = await this.send('POST', '/v1/site-preview', req, opts);
|
|
36
|
+
const form = await resp.formData();
|
|
37
|
+
const poster = form.get('poster');
|
|
38
|
+
const video = form.get('video');
|
|
39
|
+
if (!(poster instanceof Blob) || !(video instanceof Blob)) {
|
|
40
|
+
throw new RenderwolfError('the response did not include both preview files', {
|
|
41
|
+
status: resp.status, code: 'bad_response', requestId: requestIdOf(resp),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
poster: new Uint8Array(await poster.arrayBuffer()),
|
|
46
|
+
video: new Uint8Array(await video.arrayBuffer()),
|
|
47
|
+
credits: num(resp.headers.get('X-Renderwolf-Credits')),
|
|
48
|
+
renderMs: num(resp.headers.get('X-Renderwolf-Render-Ms')),
|
|
49
|
+
outputSeconds: num(resp.headers.get('X-Renderwolf-Output-Seconds')),
|
|
50
|
+
cached: resp.headers.get('X-Renderwolf-Cache') === 'hit',
|
|
51
|
+
requestId: requestIdOf(resp),
|
|
52
|
+
headers: resp.headers,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async render(path, body, opts) {
|
|
56
|
+
const resp = await this.send('POST', path, body, opts);
|
|
57
|
+
const bytes = new Uint8Array(await resp.arrayBuffer());
|
|
58
|
+
return {
|
|
59
|
+
bytes,
|
|
60
|
+
contentType: (resp.headers.get('Content-Type') ?? 'application/octet-stream').split(';')[0].trim(),
|
|
61
|
+
credits: num(resp.headers.get('X-Renderwolf-Credits')),
|
|
62
|
+
renderMs: num(resp.headers.get('X-Renderwolf-Render-Ms')),
|
|
63
|
+
cached: resp.headers.get('X-Renderwolf-Cache') === 'hit',
|
|
64
|
+
requestId: requestIdOf(resp),
|
|
65
|
+
headers: resp.headers,
|
|
66
|
+
saveTo: (target) => saveBytes(target, bytes),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/* -------------------------------- jobs -------------------------------- */
|
|
70
|
+
jobs = {
|
|
71
|
+
/**
|
|
72
|
+
* Submit a durable job. Pass `idempotencyKey` to make a retry of the same
|
|
73
|
+
* submission return the same job instead of a second one.
|
|
74
|
+
*/
|
|
75
|
+
submit: async (sub, opts = {}) => {
|
|
76
|
+
const headers = {};
|
|
77
|
+
if (opts.idempotencyKey)
|
|
78
|
+
headers['Idempotency-Key'] = opts.idempotencyKey;
|
|
79
|
+
const resp = await this.send('POST', '/v1/jobs', sub, opts, headers);
|
|
80
|
+
return (await resp.json());
|
|
81
|
+
},
|
|
82
|
+
get: async (id, opts = {}) => {
|
|
83
|
+
const resp = await this.send('GET', `/v1/jobs/${encodeURIComponent(id)}`, undefined, opts);
|
|
84
|
+
return (await resp.json());
|
|
85
|
+
},
|
|
86
|
+
list: async (params = {}, opts = {}) => {
|
|
87
|
+
const resp = await this.send('GET', '/v1/jobs' + query(params), undefined, opts);
|
|
88
|
+
return (await resp.json());
|
|
89
|
+
},
|
|
90
|
+
cancel: async (id, opts = {}) => {
|
|
91
|
+
const resp = await this.send('DELETE', `/v1/jobs/${encodeURIComponent(id)}`, undefined, opts);
|
|
92
|
+
return (await resp.json());
|
|
93
|
+
},
|
|
94
|
+
/** Download a succeeded job's result. */
|
|
95
|
+
result: async (id, opts = {}) => {
|
|
96
|
+
const resp = await this.send('GET', `/v1/jobs/${encodeURIComponent(id)}/result`, undefined, opts);
|
|
97
|
+
const bytes = new Uint8Array(await resp.arrayBuffer());
|
|
98
|
+
return {
|
|
99
|
+
bytes,
|
|
100
|
+
contentType: (resp.headers.get('Content-Type') ?? 'application/octet-stream').split(';')[0].trim(),
|
|
101
|
+
credits: 0,
|
|
102
|
+
renderMs: 0,
|
|
103
|
+
cached: false,
|
|
104
|
+
requestId: requestIdOf(resp),
|
|
105
|
+
headers: resp.headers,
|
|
106
|
+
saveTo: (target) => saveBytes(target, bytes),
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
/** Poll until the job is terminal. Resolves with the job whatever the outcome; check `status`. */
|
|
110
|
+
wait: async (id, opts = {}) => {
|
|
111
|
+
const pollMs = opts.pollMs ?? 2_000;
|
|
112
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 600_000);
|
|
113
|
+
for (;;) {
|
|
114
|
+
const job = await this.jobs.get(id, { ...(opts.signal ? { signal: opts.signal } : {}) });
|
|
115
|
+
if (job.status === 'succeeded' || job.status === 'failed' || job.status === 'cancelled') {
|
|
116
|
+
return job;
|
|
117
|
+
}
|
|
118
|
+
if (Date.now() >= deadline) {
|
|
119
|
+
throw new RenderwolfError(`job ${id} did not finish within the wait timeout`, { status: 0, code: 'timeout' });
|
|
120
|
+
}
|
|
121
|
+
await sleep(pollMs, opts.signal);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
/* --------------------------- destinations ---------------------------- */
|
|
126
|
+
destinations = {
|
|
127
|
+
/**
|
|
128
|
+
* Register where finished jobs go. A webhook destination's
|
|
129
|
+
* `signing_secret` is returned here once and never again; keep it.
|
|
130
|
+
*/
|
|
131
|
+
create: async (dest, opts = {}) => {
|
|
132
|
+
const resp = await this.send('POST', '/v1/destinations', dest, opts);
|
|
133
|
+
return (await resp.json());
|
|
134
|
+
},
|
|
135
|
+
list: async (opts = {}) => {
|
|
136
|
+
const resp = await this.send('GET', '/v1/destinations', undefined, opts);
|
|
137
|
+
return (await resp.json());
|
|
138
|
+
},
|
|
139
|
+
/** Reach the destination now; a failure is reported in the body, not thrown. */
|
|
140
|
+
test: async (id, opts = {}) => {
|
|
141
|
+
const resp = await this.send('POST', `/v1/destinations/${encodeURIComponent(id)}/test`, undefined, opts);
|
|
142
|
+
return (await resp.json());
|
|
143
|
+
},
|
|
144
|
+
remove: async (id, opts = {}) => {
|
|
145
|
+
await this.send('DELETE', `/v1/destinations/${encodeURIComponent(id)}`, undefined, opts);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
/* ------------------------------ the rest ------------------------------ */
|
|
149
|
+
/** Mint a signed render URL that an <img> or an email can fetch directly. */
|
|
150
|
+
async sign(req, opts = {}) {
|
|
151
|
+
const resp = await this.send('POST', '/v1/sign', req, opts);
|
|
152
|
+
return (await resp.json());
|
|
153
|
+
}
|
|
154
|
+
async usage(opts = {}) {
|
|
155
|
+
const resp = await this.send('GET', '/v1/usage', undefined, opts);
|
|
156
|
+
return (await resp.json());
|
|
157
|
+
}
|
|
158
|
+
/** Request history for the last seven days. */
|
|
159
|
+
async requests(filter = {}, opts = {}) {
|
|
160
|
+
const resp = await this.send('GET', '/v1/requests' + query(filter), undefined, opts);
|
|
161
|
+
return (await resp.json());
|
|
162
|
+
}
|
|
163
|
+
/* ------------------------------ transport ------------------------------ */
|
|
164
|
+
async send(method, path, body, opts, extra = {}) {
|
|
165
|
+
const headers = {
|
|
166
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
167
|
+
Accept: 'application/json, */*',
|
|
168
|
+
'X-Ironfang-Client': `renderwolf-js/${VERSION}`,
|
|
169
|
+
...extra,
|
|
170
|
+
};
|
|
171
|
+
if (body !== undefined)
|
|
172
|
+
headers['Content-Type'] = 'application/json';
|
|
173
|
+
if (opts.requestId)
|
|
174
|
+
headers['X-Request-ID'] = opts.requestId;
|
|
175
|
+
const controller = new AbortController();
|
|
176
|
+
const timer = setTimeout(() => controller.abort(new DOMException('timeout', 'TimeoutError')), opts.timeoutMs ?? this.timeoutMs);
|
|
177
|
+
const onAbort = () => controller.abort(opts.signal?.reason);
|
|
178
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
|
179
|
+
if (opts.signal?.aborted)
|
|
180
|
+
onAbort();
|
|
181
|
+
let resp;
|
|
182
|
+
try {
|
|
183
|
+
resp = await this.fetchImpl(this.baseUrl + path, {
|
|
184
|
+
method, headers, signal: controller.signal,
|
|
185
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
// No retry here on purpose: a synchronous render that timed out may
|
|
190
|
+
// have been charged, and retrying it blind doubles the bill.
|
|
191
|
+
if (controller.signal.aborted) {
|
|
192
|
+
const reason = controller.signal.reason;
|
|
193
|
+
const timedOut = reason instanceof DOMException && reason.name === 'TimeoutError';
|
|
194
|
+
throw new RenderwolfError(timedOut ? `request timed out after ${opts.timeoutMs ?? this.timeoutMs}ms` : 'request aborted', { status: 0, code: timedOut ? 'timeout' : 'aborted' });
|
|
195
|
+
}
|
|
196
|
+
throw new RenderwolfError(err instanceof Error ? err.message : 'network error', { status: 0, code: 'network_error' });
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
201
|
+
}
|
|
202
|
+
if (!resp.ok) {
|
|
203
|
+
throw await errorFrom(resp);
|
|
204
|
+
}
|
|
205
|
+
return resp;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function errorFrom(resp) {
|
|
209
|
+
let code = `http_${resp.status}`;
|
|
210
|
+
let message = `request failed with HTTP ${resp.status}`;
|
|
211
|
+
try {
|
|
212
|
+
const body = (await resp.json());
|
|
213
|
+
if (body?.error?.code)
|
|
214
|
+
code = body.error.code;
|
|
215
|
+
if (body?.error?.message)
|
|
216
|
+
message = body.error.message;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// Not JSON; the status is all there is.
|
|
220
|
+
}
|
|
221
|
+
return new RenderwolfError(message, { status: resp.status, code, requestId: requestIdOf(resp) });
|
|
222
|
+
}
|
|
223
|
+
function requestIdOf(resp) {
|
|
224
|
+
return resp.headers.get('X-Ironfang-Request-ID') ?? '';
|
|
225
|
+
}
|
|
226
|
+
function num(v) {
|
|
227
|
+
const n = Number(v ?? 0);
|
|
228
|
+
return Number.isFinite(n) ? n : 0;
|
|
229
|
+
}
|
|
230
|
+
function query(params) {
|
|
231
|
+
const q = new URLSearchParams();
|
|
232
|
+
for (const [k, v] of Object.entries(params)) {
|
|
233
|
+
if (v !== undefined && v !== null && v !== '')
|
|
234
|
+
q.set(k, String(v));
|
|
235
|
+
}
|
|
236
|
+
const s = q.toString();
|
|
237
|
+
return s ? `?${s}` : '';
|
|
238
|
+
}
|
|
239
|
+
function sleep(ms, signal) {
|
|
240
|
+
return new Promise((resolve, reject) => {
|
|
241
|
+
if (signal?.aborted)
|
|
242
|
+
return reject(new RenderwolfError('wait aborted', { status: 0, code: 'aborted' }));
|
|
243
|
+
const t = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms);
|
|
244
|
+
const onAbort = () => { clearTimeout(t); reject(new RenderwolfError('wait aborted', { status: 0, code: 'aborted' })); };
|
|
245
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
async function saveBytes(path, bytes) {
|
|
249
|
+
let fs;
|
|
250
|
+
try {
|
|
251
|
+
fs = await import('node:fs/promises');
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
throw new RenderwolfError('saveTo needs a filesystem; use the bytes directly here', { status: 0, code: 'unsupported' });
|
|
255
|
+
}
|
|
256
|
+
await fs.writeFile(path, bytes);
|
|
257
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An error the API returned, or a failure to reach it.
|
|
3
|
+
*
|
|
4
|
+
* `code` is the API's stable error code (`bad_request`, `quota_exhausted`,
|
|
5
|
+
* `render_failed`, ...) or a client-side one (`network_error`, `timeout`,
|
|
6
|
+
* `aborted`). `requestId` is the id to quote to support; it is on every
|
|
7
|
+
* response the server produced, including errors.
|
|
8
|
+
*/
|
|
9
|
+
export declare class RenderwolfError extends Error {
|
|
10
|
+
readonly status: number;
|
|
11
|
+
readonly code: string;
|
|
12
|
+
readonly requestId: string | undefined;
|
|
13
|
+
constructor(message: string, opts: {
|
|
14
|
+
status: number;
|
|
15
|
+
code: string;
|
|
16
|
+
requestId?: string | undefined;
|
|
17
|
+
});
|
|
18
|
+
/** The request was refused before any work was done. */
|
|
19
|
+
get isClientError(): boolean;
|
|
20
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An error the API returned, or a failure to reach it.
|
|
3
|
+
*
|
|
4
|
+
* `code` is the API's stable error code (`bad_request`, `quota_exhausted`,
|
|
5
|
+
* `render_failed`, ...) or a client-side one (`network_error`, `timeout`,
|
|
6
|
+
* `aborted`). `requestId` is the id to quote to support; it is on every
|
|
7
|
+
* response the server produced, including errors.
|
|
8
|
+
*/
|
|
9
|
+
export class RenderwolfError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
code;
|
|
12
|
+
requestId;
|
|
13
|
+
constructor(message, opts) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'RenderwolfError';
|
|
16
|
+
this.status = opts.status;
|
|
17
|
+
this.code = opts.code;
|
|
18
|
+
this.requestId = opts.requestId;
|
|
19
|
+
}
|
|
20
|
+
/** The request was refused before any work was done. */
|
|
21
|
+
get isClientError() {
|
|
22
|
+
return this.status >= 400 && this.status < 500;
|
|
23
|
+
}
|
|
24
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { Renderwolf, VERSION } from './client.js';
|
|
2
|
+
export type { ClientOptions, RequestOptions, RenderResult, SitePreviewResult, WaitOptions } from './client.js';
|
|
3
|
+
export { RenderwolfError } from './errors.js';
|
|
4
|
+
export { verifyWebhook, signWebhook } from './webhook.js';
|
|
5
|
+
export type { WebhookInput } from './webhook.js';
|
|
6
|
+
export type * from './types.js';
|
package/dist/index.js
ADDED
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/** Options shared by every browser-driven render. */
|
|
2
|
+
export interface RenderCommon {
|
|
3
|
+
no_cache?: boolean;
|
|
4
|
+
block_ads?: boolean;
|
|
5
|
+
block_cookie_banners?: boolean;
|
|
6
|
+
hide_selectors?: string[];
|
|
7
|
+
headers?: Record<string, string>;
|
|
8
|
+
cookies?: {
|
|
9
|
+
name: string;
|
|
10
|
+
value?: string;
|
|
11
|
+
domain: string;
|
|
12
|
+
path?: string;
|
|
13
|
+
}[];
|
|
14
|
+
authorization?: string;
|
|
15
|
+
user_agent?: string;
|
|
16
|
+
wait_until?: 'load' | 'domcontentloaded' | 'networkidle';
|
|
17
|
+
wait_for_selector?: string;
|
|
18
|
+
timeout_ms?: number;
|
|
19
|
+
device_scale_factor?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface ScreenshotRequest extends RenderCommon {
|
|
22
|
+
url?: string;
|
|
23
|
+
html?: string;
|
|
24
|
+
width?: number;
|
|
25
|
+
height?: number;
|
|
26
|
+
full_page?: boolean;
|
|
27
|
+
full_page_max_height?: number;
|
|
28
|
+
selector?: string;
|
|
29
|
+
clip?: {
|
|
30
|
+
x: number;
|
|
31
|
+
y: number;
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
};
|
|
35
|
+
omit_background?: boolean;
|
|
36
|
+
dark_mode?: boolean;
|
|
37
|
+
format?: 'png' | 'jpeg' | 'webp';
|
|
38
|
+
quality?: number;
|
|
39
|
+
device?: 'desktop' | 'tablet' | 'mobile';
|
|
40
|
+
delay_ms?: number;
|
|
41
|
+
}
|
|
42
|
+
export interface PdfRequest extends RenderCommon {
|
|
43
|
+
url?: string;
|
|
44
|
+
html?: string;
|
|
45
|
+
landscape?: boolean;
|
|
46
|
+
paper_format?: 'a3' | 'a4' | 'a5' | 'letter' | 'legal' | 'tabloid';
|
|
47
|
+
margin?: {
|
|
48
|
+
top?: number;
|
|
49
|
+
right?: number;
|
|
50
|
+
bottom?: number;
|
|
51
|
+
left?: number;
|
|
52
|
+
};
|
|
53
|
+
print_background?: boolean;
|
|
54
|
+
header_html?: string;
|
|
55
|
+
footer_html?: string;
|
|
56
|
+
scale?: number;
|
|
57
|
+
}
|
|
58
|
+
export interface QrRequest {
|
|
59
|
+
data: string;
|
|
60
|
+
size?: number;
|
|
61
|
+
ecc?: 'L' | 'M' | 'Q' | 'H';
|
|
62
|
+
dark?: string;
|
|
63
|
+
light?: string;
|
|
64
|
+
dots?: 'square' | 'circle';
|
|
65
|
+
eyes?: 'square' | 'rounded';
|
|
66
|
+
margin?: number;
|
|
67
|
+
invert?: boolean;
|
|
68
|
+
logo?: string;
|
|
69
|
+
logo_url?: string;
|
|
70
|
+
logo_size?: number;
|
|
71
|
+
logo_pad?: boolean;
|
|
72
|
+
}
|
|
73
|
+
export interface ImageRequest {
|
|
74
|
+
vars?: Record<string, unknown>;
|
|
75
|
+
format?: 'png' | 'jpeg' | 'webp';
|
|
76
|
+
}
|
|
77
|
+
export interface ClipRequest {
|
|
78
|
+
size?: 'vertical' | 'square' | 'landscape' | '720p';
|
|
79
|
+
duration?: number;
|
|
80
|
+
colour?: string;
|
|
81
|
+
background?: string;
|
|
82
|
+
watermark?: string;
|
|
83
|
+
audio?: string;
|
|
84
|
+
font_size?: number;
|
|
85
|
+
captions?: {
|
|
86
|
+
text: string;
|
|
87
|
+
start?: number;
|
|
88
|
+
end?: number;
|
|
89
|
+
}[];
|
|
90
|
+
}
|
|
91
|
+
export interface SitePreviewRequest extends RenderCommon {
|
|
92
|
+
url: string;
|
|
93
|
+
width?: number;
|
|
94
|
+
height?: number;
|
|
95
|
+
motion?: 'per_page' | 'single_sweep';
|
|
96
|
+
dark_mode?: boolean;
|
|
97
|
+
device?: 'desktop' | 'tablet' | 'mobile';
|
|
98
|
+
delay_ms?: number;
|
|
99
|
+
}
|
|
100
|
+
export type JobKind = 'screenshot' | 'pdf' | 'qr' | 'image' | 'clip' | 'site_preview';
|
|
101
|
+
export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
|
|
102
|
+
export interface JobDelivery {
|
|
103
|
+
webhook_destination?: string;
|
|
104
|
+
storage_destination?: string;
|
|
105
|
+
storage_key?: string;
|
|
106
|
+
}
|
|
107
|
+
export interface JobSubmission {
|
|
108
|
+
kind: JobKind;
|
|
109
|
+
request: Record<string, unknown>;
|
|
110
|
+
external_id?: string;
|
|
111
|
+
delivery?: JobDelivery;
|
|
112
|
+
}
|
|
113
|
+
export interface Job {
|
|
114
|
+
id: string;
|
|
115
|
+
kind: JobKind;
|
|
116
|
+
status: JobStatus;
|
|
117
|
+
external_id?: string;
|
|
118
|
+
batch_id?: string;
|
|
119
|
+
cancel_requested: boolean;
|
|
120
|
+
attempts: number;
|
|
121
|
+
created_at: string;
|
|
122
|
+
queued_at: string;
|
|
123
|
+
started_at?: string | null;
|
|
124
|
+
completed_at?: string | null;
|
|
125
|
+
credits: {
|
|
126
|
+
reserved: number;
|
|
127
|
+
charged: number;
|
|
128
|
+
refunded: number;
|
|
129
|
+
};
|
|
130
|
+
request_summary?: Record<string, unknown>;
|
|
131
|
+
delivery?: JobDelivery;
|
|
132
|
+
error?: {
|
|
133
|
+
code: string;
|
|
134
|
+
message: string;
|
|
135
|
+
};
|
|
136
|
+
result?: {
|
|
137
|
+
available: boolean;
|
|
138
|
+
content_type: string;
|
|
139
|
+
bytes: number;
|
|
140
|
+
sha256: string;
|
|
141
|
+
expires_at?: string | null;
|
|
142
|
+
url?: string;
|
|
143
|
+
url_expires_at?: string;
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
export interface SignRequest {
|
|
147
|
+
kind: 'screenshot' | 'image';
|
|
148
|
+
url?: string;
|
|
149
|
+
template?: string;
|
|
150
|
+
vars?: Record<string, unknown>;
|
|
151
|
+
width?: number;
|
|
152
|
+
height?: number;
|
|
153
|
+
full_page?: boolean;
|
|
154
|
+
ttl_hours?: number;
|
|
155
|
+
}
|
|
156
|
+
export interface SignedUrl {
|
|
157
|
+
url: string;
|
|
158
|
+
path: string;
|
|
159
|
+
}
|
|
160
|
+
export interface Usage {
|
|
161
|
+
period: string;
|
|
162
|
+
credits: number;
|
|
163
|
+
renders: number;
|
|
164
|
+
limit: number;
|
|
165
|
+
period_start: string;
|
|
166
|
+
period_end: string;
|
|
167
|
+
by_kind?: Record<string, {
|
|
168
|
+
credits: number;
|
|
169
|
+
count: number;
|
|
170
|
+
}>;
|
|
171
|
+
unattributed?: number;
|
|
172
|
+
daily?: {
|
|
173
|
+
day: string;
|
|
174
|
+
credits: number;
|
|
175
|
+
by_kind?: Record<string, number>;
|
|
176
|
+
}[];
|
|
177
|
+
}
|
|
178
|
+
/** One row of request history: what was asked for and what happened, never the values. */
|
|
179
|
+
export interface RequestRecord {
|
|
180
|
+
id: string;
|
|
181
|
+
api_key_id?: string;
|
|
182
|
+
external_id?: string;
|
|
183
|
+
source: 'api' | 'dashboard' | 'delegated' | 'job';
|
|
184
|
+
kind: string;
|
|
185
|
+
job_id?: string;
|
|
186
|
+
batch_id?: string;
|
|
187
|
+
status: number;
|
|
188
|
+
outcome: 'ok' | 'error' | 'cancelled';
|
|
189
|
+
error_code?: string;
|
|
190
|
+
cache_hit: boolean;
|
|
191
|
+
credits_charged: number;
|
|
192
|
+
credits_refunded: number;
|
|
193
|
+
response_bytes: number;
|
|
194
|
+
content_type?: string;
|
|
195
|
+
duration_ms: number;
|
|
196
|
+
target_origin?: string;
|
|
197
|
+
target_path?: string;
|
|
198
|
+
option_fields: string[];
|
|
199
|
+
client?: string;
|
|
200
|
+
created_at: string;
|
|
201
|
+
}
|
|
202
|
+
export interface RequestFilter {
|
|
203
|
+
since?: string;
|
|
204
|
+
until?: string;
|
|
205
|
+
outcome?: 'ok' | 'error' | 'cancelled';
|
|
206
|
+
kind?: string;
|
|
207
|
+
cache?: 'hit' | 'miss';
|
|
208
|
+
key?: string;
|
|
209
|
+
error?: string;
|
|
210
|
+
id?: string;
|
|
211
|
+
limit?: number;
|
|
212
|
+
offset?: number;
|
|
213
|
+
}
|
|
214
|
+
export type DestinationDraft = {
|
|
215
|
+
type: 'webhook';
|
|
216
|
+
name: string;
|
|
217
|
+
url: string;
|
|
218
|
+
} | {
|
|
219
|
+
type: 's3';
|
|
220
|
+
name: string;
|
|
221
|
+
endpoint?: string;
|
|
222
|
+
region: string;
|
|
223
|
+
bucket: string;
|
|
224
|
+
prefix?: string;
|
|
225
|
+
path_style?: boolean;
|
|
226
|
+
access_key: string;
|
|
227
|
+
secret_key: string;
|
|
228
|
+
session_token?: string;
|
|
229
|
+
};
|
|
230
|
+
export interface Destination {
|
|
231
|
+
id: string;
|
|
232
|
+
type: 'webhook' | 's3';
|
|
233
|
+
name: string;
|
|
234
|
+
enabled: boolean;
|
|
235
|
+
created_at?: string;
|
|
236
|
+
url?: string;
|
|
237
|
+
endpoint?: string;
|
|
238
|
+
region?: string;
|
|
239
|
+
bucket?: string;
|
|
240
|
+
prefix?: string;
|
|
241
|
+
/** Present on a webhook destination's creation response only. */
|
|
242
|
+
signing_secret?: string;
|
|
243
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify a Renderwolf webhook delivery.
|
|
3
|
+
*
|
|
4
|
+
* Renderwolf signs each delivery with HMAC-SHA256 over `<timestamp>.<raw body>`
|
|
5
|
+
* using your endpoint's signing secret, and sends the result as
|
|
6
|
+
* `Renderwolf-Signature: v1=<hex>` with `Renderwolf-Timestamp` beside it. The
|
|
7
|
+
* check must run over the raw bytes as received: re-serialising parsed JSON
|
|
8
|
+
* changes them and the signature will not match.
|
|
9
|
+
*/
|
|
10
|
+
export interface WebhookInput {
|
|
11
|
+
/** The endpoint's signing secret, shown once when the destination was created. */
|
|
12
|
+
secret: string;
|
|
13
|
+
/** The `Renderwolf-Timestamp` header. */
|
|
14
|
+
timestamp: string;
|
|
15
|
+
/** The `Renderwolf-Signature` header. */
|
|
16
|
+
signature: string;
|
|
17
|
+
/** The request body exactly as received. */
|
|
18
|
+
payload: Uint8Array | string;
|
|
19
|
+
/**
|
|
20
|
+
* Reject deliveries whose timestamp is further than this from now, so a
|
|
21
|
+
* captured delivery cannot be replayed later. Default five minutes; pass
|
|
22
|
+
* 0 to skip the check (for tests with fixed timestamps).
|
|
23
|
+
*/
|
|
24
|
+
toleranceSeconds?: number;
|
|
25
|
+
/** Override the clock, for tests. Seconds since the epoch. */
|
|
26
|
+
now?: number;
|
|
27
|
+
}
|
|
28
|
+
export declare function verifyWebhook(input: WebhookInput): Promise<boolean>;
|
|
29
|
+
/** Compute the signature Renderwolf would send. Exposed for tests and for tooling that replays deliveries. */
|
|
30
|
+
export declare function signWebhook(secret: string, timestamp: string, payload: Uint8Array | string): Promise<string>;
|
package/dist/webhook.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const encoder = new TextEncoder();
|
|
2
|
+
export async function verifyWebhook(input) {
|
|
3
|
+
const tolerance = input.toleranceSeconds ?? 300;
|
|
4
|
+
if (tolerance > 0) {
|
|
5
|
+
const ts = Number(input.timestamp);
|
|
6
|
+
const now = input.now ?? Math.floor(Date.now() / 1000);
|
|
7
|
+
if (!Number.isFinite(ts) || Math.abs(now - ts) > tolerance) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const expected = await signWebhook(input.secret, input.timestamp, input.payload);
|
|
12
|
+
return constantTimeEqual(expected, input.signature.trim());
|
|
13
|
+
}
|
|
14
|
+
/** Compute the signature Renderwolf would send. Exposed for tests and for tooling that replays deliveries. */
|
|
15
|
+
export async function signWebhook(secret, timestamp, payload) {
|
|
16
|
+
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
17
|
+
const body = typeof payload === 'string' ? encoder.encode(payload) : payload;
|
|
18
|
+
const signed = new Uint8Array(encoder.encode(timestamp + '.').length + body.length);
|
|
19
|
+
signed.set(encoder.encode(timestamp + '.'), 0);
|
|
20
|
+
signed.set(body, encoder.encode(timestamp + '.').length);
|
|
21
|
+
const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, signed));
|
|
22
|
+
return 'v1=' + Array.from(mac, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
23
|
+
}
|
|
24
|
+
function constantTimeEqual(a, b) {
|
|
25
|
+
const x = encoder.encode(a);
|
|
26
|
+
const y = encoder.encode(b);
|
|
27
|
+
let diff = x.length ^ y.length;
|
|
28
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
29
|
+
diff |= (x[i] ?? 0) ^ (y[i] ?? 0);
|
|
30
|
+
}
|
|
31
|
+
return diff === 0;
|
|
32
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ironfang/renderwolf",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Renderwolf API client: screenshots, PDFs, QR codes, template images, clips and site previews from a URL or HTML.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.json",
|
|
24
|
+
"test": "tsc -p tsconfig.test.json && node --test --test-reporter=spec dist-test/test/*.test.js",
|
|
25
|
+
"prepublishOnly": "npm run build && npm test"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^24.3.0",
|
|
29
|
+
"typescript": "^5.9.2"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/ironfang-ltd/renderwolf-js"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://ironfang.uk/renderwolf",
|
|
36
|
+
"keywords": [
|
|
37
|
+
"screenshot",
|
|
38
|
+
"pdf",
|
|
39
|
+
"qr",
|
|
40
|
+
"html-to-image",
|
|
41
|
+
"renderwolf",
|
|
42
|
+
"ironfang"
|
|
43
|
+
]
|
|
44
|
+
}
|