@ironfang/render 2.0.0 → 3.0.1
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 +67 -7
- package/dist/client.d.ts +29 -11
- package/dist/client.js +47 -14
- package/dist/errors.d.ts +38 -4
- package/dist/errors.js +49 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/types.d.ts +62 -21
- package/dist/webhook.d.ts +7 -1
- package/dist/webhook.js +19 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,6 +7,17 @@ URL or from HTML you send. Zero dependencies; Node 20 and later.
|
|
|
7
7
|
Formerly `@ironfang/renderwolf`; 2.0.0 removed the old `Renderwolf` and
|
|
8
8
|
`RenderwolfError` exports. Webhooks are signed with the `Ironfang-*` headers.
|
|
9
9
|
|
|
10
|
+
3.0.0 follows Ironfang's move from credits to metered usage on one billing
|
|
11
|
+
account: a render reports `meter` and `quantity` instead of `credits`, a job
|
|
12
|
+
reports `usage` instead of `credits`, `usage()` returns the month per meter,
|
|
13
|
+
request history records `meter` and `quantity`, and billing refusals throw
|
|
14
|
+
`IronfangBillingError`. Renders accept `idempotencyKey`.
|
|
15
|
+
|
|
16
|
+
3.0.1 fixes `verifyWebhook` for Render: it used the text of the signing secret as
|
|
17
|
+
the HMAC key, while a Render secret is the hex of the 32-byte key deliveries
|
|
18
|
+
are signed with, so it rejected every genuine delivery. Upgrade if you
|
|
19
|
+
verify Render webhooks with it.
|
|
20
|
+
|
|
10
21
|
```sh
|
|
11
22
|
npm install @ironfang/render
|
|
12
23
|
```
|
|
@@ -18,12 +29,12 @@ const rw = new IronfangRender({ apiKey: process.env.IRONFANG_API_KEY! });
|
|
|
18
29
|
|
|
19
30
|
const shot = await rw.screenshot({ url: 'https://example.com', full_page: true });
|
|
20
31
|
await shot.saveTo('page.png');
|
|
21
|
-
console.log(shot.
|
|
32
|
+
console.log(shot.meter, shot.quantity, shot.renderMs, shot.requestId);
|
|
22
33
|
```
|
|
23
34
|
|
|
24
35
|
Every render returns the bytes plus what the API reports about them: content
|
|
25
|
-
type,
|
|
26
|
-
to quote to support.
|
|
36
|
+
type, the billing meter it counted against and the quantity it counted there
|
|
37
|
+
(zero on a cache hit), render time and the request id to quote to support.
|
|
27
38
|
|
|
28
39
|
## Change the page before capture
|
|
29
40
|
|
|
@@ -68,9 +79,32 @@ if (done.status === 'succeeded') {
|
|
|
68
79
|
}
|
|
69
80
|
```
|
|
70
81
|
|
|
82
|
+
The job's `usage` says what it holds: `{ meter, reserved, committed }`. The
|
|
83
|
+
most the job can count is reserved when it is accepted, and what it produced
|
|
84
|
+
is committed when it succeeds. A job that fails or is cancelled while queued
|
|
85
|
+
counts nothing.
|
|
86
|
+
|
|
87
|
+
## Usage and billing
|
|
88
|
+
|
|
89
|
+
Ironfang Render has no plans or credits. Usage is billed to your
|
|
90
|
+
organisation's Ironfang billing account, shared by every Ironfang product and
|
|
91
|
+
managed in Billing in the [Ironfang portal](https://portal.ironfang.com). Each
|
|
92
|
+
kind of render counts against a meter, and each meter has a monthly free
|
|
93
|
+
allowance; beyond it, usage is pay-as-you-go once paid usage is enabled.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
const usage = await rw.usage();
|
|
97
|
+
if (!('unmetered' in usage)) {
|
|
98
|
+
for (const m of usage.meters) console.log(m.id, m.used, m.remaining_free);
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
71
102
|
## Verifying a webhook
|
|
72
103
|
|
|
73
|
-
Run the check over the raw request body, before parsing it.
|
|
104
|
+
Run the check over the raw request body, before parsing it. Pass the signing
|
|
105
|
+
secret exactly as it was shown when the destination was created: a Render (or
|
|
106
|
+
Finance) secret is 64 hex characters, and the check decodes it to the 32-byte
|
|
107
|
+
key; an Ironfang Audit secret (`awsec_...`) is used as it is.
|
|
74
108
|
|
|
75
109
|
```ts
|
|
76
110
|
import { verifyWebhook } from '@ironfang/render';
|
|
@@ -86,9 +120,35 @@ const ok = await verifyWebhook({
|
|
|
86
120
|
## Errors
|
|
87
121
|
|
|
88
122
|
Failures throw `IronfangRenderError` with `status`, the API's `code`
|
|
89
|
-
(`
|
|
90
|
-
|
|
91
|
-
|
|
123
|
+
(`render_failed`, `rate_limited`, ...), `requestId` and, when the API sent
|
|
124
|
+
one, `retryAfter` in seconds. Branch on `code` rather than the status: rate
|
|
125
|
+
limits and billing refusals can share a 429.
|
|
126
|
+
|
|
127
|
+
When the billing account refuses a render, nothing is rendered or counted and
|
|
128
|
+
the error is an `IronfangBillingError` (a subclass) with `product`, and
|
|
129
|
+
`meter` and `resetAt` when the API states them. The codes are
|
|
130
|
+
`free_allowance_exhausted`, `account_budget_exhausted`,
|
|
131
|
+
`product_budget_exhausted`, `exemption_limit_reached`, `paid_usage_paused`,
|
|
132
|
+
`payment_required`, `payment_action_required`, `account_restricted`,
|
|
133
|
+
`product_not_eligible`, `account_closed`, `operation_too_large` and
|
|
134
|
+
`billing_temporarily_unavailable`. Only the last clears by itself, after
|
|
135
|
+
`retryAfter`; the rest are resolved in Billing or when the month rolls over.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { IronfangBillingError } from '@ironfang/render';
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
await rw.screenshot({ url: 'https://example.com' });
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (err instanceof IronfangBillingError) console.error(err.code, err.resetAt);
|
|
144
|
+
else throw err;
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Synchronous renders are never retried by the client: a render that timed out
|
|
149
|
+
may already have counted, and retrying it blind counts it twice. Pass
|
|
150
|
+
`idempotencyKey` to make a render count at most once; the same key again is
|
|
151
|
+
refused with `idempotency_key_used`, since a synchronous result is not kept.
|
|
92
152
|
|
|
93
153
|
## Keys
|
|
94
154
|
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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 = "
|
|
1
|
+
import type { ClipRequest, Destination, DestinationDraft, ImageRequest, Job, JobSubmission, PdfRequest, QrRequest, RequestFilter, RequestRecord, ScreenshotRequest, SignRequest, SignedUrl, SitePreviewRequest, UnmeteredUsage, Usage } from './types.js';
|
|
2
|
+
export declare const VERSION = "3.0.1";
|
|
3
3
|
export interface ClientOptions {
|
|
4
4
|
/** Your `rw_live_...` key. Never ship this to a browser. */
|
|
5
5
|
apiKey: string;
|
|
@@ -11,18 +11,27 @@ export interface ClientOptions {
|
|
|
11
11
|
fetch?: typeof fetch;
|
|
12
12
|
}
|
|
13
13
|
export interface RequestOptions {
|
|
14
|
-
/** Cancel the request. A synchronous render that has started may still
|
|
14
|
+
/** Cancel the request. A synchronous render that has started may still count. */
|
|
15
15
|
signal?: AbortSignal;
|
|
16
16
|
/** Your own correlation id, echoed as X-Request-ID and kept in request history. */
|
|
17
17
|
requestId?: string;
|
|
18
18
|
timeoutMs?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Sent as Idempotency-Key, up to 255 characters. On a render it makes the
|
|
21
|
+
* render happen, and count, at most once: the same key again is refused
|
|
22
|
+
* with `idempotency_key_used`, because a synchronous result is not kept.
|
|
23
|
+
* On a job submission the same key returns the existing job.
|
|
24
|
+
*/
|
|
25
|
+
idempotencyKey?: string;
|
|
19
26
|
}
|
|
20
27
|
/** A finished synchronous render with the facts the API reports about it. */
|
|
21
28
|
export interface RenderResult {
|
|
22
29
|
bytes: Uint8Array;
|
|
23
30
|
contentType: string;
|
|
24
|
-
/**
|
|
25
|
-
|
|
31
|
+
/** The billing meter the render counted against, e.g. `render.screenshot`. */
|
|
32
|
+
meter: string | undefined;
|
|
33
|
+
/** What it counted there: 1 for a unit render, output seconds for video, 0 from cache. */
|
|
34
|
+
quantity: number;
|
|
26
35
|
renderMs: number;
|
|
27
36
|
cached: boolean;
|
|
28
37
|
/** The id to quote to support. */
|
|
@@ -36,7 +45,10 @@ export interface RenderResult {
|
|
|
36
45
|
export interface SitePreviewResult {
|
|
37
46
|
poster: Uint8Array;
|
|
38
47
|
video: Uint8Array;
|
|
39
|
-
|
|
48
|
+
/** `render.site_preview_second`. */
|
|
49
|
+
meter: string | undefined;
|
|
50
|
+
/** Output seconds counted, rounded up; 0 from cache. */
|
|
51
|
+
quantity: number;
|
|
40
52
|
renderMs: number;
|
|
41
53
|
outputSeconds: number;
|
|
42
54
|
cached: boolean;
|
|
@@ -66,11 +78,11 @@ export declare class IronfangRender {
|
|
|
66
78
|
readonly jobs: {
|
|
67
79
|
/**
|
|
68
80
|
* Submit a durable job. Pass `idempotencyKey` to make a retry of the same
|
|
69
|
-
* submission return the same job instead of a second one.
|
|
81
|
+
* submission return the same job instead of a second one. The most the
|
|
82
|
+
* job can count is reserved when it is accepted; its `usage` says what it
|
|
83
|
+
* holds and, once it succeeds, what it committed.
|
|
70
84
|
*/
|
|
71
|
-
submit: (sub: JobSubmission, opts?: RequestOptions
|
|
72
|
-
idempotencyKey?: string;
|
|
73
|
-
}) => Promise<Job>;
|
|
85
|
+
submit: (sub: JobSubmission, opts?: RequestOptions) => Promise<Job>;
|
|
74
86
|
get: (id: string, opts?: RequestOptions) => Promise<Job>;
|
|
75
87
|
list: (params?: {
|
|
76
88
|
status?: string;
|
|
@@ -104,7 +116,13 @@ export declare class IronfangRender {
|
|
|
104
116
|
};
|
|
105
117
|
/** Mint a signed render URL that an <img> or an email can fetch directly. */
|
|
106
118
|
sign(req: SignRequest, opts?: RequestOptions): Promise<SignedUrl>;
|
|
107
|
-
|
|
119
|
+
/**
|
|
120
|
+
* This month's Ironfang Render usage from your organisation's billing
|
|
121
|
+
* account: per meter, what was used, how much the free allowance covered
|
|
122
|
+
* and what is held by work in flight. A deployment with billing off, or an
|
|
123
|
+
* unmetered key, answers `{ unmetered: true }`.
|
|
124
|
+
*/
|
|
125
|
+
usage(opts?: RequestOptions): Promise<Usage | UnmeteredUsage>;
|
|
108
126
|
/** Request history for the last seven days. */
|
|
109
127
|
requests(filter?: RequestFilter, opts?: RequestOptions): Promise<{
|
|
110
128
|
requests: RequestRecord[];
|
package/dist/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { IronfangRenderError } from './errors.js';
|
|
2
|
-
export const VERSION = '
|
|
1
|
+
import { BILLING_CODES, IronfangBillingError, IronfangRenderError } from './errors.js';
|
|
2
|
+
export const VERSION = '3.0.1';
|
|
3
3
|
export class IronfangRender {
|
|
4
4
|
apiKey;
|
|
5
5
|
baseUrl;
|
|
@@ -32,7 +32,7 @@ export class IronfangRender {
|
|
|
32
32
|
return this.render('/v1/video', req, opts);
|
|
33
33
|
}
|
|
34
34
|
async sitePreview(req, opts = {}) {
|
|
35
|
-
const resp = await this.send('POST', '/v1/site-preview', req, opts);
|
|
35
|
+
const resp = await this.send('POST', '/v1/site-preview', req, opts, idempotency(opts));
|
|
36
36
|
const form = await resp.formData();
|
|
37
37
|
const poster = form.get('poster');
|
|
38
38
|
const video = form.get('video');
|
|
@@ -44,7 +44,7 @@ export class IronfangRender {
|
|
|
44
44
|
return {
|
|
45
45
|
poster: new Uint8Array(await poster.arrayBuffer()),
|
|
46
46
|
video: new Uint8Array(await video.arrayBuffer()),
|
|
47
|
-
|
|
47
|
+
...usageOf(resp),
|
|
48
48
|
renderMs: num(resp.headers.get('X-Ironfang-Render-Ms')),
|
|
49
49
|
outputSeconds: num(resp.headers.get('X-Ironfang-Output-Seconds')),
|
|
50
50
|
cached: resp.headers.get('X-Ironfang-Cache') === 'hit',
|
|
@@ -53,12 +53,12 @@ export class IronfangRender {
|
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
async render(path, body, opts) {
|
|
56
|
-
const resp = await this.send('POST', path, body, opts);
|
|
56
|
+
const resp = await this.send('POST', path, body, opts, idempotency(opts));
|
|
57
57
|
const bytes = new Uint8Array(await resp.arrayBuffer());
|
|
58
58
|
return {
|
|
59
59
|
bytes,
|
|
60
60
|
contentType: (resp.headers.get('Content-Type') ?? 'application/octet-stream').split(';')[0].trim(),
|
|
61
|
-
|
|
61
|
+
...usageOf(resp),
|
|
62
62
|
renderMs: num(resp.headers.get('X-Ironfang-Render-Ms')),
|
|
63
63
|
cached: resp.headers.get('X-Ironfang-Cache') === 'hit',
|
|
64
64
|
requestId: requestIdOf(resp),
|
|
@@ -70,13 +70,12 @@ export class IronfangRender {
|
|
|
70
70
|
jobs = {
|
|
71
71
|
/**
|
|
72
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.
|
|
73
|
+
* submission return the same job instead of a second one. The most the
|
|
74
|
+
* job can count is reserved when it is accepted; its `usage` says what it
|
|
75
|
+
* holds and, once it succeeds, what it committed.
|
|
74
76
|
*/
|
|
75
77
|
submit: async (sub, opts = {}) => {
|
|
76
|
-
const
|
|
77
|
-
if (opts.idempotencyKey)
|
|
78
|
-
headers['Idempotency-Key'] = opts.idempotencyKey;
|
|
79
|
-
const resp = await this.send('POST', '/v1/jobs', sub, opts, headers);
|
|
78
|
+
const resp = await this.send('POST', '/v1/jobs', sub, opts, idempotency(opts));
|
|
80
79
|
return (await resp.json());
|
|
81
80
|
},
|
|
82
81
|
get: async (id, opts = {}) => {
|
|
@@ -98,7 +97,9 @@ export class IronfangRender {
|
|
|
98
97
|
return {
|
|
99
98
|
bytes,
|
|
100
99
|
contentType: (resp.headers.get('Content-Type') ?? 'application/octet-stream').split(';')[0].trim(),
|
|
101
|
-
|
|
100
|
+
// Collecting a result counts nothing; the job's `usage` says what it did.
|
|
101
|
+
meter: undefined,
|
|
102
|
+
quantity: 0,
|
|
102
103
|
renderMs: 0,
|
|
103
104
|
cached: false,
|
|
104
105
|
requestId: requestIdOf(resp),
|
|
@@ -151,6 +152,12 @@ export class IronfangRender {
|
|
|
151
152
|
const resp = await this.send('POST', '/v1/sign', req, opts);
|
|
152
153
|
return (await resp.json());
|
|
153
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* This month's Ironfang Render usage from your organisation's billing
|
|
157
|
+
* account: per meter, what was used, how much the free allowance covered
|
|
158
|
+
* and what is held by work in flight. A deployment with billing off, or an
|
|
159
|
+
* unmetered key, answers `{ unmetered: true }`.
|
|
160
|
+
*/
|
|
154
161
|
async usage(opts = {}) {
|
|
155
162
|
const resp = await this.send('GET', '/v1/usage', undefined, opts);
|
|
156
163
|
return (await resp.json());
|
|
@@ -187,7 +194,7 @@ export class IronfangRender {
|
|
|
187
194
|
}
|
|
188
195
|
catch (err) {
|
|
189
196
|
// No retry here on purpose: a synchronous render that timed out may
|
|
190
|
-
// have
|
|
197
|
+
// already have counted, and retrying it blind counts it twice.
|
|
191
198
|
if (controller.signal.aborted) {
|
|
192
199
|
const reason = controller.signal.reason;
|
|
193
200
|
const timedOut = reason instanceof DOMException && reason.name === 'TimeoutError';
|
|
@@ -208,17 +215,43 @@ export class IronfangRender {
|
|
|
208
215
|
async function errorFrom(resp) {
|
|
209
216
|
let code = `http_${resp.status}`;
|
|
210
217
|
let message = `request failed with HTTP ${resp.status}`;
|
|
218
|
+
let detail = {};
|
|
211
219
|
try {
|
|
212
220
|
const body = (await resp.json());
|
|
213
221
|
if (body?.error?.code)
|
|
214
222
|
code = body.error.code;
|
|
215
223
|
if (body?.error?.message)
|
|
216
224
|
message = body.error.message;
|
|
225
|
+
if (body?.error)
|
|
226
|
+
detail = body.error;
|
|
217
227
|
}
|
|
218
228
|
catch {
|
|
219
229
|
// Not JSON; the status is all there is.
|
|
220
230
|
}
|
|
221
|
-
|
|
231
|
+
const opts = { status: resp.status, code, requestId: requestIdOf(resp), retryAfter: retryAfterOf(resp) };
|
|
232
|
+
if (BILLING_CODES.includes(code)) {
|
|
233
|
+
return new IronfangBillingError(message, {
|
|
234
|
+
...opts, product: text(detail.product), meter: text(detail.meter), resetAt: text(detail.reset_at),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return new IronfangRenderError(message, opts);
|
|
238
|
+
}
|
|
239
|
+
/** The meter and quantity a render response reports. */
|
|
240
|
+
function usageOf(resp) {
|
|
241
|
+
return { meter: resp.headers.get('X-Ironfang-Meter') ?? undefined, quantity: num(resp.headers.get('X-Ironfang-Quantity')) };
|
|
242
|
+
}
|
|
243
|
+
function idempotency(opts) {
|
|
244
|
+
return opts.idempotencyKey ? { 'Idempotency-Key': opts.idempotencyKey } : {};
|
|
245
|
+
}
|
|
246
|
+
/** Retry-After in seconds; the API sends whole seconds. */
|
|
247
|
+
function retryAfterOf(resp) {
|
|
248
|
+
const v = resp.headers.get('Retry-After');
|
|
249
|
+
if (v === null || !/^\d+$/.test(v.trim()))
|
|
250
|
+
return undefined;
|
|
251
|
+
return Number(v.trim());
|
|
252
|
+
}
|
|
253
|
+
function text(v) {
|
|
254
|
+
return typeof v === 'string' && v !== '' ? v : undefined;
|
|
222
255
|
}
|
|
223
256
|
function requestIdOf(resp) {
|
|
224
257
|
return resp.headers.get('X-Ironfang-Request-ID') ?? '';
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,20 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* An error the API returned, or a failure to reach it.
|
|
3
3
|
*
|
|
4
|
-
* `code` is the API's stable error code (`bad_request`, `
|
|
5
|
-
* `
|
|
6
|
-
* `aborted`). `requestId` is the id to quote to support; it is on
|
|
7
|
-
* response the server produced, including errors.
|
|
4
|
+
* `code` is the API's stable error code (`bad_request`, `render_failed`,
|
|
5
|
+
* `free_allowance_exhausted`, ...) or a client-side one (`network_error`,
|
|
6
|
+
* `timeout`, `aborted`). `requestId` is the id to quote to support; it is on
|
|
7
|
+
* every response the server produced, including errors. `retryAfter` is the
|
|
8
|
+
* API's `Retry-After` in seconds, when it sent one.
|
|
8
9
|
*/
|
|
9
10
|
export declare class IronfangRenderError extends Error {
|
|
10
11
|
readonly status: number;
|
|
11
12
|
readonly code: string;
|
|
12
13
|
readonly requestId: string | undefined;
|
|
14
|
+
readonly retryAfter: number | undefined;
|
|
13
15
|
constructor(message: string, opts: {
|
|
14
16
|
status: number;
|
|
15
17
|
code: string;
|
|
16
18
|
requestId?: string | undefined;
|
|
19
|
+
retryAfter?: number | undefined;
|
|
17
20
|
});
|
|
18
21
|
/** The request was refused before any work was done. */
|
|
19
22
|
get isClientError(): boolean;
|
|
20
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* The codes a billing refusal carries. They are the same in every Ironfang
|
|
26
|
+
* product, because every product admits work through the one billing account.
|
|
27
|
+
*/
|
|
28
|
+
export declare const BILLING_CODES: readonly string[];
|
|
29
|
+
/**
|
|
30
|
+
* The organisation's billing account refused the request before any work
|
|
31
|
+
* started: nothing was rendered and nothing counted. Most refusals are
|
|
32
|
+
* resolved in Billing in the Ironfang portal (enable paid usage, raise a
|
|
33
|
+
* limit, update the payment method) or by the month rolling over; only
|
|
34
|
+
* `billing_temporarily_unavailable` clears on its own, after `retryAfter`
|
|
35
|
+
* seconds.
|
|
36
|
+
*
|
|
37
|
+
* `product` is the product whose usage was refused (always `render` here).
|
|
38
|
+
* `meter` and `resetAt` (when the free allowance renews, RFC 3339) are set
|
|
39
|
+
* when the API states them.
|
|
40
|
+
*/
|
|
41
|
+
export declare class IronfangBillingError extends IronfangRenderError {
|
|
42
|
+
readonly product: string;
|
|
43
|
+
readonly meter: string | undefined;
|
|
44
|
+
readonly resetAt: string | undefined;
|
|
45
|
+
constructor(message: string, opts: {
|
|
46
|
+
status: number;
|
|
47
|
+
code: string;
|
|
48
|
+
requestId?: string | undefined;
|
|
49
|
+
retryAfter?: number | undefined;
|
|
50
|
+
product?: string | undefined;
|
|
51
|
+
meter?: string | undefined;
|
|
52
|
+
resetAt?: string | undefined;
|
|
53
|
+
});
|
|
54
|
+
}
|
package/dist/errors.js
CHANGED
|
@@ -1,24 +1,69 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* An error the API returned, or a failure to reach it.
|
|
3
3
|
*
|
|
4
|
-
* `code` is the API's stable error code (`bad_request`, `
|
|
5
|
-
* `
|
|
6
|
-
* `aborted`). `requestId` is the id to quote to support; it is on
|
|
7
|
-
* response the server produced, including errors.
|
|
4
|
+
* `code` is the API's stable error code (`bad_request`, `render_failed`,
|
|
5
|
+
* `free_allowance_exhausted`, ...) or a client-side one (`network_error`,
|
|
6
|
+
* `timeout`, `aborted`). `requestId` is the id to quote to support; it is on
|
|
7
|
+
* every response the server produced, including errors. `retryAfter` is the
|
|
8
|
+
* API's `Retry-After` in seconds, when it sent one.
|
|
8
9
|
*/
|
|
9
10
|
export class IronfangRenderError extends Error {
|
|
10
11
|
status;
|
|
11
12
|
code;
|
|
12
13
|
requestId;
|
|
14
|
+
retryAfter;
|
|
13
15
|
constructor(message, opts) {
|
|
14
16
|
super(message);
|
|
15
17
|
this.name = 'IronfangRenderError';
|
|
16
18
|
this.status = opts.status;
|
|
17
19
|
this.code = opts.code;
|
|
18
20
|
this.requestId = opts.requestId;
|
|
21
|
+
this.retryAfter = opts.retryAfter;
|
|
19
22
|
}
|
|
20
23
|
/** The request was refused before any work was done. */
|
|
21
24
|
get isClientError() {
|
|
22
25
|
return this.status >= 400 && this.status < 500;
|
|
23
26
|
}
|
|
24
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* The codes a billing refusal carries. They are the same in every Ironfang
|
|
30
|
+
* product, because every product admits work through the one billing account.
|
|
31
|
+
*/
|
|
32
|
+
export const BILLING_CODES = [
|
|
33
|
+
'free_allowance_exhausted',
|
|
34
|
+
'account_budget_exhausted',
|
|
35
|
+
'product_budget_exhausted',
|
|
36
|
+
'exemption_limit_reached',
|
|
37
|
+
'paid_usage_paused',
|
|
38
|
+
'payment_required',
|
|
39
|
+
'payment_action_required',
|
|
40
|
+
'account_restricted',
|
|
41
|
+
'product_not_eligible',
|
|
42
|
+
'account_closed',
|
|
43
|
+
'operation_too_large',
|
|
44
|
+
'billing_temporarily_unavailable',
|
|
45
|
+
];
|
|
46
|
+
/**
|
|
47
|
+
* The organisation's billing account refused the request before any work
|
|
48
|
+
* started: nothing was rendered and nothing counted. Most refusals are
|
|
49
|
+
* resolved in Billing in the Ironfang portal (enable paid usage, raise a
|
|
50
|
+
* limit, update the payment method) or by the month rolling over; only
|
|
51
|
+
* `billing_temporarily_unavailable` clears on its own, after `retryAfter`
|
|
52
|
+
* seconds.
|
|
53
|
+
*
|
|
54
|
+
* `product` is the product whose usage was refused (always `render` here).
|
|
55
|
+
* `meter` and `resetAt` (when the free allowance renews, RFC 3339) are set
|
|
56
|
+
* when the API states them.
|
|
57
|
+
*/
|
|
58
|
+
export class IronfangBillingError extends IronfangRenderError {
|
|
59
|
+
product;
|
|
60
|
+
meter;
|
|
61
|
+
resetAt;
|
|
62
|
+
constructor(message, opts) {
|
|
63
|
+
super(message, opts);
|
|
64
|
+
this.name = 'IronfangBillingError';
|
|
65
|
+
this.product = opts.product ?? 'render';
|
|
66
|
+
this.meter = opts.meter;
|
|
67
|
+
this.resetAt = opts.resetAt;
|
|
68
|
+
}
|
|
69
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { IronfangRender, VERSION } from './client.js';
|
|
2
2
|
export type { ClientOptions, RequestOptions, RenderResult, SitePreviewResult, WaitOptions } from './client.js';
|
|
3
|
-
export { IronfangRenderError } from './errors.js';
|
|
3
|
+
export { IronfangBillingError, IronfangRenderError } from './errors.js';
|
|
4
4
|
export { verifyWebhook, signWebhook } from './webhook.js';
|
|
5
5
|
export type { WebhookInput } from './webhook.js';
|
|
6
6
|
export type * from './types.js';
|
package/dist/index.js
CHANGED
package/dist/types.d.ts
CHANGED
|
@@ -118,6 +118,17 @@ export interface JobDelivery {
|
|
|
118
118
|
storage_destination?: string;
|
|
119
119
|
storage_key?: string;
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* What a job counts against the billing account: its meter, the quantity
|
|
123
|
+
* reserved when it was accepted (0 when unmetered) and the quantity its
|
|
124
|
+
* success committed, never more than was reserved. A job that fails or is
|
|
125
|
+
* cancelled commits nothing.
|
|
126
|
+
*/
|
|
127
|
+
export interface JobUsage {
|
|
128
|
+
meter: string;
|
|
129
|
+
reserved: number;
|
|
130
|
+
committed: number;
|
|
131
|
+
}
|
|
121
132
|
export interface JobSubmission {
|
|
122
133
|
kind: JobKind;
|
|
123
134
|
request: Record<string, unknown>;
|
|
@@ -136,11 +147,7 @@ export interface Job {
|
|
|
136
147
|
queued_at: string;
|
|
137
148
|
started_at?: string | null;
|
|
138
149
|
completed_at?: string | null;
|
|
139
|
-
|
|
140
|
-
reserved: number;
|
|
141
|
-
charged: number;
|
|
142
|
-
refunded: number;
|
|
143
|
-
};
|
|
150
|
+
usage: JobUsage;
|
|
144
151
|
request_summary?: Record<string, unknown>;
|
|
145
152
|
delivery?: JobDelivery;
|
|
146
153
|
error?: {
|
|
@@ -171,23 +178,55 @@ export interface SignedUrl {
|
|
|
171
178
|
url: string;
|
|
172
179
|
path: string;
|
|
173
180
|
}
|
|
181
|
+
/** One Ironfang Render meter's position in the month. */
|
|
182
|
+
export interface MeterUsage {
|
|
183
|
+
/** The meter, e.g. `render.screenshot` or `render.video_second_hd`. */
|
|
184
|
+
id: string;
|
|
185
|
+
unit: string;
|
|
186
|
+
unit_plural: string;
|
|
187
|
+
/** This month's free quantity. */
|
|
188
|
+
free_allowance: number;
|
|
189
|
+
/** Quantity committed this month. */
|
|
190
|
+
used: number;
|
|
191
|
+
/** How much of `used` the free allowance covered. */
|
|
192
|
+
free_used: number;
|
|
193
|
+
/** `used` beyond the free allowance. */
|
|
194
|
+
billable: number;
|
|
195
|
+
/** Quantity held by work in flight. */
|
|
196
|
+
reserved: number;
|
|
197
|
+
/** Free allowance left once work in flight is counted. */
|
|
198
|
+
remaining_free: number;
|
|
199
|
+
/** What `billable` costs before tax, as an exact decimal string. */
|
|
200
|
+
charge: string;
|
|
201
|
+
/** A meter that is never charged (QR codes). */
|
|
202
|
+
free_utility?: boolean;
|
|
203
|
+
/** The account is exempt from settling this product. */
|
|
204
|
+
exempt?: boolean;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* This UTC calendar month's Ironfang Render usage from the organisation's
|
|
208
|
+
* billing account. Amounts are exact decimal strings in `currency`, before
|
|
209
|
+
* tax. The whole account, every product included, is managed in Billing in
|
|
210
|
+
* the Ironfang portal.
|
|
211
|
+
*/
|
|
174
212
|
export interface Usage {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
renders: number;
|
|
178
|
-
limit: number;
|
|
213
|
+
product: 'render';
|
|
214
|
+
/** The period as [period_start, period_end) in UTC dates. */
|
|
179
215
|
period_start: string;
|
|
180
216
|
period_end: string;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
217
|
+
catalogue_version: string;
|
|
218
|
+
/** `enabled` means paid usage beyond the free allowances is on. */
|
|
219
|
+
mode: 'free' | 'setup_pending' | 'enabled' | 'paused' | 'closed';
|
|
220
|
+
currency: string;
|
|
221
|
+
/** What Ironfang Render's settled usage costs this period. */
|
|
222
|
+
settled: string;
|
|
223
|
+
/** The most work in flight could add if it all succeeds; not charged on top of `settled`. */
|
|
224
|
+
reserved: string;
|
|
225
|
+
meters: MeterUsage[];
|
|
226
|
+
}
|
|
227
|
+
/** The usage answer for an unmetered key, or a deployment with billing off. */
|
|
228
|
+
export interface UnmeteredUsage {
|
|
229
|
+
unmetered: true;
|
|
191
230
|
}
|
|
192
231
|
/** One row of request history: what was asked for and what happened, never the values. */
|
|
193
232
|
export interface RequestRecord {
|
|
@@ -202,8 +241,10 @@ export interface RequestRecord {
|
|
|
202
241
|
outcome: 'ok' | 'error' | 'cancelled';
|
|
203
242
|
error_code?: string;
|
|
204
243
|
cache_hit: boolean;
|
|
205
|
-
|
|
206
|
-
|
|
244
|
+
/** The billing meter the request counted against. */
|
|
245
|
+
meter?: string;
|
|
246
|
+
/** What it committed there: 0 for a cache hit, a failure or an unmetered key. */
|
|
247
|
+
quantity: number;
|
|
207
248
|
response_bytes: number;
|
|
208
249
|
content_type?: string;
|
|
209
250
|
duration_ms: number;
|
package/dist/webhook.d.ts
CHANGED
|
@@ -6,9 +6,15 @@
|
|
|
6
6
|
* result as `Ironfang-Signature: v1=<hex>` with `Ironfang-Timestamp` beside
|
|
7
7
|
* it. The check must run over the raw bytes as received: re-serialising parsed
|
|
8
8
|
* JSON changes them and the signature will not match.
|
|
9
|
+
*
|
|
10
|
+
* Pass the signing secret exactly as Ironfang returned it. A Render or
|
|
11
|
+
* Finance secret is 64 hex characters, the hex form of a 32-byte key, and the
|
|
12
|
+
* key is those 32 bytes; an Audit secret (`awsec_...`) is used as it is. This
|
|
13
|
+
* module decodes accordingly, so the same call verifies a delivery from any of
|
|
14
|
+
* the three.
|
|
9
15
|
*/
|
|
10
16
|
export interface WebhookInput {
|
|
11
|
-
/** The endpoint's signing secret, shown once when the destination was created. */
|
|
17
|
+
/** The endpoint's signing secret, exactly as it was shown once when the destination was created. */
|
|
12
18
|
secret: string;
|
|
13
19
|
/** The `Ironfang-Timestamp` header. */
|
|
14
20
|
timestamp: string;
|
package/dist/webhook.js
CHANGED
|
@@ -13,7 +13,7 @@ export async function verifyWebhook(input) {
|
|
|
13
13
|
}
|
|
14
14
|
/** Compute the signature Ironfang Render would send. Exposed for tests and for tooling that replays deliveries. */
|
|
15
15
|
export async function signWebhook(secret, timestamp, payload) {
|
|
16
|
-
const key = await crypto.subtle.importKey('raw',
|
|
16
|
+
const key = await crypto.subtle.importKey('raw', signingKey(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
17
17
|
const body = typeof payload === 'string' ? encoder.encode(payload) : payload;
|
|
18
18
|
const signed = new Uint8Array(encoder.encode(timestamp + '.').length + body.length);
|
|
19
19
|
signed.set(encoder.encode(timestamp + '.'), 0);
|
|
@@ -21,6 +21,24 @@ export async function signWebhook(secret, timestamp, payload) {
|
|
|
21
21
|
const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, signed));
|
|
22
22
|
return 'v1=' + Array.from(mac, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
23
23
|
}
|
|
24
|
+
const HEX_SECRET = /^[0-9a-f]{64}$/i;
|
|
25
|
+
/**
|
|
26
|
+
* The HMAC key a secret stands for. Render and Finance mint 32 random bytes
|
|
27
|
+
* and show them as 64 hex characters, and sign with the bytes; Audit shows an
|
|
28
|
+
* `awsec_` string and signs with that string. Until 3.0.1 this used the text
|
|
29
|
+
* of every secret, so no Render delivery verified.
|
|
30
|
+
*/
|
|
31
|
+
function signingKey(secret) {
|
|
32
|
+
const trimmed = secret.trim();
|
|
33
|
+
if (!HEX_SECRET.test(trimmed)) {
|
|
34
|
+
return encoder.encode(secret);
|
|
35
|
+
}
|
|
36
|
+
const key = new Uint8Array(32);
|
|
37
|
+
for (let i = 0; i < 32; i++) {
|
|
38
|
+
key[i] = parseInt(trimmed.slice(2 * i, 2 * i + 2), 16);
|
|
39
|
+
}
|
|
40
|
+
return key;
|
|
41
|
+
}
|
|
24
42
|
function constantTimeEqual(a, b) {
|
|
25
43
|
const x = encoder.encode(a);
|
|
26
44
|
const y = encoder.encode(b);
|
package/package.json
CHANGED