@rerout/sdk 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/dist/index.js ADDED
@@ -0,0 +1,334 @@
1
+ // src/errors.ts
2
+ var ReroutError = class extends Error {
3
+ /** Stable error code, either from the API or a synthetic client-side one. */
4
+ code;
5
+ /** HTTP status code, or 0 when the request never reached the server. */
6
+ status;
7
+ /** The raw response body (parsed JSON or string), useful for debugging. */
8
+ details;
9
+ constructor(opts) {
10
+ super(opts.message);
11
+ this.name = "ReroutError";
12
+ this.code = opts.code;
13
+ this.status = opts.status;
14
+ this.details = opts.details;
15
+ }
16
+ /** True for HTTP 5xx responses (server-side issues). */
17
+ get isServerError() {
18
+ return this.status >= 500 && this.status < 600;
19
+ }
20
+ /** True for HTTP 429 — caller should back off and retry. */
21
+ get isRateLimited() {
22
+ return this.status === 429;
23
+ }
24
+ };
25
+
26
+ // src/qr.ts
27
+ function buildQrUrl(args) {
28
+ const base = args.baseUrl.replace(/\/+$/, "");
29
+ const url = new URL(`${base}/v1/links/${encodeURIComponent(args.code)}/qr`);
30
+ const opts = args.options ?? {};
31
+ if (opts.size !== void 0) url.searchParams.set("size", String(opts.size));
32
+ if (opts.margin !== void 0) url.searchParams.set("margin", String(opts.margin));
33
+ if (opts.ecc !== void 0) url.searchParams.set("ecc", opts.ecc);
34
+ if (opts.domain !== void 0) url.searchParams.set("domain", opts.domain);
35
+ if (opts.refresh !== void 0) {
36
+ url.searchParams.set("refresh", opts.refresh === true ? "1" : opts.refresh);
37
+ }
38
+ return url.toString();
39
+ }
40
+
41
+ // src/client.ts
42
+ var DEFAULT_BASE_URL = "https://api.rerout.co";
43
+ var Rerout = class {
44
+ /** Link operations: create, list, get, update, delete, stats. */
45
+ links;
46
+ /** Project-level operations: aggregate stats. */
47
+ project;
48
+ /** QR helpers (pure URL builders + signed fetch). */
49
+ qr;
50
+ apiKey;
51
+ baseUrl;
52
+ fetchImpl;
53
+ timeoutMs;
54
+ defaultHeaders;
55
+ constructor(options) {
56
+ if (!options.apiKey || typeof options.apiKey !== "string") {
57
+ throw new ReroutError({
58
+ code: "missing_api_key",
59
+ message: "A project API key is required to construct Rerout.",
60
+ status: 0
61
+ });
62
+ }
63
+ this.apiKey = options.apiKey;
64
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
65
+ this.fetchImpl = options.fetch ?? (typeof fetch !== "undefined" ? fetch : (() => {
66
+ throw new ReroutError({
67
+ code: "missing_fetch",
68
+ message: "No global fetch available. Pass `fetch` in ReroutClientOptions or run on Node 18+.",
69
+ status: 0
70
+ });
71
+ })());
72
+ this.timeoutMs = options.timeoutMs ?? 3e4;
73
+ this.defaultHeaders = options.defaultHeaders ?? {};
74
+ this.links = new Links(this);
75
+ this.project = new Project(this);
76
+ this.qr = new Qr(this);
77
+ }
78
+ /** @internal — invoked by Links / Project / Qr. */
79
+ async request(init) {
80
+ const url = new URL(this.baseUrl + init.path);
81
+ if (init.query) {
82
+ for (const [k, v] of Object.entries(init.query)) {
83
+ if (v !== void 0 && v !== null) url.searchParams.set(k, String(v));
84
+ }
85
+ }
86
+ const headers = {
87
+ authorization: `Bearer ${this.apiKey}`,
88
+ accept: "application/json",
89
+ ...this.defaultHeaders
90
+ };
91
+ const body = init.body === void 0 ? void 0 : JSON.stringify(init.body);
92
+ if (body !== void 0) headers["content-type"] = "application/json";
93
+ const abort = new AbortController();
94
+ const timer = setTimeout(() => abort.abort(), this.timeoutMs);
95
+ let response;
96
+ try {
97
+ response = await this.fetchImpl(url.toString(), {
98
+ method: init.method,
99
+ headers,
100
+ body,
101
+ signal: abort.signal
102
+ });
103
+ } catch (error) {
104
+ throw new ReroutError({
105
+ code: abort.signal.aborted ? "timeout" : "network_error",
106
+ message: error instanceof Error ? error.message : "Request to Rerout failed before the server replied.",
107
+ status: 0,
108
+ details: error
109
+ });
110
+ } finally {
111
+ clearTimeout(timer);
112
+ }
113
+ const text = await response.text();
114
+ if (!response.ok) throw parseError(response.status, text);
115
+ if (text.length === 0) return void 0;
116
+ try {
117
+ return JSON.parse(text);
118
+ } catch (error) {
119
+ throw new ReroutError({
120
+ code: "unexpected_response",
121
+ message: "Rerout returned a non-JSON success body.",
122
+ status: response.status,
123
+ details: { body: text, error }
124
+ });
125
+ }
126
+ }
127
+ /** Internal — used by Qr to expose the resolved base URL. */
128
+ get resolvedBaseUrl() {
129
+ return this.baseUrl;
130
+ }
131
+ };
132
+ function parseError(status, body) {
133
+ if (body.length === 0) {
134
+ return new ReroutError({
135
+ code: synthCodeForStatus(status),
136
+ message: `Rerout returned HTTP ${status} with no body.`,
137
+ status
138
+ });
139
+ }
140
+ try {
141
+ const parsed = JSON.parse(body);
142
+ return new ReroutError({
143
+ code: parsed.code ?? synthCodeForStatus(status),
144
+ message: parsed.message ?? `Rerout returned HTTP ${status}.`,
145
+ status,
146
+ details: parsed
147
+ });
148
+ } catch {
149
+ return new ReroutError({
150
+ code: synthCodeForStatus(status),
151
+ message: `Rerout returned HTTP ${status} (non-JSON body).`,
152
+ status,
153
+ details: { body }
154
+ });
155
+ }
156
+ }
157
+ function synthCodeForStatus(status) {
158
+ if (status === 401) return "unauthorized";
159
+ if (status === 403) return "forbidden";
160
+ if (status === 404) return "not_found";
161
+ if (status === 429) return "rate_limited";
162
+ if (status >= 500) return "server_error";
163
+ return "client_error";
164
+ }
165
+ var Links = class {
166
+ /** @internal */
167
+ constructor(client) {
168
+ this.client = client;
169
+ }
170
+ client;
171
+ /** Create a new short link. */
172
+ create(input) {
173
+ return this.client.request({
174
+ method: "POST",
175
+ path: "/v1/links",
176
+ body: input
177
+ });
178
+ }
179
+ /** Paginated list of links in the project. */
180
+ list(params) {
181
+ return this.client.request({
182
+ method: "GET",
183
+ path: "/v1/links",
184
+ query: params
185
+ });
186
+ }
187
+ /** Get a single link by code. */
188
+ get(code) {
189
+ return this.client.request({
190
+ method: "GET",
191
+ path: `/v1/links/${encodeURIComponent(code)}`
192
+ });
193
+ }
194
+ /** Patch a link. Only fields present in `input` are changed. */
195
+ update(code, input) {
196
+ return this.client.request({
197
+ method: "PATCH",
198
+ path: `/v1/links/${encodeURIComponent(code)}`,
199
+ body: input
200
+ });
201
+ }
202
+ /** Soft-delete a link. The short URL stops redirecting and is gone from lists. */
203
+ delete(code) {
204
+ return this.client.request({
205
+ method: "DELETE",
206
+ path: `/v1/links/${encodeURIComponent(code)}`
207
+ });
208
+ }
209
+ /** Per-link click stats. Defaults to 30 days. */
210
+ stats(code, days = 30) {
211
+ return this.client.request({
212
+ method: "GET",
213
+ path: `/v1/links/${encodeURIComponent(code)}/stats`,
214
+ query: { days }
215
+ });
216
+ }
217
+ };
218
+ var Project = class {
219
+ /** @internal */
220
+ constructor(client) {
221
+ this.client = client;
222
+ }
223
+ client;
224
+ /** Aggregate stats across every link in the project. */
225
+ stats(days = 30) {
226
+ return this.client.request({
227
+ method: "GET",
228
+ path: "/v1/projects/me/stats",
229
+ query: { days }
230
+ });
231
+ }
232
+ /** Info about the project that owns the current API key. */
233
+ me() {
234
+ return this.client.request({
235
+ method: "GET",
236
+ path: "/v1/projects/me"
237
+ });
238
+ }
239
+ };
240
+ var Qr = class {
241
+ /** @internal */
242
+ constructor(client) {
243
+ this.client = client;
244
+ }
245
+ client;
246
+ /**
247
+ * Build the URL the API serves the QR SVG from. Pure — does not call the API.
248
+ */
249
+ url(code, options) {
250
+ return buildQrUrl({
251
+ baseUrl: this.client.resolvedBaseUrl,
252
+ code,
253
+ options
254
+ });
255
+ }
256
+ /**
257
+ * Fetch the QR as an SVG string. Hits the same endpoint as `url()` but
258
+ * attaches the bearer token and returns the rendered body.
259
+ */
260
+ async svg(code, options) {
261
+ return this.client.request({
262
+ method: "GET",
263
+ path: `/v1/links/${encodeURIComponent(code)}/qr` + qrQueryString(options)
264
+ });
265
+ }
266
+ };
267
+ function qrQueryString(options) {
268
+ if (!options) return "";
269
+ const params = new URLSearchParams();
270
+ if (options.size !== void 0) params.set("size", String(options.size));
271
+ if (options.margin !== void 0) params.set("margin", String(options.margin));
272
+ if (options.ecc !== void 0) params.set("ecc", options.ecc);
273
+ if (options.domain !== void 0) params.set("domain", options.domain);
274
+ if (options.refresh !== void 0) {
275
+ params.set("refresh", options.refresh === true ? "1" : options.refresh);
276
+ }
277
+ const qs = params.toString();
278
+ return qs.length === 0 ? "" : `?${qs}`;
279
+ }
280
+
281
+ // src/webhooks.ts
282
+ import { createHmac, timingSafeEqual } from "crypto";
283
+ var DEFAULT_SIGNATURE_TOLERANCE_SECONDS = 300;
284
+ function verifyReroutSignature(opts) {
285
+ if (!opts.signatureHeader || !opts.secret || opts.rawBody === void 0) {
286
+ return false;
287
+ }
288
+ const parts = parseSignatureHeader(opts.signatureHeader);
289
+ if (!parts) return false;
290
+ const tolerance = opts.toleranceSeconds ?? DEFAULT_SIGNATURE_TOLERANCE_SECONDS;
291
+ if (tolerance > 0) {
292
+ const now = opts.now ? opts.now() : Math.floor(Date.now() / 1e3);
293
+ if (Math.abs(now - parts.timestamp) > tolerance) return false;
294
+ }
295
+ const expectedHex = createHmac("sha256", opts.secret).update(`${parts.timestamp}.${opts.rawBody}`).digest("hex");
296
+ const expected = safeFromHex(expectedHex);
297
+ const actual = safeFromHex(parts.v1);
298
+ if (!expected || !actual || expected.length !== actual.length) return false;
299
+ return timingSafeEqual(expected, actual);
300
+ }
301
+ function parseSignatureHeader(header) {
302
+ let timestamp;
303
+ let v1;
304
+ for (const segment of header.split(",")) {
305
+ const eq = segment.indexOf("=");
306
+ if (eq <= 0) continue;
307
+ const key = segment.slice(0, eq).trim().toLowerCase();
308
+ const value = segment.slice(eq + 1).trim();
309
+ if (key === "t") {
310
+ const parsed = Number.parseInt(value, 10);
311
+ if (Number.isFinite(parsed) && parsed > 0) timestamp = parsed;
312
+ } else if (key === "v1") {
313
+ v1 = value;
314
+ }
315
+ }
316
+ if (timestamp === void 0 || !v1) return null;
317
+ return { timestamp, v1 };
318
+ }
319
+ function safeFromHex(hex) {
320
+ if (hex.length === 0 || hex.length % 2 !== 0) return null;
321
+ if (!/^[0-9a-fA-F]+$/.test(hex)) return null;
322
+ return Buffer.from(hex, "hex");
323
+ }
324
+ export {
325
+ DEFAULT_BASE_URL,
326
+ DEFAULT_SIGNATURE_TOLERANCE_SECONDS,
327
+ Links,
328
+ Project,
329
+ Qr,
330
+ Rerout,
331
+ ReroutError,
332
+ buildQrUrl,
333
+ verifyReroutSignature
334
+ };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@rerout/sdk",
3
+ "version": "0.1.0",
4
+ "description": "Official TypeScript / JavaScript SDK for the Rerout branded-link API.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Codecraft Solutions <hello@codecraftsolutions.co.za>",
8
+ "homepage": "https://github.com/ModestNerds-Co/rerout-sdks/tree/master/typescript",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/ModestNerds-Co/rerout-sdks.git",
12
+ "directory": "typescript"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/ModestNerds-Co/rerout-sdks/issues"
16
+ },
17
+ "keywords": [
18
+ "rerout",
19
+ "url-shortener",
20
+ "branded-links",
21
+ "qr-codes",
22
+ "webhooks",
23
+ "cloudflare"
24
+ ],
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "main": "./dist/index.cjs",
29
+ "module": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
+ "exports": {
32
+ ".": {
33
+ "import": {
34
+ "types": "./dist/index.d.ts",
35
+ "default": "./dist/index.js"
36
+ },
37
+ "require": {
38
+ "types": "./dist/index.d.cts",
39
+ "default": "./dist/index.cjs"
40
+ }
41
+ }
42
+ },
43
+ "files": [
44
+ "dist",
45
+ "README.md",
46
+ "CHANGELOG.md",
47
+ "LICENSE"
48
+ ],
49
+ "scripts": {
50
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean --target node18",
51
+ "typecheck": "tsc --noEmit",
52
+ "test": "vitest run",
53
+ "test:watch": "vitest"
54
+ },
55
+ "devDependencies": {
56
+ "tsup": "^8.3.5",
57
+ "typescript": "^5.6.0",
58
+ "vitest": "^2.1.0",
59
+ "@types/node": "^22.10.0"
60
+ }
61
+ }