@hasna/shortlinks 0.1.14 → 0.1.16

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/config.d.ts CHANGED
@@ -3,6 +3,12 @@ export declare const DEFAULT_DATA_DIR: string;
3
3
  export interface ShortlinksConfig {
4
4
  defaultDomain?: string;
5
5
  publicBaseUrl?: string;
6
+ mode?: "local" | "remote" | "api";
7
+ api?: {
8
+ baseUrl?: string;
9
+ token?: string;
10
+ tokenEnv?: string;
11
+ };
6
12
  cloudflare?: {
7
13
  accountId?: string;
8
14
  workerName?: string;
@@ -16,5 +22,7 @@ export declare function getDatabasePath(explicitPath?: string): string;
16
22
  export declare function loadConfig(): ShortlinksConfig;
17
23
  export declare function saveConfig(config: ShortlinksConfig): void;
18
24
  export declare function updateConfig(patch: ShortlinksConfig): ShortlinksConfig;
25
+ export declare function getApiBaseUrl(config?: ShortlinksConfig): string | null;
26
+ export declare function getApiToken(config?: ShortlinksConfig): string | null;
19
27
  export declare function normalizeHostname(input: string): string;
20
28
  export declare function formatShortUrl(hostname: string, slug: string, publicBaseUrl?: string): string;
package/dist/index.d.ts CHANGED
@@ -8,9 +8,10 @@ export { applyPgMigrations } from "./pg-migrate.js";
8
8
  export { SHORTLINKS_STORAGE_TABLES, STORAGE_TABLES, getStoragePg, getStorageStatus, parseStorageTables, pullStorageChanges, pushStorageChanges, runStorageMigrations, syncStorageChanges, } from "./storage-sync.js";
9
9
  export type { StorageStatus, StorageSyncResult, SyncResult } from "./storage-sync.js";
10
10
  export { createShortlinksHandler, serveShortlinks } from "./server.js";
11
+ export { ShortlinksApiClient } from "./api-client.js";
11
12
  export { createCloudflarePlan, generateWorkerScript, writeWorkerFiles, upsertCloudflareDnsRecord } from "./cloudflare.js";
12
13
  export { createLocalSetupPlan, registerMachinesDns } from "./local.js";
13
14
  export { PG_MIGRATIONS } from "./pg-migrations.js";
14
- export { formatShortUrl, getConfigPath, getDataDir, getDatabasePath, loadConfig, normalizeHostname, saveConfig } from "./config.js";
15
+ export { formatShortUrl, getApiBaseUrl, getApiToken, getConfigPath, getDataDir, getDatabasePath, loadConfig, normalizeHostname, saveConfig } from "./config.js";
15
16
  export { normalizeSlug, randomToken } from "./slug.js";
16
17
  export type { AddDomainInput, Click, ClickInput, CreateLinkInput, Domain, Link, LinkStats } from "./types.js";
package/dist/index.js CHANGED
@@ -5136,17 +5136,31 @@ function saveConfig(config) {
5136
5136
  `);
5137
5137
  }
5138
5138
  function updateConfig(patch) {
5139
+ const current = loadConfig();
5139
5140
  const next = {
5140
- ...loadConfig(),
5141
+ ...current,
5141
5142
  ...patch,
5143
+ api: {
5144
+ ...current.api,
5145
+ ...patch.api
5146
+ },
5142
5147
  cloudflare: {
5143
- ...loadConfig().cloudflare,
5148
+ ...current.cloudflare,
5144
5149
  ...patch.cloudflare
5145
5150
  }
5146
5151
  };
5147
5152
  saveConfig(next);
5148
5153
  return next;
5149
5154
  }
5155
+ function getApiBaseUrl(config = loadConfig()) {
5156
+ const baseUrl = process.env.SHORTLINKS_API_URL || process.env.HASNA_SHORTLINKS_API_URL || config.api?.baseUrl || "";
5157
+ return baseUrl ? baseUrl.replace(/\/+$/, "") : null;
5158
+ }
5159
+ function getApiToken(config = loadConfig()) {
5160
+ const envName = config.api?.tokenEnv || "SHORTLINKS_API_TOKEN";
5161
+ const token = process.env[envName] || process.env.SHORTLINKS_API_TOKEN || process.env.HASNA_SHORTLINKS_API_TOKEN || config.api?.token || "";
5162
+ return token || null;
5163
+ }
5150
5164
  function normalizeHostname(input) {
5151
5165
  const raw = input.trim().toLowerCase();
5152
5166
  if (!raw)
@@ -5243,6 +5257,10 @@ var SQLITE_MIGRATIONS = [
5243
5257
  CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
5244
5258
  CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
5245
5259
  CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
5260
+ `,
5261
+ `
5262
+ ALTER TABLE links ADD COLUMN max_uses INTEGER;
5263
+ ALTER TABLE links ADD COLUMN used_count INTEGER NOT NULL DEFAULT 0;
5246
5264
  `
5247
5265
  ];
5248
5266
 
@@ -5351,6 +5369,8 @@ function linkFromRow(row) {
5351
5369
  return {
5352
5370
  ...row,
5353
5371
  active: Boolean(row.active),
5372
+ max_uses: row.max_uses ?? null,
5373
+ used_count: row.used_count ?? 0,
5354
5374
  metadata: parseJsonObject(row.metadata),
5355
5375
  short_url: formatShortUrl(row.hostname, row.slug, publicBaseUrl)
5356
5376
  };
@@ -5381,6 +5401,13 @@ function isoOrNull(input) {
5381
5401
  throw new Error(`Invalid date: ${input}`);
5382
5402
  return date.toISOString();
5383
5403
  }
5404
+ function normalizeMaxUses(value) {
5405
+ if (value === null || value === undefined)
5406
+ return null;
5407
+ if (!Number.isInteger(value) || value <= 0)
5408
+ throw new Error("maxUses must be a positive integer.");
5409
+ return value;
5410
+ }
5384
5411
 
5385
5412
  class ShortlinksStore {
5386
5413
  database;
@@ -5456,15 +5483,16 @@ class ShortlinksStore {
5456
5483
  const timestamp = now();
5457
5484
  const machineId = getMachineId();
5458
5485
  const expiresAt = isoOrNull(input.expiresAt);
5486
+ const maxUses = normalizeMaxUses(input.maxUses);
5459
5487
  const slug = input.slug ? normalizeSlug(input.slug) : this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
5460
5488
  try {
5461
5489
  this.database.db.query(`
5462
5490
  INSERT INTO links (
5463
- id, domain_id, slug, destination_url, title, active, expires_at, metadata,
5491
+ id, domain_id, slug, destination_url, title, active, expires_at, max_uses, used_count, metadata,
5464
5492
  machine_id, synced_at, created_at, updated_at
5465
5493
  )
5466
- VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
5467
- `).run(makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
5494
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, 0, ?, ?, NULL, ?, ?)
5495
+ `).run(makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, maxUses, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
5468
5496
  } catch (error) {
5469
5497
  const message = error instanceof Error ? error.message : String(error);
5470
5498
  if (message.includes("UNIQUE")) {
@@ -5474,6 +5502,19 @@ class ShortlinksStore {
5474
5502
  }
5475
5503
  return this.getLink(domain.hostname, slug);
5476
5504
  }
5505
+ consumeLinkUse(link) {
5506
+ const timestamp = now();
5507
+ const result = this.database.db.query(`
5508
+ UPDATE links
5509
+ SET used_count = used_count + 1, updated_at = ?, synced_at = NULL
5510
+ WHERE id = ?
5511
+ AND active = 1
5512
+ AND (max_uses IS NULL OR used_count < max_uses)
5513
+ `).run(timestamp, link.id);
5514
+ if (result.changes === 0)
5515
+ return null;
5516
+ return this.getLink(link.hostname, link.slug);
5517
+ }
5477
5518
  listLinks(options = {}) {
5478
5519
  const params = [];
5479
5520
  let where = "WHERE 1 = 1";
@@ -5657,6 +5698,8 @@ function linkFromRow2(row) {
5657
5698
  return {
5658
5699
  ...row,
5659
5700
  active: Boolean(row.active),
5701
+ max_uses: row.max_uses ?? null,
5702
+ used_count: row.used_count ?? 0,
5660
5703
  expires_at: nullableIso(row.expires_at),
5661
5704
  synced_at: nullableIso(row.synced_at),
5662
5705
  created_at: toIsoString(row.created_at),
@@ -5685,6 +5728,13 @@ function isoOrNull2(input) {
5685
5728
  throw new Error(`Invalid date: ${input}`);
5686
5729
  return date.toISOString();
5687
5730
  }
5731
+ function normalizeMaxUses2(value) {
5732
+ if (value === null || value === undefined)
5733
+ return null;
5734
+ if (!Number.isInteger(value) || value <= 0)
5735
+ throw new Error("maxUses must be a positive integer.");
5736
+ return value;
5737
+ }
5688
5738
  function clickFromRow2(row) {
5689
5739
  return {
5690
5740
  ...row,
@@ -5772,15 +5822,16 @@ class PgShortlinksStore {
5772
5822
  const timestamp = now();
5773
5823
  const machineId = getMachineId();
5774
5824
  const expiresAt = isoOrNull2(input.expiresAt);
5825
+ const maxUses = normalizeMaxUses2(input.maxUses);
5775
5826
  const slug = input.slug ? normalizeSlug(input.slug) : await this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
5776
5827
  try {
5777
5828
  await this.pg.run(`
5778
5829
  INSERT INTO links (
5779
- id, domain_id, slug, destination_url, title, active, expires_at, metadata,
5830
+ id, domain_id, slug, destination_url, title, active, expires_at, max_uses, used_count, metadata,
5780
5831
  machine_id, synced_at, created_at, updated_at
5781
5832
  )
5782
- VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
5783
- `, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
5833
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, 0, ?, ?, NULL, ?, ?)
5834
+ `, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, maxUses, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
5784
5835
  } catch (error) {
5785
5836
  const message = error instanceof Error ? error.message : String(error);
5786
5837
  if (message.includes("unique") || message.includes("duplicate")) {
@@ -5790,6 +5841,22 @@ class PgShortlinksStore {
5790
5841
  }
5791
5842
  return await this.getLink(domain.hostname, slug);
5792
5843
  }
5844
+ async consumeLinkUse(link) {
5845
+ const timestamp = now();
5846
+ await this.pg.run(`
5847
+ UPDATE links
5848
+ SET used_count = used_count + 1, updated_at = ?, synced_at = NULL
5849
+ WHERE id = ?
5850
+ AND active = 1
5851
+ AND (max_uses IS NULL OR used_count < max_uses)
5852
+ `, timestamp, link.id);
5853
+ const refreshed = await this.getLink(link.hostname, link.slug);
5854
+ if (!refreshed)
5855
+ return null;
5856
+ if (link.max_uses !== null && refreshed.used_count === link.used_count)
5857
+ return null;
5858
+ return refreshed;
5859
+ }
5793
5860
  async listLinks(options = {}) {
5794
5861
  const params = [];
5795
5862
  let where = "WHERE 1 = 1";
@@ -6010,6 +6077,10 @@ var PG_MIGRATIONS = [
6010
6077
  CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
6011
6078
  CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
6012
6079
  CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
6080
+ `,
6081
+ `
6082
+ ALTER TABLE links ADD COLUMN IF NOT EXISTS max_uses INTEGER;
6083
+ ALTER TABLE links ADD COLUMN IF NOT EXISTS used_count INTEGER NOT NULL DEFAULT 0;
6013
6084
  `
6014
6085
  ];
6015
6086
 
@@ -6212,6 +6283,121 @@ function json(data, status = 200) {
6212
6283
  headers: { "content-type": "application/json; charset=utf-8" }
6213
6284
  });
6214
6285
  }
6286
+ function apiToken(options) {
6287
+ return options.apiToken || process.env.SHORTLINKS_API_TOKEN || process.env.HASNA_SHORTLINKS_API_TOKEN || null;
6288
+ }
6289
+ function requestToken(request) {
6290
+ const auth = request.headers.get("authorization") || "";
6291
+ const bearer = auth.toLowerCase().startsWith("bearer ") ? auth.slice(7).trim() : "";
6292
+ return bearer || request.headers.get("x-shortlinks-token") || request.headers.get("x-api-key");
6293
+ }
6294
+ function isAuthorized(request, options) {
6295
+ const token = apiToken(options);
6296
+ if (!token)
6297
+ return true;
6298
+ return requestToken(request) === token;
6299
+ }
6300
+ async function readJsonBody(request) {
6301
+ try {
6302
+ const parsed = await request.json();
6303
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
6304
+ } catch {
6305
+ return {};
6306
+ }
6307
+ }
6308
+ function requireMethod(store, method) {
6309
+ const fn = store[method];
6310
+ if (typeof fn !== "function")
6311
+ throw new Error(`Store does not support ${String(method)}.`);
6312
+ return fn.bind(store);
6313
+ }
6314
+ async function handleApi(request, apiPath, store, options) {
6315
+ if (apiPath === "/health") {
6316
+ return json({ ok: true, service: "shortlinks", api_auth_required: Boolean(apiToken(options)), stats: await store.totalStats() });
6317
+ }
6318
+ if (!isAuthorized(request, options))
6319
+ return json({ error: "Unauthorized" }, 401);
6320
+ const url = new URL(request.url);
6321
+ const segments = apiPath.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
6322
+ try {
6323
+ if (apiPath === "/links" && request.method === "GET") {
6324
+ const listLinks = requireMethod(store, "listLinks");
6325
+ return json(await listLinks({
6326
+ domain: url.searchParams.get("domain") || undefined,
6327
+ activeOnly: url.searchParams.get("active") === "true",
6328
+ limit: Number(url.searchParams.get("limit") || "100")
6329
+ }));
6330
+ }
6331
+ if (apiPath === "/links" && request.method === "POST") {
6332
+ const body = await readJsonBody(request);
6333
+ const destinationUrl = String(body.destination_url || body.url || "");
6334
+ if (!destinationUrl)
6335
+ return json({ error: "destination_url is required" }, 400);
6336
+ const createLink = requireMethod(store, "createLink");
6337
+ return json(await createLink({
6338
+ destinationUrl,
6339
+ domain: typeof body.domain === "string" ? body.domain : undefined,
6340
+ slug: typeof body.slug === "string" ? body.slug : undefined,
6341
+ title: typeof body.title === "string" ? body.title : undefined,
6342
+ expiresAt: typeof body.expires_at === "string" ? body.expires_at : typeof body.expires === "string" ? body.expires : undefined,
6343
+ maxUses: typeof body.max_uses === "number" ? body.max_uses : typeof body.maxUses === "number" ? body.maxUses : undefined,
6344
+ slugLength: typeof body.length === "number" ? body.length : undefined
6345
+ }), 201);
6346
+ }
6347
+ if (segments[0] === "links" && segments[1] && request.method === "GET") {
6348
+ const getLink = requireMethod(store, "getLink");
6349
+ const domain = url.searchParams.get("domain") || undefined;
6350
+ const link = domain ? await getLink(domain, segments[1]) : await getLink(segments[1]);
6351
+ return link ? json(link) : json({ error: "Link not found." }, 404);
6352
+ }
6353
+ if (segments[0] === "links" && segments[1] && request.method === "DELETE") {
6354
+ const deleteLink = requireMethod(store, "deleteLink");
6355
+ const domain = url.searchParams.get("domain") || undefined;
6356
+ return json(domain ? await deleteLink(domain, segments[1]) : await deleteLink(segments[1]));
6357
+ }
6358
+ if (segments[0] === "links" && segments[1] && segments[2] === "active" && request.method === "POST") {
6359
+ const body = await readJsonBody(request);
6360
+ const setLinkActive = requireMethod(store, "setLinkActive");
6361
+ const domain = url.searchParams.get("domain") || undefined;
6362
+ const active = Boolean(body.active);
6363
+ return json(domain ? await setLinkActive(domain, segments[1], active) : await setLinkActive(segments[1], active));
6364
+ }
6365
+ if (apiPath === "/stats" && request.method === "GET") {
6366
+ return json(await store.totalStats());
6367
+ }
6368
+ if (segments[0] === "stats" && segments[1] && request.method === "GET") {
6369
+ const getStats = requireMethod(store, "getStats");
6370
+ const domain = url.searchParams.get("domain") || undefined;
6371
+ return json(domain ? await getStats(domain, segments[1]) : await getStats(segments[1]));
6372
+ }
6373
+ if (apiPath === "/domains" && request.method === "GET") {
6374
+ const listDomains = requireMethod(store, "listDomains");
6375
+ return json(await listDomains());
6376
+ }
6377
+ if (apiPath === "/domains" && request.method === "POST") {
6378
+ const body = await readJsonBody(request);
6379
+ const hostname2 = String(body.hostname || "");
6380
+ if (!hostname2)
6381
+ return json({ error: "hostname is required" }, 400);
6382
+ const addDomain = requireMethod(store, "addDomain");
6383
+ return json(await addDomain({
6384
+ hostname: hostname2,
6385
+ provider: typeof body.provider === "string" ? body.provider : "manual",
6386
+ defaultDomain: Boolean(body.default_domain || body.default),
6387
+ originUrl: typeof body.origin_url === "string" ? body.origin_url : undefined,
6388
+ notes: typeof body.notes === "string" ? body.notes : undefined
6389
+ }), 201);
6390
+ }
6391
+ if (segments[0] === "domains" && segments[1] && request.method === "GET") {
6392
+ const getDomain = requireMethod(store, "getDomain");
6393
+ const domain = await getDomain(segments[1]);
6394
+ return domain ? json(domain) : json({ error: "Domain not found." }, 404);
6395
+ }
6396
+ } catch (error) {
6397
+ return json({ error: error instanceof Error ? error.message : String(error) }, 400);
6398
+ }
6399
+ return json({ error: "Not found." }, 404);
6400
+ }
6215
6401
  function getHost(request, fallback) {
6216
6402
  const forwarded = request.headers.get("x-forwarded-host");
6217
6403
  const host = forwarded || request.headers.get("host") || fallback || "";
@@ -6230,8 +6416,13 @@ function createShortlinksHandler(options = {}) {
6230
6416
  const store = options.store || new ShortlinksStore(options.dbPath);
6231
6417
  const redirectStatus = options.redirectStatus || 302;
6232
6418
  const reservedPathPrefixes = new Set((options.reservedPathPrefixes ?? ["a"]).map((prefix) => prefix.toLowerCase()));
6419
+ const apiPathPrefix = (options.apiPathPrefix || process.env.SHORTLINKS_API_PATH_PREFIX || "/api").replace(/\/+$/, "") || "/api";
6233
6420
  return async (request) => {
6234
6421
  const url = new URL(request.url);
6422
+ if (url.pathname === apiPathPrefix || url.pathname.startsWith(`${apiPathPrefix}/`)) {
6423
+ const apiPath = url.pathname.slice(apiPathPrefix.length) || "/";
6424
+ return handleApi(request, apiPath, store, options);
6425
+ }
6235
6426
  if (url.pathname === "/healthz") {
6236
6427
  return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
6237
6428
  }
@@ -6264,7 +6455,14 @@ function createShortlinksHandler(options = {}) {
6264
6455
  return json({ error: "Shortlink is disabled.", slug, host }, 410);
6265
6456
  if (isExpired(link))
6266
6457
  return json({ error: "Shortlink is expired.", slug, host }, 410);
6267
- await store.recordClick(link, {
6458
+ if (link.max_uses !== null && link.used_count >= link.max_uses) {
6459
+ return json({ error: "Shortlink max uses reached.", slug, host }, 410);
6460
+ }
6461
+ const consumed = store.consumeLinkUse ? await store.consumeLinkUse(link) : link;
6462
+ if (!consumed) {
6463
+ return json({ error: "Shortlink max uses reached.", slug, host }, 410);
6464
+ }
6465
+ await store.recordClick(consumed, {
6268
6466
  ip: getClientIp(request),
6269
6467
  userAgent: request.headers.get("user-agent"),
6270
6468
  referer: request.headers.get("referer"),
@@ -6283,6 +6481,155 @@ function serveShortlinks(options = {}) {
6283
6481
  const fetch2 = createShortlinksHandler(options);
6284
6482
  return Bun.serve({ hostname: host, port, fetch: fetch2 });
6285
6483
  }
6484
+ // src/api-client.ts
6485
+ function normalizeBaseUrl(value) {
6486
+ return value.replace(/\/+$/, "");
6487
+ }
6488
+ function requireBaseUrl(options) {
6489
+ const baseUrl = options.baseUrl || getApiBaseUrl(loadConfig());
6490
+ if (!baseUrl) {
6491
+ throw new Error("Shortlinks API URL is not configured. Run `shortlinks config set api-url <url>` or set SHORTLINKS_API_URL.");
6492
+ }
6493
+ return normalizeBaseUrl(baseUrl);
6494
+ }
6495
+ function requireToken(options) {
6496
+ const token = options.token || getApiToken(loadConfig());
6497
+ if (!token) {
6498
+ throw new Error("Shortlinks API token is not configured. Set SHORTLINKS_API_TOKEN or run `shortlinks config set api-token-env <name>`.");
6499
+ }
6500
+ return token;
6501
+ }
6502
+ async function readJson(response) {
6503
+ const text = await response.text();
6504
+ let parsed = {};
6505
+ if (text) {
6506
+ try {
6507
+ parsed = JSON.parse(text);
6508
+ } catch {
6509
+ parsed = { error: text };
6510
+ }
6511
+ }
6512
+ if (!response.ok) {
6513
+ const message = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : `HTTP ${response.status}`;
6514
+ throw new Error(message);
6515
+ }
6516
+ return parsed;
6517
+ }
6518
+
6519
+ class ShortlinksApiClient {
6520
+ options;
6521
+ constructor(options = {}) {
6522
+ this.options = options;
6523
+ }
6524
+ url(path, query) {
6525
+ const url = new URL(`${requireBaseUrl(this.options)}${path}`);
6526
+ for (const [key, value] of Object.entries(query ?? {})) {
6527
+ if (value !== undefined && value !== "")
6528
+ url.searchParams.set(key, String(value));
6529
+ }
6530
+ return url.toString();
6531
+ }
6532
+ headers(extra) {
6533
+ return {
6534
+ authorization: `Bearer ${requireToken(this.options)}`,
6535
+ ...extra ?? {}
6536
+ };
6537
+ }
6538
+ async totalStats() {
6539
+ const response = await fetch(this.url("/stats"), { headers: this.headers() });
6540
+ return readJson(response);
6541
+ }
6542
+ async createLink(input) {
6543
+ const response = await fetch(this.url("/links"), {
6544
+ method: "POST",
6545
+ headers: this.headers({ "content-type": "application/json" }),
6546
+ body: JSON.stringify({
6547
+ destination_url: input.destinationUrl,
6548
+ domain: input.domain,
6549
+ slug: input.slug,
6550
+ title: input.title,
6551
+ expires_at: input.expiresAt,
6552
+ max_uses: input.maxUses,
6553
+ length: input.slugLength
6554
+ })
6555
+ });
6556
+ return readJson(response);
6557
+ }
6558
+ async listLinks(options = {}) {
6559
+ const response = await fetch(this.url("/links", {
6560
+ domain: options.domain,
6561
+ active: options.activeOnly,
6562
+ limit: options.limit
6563
+ }), { headers: this.headers() });
6564
+ return readJson(response);
6565
+ }
6566
+ async getLink(domainOrSlug, maybeSlug) {
6567
+ const slug = maybeSlug ?? domainOrSlug;
6568
+ const response = await fetch(this.url(`/links/${encodeURIComponent(slug)}`, {
6569
+ domain: maybeSlug ? domainOrSlug : undefined
6570
+ }), { headers: this.headers() });
6571
+ if (response.status === 404)
6572
+ return null;
6573
+ return readJson(response);
6574
+ }
6575
+ async setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
6576
+ const hasDomain = typeof maybeSlugOrActive === "string";
6577
+ const slug = hasDomain ? maybeSlugOrActive : domainOrSlug;
6578
+ const active = hasDomain ? Boolean(maybeActive) : Boolean(maybeSlugOrActive);
6579
+ const response = await fetch(this.url(`/links/${encodeURIComponent(slug)}/active`, {
6580
+ domain: hasDomain ? domainOrSlug : undefined
6581
+ }), {
6582
+ method: "POST",
6583
+ headers: this.headers({ "content-type": "application/json" }),
6584
+ body: JSON.stringify({ active })
6585
+ });
6586
+ return readJson(response);
6587
+ }
6588
+ async deleteLink(domainOrSlug, maybeSlug) {
6589
+ const slug = maybeSlug ?? domainOrSlug;
6590
+ const response = await fetch(this.url(`/links/${encodeURIComponent(slug)}`, {
6591
+ domain: maybeSlug ? domainOrSlug : undefined
6592
+ }), {
6593
+ method: "DELETE",
6594
+ headers: this.headers()
6595
+ });
6596
+ return readJson(response);
6597
+ }
6598
+ async getStats(domainOrSlug, maybeSlug) {
6599
+ const slug = maybeSlug ?? domainOrSlug;
6600
+ const response = await fetch(this.url(`/stats/${encodeURIComponent(slug)}`, {
6601
+ domain: maybeSlug ? domainOrSlug : undefined
6602
+ }), { headers: this.headers() });
6603
+ return readJson(response);
6604
+ }
6605
+ async addDomain(input) {
6606
+ const response = await fetch(this.url("/domains"), {
6607
+ method: "POST",
6608
+ headers: this.headers({ "content-type": "application/json" }),
6609
+ body: JSON.stringify({
6610
+ hostname: input.hostname,
6611
+ provider: input.provider,
6612
+ default_domain: input.defaultDomain,
6613
+ origin_url: input.originUrl,
6614
+ notes: input.notes
6615
+ })
6616
+ });
6617
+ return readJson(response);
6618
+ }
6619
+ async listDomains() {
6620
+ const response = await fetch(this.url("/domains"), { headers: this.headers() });
6621
+ return readJson(response);
6622
+ }
6623
+ async getDomain(hostname2) {
6624
+ const response = await fetch(this.url(`/domains/${encodeURIComponent(hostname2)}`), { headers: this.headers() });
6625
+ if (response.status === 404)
6626
+ return null;
6627
+ return readJson(response);
6628
+ }
6629
+ async close() {
6630
+ return;
6631
+ }
6632
+ }
6286
6633
  // src/cloudflare.ts
6287
6634
  import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
6288
6635
  import { join as join4 } from "path";
@@ -6365,9 +6712,9 @@ SHORTLINKS_RESERVED_PATH_PREFIXES = "a"
6365
6712
  return { workerPath, wranglerPath };
6366
6713
  }
6367
6714
  function cloudflareAuthHeaders(token) {
6368
- const apiToken = token || process.env.CLOUDFLARE_API_TOKEN;
6369
- if (apiToken)
6370
- return { authorization: `Bearer ${apiToken}` };
6715
+ const apiToken2 = token || process.env.CLOUDFLARE_API_TOKEN;
6716
+ if (apiToken2)
6717
+ return { authorization: `Bearer ${apiToken2}` };
6371
6718
  const apiKey = process.env.CLOUDFLARE_API_KEY;
6372
6719
  const email = process.env.CLOUDFLARE_EMAIL;
6373
6720
  if (apiKey && email) {
@@ -6512,6 +6859,8 @@ export {
6512
6859
  getConnectionString,
6513
6860
  getConfigPath,
6514
6861
  getCanonicalShortlinksRdsConfig,
6862
+ getApiToken,
6863
+ getApiBaseUrl,
6515
6864
  generateWorkerScript,
6516
6865
  formatShortUrl,
6517
6866
  createShortlinksHandler,
@@ -6520,6 +6869,7 @@ export {
6520
6869
  applyPgMigrations,
6521
6870
  ShortlinksStore,
6522
6871
  ShortlinksDatabase,
6872
+ ShortlinksApiClient,
6523
6873
  STORAGE_TABLES,
6524
6874
  STORAGE_MODE_ENV,
6525
6875
  STORAGE_DATABASE_ENV,
@@ -17,6 +17,7 @@ export declare class PgShortlinksStore {
17
17
  getDomain(hostnameOrId: string): Promise<Domain | null>;
18
18
  getDefaultDomain(): Promise<Domain | null>;
19
19
  createLink(input: CreateLinkInput): Promise<Link>;
20
+ consumeLinkUse(link: Link): Promise<Link | null>;
20
21
  listLinks(options?: {
21
22
  domain?: string;
22
23
  activeOnly?: boolean;
package/dist/server.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ClickInput, Link } from "./types.js";
1
+ import type { AddDomainInput, ClickInput, CreateLinkInput, Domain, Link, LinkStats } from "./types.js";
2
2
  export interface ShortlinksRuntimeStore {
3
3
  totalStats(): {
4
4
  domains: number;
@@ -11,6 +11,20 @@ export interface ShortlinksRuntimeStore {
11
11
  }>;
12
12
  resolve(hostname: string, slug: string): Link | null | Promise<Link | null>;
13
13
  recordClick(link: Link, input?: ClickInput): unknown | Promise<unknown>;
14
+ consumeLinkUse?(link: Link): Link | null | Promise<Link | null>;
15
+ createLink?(input: CreateLinkInput): Link | Promise<Link>;
16
+ listLinks?(input?: {
17
+ domain?: string;
18
+ activeOnly?: boolean;
19
+ limit?: number;
20
+ }): Link[] | Promise<Link[]>;
21
+ getLink?(hostnameOrSlug: string, slug?: string): Link | null | Promise<Link | null>;
22
+ setLinkActive?(hostnameOrSlug: string, slugOrActive: string | boolean, maybeActive?: boolean): Link | Promise<Link>;
23
+ deleteLink?(hostnameOrSlug: string, slug?: string): Link | Promise<Link>;
24
+ getStats?(hostnameOrSlug: string, slug?: string): LinkStats | Promise<LinkStats>;
25
+ addDomain?(input: AddDomainInput): Domain | Promise<Domain>;
26
+ listDomains?(): Domain[] | Promise<Domain[]>;
27
+ getDomain?(hostname: string): Domain | null | Promise<Domain | null>;
14
28
  }
15
29
  export interface ShortlinksHandlerOptions {
16
30
  store?: ShortlinksRuntimeStore;
@@ -18,6 +32,8 @@ export interface ShortlinksHandlerOptions {
18
32
  defaultHost?: string;
19
33
  redirectStatus?: 301 | 302 | 307 | 308;
20
34
  reservedPathPrefixes?: string[];
35
+ apiPathPrefix?: string;
36
+ apiToken?: string | null;
21
37
  }
22
38
  export declare function createShortlinksHandler(options?: ShortlinksHandlerOptions): (request: Request) => Response | Promise<Response>;
23
39
  export declare function serveShortlinks(options?: ShortlinksHandlerOptions & {