Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:

strategy:
matrix:
node-version: ["16.x", "18.x", "20.x"]
node-version: ["18.x", "20.x", "22.x"]

steps:
- uses: actions/checkout@v4
Expand Down
1 change: 1 addition & 0 deletions .npmignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
src/
scripts/
100 changes: 99 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,105 @@ Signup at [Urlbox](https://urlbox.com) to get an API key and secret.
npm install urlbox --save
```

## Example
## API client

`Urlbox` calls the render API directly. It needs Node 18+ (or any runtime with a global `fetch`; pass your own as `fetch` otherwise) and both your API key and secret, so use it server-side only.

```ts
import { Urlbox } from "urlbox";

const urlbox = new Urlbox({
apiKey: process.env.URLBOX_API_KEY!,
secret: process.env.URLBOX_SECRET!,
});

// Render and wait for the result (POST /v1/render/sync)
const result = await urlbox.render({
url: "https://example.com",
format: "png",
full_page: true,
});
console.log(result.renderUrl, result.size);

// Queue a render (POST /v1/render/async) and poll for it
const { renderId } = await urlbox.renderAsync({
url: "https://example.com",
webhook_url: "https://yourapp.com/webhooks/urlbox",
});
const status = await urlbox.getRender(renderId); // created | retrying | succeeded | failed | not-found
const done = await urlbox.waitForRender(renderId, { intervalMs: 2000, timeoutMs: 120_000 });

// Signed render link, the same as the default export's generateRenderLink
const link = urlbox.generateRenderLink({ url: "https://example.com" });
```

Constructor options: `apiKey`, `secret`, `baseUrl` (default `https://api.urlbox.com`), `fetch`, and `timeoutMs` (per request, default 120000).

`RenderOptions` is generated from the API's option schema, so every option is typed and documented in your editor. See [urlbox.com/docs](https://urlbox.com/docs) for the full reference.

### Errors

Failed requests throw `UrlboxError`:

```ts
import { UrlboxError, UrlboxRenderFailedError } from "urlbox";

try {
await urlbox.render({ url: "https://example.com" });
} catch (err) {
if (err instanceof UrlboxError) {
err.status; // HTTP status (0 for network errors and timeouts)
err.message; // from the x-urlbox-error-message header or the body
err.code; // e.g. "InvalidOptions"
err.requestId; // x-urlbox-request-id, quote it to support
err.rateLimit; // { limit, remaining, reset, retryAfter } when present
}
}
```

`waitForRender` throws `UrlboxRenderFailedError` (a subclass of `UrlboxError`) when the render fails, and a `UrlboxError` with code `WAIT_TIMEOUT` if it does not finish in time.

## Verifying webhooks

Urlbox signs every webhook with the `X-Urlbox-Signature` header: `t=<unix seconds>,sha256=<hex>`, with one `sha256` entry per active secret while you rotate. Verify it against the **raw request body**, exactly as received. Re-serialising parsed JSON will not match.

```ts
import express from "express";
import { parseWebhook, UrlboxWebhookSignatureError } from "urlbox";

const app = express();

app.post(
"/webhooks/urlbox",
express.raw({ type: "application/json" }), // keep the body as a Buffer
(req, res) => {
try {
const event = parseWebhook({
header: req.get("x-urlbox-signature"),
rawBody: req.body, // string or Buffer
secret: process.env.URLBOX_WEBHOOK_SECRET!, // or [newSecret, oldSecret]
});
if (event.event === "render.succeeded") {
console.log(event.renderId, event.result.renderUrl);
} else {
console.log(event.renderId, event.error.message);
}
res.sendStatus(200);
} catch (err) {
if (err instanceof UrlboxWebhookSignatureError) return res.sendStatus(400);
throw err;
}
}
);
```

`verifyWebhookSignature` takes the same arguments and returns a boolean. Signatures older than `toleranceSeconds` (default 300) are rejected; pass `0` to turn the check off.

## Render links

The default export generates render links: URLs you can put straight into an `<img>` tag or link to.

### Example

```js
import Urlbox from "urlbox";
Expand Down
21 changes: 15 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
{
"name": "urlbox",
"version": "2.2.0",
"version": "2.3.0",
"description": "Generate website screenshots, PDFs and other renders from URLs or HTML using the urlbox website screenshot API",
"scripts": {
"test": "vitest --run",
"test:watch": "vitest",
"build": "rm -fr dist/* && tsc -p tsconfig.json && tsc -p tsconfig-cjs.json && ./fixup",
"prepublishOnly": "npm run build && npm run test"
"prepublishOnly": "npm run build && npm run test",
"generate:options": "node --experimental-strip-types --no-warnings scripts/generate-render-options.ts",
"typecheck": "tsc -p tsconfig.test.json"
},
"types": "dist/mjs/index.d.ts",
"types": "dist/cjs/index.d.ts",
"main": "dist/cjs/index.js",
"module": "dist/mjs/index.js",
"exports": {
".": {
"import": "./dist/mjs/index.js",
"require": "./dist/cjs/index.js"
}
"import": {
"types": "./dist/mjs/index.d.ts",
"default": "./dist/mjs/index.js"
},
"require": {
"types": "./dist/cjs/index.d.ts",
"default": "./dist/cjs/index.js"
}
},
"./package.json": "./package.json"
},
"repository": {
"type": "git",
Expand Down
236 changes: 236 additions & 0 deletions scripts/generate-render-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
/**
* Regenerates src/render-options.generated.ts from the canonical Zod render
* options schema in the Urlbox API monorepo.
*
* Usage (Node 22+):
* node --experimental-strip-types scripts/generate-render-options.ts \
* <path to built packages/types/dist> [path to apps/docs/src/lib/options.ts]
*
* The docs catalogue is optional; when given, its descriptions and defaults
* become JSDoc on each field.
*/
import { writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

type Def = {
type: string;
[key: string]: unknown;
};
type Schema = {
_zod: { def: Def };
safeParse: (value: unknown) => { success: boolean; data?: unknown };
description?: string;
};
type DocOption = { name: string; desc?: string; defaultVal?: unknown };

const [distArg, docsArg] = process.argv.slice(2);
if (!distArg) {
console.error(
"usage: generate-render-options.ts <types dist dir> [docs options.ts]",
);
process.exit(1);
}

const here = path.dirname(fileURLToPath(import.meta.url));
const outPath = path.resolve(here, "../src/render-options.generated.ts");
const SCHEMA_EXPORT = "tightRenderOptionsSchemaNoRequiredOptions";
const REQUIRED_ONE_OF = new Set(["url", "html"]);

const typesModule = await import(
pathToFileURL(path.resolve(distArg, "render/render.types.js")).href
);
const schema = typesModule[SCHEMA_EXPORT] as Schema & {
shape: Record<string, Schema>;
};
if (!schema?.shape) {
throw new Error(`${SCHEMA_EXPORT} not found in ${distArg}`);
}

const docs = new Map<string, DocOption>();
if (docsArg) {
const docsModule = await import(pathToFileURL(path.resolve(docsArg)).href);
for (const group of docsModule.options as { options: DocOption[] }[]) {
for (const option of group.options) {
if (!docs.has(option.name)) docs.set(option.name, option);
}
}
}

const def = (s: Schema): Def => s._zod.def;
const isType = (s: Schema, type: string) => def(s).type === type;
const unionOptions = (s: Schema): Schema[] =>
isType(s, "union") ? (def(s).options as Schema[]) : [];
const unionOf = (s: Schema, types: string[]) => {
const options = unionOptions(s).map((o) => def(o).type);
return (
options.length === types.length && types.every((t) => options.includes(t))
);
};
const quote = (value: unknown) => JSON.stringify(value);

/** Coercing union -> transform: decide what the transform produces by running it. */
const coercedType = (pipe: Schema, input: Schema): string | undefined => {
if (unionOf(input, ["string", "boolean", "number"])) return "boolean";
if (unionOf(input, ["string", "number"])) {
const transform = def(def(pipe).out as Schema).transform as (
value: unknown,
ctx: unknown,
) => unknown;
const ctx = { issues: [], addIssue: () => {} };
const numeric = typeof transform("7", ctx);
const text = typeof transform("top", ctx);
if (numeric === "number" && text === "string") return "string | number";
return numeric === "number" ? "number" : "string";
}
const strings = unionOptions(input);
if (
strings.length === 2 &&
strings.some((o) => isType(o, "string")) &&
strings.some((o) => isType(o, "array"))
) {
return "string | string[]";
}
return undefined;
};

const objectType = (shape: Record<string, Schema>, indent: string): string => {
const inner = `${indent} `;
const fields = Object.entries(shape).map(
([key, value]) =>
`${inner}${/^[A-Za-z_$][\w$]*$/.test(key) ? key : quote(key)}${isOptional(value) ? "?" : ""}: ${tsType(value, inner)};`,
);
return fields.length
? `{\n${fields.join("\n")}\n${indent}}`
: "Record<string, unknown>";
};

const isOptional = (s: Schema): boolean => {
const t = def(s).type;
if (t === "optional" || t === "default" || t === "prefault") return true;
if (t === "nullable" || t === "readonly" || t === "catch") {
return isOptional(def(s).innerType as Schema);
}
return false;
};

const wrap = (type: string) => (/[|&]/.test(type) ? `(${type})` : type);

function tsType(s: Schema, indent = " "): string {
const d = def(s);
switch (d.type) {
case "optional":
case "default":
case "prefault":
case "catch":
case "readonly":
case "nonoptional":
return tsType(d.innerType as Schema, indent);
case "nullable":
return `${tsType(d.innerType as Schema, indent)} | null`;
case "pipe": {
const input = d.in as Schema;
const output = d.out as Schema;
// z.preprocess(fn, X): the caller sends what X accepts.
if (isType(input, "transform")) return tsType(output, indent);
if (isType(output, "transform")) {
return coercedType(s, input) ?? tsType(input, indent);
}
return tsType(input, indent);
}
case "union": {
const parts = [
...new Set((d.options as Schema[]).map((o) => tsType(o, indent))),
];
if (parts.includes("unknown")) return "unknown";
// Keep literal autocompletion when an open string is also accepted.
const hasLiterals = parts.some((p) => p.startsWith('"'));
return parts
.map((p) => (p === "string" && hasLiterals ? "(string & {})" : p))
.join(" | ");
}
case "enum":
return [...new Set(Object.values(d.entries as object))]
.map(quote)
.join(" | ");
case "literal":
return (d.values as unknown[]).map(quote).join(" | ");
case "array":
return `${wrap(tsType(d.element as Schema, indent))}[]`;
case "object":
return objectType(d.shape as Record<string, Schema>, indent);
case "record":
return `Record<string, ${tsType(d.valueType as Schema, indent)}>`;
case "string":
return "string";
case "number":
case "int":
case "bigint":
return "number";
case "boolean":
return "boolean";
case "null":
return "null";
default:
return "unknown";
}
}

const jsdoc = (key: string, s: Schema): string => {
const doc = docs.get(key);
const lines: string[] = [];
const description = doc?.desc ?? s.description;
if (description) {
lines.push(
...description
.trim()
.split("\n")
.map((l) => l.trimEnd()),
);
}
const defaultVal = doc?.defaultVal;
if (
typeof defaultVal === "string" ||
typeof defaultVal === "number" ||
typeof defaultVal === "boolean"
) {
lines.push(`@default ${quote(defaultVal)}`);
}
if (!lines.length) return "";
const body = lines
.map((l) => ` *${l ? ` ${l.replaceAll("*/", "*\\/")}` : ""}`)
.join("\n");
return ` /**\n${body}\n */\n`;
};

const keys = Object.keys(schema.shape).sort();
const fields = keys
.filter((key) => !REQUIRED_ONE_OF.has(key))
.map((key) => {
const field = schema.shape[key];
return `${jsdoc(key, field)} ${key}?: ${tsType(field)};`;
});

const source = `// AUTO-GENERATED by scripts/generate-render-options.ts — do not edit by hand.
// Source: ${SCHEMA_EXPORT} (${keys.length} options) in the Urlbox API.

/** Every render option except \`url\` / \`html\`, all optional. */
export interface GeneratedRenderOptionFields {
${fields.join("\n")}
}

export interface UrlInput {
${jsdoc("url", schema.shape.url)} url: string;
html?: never;
}

export interface HtmlInput {
${jsdoc("html", schema.shape.html)} html: string;
url?: never;
}
`;

writeFileSync(outPath, source);
console.log(
`wrote ${path.relative(process.cwd(), outPath)} (${keys.length} options)`,
);
Loading
Loading