@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/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@rerout/sdk` are documented in this file. The format is
4
+ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
5
+ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] - 2026-05-20
8
+
9
+ ### Added
10
+
11
+ - Initial public release.
12
+ - `Rerout` client with `links`, `project`, and `qr` namespaces.
13
+ - Link operations: `create`, `list`, `get`, `update`, `delete`, `stats`.
14
+ - Project operations: `stats`, `me`.
15
+ - QR helpers: pure URL builder + signed SVG fetch.
16
+ - `verifyReroutSignature` — HMAC-SHA256 webhook signature verification with
17
+ configurable timestamp tolerance and constant-time comparison.
18
+ - `ReroutError` with stable `code`, `status`, `details`; `isRateLimited`,
19
+ `isServerError` convenience flags.
20
+ - ESM + CJS dual build with bundled `.d.ts` declarations.
21
+
22
+ [0.1.0]: https://github.com/ModestNerds-Co/rerout-sdks/releases/tag/typescript-v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Codecraft Solutions
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # @rerout/sdk
2
+
3
+ Official TypeScript / JavaScript SDK for the [Rerout](https://rerout.co) API.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @rerout/sdk
9
+ # or
10
+ pnpm add @rerout/sdk
11
+ # or
12
+ bun add @rerout/sdk
13
+ ```
14
+
15
+ Requires Node 18+ (uses the global `fetch` and `AbortController`). Works in
16
+ modern bundlers, Bun, Deno, and Cloudflare Workers — pass a custom `fetch` in
17
+ edge runtimes if needed.
18
+
19
+ ## Usage
20
+
21
+ ```ts
22
+ import { Rerout } from '@rerout/sdk'
23
+
24
+ const rerout = new Rerout({ apiKey: process.env.REROUT_API_KEY! })
25
+
26
+ const link = await rerout.links.create({
27
+ target_url: 'https://example.com/q4-sale',
28
+ domain_hostname: 'go.brand.com',
29
+ code: 'q4',
30
+ })
31
+
32
+ console.log(link.short_url) // https://go.brand.com/q4
33
+
34
+ const stats = await rerout.project.stats(7)
35
+ console.log(`Last 7 days: ${stats.total_clicks} clicks, ${stats.qr_scans} QR scans`)
36
+ ```
37
+
38
+ ## API
39
+
40
+ ### Construction
41
+
42
+ ```ts
43
+ const rerout = new Rerout({
44
+ apiKey: 'rrk_…', // required
45
+ baseUrl: 'https://api.rerout.co', // optional, defaults shown
46
+ timeoutMs: 30_000, // optional
47
+ fetch: customFetch, // optional — inject your own fetch
48
+ defaultHeaders: { // optional — added to every request
49
+ 'user-agent': 'my-app/1.0',
50
+ },
51
+ })
52
+ ```
53
+
54
+ ### Links
55
+
56
+ ```ts
57
+ rerout.links.create({ target_url, domain_hostname?, code?, expires_at?, ...seo })
58
+ rerout.links.list({ cursor?, limit? })
59
+ rerout.links.get(code)
60
+ rerout.links.update(code, { target_url?, expires_at?, is_active?, ...seo })
61
+ rerout.links.delete(code)
62
+ rerout.links.stats(code, days = 30)
63
+ ```
64
+
65
+ ### Project
66
+
67
+ ```ts
68
+ rerout.project.stats(days = 30)
69
+ rerout.project.me()
70
+ ```
71
+
72
+ ### QR codes
73
+
74
+ ```ts
75
+ rerout.qr.url(code, { size?, margin?, ecc?, domain?, refresh? }) // returns string
76
+ await rerout.qr.svg(code, opts) // fetches the rendered SVG
77
+ ```
78
+
79
+ ### Webhook signature verification
80
+
81
+ ```ts
82
+ import { verifyReroutSignature } from '@rerout/sdk'
83
+
84
+ const ok = verifyReroutSignature({
85
+ rawBody,
86
+ signatureHeader: req.headers['x-rerout-signature']!,
87
+ secret: process.env.REROUT_WEBHOOK_SECRET!,
88
+ })
89
+ ```
90
+
91
+ Defaults to a 5-minute timestamp tolerance; pass `toleranceSeconds: 0` to
92
+ disable that check.
93
+
94
+ ## Error handling
95
+
96
+ Every method throws `ReroutError` on failure:
97
+
98
+ ```ts
99
+ import { ReroutError } from '@rerout/sdk'
100
+
101
+ try {
102
+ await rerout.links.create({ target_url: 'http://insecure.example' })
103
+ } catch (err) {
104
+ if (err instanceof ReroutError) {
105
+ console.error(err.code) // 'bad_target_url'
106
+ console.error(err.status) // 400
107
+ console.error(err.message) // 'target_url must use https.'
108
+ if (err.isRateLimited) { /* back off */ }
109
+ }
110
+ }
111
+ ```
112
+
113
+ Synthetic codes when the server didn't return a JSON body:
114
+ `network_error`, `timeout`, `unexpected_response`, `unauthorized`,
115
+ `forbidden`, `not_found`, `rate_limited`, `server_error`, `client_error`.
116
+
117
+ ## Local development
118
+
119
+ ```bash
120
+ npm install
121
+ npm run typecheck
122
+ npm run test
123
+ npm run build
124
+ ```
125
+
126
+ ## License
127
+
128
+ MIT — see [LICENSE](../LICENSE).
package/dist/index.cjs ADDED
@@ -0,0 +1,369 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ DEFAULT_BASE_URL: () => DEFAULT_BASE_URL,
24
+ DEFAULT_SIGNATURE_TOLERANCE_SECONDS: () => DEFAULT_SIGNATURE_TOLERANCE_SECONDS,
25
+ Links: () => Links,
26
+ Project: () => Project,
27
+ Qr: () => Qr,
28
+ Rerout: () => Rerout,
29
+ ReroutError: () => ReroutError,
30
+ buildQrUrl: () => buildQrUrl,
31
+ verifyReroutSignature: () => verifyReroutSignature
32
+ });
33
+ module.exports = __toCommonJS(index_exports);
34
+
35
+ // src/errors.ts
36
+ var ReroutError = class extends Error {
37
+ /** Stable error code, either from the API or a synthetic client-side one. */
38
+ code;
39
+ /** HTTP status code, or 0 when the request never reached the server. */
40
+ status;
41
+ /** The raw response body (parsed JSON or string), useful for debugging. */
42
+ details;
43
+ constructor(opts) {
44
+ super(opts.message);
45
+ this.name = "ReroutError";
46
+ this.code = opts.code;
47
+ this.status = opts.status;
48
+ this.details = opts.details;
49
+ }
50
+ /** True for HTTP 5xx responses (server-side issues). */
51
+ get isServerError() {
52
+ return this.status >= 500 && this.status < 600;
53
+ }
54
+ /** True for HTTP 429 — caller should back off and retry. */
55
+ get isRateLimited() {
56
+ return this.status === 429;
57
+ }
58
+ };
59
+
60
+ // src/qr.ts
61
+ function buildQrUrl(args) {
62
+ const base = args.baseUrl.replace(/\/+$/, "");
63
+ const url = new URL(`${base}/v1/links/${encodeURIComponent(args.code)}/qr`);
64
+ const opts = args.options ?? {};
65
+ if (opts.size !== void 0) url.searchParams.set("size", String(opts.size));
66
+ if (opts.margin !== void 0) url.searchParams.set("margin", String(opts.margin));
67
+ if (opts.ecc !== void 0) url.searchParams.set("ecc", opts.ecc);
68
+ if (opts.domain !== void 0) url.searchParams.set("domain", opts.domain);
69
+ if (opts.refresh !== void 0) {
70
+ url.searchParams.set("refresh", opts.refresh === true ? "1" : opts.refresh);
71
+ }
72
+ return url.toString();
73
+ }
74
+
75
+ // src/client.ts
76
+ var DEFAULT_BASE_URL = "https://api.rerout.co";
77
+ var Rerout = class {
78
+ /** Link operations: create, list, get, update, delete, stats. */
79
+ links;
80
+ /** Project-level operations: aggregate stats. */
81
+ project;
82
+ /** QR helpers (pure URL builders + signed fetch). */
83
+ qr;
84
+ apiKey;
85
+ baseUrl;
86
+ fetchImpl;
87
+ timeoutMs;
88
+ defaultHeaders;
89
+ constructor(options) {
90
+ if (!options.apiKey || typeof options.apiKey !== "string") {
91
+ throw new ReroutError({
92
+ code: "missing_api_key",
93
+ message: "A project API key is required to construct Rerout.",
94
+ status: 0
95
+ });
96
+ }
97
+ this.apiKey = options.apiKey;
98
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
99
+ this.fetchImpl = options.fetch ?? (typeof fetch !== "undefined" ? fetch : (() => {
100
+ throw new ReroutError({
101
+ code: "missing_fetch",
102
+ message: "No global fetch available. Pass `fetch` in ReroutClientOptions or run on Node 18+.",
103
+ status: 0
104
+ });
105
+ })());
106
+ this.timeoutMs = options.timeoutMs ?? 3e4;
107
+ this.defaultHeaders = options.defaultHeaders ?? {};
108
+ this.links = new Links(this);
109
+ this.project = new Project(this);
110
+ this.qr = new Qr(this);
111
+ }
112
+ /** @internal — invoked by Links / Project / Qr. */
113
+ async request(init) {
114
+ const url = new URL(this.baseUrl + init.path);
115
+ if (init.query) {
116
+ for (const [k, v] of Object.entries(init.query)) {
117
+ if (v !== void 0 && v !== null) url.searchParams.set(k, String(v));
118
+ }
119
+ }
120
+ const headers = {
121
+ authorization: `Bearer ${this.apiKey}`,
122
+ accept: "application/json",
123
+ ...this.defaultHeaders
124
+ };
125
+ const body = init.body === void 0 ? void 0 : JSON.stringify(init.body);
126
+ if (body !== void 0) headers["content-type"] = "application/json";
127
+ const abort = new AbortController();
128
+ const timer = setTimeout(() => abort.abort(), this.timeoutMs);
129
+ let response;
130
+ try {
131
+ response = await this.fetchImpl(url.toString(), {
132
+ method: init.method,
133
+ headers,
134
+ body,
135
+ signal: abort.signal
136
+ });
137
+ } catch (error) {
138
+ throw new ReroutError({
139
+ code: abort.signal.aborted ? "timeout" : "network_error",
140
+ message: error instanceof Error ? error.message : "Request to Rerout failed before the server replied.",
141
+ status: 0,
142
+ details: error
143
+ });
144
+ } finally {
145
+ clearTimeout(timer);
146
+ }
147
+ const text = await response.text();
148
+ if (!response.ok) throw parseError(response.status, text);
149
+ if (text.length === 0) return void 0;
150
+ try {
151
+ return JSON.parse(text);
152
+ } catch (error) {
153
+ throw new ReroutError({
154
+ code: "unexpected_response",
155
+ message: "Rerout returned a non-JSON success body.",
156
+ status: response.status,
157
+ details: { body: text, error }
158
+ });
159
+ }
160
+ }
161
+ /** Internal — used by Qr to expose the resolved base URL. */
162
+ get resolvedBaseUrl() {
163
+ return this.baseUrl;
164
+ }
165
+ };
166
+ function parseError(status, body) {
167
+ if (body.length === 0) {
168
+ return new ReroutError({
169
+ code: synthCodeForStatus(status),
170
+ message: `Rerout returned HTTP ${status} with no body.`,
171
+ status
172
+ });
173
+ }
174
+ try {
175
+ const parsed = JSON.parse(body);
176
+ return new ReroutError({
177
+ code: parsed.code ?? synthCodeForStatus(status),
178
+ message: parsed.message ?? `Rerout returned HTTP ${status}.`,
179
+ status,
180
+ details: parsed
181
+ });
182
+ } catch {
183
+ return new ReroutError({
184
+ code: synthCodeForStatus(status),
185
+ message: `Rerout returned HTTP ${status} (non-JSON body).`,
186
+ status,
187
+ details: { body }
188
+ });
189
+ }
190
+ }
191
+ function synthCodeForStatus(status) {
192
+ if (status === 401) return "unauthorized";
193
+ if (status === 403) return "forbidden";
194
+ if (status === 404) return "not_found";
195
+ if (status === 429) return "rate_limited";
196
+ if (status >= 500) return "server_error";
197
+ return "client_error";
198
+ }
199
+ var Links = class {
200
+ /** @internal */
201
+ constructor(client) {
202
+ this.client = client;
203
+ }
204
+ client;
205
+ /** Create a new short link. */
206
+ create(input) {
207
+ return this.client.request({
208
+ method: "POST",
209
+ path: "/v1/links",
210
+ body: input
211
+ });
212
+ }
213
+ /** Paginated list of links in the project. */
214
+ list(params) {
215
+ return this.client.request({
216
+ method: "GET",
217
+ path: "/v1/links",
218
+ query: params
219
+ });
220
+ }
221
+ /** Get a single link by code. */
222
+ get(code) {
223
+ return this.client.request({
224
+ method: "GET",
225
+ path: `/v1/links/${encodeURIComponent(code)}`
226
+ });
227
+ }
228
+ /** Patch a link. Only fields present in `input` are changed. */
229
+ update(code, input) {
230
+ return this.client.request({
231
+ method: "PATCH",
232
+ path: `/v1/links/${encodeURIComponent(code)}`,
233
+ body: input
234
+ });
235
+ }
236
+ /** Soft-delete a link. The short URL stops redirecting and is gone from lists. */
237
+ delete(code) {
238
+ return this.client.request({
239
+ method: "DELETE",
240
+ path: `/v1/links/${encodeURIComponent(code)}`
241
+ });
242
+ }
243
+ /** Per-link click stats. Defaults to 30 days. */
244
+ stats(code, days = 30) {
245
+ return this.client.request({
246
+ method: "GET",
247
+ path: `/v1/links/${encodeURIComponent(code)}/stats`,
248
+ query: { days }
249
+ });
250
+ }
251
+ };
252
+ var Project = class {
253
+ /** @internal */
254
+ constructor(client) {
255
+ this.client = client;
256
+ }
257
+ client;
258
+ /** Aggregate stats across every link in the project. */
259
+ stats(days = 30) {
260
+ return this.client.request({
261
+ method: "GET",
262
+ path: "/v1/projects/me/stats",
263
+ query: { days }
264
+ });
265
+ }
266
+ /** Info about the project that owns the current API key. */
267
+ me() {
268
+ return this.client.request({
269
+ method: "GET",
270
+ path: "/v1/projects/me"
271
+ });
272
+ }
273
+ };
274
+ var Qr = class {
275
+ /** @internal */
276
+ constructor(client) {
277
+ this.client = client;
278
+ }
279
+ client;
280
+ /**
281
+ * Build the URL the API serves the QR SVG from. Pure — does not call the API.
282
+ */
283
+ url(code, options) {
284
+ return buildQrUrl({
285
+ baseUrl: this.client.resolvedBaseUrl,
286
+ code,
287
+ options
288
+ });
289
+ }
290
+ /**
291
+ * Fetch the QR as an SVG string. Hits the same endpoint as `url()` but
292
+ * attaches the bearer token and returns the rendered body.
293
+ */
294
+ async svg(code, options) {
295
+ return this.client.request({
296
+ method: "GET",
297
+ path: `/v1/links/${encodeURIComponent(code)}/qr` + qrQueryString(options)
298
+ });
299
+ }
300
+ };
301
+ function qrQueryString(options) {
302
+ if (!options) return "";
303
+ const params = new URLSearchParams();
304
+ if (options.size !== void 0) params.set("size", String(options.size));
305
+ if (options.margin !== void 0) params.set("margin", String(options.margin));
306
+ if (options.ecc !== void 0) params.set("ecc", options.ecc);
307
+ if (options.domain !== void 0) params.set("domain", options.domain);
308
+ if (options.refresh !== void 0) {
309
+ params.set("refresh", options.refresh === true ? "1" : options.refresh);
310
+ }
311
+ const qs = params.toString();
312
+ return qs.length === 0 ? "" : `?${qs}`;
313
+ }
314
+
315
+ // src/webhooks.ts
316
+ var import_node_crypto = require("crypto");
317
+ var DEFAULT_SIGNATURE_TOLERANCE_SECONDS = 300;
318
+ function verifyReroutSignature(opts) {
319
+ if (!opts.signatureHeader || !opts.secret || opts.rawBody === void 0) {
320
+ return false;
321
+ }
322
+ const parts = parseSignatureHeader(opts.signatureHeader);
323
+ if (!parts) return false;
324
+ const tolerance = opts.toleranceSeconds ?? DEFAULT_SIGNATURE_TOLERANCE_SECONDS;
325
+ if (tolerance > 0) {
326
+ const now = opts.now ? opts.now() : Math.floor(Date.now() / 1e3);
327
+ if (Math.abs(now - parts.timestamp) > tolerance) return false;
328
+ }
329
+ const expectedHex = (0, import_node_crypto.createHmac)("sha256", opts.secret).update(`${parts.timestamp}.${opts.rawBody}`).digest("hex");
330
+ const expected = safeFromHex(expectedHex);
331
+ const actual = safeFromHex(parts.v1);
332
+ if (!expected || !actual || expected.length !== actual.length) return false;
333
+ return (0, import_node_crypto.timingSafeEqual)(expected, actual);
334
+ }
335
+ function parseSignatureHeader(header) {
336
+ let timestamp;
337
+ let v1;
338
+ for (const segment of header.split(",")) {
339
+ const eq = segment.indexOf("=");
340
+ if (eq <= 0) continue;
341
+ const key = segment.slice(0, eq).trim().toLowerCase();
342
+ const value = segment.slice(eq + 1).trim();
343
+ if (key === "t") {
344
+ const parsed = Number.parseInt(value, 10);
345
+ if (Number.isFinite(parsed) && parsed > 0) timestamp = parsed;
346
+ } else if (key === "v1") {
347
+ v1 = value;
348
+ }
349
+ }
350
+ if (timestamp === void 0 || !v1) return null;
351
+ return { timestamp, v1 };
352
+ }
353
+ function safeFromHex(hex) {
354
+ if (hex.length === 0 || hex.length % 2 !== 0) return null;
355
+ if (!/^[0-9a-fA-F]+$/.test(hex)) return null;
356
+ return Buffer.from(hex, "hex");
357
+ }
358
+ // Annotate the CommonJS export names for ESM import in node:
359
+ 0 && (module.exports = {
360
+ DEFAULT_BASE_URL,
361
+ DEFAULT_SIGNATURE_TOLERANCE_SECONDS,
362
+ Links,
363
+ Project,
364
+ Qr,
365
+ Rerout,
366
+ ReroutError,
367
+ buildQrUrl,
368
+ verifyReroutSignature
369
+ });