@hasna/shortlinks 0.1.7 → 0.1.8

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  CLI-only shortlink management for custom domains.
4
4
 
5
- `shortlinks` creates Bitly-style short URLs, supports multiple domains, records click analytics, can run a tiny redirect server, and includes helper commands for Cloudflare DNS/Workers, `@hasna/domains`, and `@hasna/cloud` sync.
5
+ `shortlinks` creates Bitly-style short URLs, supports multiple domains, records click analytics, can run a tiny redirect server, and includes helper commands for Cloudflare DNS/Workers, `@hasna/domains`, and `@hasna/cloud` sync. Production serving can run directly against the shared RDS database with `--cloud`; local SQLite is only for explicit local/offline use.
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/@hasna/shortlinks)](https://www.npmjs.com/package/@hasna/shortlinks)
8
8
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
@@ -64,6 +64,7 @@ shortlinks link enable home --domain has.na
64
64
  shortlinks stats home --domain has.na
65
65
 
66
66
  shortlinks serve --port 8787
67
+ shortlinks serve --cloud --port 8787
67
68
  shortlinks doctor
68
69
  ```
69
70
 
@@ -137,15 +138,21 @@ shortlinks cloud sync
137
138
  ```
138
139
 
139
140
  The cloud database service name is `shortlinks`.
141
+ Use direct RDS mode for production and live management:
142
+
143
+ ```bash
144
+ shortlinks --cloud create https://example.com
145
+ shortlinks --cloud link list
146
+ shortlinks serve --cloud --host 127.0.0.1 --port 8787
147
+ ```
140
148
 
141
149
  ## AWS Origin
142
150
 
143
151
  For an apex domain that needs stable A records, `infra/aws-ec2-user-data.sh` bootstraps a small EC2 redirect origin with:
144
152
 
145
153
  - `@hasna/shortlinks` installed through Bun
146
- - local SQLite data synced with the `shortlinks` RDS database through `@hasna/cloud`
154
+ - direct reads and click writes against the `shortlinks` RDS database through `@hasna/cloud`
147
155
  - Caddy terminating HTTPS and proxying to `shortlinks serve`
148
- - a systemd timer that syncs links and clicks every minute
149
156
 
150
157
  The script reads the RDS password from AWS Secrets Manager through the instance role; it does not contain secret values.
151
158
 
package/dist/cli/index.js CHANGED
@@ -3096,6 +3096,326 @@ class ShortlinksStore {
3096
3096
  }
3097
3097
  }
3098
3098
 
3099
+ // src/pg-store.ts
3100
+ import { createHash as createHash2 } from "crypto";
3101
+ function parseJsonObject2(value) {
3102
+ if (!value)
3103
+ return {};
3104
+ if (typeof value === "object" && !Array.isArray(value))
3105
+ return value;
3106
+ try {
3107
+ const parsed = JSON.parse(String(value));
3108
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3109
+ } catch {
3110
+ return {};
3111
+ }
3112
+ }
3113
+ function toIsoString(value) {
3114
+ if (value instanceof Date)
3115
+ return value.toISOString();
3116
+ return String(value);
3117
+ }
3118
+ function nullableIso(value) {
3119
+ if (value === null || value === undefined)
3120
+ return null;
3121
+ return toIsoString(value);
3122
+ }
3123
+ function domainFromRow2(row) {
3124
+ return {
3125
+ ...row,
3126
+ default_domain: Boolean(row.default_domain),
3127
+ synced_at: nullableIso(row.synced_at),
3128
+ created_at: toIsoString(row.created_at),
3129
+ updated_at: toIsoString(row.updated_at),
3130
+ metadata: parseJsonObject2(row.metadata)
3131
+ };
3132
+ }
3133
+ function linkFromRow2(row) {
3134
+ return {
3135
+ ...row,
3136
+ active: Boolean(row.active),
3137
+ expires_at: nullableIso(row.expires_at),
3138
+ synced_at: nullableIso(row.synced_at),
3139
+ created_at: toIsoString(row.created_at),
3140
+ updated_at: toIsoString(row.updated_at),
3141
+ metadata: parseJsonObject2(row.metadata),
3142
+ short_url: formatShortUrl(row.hostname, row.slug)
3143
+ };
3144
+ }
3145
+ function validateDestinationUrl2(url) {
3146
+ let parsed;
3147
+ try {
3148
+ parsed = new URL(url);
3149
+ } catch {
3150
+ throw new Error(`Invalid destination URL: ${url}`);
3151
+ }
3152
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
3153
+ throw new Error("Destination URL must start with http:// or https://.");
3154
+ }
3155
+ return parsed.toString();
3156
+ }
3157
+ function isoOrNull2(input) {
3158
+ if (!input)
3159
+ return null;
3160
+ const date = new Date(input);
3161
+ if (Number.isNaN(date.getTime()))
3162
+ throw new Error(`Invalid date: ${input}`);
3163
+ return date.toISOString();
3164
+ }
3165
+ function clickFromRow2(row) {
3166
+ return {
3167
+ ...row,
3168
+ clicked_at: toIsoString(row.clicked_at),
3169
+ synced_at: nullableIso(row.synced_at),
3170
+ created_at: toIsoString(row.created_at),
3171
+ updated_at: toIsoString(row.updated_at),
3172
+ metadata: parseJsonObject2(row.metadata)
3173
+ };
3174
+ }
3175
+
3176
+ class PgShortlinksStore {
3177
+ pg;
3178
+ constructor(pg) {
3179
+ this.pg = pg;
3180
+ }
3181
+ static async fromConnectionString(connectionString) {
3182
+ const { PgAdapterAsync } = await import("@hasna/cloud");
3183
+ return new PgShortlinksStore(new PgAdapterAsync(connectionString));
3184
+ }
3185
+ static async fromCloud(service = "shortlinks") {
3186
+ const { getConnectionString } = await import("@hasna/cloud");
3187
+ return PgShortlinksStore.fromConnectionString(getConnectionString(service));
3188
+ }
3189
+ async close() {
3190
+ await this.pg.close?.();
3191
+ }
3192
+ async addDomain(input) {
3193
+ const hostname2 = normalizeHostname(input.hostname);
3194
+ const timestamp = now();
3195
+ const machineId = getMachineId();
3196
+ const existing = await this.getDomain(hostname2);
3197
+ const id = existing?.id || makeId("dom");
3198
+ if (input.defaultDomain) {
3199
+ await this.pg.run("UPDATE domains SET default_domain = 0, updated_at = ? WHERE default_domain = 1", timestamp);
3200
+ }
3201
+ await this.pg.run(`
3202
+ INSERT INTO domains (
3203
+ id, hostname, provider, default_domain, cloudflare_zone_id, cloudflare_account_id,
3204
+ cloudflare_worker_name, origin_url, notes, metadata, machine_id, synced_at, created_at, updated_at
3205
+ )
3206
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
3207
+ ON CONFLICT(hostname) DO UPDATE SET
3208
+ provider = excluded.provider,
3209
+ default_domain = excluded.default_domain,
3210
+ cloudflare_zone_id = COALESCE(excluded.cloudflare_zone_id, domains.cloudflare_zone_id),
3211
+ cloudflare_account_id = COALESCE(excluded.cloudflare_account_id, domains.cloudflare_account_id),
3212
+ cloudflare_worker_name = COALESCE(excluded.cloudflare_worker_name, domains.cloudflare_worker_name),
3213
+ origin_url = COALESCE(excluded.origin_url, domains.origin_url),
3214
+ notes = COALESCE(excluded.notes, domains.notes),
3215
+ metadata = excluded.metadata,
3216
+ machine_id = excluded.machine_id,
3217
+ synced_at = NULL,
3218
+ updated_at = excluded.updated_at
3219
+ `, id, hostname2, input.provider || existing?.provider || "manual", input.defaultDomain ?? existing?.default_domain ? 1 : 0, input.cloudflareZoneId || existing?.cloudflare_zone_id || null, input.cloudflareAccountId || existing?.cloudflare_account_id || null, input.cloudflareWorkerName || existing?.cloudflare_worker_name || null, input.originUrl || existing?.origin_url || null, input.notes || existing?.notes || null, JSON.stringify(input.metadata || existing?.metadata || {}), machineId, existing?.created_at || timestamp, timestamp);
3220
+ return await this.getDomain(hostname2);
3221
+ }
3222
+ async listDomains() {
3223
+ const rows = await this.pg.all(`
3224
+ SELECT * FROM domains
3225
+ ORDER BY default_domain DESC, hostname ASC
3226
+ `);
3227
+ return rows.map(domainFromRow2);
3228
+ }
3229
+ async getDomain(hostnameOrId) {
3230
+ const normalized = hostnameOrId.includes(".") || hostnameOrId.includes("://") ? normalizeHostname(hostnameOrId) : hostnameOrId;
3231
+ const row = await this.pg.get(`
3232
+ SELECT * FROM domains WHERE hostname = ? OR id = ? LIMIT 1
3233
+ `, normalized, hostnameOrId);
3234
+ return row ? domainFromRow2(row) : null;
3235
+ }
3236
+ async getDefaultDomain() {
3237
+ const row = await this.pg.get(`
3238
+ SELECT * FROM domains ORDER BY default_domain DESC, created_at ASC LIMIT 1
3239
+ `);
3240
+ return row ? domainFromRow2(row) : null;
3241
+ }
3242
+ async createLink(input) {
3243
+ const domain = input.domain ? await this.getDomain(input.domain) : await this.getDefaultDomain();
3244
+ if (!domain) {
3245
+ throw new Error("No domain configured. Run `shortlinks domain add <domain> --default` first.");
3246
+ }
3247
+ const destinationUrl = validateDestinationUrl2(input.destinationUrl);
3248
+ const timestamp = now();
3249
+ const machineId = getMachineId();
3250
+ const expiresAt = isoOrNull2(input.expiresAt);
3251
+ const slug = input.slug ? normalizeSlug(input.slug) : await this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
3252
+ try {
3253
+ await this.pg.run(`
3254
+ INSERT INTO links (
3255
+ id, domain_id, slug, destination_url, title, active, expires_at, metadata,
3256
+ machine_id, synced_at, created_at, updated_at
3257
+ )
3258
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
3259
+ `, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
3260
+ } catch (error) {
3261
+ const message = error instanceof Error ? error.message : String(error);
3262
+ if (message.includes("unique") || message.includes("duplicate")) {
3263
+ throw new Error(`Slug already exists for ${domain.hostname}: ${slug}`);
3264
+ }
3265
+ throw error;
3266
+ }
3267
+ return await this.getLink(domain.hostname, slug);
3268
+ }
3269
+ async listLinks(options = {}) {
3270
+ const params = [];
3271
+ let where = "WHERE 1 = 1";
3272
+ if (options.domain) {
3273
+ where += " AND d.hostname = ?";
3274
+ params.push(normalizeHostname(options.domain));
3275
+ }
3276
+ if (options.activeOnly)
3277
+ where += " AND l.active = 1";
3278
+ params.push(options.limit || 100);
3279
+ const rows = await this.pg.all(`
3280
+ SELECT l.*, d.hostname
3281
+ FROM links l
3282
+ JOIN domains d ON d.id = l.domain_id
3283
+ ${where}
3284
+ ORDER BY l.created_at DESC
3285
+ LIMIT ?
3286
+ `, ...params);
3287
+ return rows.map(linkFromRow2);
3288
+ }
3289
+ async getLink(domainOrSlug, maybeSlug) {
3290
+ const slug = normalizeSlug(maybeSlug || domainOrSlug);
3291
+ const params = [slug];
3292
+ let domainClause = "";
3293
+ if (maybeSlug) {
3294
+ domainClause = "AND d.hostname = ?";
3295
+ params.push(normalizeHostname(domainOrSlug));
3296
+ }
3297
+ const row = await this.pg.get(`
3298
+ SELECT l.*, d.hostname
3299
+ FROM links l
3300
+ JOIN domains d ON d.id = l.domain_id
3301
+ WHERE l.slug = ? ${domainClause}
3302
+ ORDER BY d.default_domain DESC, l.created_at ASC
3303
+ LIMIT 1
3304
+ `, ...params);
3305
+ return row ? linkFromRow2(row) : null;
3306
+ }
3307
+ async totalStats() {
3308
+ const row = await this.pg.get(`
3309
+ SELECT
3310
+ (SELECT COUNT(*)::int FROM domains) AS domains,
3311
+ (SELECT COUNT(*)::int FROM links) AS links,
3312
+ (SELECT COUNT(*)::int FROM clicks) AS clicks
3313
+ `);
3314
+ return row;
3315
+ }
3316
+ async resolve(hostname2, slug) {
3317
+ const normalizedHost = normalizeHostname(hostname2);
3318
+ const normalizedSlug = normalizeSlug(slug);
3319
+ const row = await this.pg.get(`
3320
+ SELECT l.*, d.hostname
3321
+ FROM links l
3322
+ JOIN domains d ON d.id = l.domain_id
3323
+ WHERE d.hostname = ? AND l.slug = ?
3324
+ LIMIT 1
3325
+ `, normalizedHost, normalizedSlug);
3326
+ if (row)
3327
+ return linkFromRow2(row);
3328
+ const fallback = await this.pg.get(`
3329
+ SELECT l.*, d.hostname
3330
+ FROM links l
3331
+ JOIN domains d ON d.id = l.domain_id
3332
+ WHERE d.default_domain = 1 AND l.slug = ?
3333
+ ORDER BY d.created_at ASC
3334
+ LIMIT 1
3335
+ `, normalizedSlug);
3336
+ return fallback ? linkFromRow2(fallback) : null;
3337
+ }
3338
+ async setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
3339
+ const active = typeof maybeSlugOrActive === "boolean" ? maybeSlugOrActive : Boolean(maybeActive);
3340
+ const link = typeof maybeSlugOrActive === "boolean" ? await this.getLink(domainOrSlug) : await this.getLink(domainOrSlug, maybeSlugOrActive);
3341
+ if (!link)
3342
+ throw new Error("Link not found.");
3343
+ const timestamp = now();
3344
+ await this.pg.run(`
3345
+ UPDATE links SET active = ?, updated_at = ?, synced_at = NULL WHERE id = ?
3346
+ `, active ? 1 : 0, timestamp, link.id);
3347
+ return await this.getLink(link.hostname, link.slug);
3348
+ }
3349
+ async deleteLink(domainOrSlug, maybeSlug) {
3350
+ const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
3351
+ if (!link)
3352
+ throw new Error("Link not found.");
3353
+ await this.pg.run("DELETE FROM links WHERE id = ?", link.id);
3354
+ return link;
3355
+ }
3356
+ async recordClick(link, input = {}) {
3357
+ const timestamp = now();
3358
+ const machineId = getMachineId();
3359
+ const ipHash = input.ip ? this.hashIp(input.ip) : null;
3360
+ const id = makeId("clk");
3361
+ await this.pg.run(`
3362
+ INSERT INTO clicks (
3363
+ id, link_id, domain_id, slug, clicked_at, ip_hash, user_agent, referer,
3364
+ country, city, metadata, machine_id, synced_at, created_at, updated_at
3365
+ )
3366
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
3367
+ `, id, link.id, link.domain_id, link.slug, timestamp, ipHash, input.userAgent || null, input.referer || null, input.country || null, input.city || null, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
3368
+ const row = await this.pg.get("SELECT * FROM clicks WHERE id = ?", id);
3369
+ return clickFromRow2(row);
3370
+ }
3371
+ async getStats(domainOrSlug, maybeSlug) {
3372
+ const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
3373
+ if (!link)
3374
+ throw new Error("Link not found.");
3375
+ const summary = await this.pg.get(`
3376
+ SELECT COUNT(*)::int AS clicks, MAX(clicked_at) AS last_clicked_at FROM clicks WHERE link_id = ?
3377
+ `, link.id);
3378
+ const topReferrers = await this.pg.all(`
3379
+ SELECT referer, COUNT(*)::int AS clicks
3380
+ FROM clicks
3381
+ WHERE link_id = ?
3382
+ GROUP BY referer
3383
+ ORDER BY clicks DESC
3384
+ LIMIT 10
3385
+ `, link.id);
3386
+ const topUserAgents = await this.pg.all(`
3387
+ SELECT user_agent, COUNT(*)::int AS clicks
3388
+ FROM clicks
3389
+ WHERE link_id = ?
3390
+ GROUP BY user_agent
3391
+ ORDER BY clicks DESC
3392
+ LIMIT 10
3393
+ `, link.id);
3394
+ return {
3395
+ link,
3396
+ clicks: summary.clicks,
3397
+ last_clicked_at: nullableIso(summary.last_clicked_at),
3398
+ top_referrers: topReferrers,
3399
+ top_user_agents: topUserAgents
3400
+ };
3401
+ }
3402
+ hashIp(ip) {
3403
+ const salt = process.env.SHORTLINKS_CLICK_SALT || "shortlinks-cloud";
3404
+ return createHash2("sha256").update(`${salt}:${ip}`).digest("hex");
3405
+ }
3406
+ async generateAvailableSlug(domainId, length) {
3407
+ for (let attempt = 0;attempt < 32; attempt += 1) {
3408
+ const slug = randomToken(length);
3409
+ const exists = await this.pg.get(`
3410
+ SELECT 1 FROM links WHERE domain_id = ? AND slug = ? LIMIT 1
3411
+ `, domainId, slug);
3412
+ if (!exists)
3413
+ return slug;
3414
+ }
3415
+ throw new Error("Could not generate an unused slug after 32 attempts.");
3416
+ }
3417
+ }
3418
+
3099
3419
  // src/server.ts
3100
3420
  function json(data, status = 200) {
3101
3421
  return new Response(JSON.stringify(data, null, 2), {
@@ -3123,7 +3443,7 @@ function createShortlinksHandler(options = {}) {
3123
3443
  return async (request) => {
3124
3444
  const url = new URL(request.url);
3125
3445
  if (url.pathname === "/healthz") {
3126
- return json({ ok: true, service: "shortlinks", stats: store.totalStats() });
3446
+ return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
3127
3447
  }
3128
3448
  if (url.pathname === "/" || url.pathname === "") {
3129
3449
  return json({ service: "shortlinks", ok: true });
@@ -3141,7 +3461,7 @@ function createShortlinksHandler(options = {}) {
3141
3461
  return json({ error: "Missing Host header." }, 400);
3142
3462
  let link = null;
3143
3463
  try {
3144
- link = store.resolve(host, slug);
3464
+ link = await store.resolve(host, slug);
3145
3465
  } catch {
3146
3466
  return json({ error: "Shortlink not found.", slug, host }, 404);
3147
3467
  }
@@ -3151,7 +3471,7 @@ function createShortlinksHandler(options = {}) {
3151
3471
  return json({ error: "Shortlink is disabled.", slug, host }, 410);
3152
3472
  if (isExpired(link))
3153
3473
  return json({ error: "Shortlink is expired.", slug, host }, 410);
3154
- store.recordClick(link, {
3474
+ await store.recordClick(link, {
3155
3475
  ip: getClientIp(request),
3156
3476
  userAgent: request.headers.get("user-agent"),
3157
3477
  referer: request.headers.get("referer"),
@@ -3486,7 +3806,22 @@ function withStore(fn) {
3486
3806
  store.close();
3487
3807
  }
3488
3808
  }
3489
- async function withStoreAsync(fn) {
3809
+ function storeMode() {
3810
+ const opts = program2.opts();
3811
+ const value = String(opts.cloud ? "cloud" : opts.store || process.env.SHORTLINKS_STORE || "local").toLowerCase();
3812
+ if (value !== "local" && value !== "cloud")
3813
+ throw new Error(`Unknown store mode: ${value}`);
3814
+ return value;
3815
+ }
3816
+ async function withRuntimeStore(fn) {
3817
+ if (storeMode() === "cloud") {
3818
+ const store2 = await PgShortlinksStore.fromCloud("shortlinks");
3819
+ try {
3820
+ return await fn(store2);
3821
+ } finally {
3822
+ await store2.close();
3823
+ }
3824
+ }
3490
3825
  const store = new ShortlinksStore(program2.opts().db);
3491
3826
  try {
3492
3827
  return await fn(store);
@@ -3501,15 +3836,15 @@ function commandExists(command) {
3501
3836
  const result = spawnSync3("which", [command], { encoding: "utf-8" });
3502
3837
  return result.status === 0;
3503
3838
  }
3504
- program2.name("shortlinks").description("CLI-only shortlink manager with custom domains, click tracking, Cloudflare helpers, and cloud sync").version(getPackageVersion()).option("--db <path>", "SQLite database path").option("-j, --json", "Output JSON for agents and scripts");
3505
- program2.command("init").description("Initialize local shortlinks storage").option("--domain <hostname>", "Add a default shortlink domain").option("--public-base-url <url>", "Public URL base for generated links").option("-j, --json", "Output JSON").action((opts) => {
3839
+ program2.name("shortlinks").description("CLI-only shortlink manager with custom domains, click tracking, Cloudflare helpers, and cloud sync").version(getPackageVersion()).option("--db <path>", "SQLite database path").option("--store <mode>", "Data store mode: cloud or local", process.env.SHORTLINKS_STORE || "local").option("--cloud", "Use the shortlinks PostgreSQL database directly").option("-j, --json", "Output JSON for agents and scripts");
3840
+ program2.command("init").description("Initialize local shortlinks storage").option("--domain <hostname>", "Add a default shortlink domain").option("--public-base-url <url>", "Public URL base for generated links").option("-j, --json", "Output JSON").action(async (opts) => {
3506
3841
  try {
3507
- const result = withStore((store) => {
3842
+ const result = await withRuntimeStore(async (store) => {
3508
3843
  const config = loadConfig();
3509
3844
  if (opts.publicBaseUrl)
3510
3845
  config.publicBaseUrl = opts.publicBaseUrl;
3511
3846
  if (opts.domain) {
3512
- const domain = store.addDomain({
3847
+ const domain = await store.addDomain({
3513
3848
  hostname: opts.domain,
3514
3849
  provider: "manual",
3515
3850
  defaultDomain: true
@@ -3517,13 +3852,15 @@ program2.command("init").description("Initialize local shortlinks storage").opti
3517
3852
  config.defaultDomain = domain.hostname;
3518
3853
  config.publicBaseUrl = opts.publicBaseUrl || `https://${domain.hostname}`;
3519
3854
  }
3520
- saveConfig(config);
3855
+ if (storeMode() === "local")
3856
+ saveConfig(config);
3521
3857
  return {
3522
3858
  data_dir: getDataDir(),
3523
3859
  config_path: getConfigPath(),
3524
3860
  db_path: getDatabasePath(program2.opts().db),
3861
+ store: storeMode(),
3525
3862
  config,
3526
- stats: store.totalStats()
3863
+ stats: await store.totalStats()
3527
3864
  };
3528
3865
  });
3529
3866
  print(result, opts, () => {
@@ -3570,9 +3907,9 @@ configCmd.command("set <key> <value>").description("Set config value: default-do
3570
3907
  }
3571
3908
  });
3572
3909
  var domainCmd = program2.command("domain").alias("domains").description("Manage custom shortlink domains");
3573
- domainCmd.command("add <hostname>").description("Add or update a custom domain").option("--provider <provider>", "Provider label", "manual").option("--default", "Make this the default domain").option("--cloudflare-zone-id <id>", "Cloudflare zone ID").option("--cloudflare-account-id <id>", "Cloudflare account ID").option("--cloudflare-worker-name <name>", "Cloudflare Worker name").option("--origin <url>", "Origin redirect server URL").option("--notes <text>", "Notes").option("-j, --json", "Output JSON").action((hostname2, opts) => {
3910
+ domainCmd.command("add <hostname>").description("Add or update a custom domain").option("--provider <provider>", "Provider label", "manual").option("--default", "Make this the default domain").option("--cloudflare-zone-id <id>", "Cloudflare zone ID").option("--cloudflare-account-id <id>", "Cloudflare account ID").option("--cloudflare-worker-name <name>", "Cloudflare Worker name").option("--origin <url>", "Origin redirect server URL").option("--notes <text>", "Notes").option("-j, --json", "Output JSON").action(async (hostname2, opts) => {
3574
3911
  try {
3575
- const domain = withStore((store) => store.addDomain({
3912
+ const domain = await withRuntimeStore((store) => store.addDomain({
3576
3913
  hostname: hostname2,
3577
3914
  provider: opts.provider,
3578
3915
  defaultDomain: opts.default,
@@ -3591,9 +3928,9 @@ domainCmd.command("add <hostname>").description("Add or update a custom domain")
3591
3928
  handleError(error);
3592
3929
  }
3593
3930
  });
3594
- domainCmd.command("list").description("List configured domains").option("-j, --json", "Output JSON").action((opts) => {
3931
+ domainCmd.command("list").description("List configured domains").option("-j, --json", "Output JSON").action(async (opts) => {
3595
3932
  try {
3596
- const domains = withStore((store) => store.listDomains());
3933
+ const domains = await withRuntimeStore((store) => store.listDomains());
3597
3934
  print(domains, opts, () => {
3598
3935
  if (domains.length === 0) {
3599
3936
  console.log(source_default.dim("No domains configured."));
@@ -3608,9 +3945,9 @@ domainCmd.command("list").description("List configured domains").option("-j, --j
3608
3945
  handleError(error);
3609
3946
  }
3610
3947
  });
3611
- domainCmd.command("get <hostname>").description("Show a configured domain").option("-j, --json", "Output JSON").action((hostname2, opts) => {
3948
+ domainCmd.command("get <hostname>").description("Show a configured domain").option("-j, --json", "Output JSON").action(async (hostname2, opts) => {
3612
3949
  try {
3613
- const domain = withStore((store) => store.getDomain(hostname2));
3950
+ const domain = await withRuntimeStore((store) => store.getDomain(hostname2));
3614
3951
  if (!domain)
3615
3952
  throw new Error("Domain not found.");
3616
3953
  print(domain, opts, () => console.log(JSON.stringify(domain, null, 2)));
@@ -3620,8 +3957,8 @@ domainCmd.command("get <hostname>").description("Show a configured domain").opti
3620
3957
  });
3621
3958
  domainCmd.command("setup <hostname>").description("Add a domain locally and optionally prepare Cloudflare DNS").option("--default", "Make this the default domain").option("--origin <url>", "Origin redirect server URL").option("--cloudflare", "Upsert Cloudflare CNAME record").option("--target <hostname>", "CNAME target for Cloudflare DNS").option("--zone-id <id>", "Cloudflare zone ID").option("--dry-run", "Show the Cloudflare plan without changing DNS").option("-j, --json", "Output JSON").action(async (hostname2, opts) => {
3622
3959
  try {
3623
- const result = await withStoreAsync(async (store) => {
3624
- const domain = store.addDomain({
3960
+ const result = await withRuntimeStore(async (store) => {
3961
+ const domain = await store.addDomain({
3625
3962
  hostname: hostname2,
3626
3963
  provider: opts.cloudflare ? "cloudflare" : "manual",
3627
3964
  defaultDomain: opts.default,
@@ -3667,9 +4004,9 @@ domainCmd.command("buy <hostname>").description("Buy a domain through @hasna/dom
3667
4004
  });
3668
4005
  });
3669
4006
  var linkCmd = program2.command("link").alias("links").description("Manage shortlinks");
3670
- function createLinkAction(url, opts) {
4007
+ async function createLinkAction(url, opts) {
3671
4008
  try {
3672
- const link = withStore((store) => store.createLink({
4009
+ const link = await withRuntimeStore((store) => store.createLink({
3673
4010
  destinationUrl: url,
3674
4011
  domain: opts.domain,
3675
4012
  slug: opts.slug,
@@ -3684,9 +4021,9 @@ function createLinkAction(url, opts) {
3684
4021
  }
3685
4022
  linkCmd.command("create <url>").description("Create a shortlink").option("--domain <hostname>", "Domain to use").option("--slug <slug>", "Custom slug").option("--title <title>", "Human title").option("--expires <date>", "Expiration date").option("--length <n>", "Generated slug length", "7").option("-j, --json", "Output JSON").action(createLinkAction);
3686
4023
  program2.command("create <url>").description("Create a shortlink").option("--domain <hostname>", "Domain to use").option("--slug <slug>", "Custom slug").option("--title <title>", "Human title").option("--expires <date>", "Expiration date").option("--length <n>", "Generated slug length", "7").option("-j, --json", "Output JSON").action(createLinkAction);
3687
- linkCmd.command("list").description("List shortlinks").option("--domain <hostname>", "Filter by domain").option("--active", "Only active links").option("--limit <n>", "Maximum rows", "100").option("-j, --json", "Output JSON").action((opts) => {
4024
+ linkCmd.command("list").description("List shortlinks").option("--domain <hostname>", "Filter by domain").option("--active", "Only active links").option("--limit <n>", "Maximum rows", "100").option("-j, --json", "Output JSON").action(async (opts) => {
3688
4025
  try {
3689
- const links = withStore((store) => store.listLinks({
4026
+ const links = await withRuntimeStore((store) => store.listLinks({
3690
4027
  domain: opts.domain,
3691
4028
  activeOnly: opts.active,
3692
4029
  limit: Number(opts.limit)
@@ -3703,9 +4040,9 @@ linkCmd.command("list").description("List shortlinks").option("--domain <hostnam
3703
4040
  handleError(error);
3704
4041
  }
3705
4042
  });
3706
- linkCmd.command("get <slug>").description("Show a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action((slug, opts) => {
4043
+ linkCmd.command("get <slug>").description("Show a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action(async (slug, opts) => {
3707
4044
  try {
3708
- const link = withStore((store) => opts.domain ? store.getLink(opts.domain, slug) : store.getLink(slug));
4045
+ const link = await withRuntimeStore((store) => opts.domain ? store.getLink(opts.domain, slug) : store.getLink(slug));
3709
4046
  if (!link)
3710
4047
  throw new Error("Link not found.");
3711
4048
  print(link, opts, () => console.log(JSON.stringify(link, null, 2)));
@@ -3713,33 +4050,33 @@ linkCmd.command("get <slug>").description("Show a shortlink").option("--domain <
3713
4050
  handleError(error);
3714
4051
  }
3715
4052
  });
3716
- linkCmd.command("disable <slug>").description("Disable a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action((slug, opts) => {
4053
+ linkCmd.command("disable <slug>").description("Disable a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action(async (slug, opts) => {
3717
4054
  try {
3718
- const link = withStore((store) => opts.domain ? store.setLinkActive(opts.domain, slug, false) : store.setLinkActive(slug, false));
4055
+ const link = await withRuntimeStore((store) => opts.domain ? store.setLinkActive(opts.domain, slug, false) : store.setLinkActive(slug, false));
3719
4056
  print(link, opts, () => console.log(source_default.green(`Disabled ${link.short_url}`)));
3720
4057
  } catch (error) {
3721
4058
  handleError(error);
3722
4059
  }
3723
4060
  });
3724
- linkCmd.command("enable <slug>").description("Enable a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action((slug, opts) => {
4061
+ linkCmd.command("enable <slug>").description("Enable a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action(async (slug, opts) => {
3725
4062
  try {
3726
- const link = withStore((store) => opts.domain ? store.setLinkActive(opts.domain, slug, true) : store.setLinkActive(slug, true));
4063
+ const link = await withRuntimeStore((store) => opts.domain ? store.setLinkActive(opts.domain, slug, true) : store.setLinkActive(slug, true));
3727
4064
  print(link, opts, () => console.log(source_default.green(`Enabled ${link.short_url}`)));
3728
4065
  } catch (error) {
3729
4066
  handleError(error);
3730
4067
  }
3731
4068
  });
3732
- linkCmd.command("delete <slug>").description("Delete a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action((slug, opts) => {
4069
+ linkCmd.command("delete <slug>").description("Delete a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action(async (slug, opts) => {
3733
4070
  try {
3734
- const link = withStore((store) => opts.domain ? store.deleteLink(opts.domain, slug) : store.deleteLink(slug));
4071
+ const link = await withRuntimeStore((store) => opts.domain ? store.deleteLink(opts.domain, slug) : store.deleteLink(slug));
3735
4072
  print(link, opts, () => console.log(source_default.green(`Deleted ${link.short_url}`)));
3736
4073
  } catch (error) {
3737
4074
  handleError(error);
3738
4075
  }
3739
4076
  });
3740
- program2.command("resolve <slug>").description("Resolve a slug to its destination without recording a click").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action((slug, opts) => {
4077
+ program2.command("resolve <slug>").description("Resolve a slug to its destination without recording a click").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action(async (slug, opts) => {
3741
4078
  try {
3742
- const link = withStore((store) => opts.domain ? store.getLink(opts.domain, slug) : store.getLink(slug));
4079
+ const link = await withRuntimeStore((store) => opts.domain ? store.getLink(opts.domain, slug) : store.getLink(slug));
3743
4080
  if (!link)
3744
4081
  throw new Error("Link not found.");
3745
4082
  print(link, opts, () => console.log(link.destination_url));
@@ -3747,9 +4084,9 @@ program2.command("resolve <slug>").description("Resolve a slug to its destinatio
3747
4084
  handleError(error);
3748
4085
  }
3749
4086
  });
3750
- program2.command("stats [slug]").description("Show overall stats or stats for a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action((slug, opts) => {
4087
+ program2.command("stats [slug]").description("Show overall stats or stats for a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action(async (slug, opts) => {
3751
4088
  try {
3752
- const result = withStore((store) => {
4089
+ const result = await withRuntimeStore((store) => {
3753
4090
  if (slug)
3754
4091
  return opts.domain ? store.getStats(opts.domain, slug) : store.getStats(slug);
3755
4092
  return store.totalStats();
@@ -3759,15 +4096,18 @@ program2.command("stats [slug]").description("Show overall stats or stats for a
3759
4096
  handleError(error);
3760
4097
  }
3761
4098
  });
3762
- program2.command("serve").description("Run the redirect server that records clicks").option("--host <host>", "Bind host", "127.0.0.1").option("--port <port>", "Port", "8787").option("--default-host <hostname>", "Fallback host if the request has no Host header").action((opts) => {
4099
+ program2.command("serve").description("Run the redirect server that records clicks").option("--host <host>", "Bind host", "127.0.0.1").option("--port <port>", "Port", "8787").option("--default-host <hostname>", "Fallback host if the request has no Host header").option("--cloud", "Serve directly from the shortlinks PostgreSQL database").action(async (opts) => {
3763
4100
  try {
4101
+ const store = opts.cloud || storeMode() === "cloud" ? await PgShortlinksStore.fromCloud("shortlinks") : undefined;
3764
4102
  const server = serveShortlinks({
4103
+ store,
3765
4104
  dbPath: program2.opts().db,
3766
4105
  host: opts.host,
3767
4106
  port: Number(opts.port),
3768
4107
  defaultHost: opts.defaultHost
3769
4108
  });
3770
- console.log(source_default.green(`shortlinks redirect server listening on http://${server.hostname}:${server.port}`));
4109
+ const mode = store ? "cloud" : "local";
4110
+ console.log(source_default.green(`shortlinks redirect server listening on http://${server.hostname}:${server.port} (${mode})`));
3771
4111
  } catch (error) {
3772
4112
  handleError(error);
3773
4113
  }
@@ -3913,11 +4253,13 @@ localCmd.command("setup <domain>").description("Record local domain mapping with
3913
4253
  handleError(error);
3914
4254
  }
3915
4255
  });
3916
- program2.command("doctor").description("Check local shortlinks tooling and integration readiness").option("-j, --json", "Output JSON").action((opts) => {
4256
+ program2.command("doctor").description("Check local shortlinks tooling and integration readiness").option("-j, --json", "Output JSON").action(async (opts) => {
3917
4257
  try {
3918
- const stats = withStore((store) => store.totalStats());
4258
+ const mode = storeMode();
4259
+ const stats = await withRuntimeStore((store) => store.totalStats());
3919
4260
  const data = {
3920
4261
  service: "shortlinks",
4262
+ store: mode,
3921
4263
  data_dir: getDataDir(),
3922
4264
  config_path: getConfigPath(),
3923
4265
  db_path: getDatabasePath(program2.opts().db),
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { ShortlinksDatabase, SQLITE_MIGRATIONS, makeId, now } from "./database.js";
2
2
  export { ShortlinksStore } from "./store.js";
3
+ export { PgShortlinksStore } from "./pg-store.js";
3
4
  export { createShortlinksHandler, serveShortlinks } from "./server.js";
4
5
  export { createCloudflarePlan, generateWorkerScript, writeWorkerFiles, upsertCloudflareDnsRecord } from "./cloudflare.js";
5
6
  export { createLocalSetupPlan, registerMachinesDns } from "./local.js";
package/dist/index.js CHANGED
@@ -1,4 +1,22 @@
1
1
  // @bun
2
+ var __create = Object.create;
3
+ var __getProtoOf = Object.getPrototypeOf;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __toESM = (mod, isNodeMode, target) => {
8
+ target = mod != null ? __create(__getProtoOf(mod)) : {};
9
+ const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
10
+ for (let key of __getOwnPropNames(mod))
11
+ if (!__hasOwnProp.call(to, key))
12
+ __defProp(to, key, {
13
+ get: () => mod[key],
14
+ enumerable: true
15
+ });
16
+ return to;
17
+ };
18
+ var __require = import.meta.require;
19
+
2
20
  // src/database.ts
3
21
  import { Database } from "bun:sqlite";
4
22
  import { mkdirSync as mkdirSync2 } from "fs";
@@ -529,6 +547,325 @@ class ShortlinksStore {
529
547
  return createHash("sha256").update(`${salt}:${ip}`).digest("hex");
530
548
  }
531
549
  }
550
+ // src/pg-store.ts
551
+ import { createHash as createHash2 } from "crypto";
552
+ function parseJsonObject2(value) {
553
+ if (!value)
554
+ return {};
555
+ if (typeof value === "object" && !Array.isArray(value))
556
+ return value;
557
+ try {
558
+ const parsed = JSON.parse(String(value));
559
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
560
+ } catch {
561
+ return {};
562
+ }
563
+ }
564
+ function toIsoString(value) {
565
+ if (value instanceof Date)
566
+ return value.toISOString();
567
+ return String(value);
568
+ }
569
+ function nullableIso(value) {
570
+ if (value === null || value === undefined)
571
+ return null;
572
+ return toIsoString(value);
573
+ }
574
+ function domainFromRow2(row) {
575
+ return {
576
+ ...row,
577
+ default_domain: Boolean(row.default_domain),
578
+ synced_at: nullableIso(row.synced_at),
579
+ created_at: toIsoString(row.created_at),
580
+ updated_at: toIsoString(row.updated_at),
581
+ metadata: parseJsonObject2(row.metadata)
582
+ };
583
+ }
584
+ function linkFromRow2(row) {
585
+ return {
586
+ ...row,
587
+ active: Boolean(row.active),
588
+ expires_at: nullableIso(row.expires_at),
589
+ synced_at: nullableIso(row.synced_at),
590
+ created_at: toIsoString(row.created_at),
591
+ updated_at: toIsoString(row.updated_at),
592
+ metadata: parseJsonObject2(row.metadata),
593
+ short_url: formatShortUrl(row.hostname, row.slug)
594
+ };
595
+ }
596
+ function validateDestinationUrl2(url) {
597
+ let parsed;
598
+ try {
599
+ parsed = new URL(url);
600
+ } catch {
601
+ throw new Error(`Invalid destination URL: ${url}`);
602
+ }
603
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
604
+ throw new Error("Destination URL must start with http:// or https://.");
605
+ }
606
+ return parsed.toString();
607
+ }
608
+ function isoOrNull2(input) {
609
+ if (!input)
610
+ return null;
611
+ const date = new Date(input);
612
+ if (Number.isNaN(date.getTime()))
613
+ throw new Error(`Invalid date: ${input}`);
614
+ return date.toISOString();
615
+ }
616
+ function clickFromRow2(row) {
617
+ return {
618
+ ...row,
619
+ clicked_at: toIsoString(row.clicked_at),
620
+ synced_at: nullableIso(row.synced_at),
621
+ created_at: toIsoString(row.created_at),
622
+ updated_at: toIsoString(row.updated_at),
623
+ metadata: parseJsonObject2(row.metadata)
624
+ };
625
+ }
626
+
627
+ class PgShortlinksStore {
628
+ pg;
629
+ constructor(pg) {
630
+ this.pg = pg;
631
+ }
632
+ static async fromConnectionString(connectionString) {
633
+ const { PgAdapterAsync } = await import("@hasna/cloud");
634
+ return new PgShortlinksStore(new PgAdapterAsync(connectionString));
635
+ }
636
+ static async fromCloud(service = "shortlinks") {
637
+ const { getConnectionString } = await import("@hasna/cloud");
638
+ return PgShortlinksStore.fromConnectionString(getConnectionString(service));
639
+ }
640
+ async close() {
641
+ await this.pg.close?.();
642
+ }
643
+ async addDomain(input) {
644
+ const hostname2 = normalizeHostname(input.hostname);
645
+ const timestamp = now();
646
+ const machineId = getMachineId();
647
+ const existing = await this.getDomain(hostname2);
648
+ const id = existing?.id || makeId("dom");
649
+ if (input.defaultDomain) {
650
+ await this.pg.run("UPDATE domains SET default_domain = 0, updated_at = ? WHERE default_domain = 1", timestamp);
651
+ }
652
+ await this.pg.run(`
653
+ INSERT INTO domains (
654
+ id, hostname, provider, default_domain, cloudflare_zone_id, cloudflare_account_id,
655
+ cloudflare_worker_name, origin_url, notes, metadata, machine_id, synced_at, created_at, updated_at
656
+ )
657
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
658
+ ON CONFLICT(hostname) DO UPDATE SET
659
+ provider = excluded.provider,
660
+ default_domain = excluded.default_domain,
661
+ cloudflare_zone_id = COALESCE(excluded.cloudflare_zone_id, domains.cloudflare_zone_id),
662
+ cloudflare_account_id = COALESCE(excluded.cloudflare_account_id, domains.cloudflare_account_id),
663
+ cloudflare_worker_name = COALESCE(excluded.cloudflare_worker_name, domains.cloudflare_worker_name),
664
+ origin_url = COALESCE(excluded.origin_url, domains.origin_url),
665
+ notes = COALESCE(excluded.notes, domains.notes),
666
+ metadata = excluded.metadata,
667
+ machine_id = excluded.machine_id,
668
+ synced_at = NULL,
669
+ updated_at = excluded.updated_at
670
+ `, id, hostname2, input.provider || existing?.provider || "manual", input.defaultDomain ?? existing?.default_domain ? 1 : 0, input.cloudflareZoneId || existing?.cloudflare_zone_id || null, input.cloudflareAccountId || existing?.cloudflare_account_id || null, input.cloudflareWorkerName || existing?.cloudflare_worker_name || null, input.originUrl || existing?.origin_url || null, input.notes || existing?.notes || null, JSON.stringify(input.metadata || existing?.metadata || {}), machineId, existing?.created_at || timestamp, timestamp);
671
+ return await this.getDomain(hostname2);
672
+ }
673
+ async listDomains() {
674
+ const rows = await this.pg.all(`
675
+ SELECT * FROM domains
676
+ ORDER BY default_domain DESC, hostname ASC
677
+ `);
678
+ return rows.map(domainFromRow2);
679
+ }
680
+ async getDomain(hostnameOrId) {
681
+ const normalized = hostnameOrId.includes(".") || hostnameOrId.includes("://") ? normalizeHostname(hostnameOrId) : hostnameOrId;
682
+ const row = await this.pg.get(`
683
+ SELECT * FROM domains WHERE hostname = ? OR id = ? LIMIT 1
684
+ `, normalized, hostnameOrId);
685
+ return row ? domainFromRow2(row) : null;
686
+ }
687
+ async getDefaultDomain() {
688
+ const row = await this.pg.get(`
689
+ SELECT * FROM domains ORDER BY default_domain DESC, created_at ASC LIMIT 1
690
+ `);
691
+ return row ? domainFromRow2(row) : null;
692
+ }
693
+ async createLink(input) {
694
+ const domain = input.domain ? await this.getDomain(input.domain) : await this.getDefaultDomain();
695
+ if (!domain) {
696
+ throw new Error("No domain configured. Run `shortlinks domain add <domain> --default` first.");
697
+ }
698
+ const destinationUrl = validateDestinationUrl2(input.destinationUrl);
699
+ const timestamp = now();
700
+ const machineId = getMachineId();
701
+ const expiresAt = isoOrNull2(input.expiresAt);
702
+ const slug = input.slug ? normalizeSlug(input.slug) : await this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
703
+ try {
704
+ await this.pg.run(`
705
+ INSERT INTO links (
706
+ id, domain_id, slug, destination_url, title, active, expires_at, metadata,
707
+ machine_id, synced_at, created_at, updated_at
708
+ )
709
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
710
+ `, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
711
+ } catch (error) {
712
+ const message = error instanceof Error ? error.message : String(error);
713
+ if (message.includes("unique") || message.includes("duplicate")) {
714
+ throw new Error(`Slug already exists for ${domain.hostname}: ${slug}`);
715
+ }
716
+ throw error;
717
+ }
718
+ return await this.getLink(domain.hostname, slug);
719
+ }
720
+ async listLinks(options = {}) {
721
+ const params = [];
722
+ let where = "WHERE 1 = 1";
723
+ if (options.domain) {
724
+ where += " AND d.hostname = ?";
725
+ params.push(normalizeHostname(options.domain));
726
+ }
727
+ if (options.activeOnly)
728
+ where += " AND l.active = 1";
729
+ params.push(options.limit || 100);
730
+ const rows = await this.pg.all(`
731
+ SELECT l.*, d.hostname
732
+ FROM links l
733
+ JOIN domains d ON d.id = l.domain_id
734
+ ${where}
735
+ ORDER BY l.created_at DESC
736
+ LIMIT ?
737
+ `, ...params);
738
+ return rows.map(linkFromRow2);
739
+ }
740
+ async getLink(domainOrSlug, maybeSlug) {
741
+ const slug = normalizeSlug(maybeSlug || domainOrSlug);
742
+ const params = [slug];
743
+ let domainClause = "";
744
+ if (maybeSlug) {
745
+ domainClause = "AND d.hostname = ?";
746
+ params.push(normalizeHostname(domainOrSlug));
747
+ }
748
+ const row = await this.pg.get(`
749
+ SELECT l.*, d.hostname
750
+ FROM links l
751
+ JOIN domains d ON d.id = l.domain_id
752
+ WHERE l.slug = ? ${domainClause}
753
+ ORDER BY d.default_domain DESC, l.created_at ASC
754
+ LIMIT 1
755
+ `, ...params);
756
+ return row ? linkFromRow2(row) : null;
757
+ }
758
+ async totalStats() {
759
+ const row = await this.pg.get(`
760
+ SELECT
761
+ (SELECT COUNT(*)::int FROM domains) AS domains,
762
+ (SELECT COUNT(*)::int FROM links) AS links,
763
+ (SELECT COUNT(*)::int FROM clicks) AS clicks
764
+ `);
765
+ return row;
766
+ }
767
+ async resolve(hostname2, slug) {
768
+ const normalizedHost = normalizeHostname(hostname2);
769
+ const normalizedSlug = normalizeSlug(slug);
770
+ const row = await this.pg.get(`
771
+ SELECT l.*, d.hostname
772
+ FROM links l
773
+ JOIN domains d ON d.id = l.domain_id
774
+ WHERE d.hostname = ? AND l.slug = ?
775
+ LIMIT 1
776
+ `, normalizedHost, normalizedSlug);
777
+ if (row)
778
+ return linkFromRow2(row);
779
+ const fallback = await this.pg.get(`
780
+ SELECT l.*, d.hostname
781
+ FROM links l
782
+ JOIN domains d ON d.id = l.domain_id
783
+ WHERE d.default_domain = 1 AND l.slug = ?
784
+ ORDER BY d.created_at ASC
785
+ LIMIT 1
786
+ `, normalizedSlug);
787
+ return fallback ? linkFromRow2(fallback) : null;
788
+ }
789
+ async setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
790
+ const active = typeof maybeSlugOrActive === "boolean" ? maybeSlugOrActive : Boolean(maybeActive);
791
+ const link = typeof maybeSlugOrActive === "boolean" ? await this.getLink(domainOrSlug) : await this.getLink(domainOrSlug, maybeSlugOrActive);
792
+ if (!link)
793
+ throw new Error("Link not found.");
794
+ const timestamp = now();
795
+ await this.pg.run(`
796
+ UPDATE links SET active = ?, updated_at = ?, synced_at = NULL WHERE id = ?
797
+ `, active ? 1 : 0, timestamp, link.id);
798
+ return await this.getLink(link.hostname, link.slug);
799
+ }
800
+ async deleteLink(domainOrSlug, maybeSlug) {
801
+ const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
802
+ if (!link)
803
+ throw new Error("Link not found.");
804
+ await this.pg.run("DELETE FROM links WHERE id = ?", link.id);
805
+ return link;
806
+ }
807
+ async recordClick(link, input = {}) {
808
+ const timestamp = now();
809
+ const machineId = getMachineId();
810
+ const ipHash = input.ip ? this.hashIp(input.ip) : null;
811
+ const id = makeId("clk");
812
+ await this.pg.run(`
813
+ INSERT INTO clicks (
814
+ id, link_id, domain_id, slug, clicked_at, ip_hash, user_agent, referer,
815
+ country, city, metadata, machine_id, synced_at, created_at, updated_at
816
+ )
817
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
818
+ `, id, link.id, link.domain_id, link.slug, timestamp, ipHash, input.userAgent || null, input.referer || null, input.country || null, input.city || null, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
819
+ const row = await this.pg.get("SELECT * FROM clicks WHERE id = ?", id);
820
+ return clickFromRow2(row);
821
+ }
822
+ async getStats(domainOrSlug, maybeSlug) {
823
+ const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
824
+ if (!link)
825
+ throw new Error("Link not found.");
826
+ const summary = await this.pg.get(`
827
+ SELECT COUNT(*)::int AS clicks, MAX(clicked_at) AS last_clicked_at FROM clicks WHERE link_id = ?
828
+ `, link.id);
829
+ const topReferrers = await this.pg.all(`
830
+ SELECT referer, COUNT(*)::int AS clicks
831
+ FROM clicks
832
+ WHERE link_id = ?
833
+ GROUP BY referer
834
+ ORDER BY clicks DESC
835
+ LIMIT 10
836
+ `, link.id);
837
+ const topUserAgents = await this.pg.all(`
838
+ SELECT user_agent, COUNT(*)::int AS clicks
839
+ FROM clicks
840
+ WHERE link_id = ?
841
+ GROUP BY user_agent
842
+ ORDER BY clicks DESC
843
+ LIMIT 10
844
+ `, link.id);
845
+ return {
846
+ link,
847
+ clicks: summary.clicks,
848
+ last_clicked_at: nullableIso(summary.last_clicked_at),
849
+ top_referrers: topReferrers,
850
+ top_user_agents: topUserAgents
851
+ };
852
+ }
853
+ hashIp(ip) {
854
+ const salt = process.env.SHORTLINKS_CLICK_SALT || "shortlinks-cloud";
855
+ return createHash2("sha256").update(`${salt}:${ip}`).digest("hex");
856
+ }
857
+ async generateAvailableSlug(domainId, length) {
858
+ for (let attempt = 0;attempt < 32; attempt += 1) {
859
+ const slug = randomToken(length);
860
+ const exists = await this.pg.get(`
861
+ SELECT 1 FROM links WHERE domain_id = ? AND slug = ? LIMIT 1
862
+ `, domainId, slug);
863
+ if (!exists)
864
+ return slug;
865
+ }
866
+ throw new Error("Could not generate an unused slug after 32 attempts.");
867
+ }
868
+ }
532
869
  // src/server.ts
533
870
  function json(data, status = 200) {
534
871
  return new Response(JSON.stringify(data, null, 2), {
@@ -556,7 +893,7 @@ function createShortlinksHandler(options = {}) {
556
893
  return async (request) => {
557
894
  const url = new URL(request.url);
558
895
  if (url.pathname === "/healthz") {
559
- return json({ ok: true, service: "shortlinks", stats: store.totalStats() });
896
+ return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
560
897
  }
561
898
  if (url.pathname === "/" || url.pathname === "") {
562
899
  return json({ service: "shortlinks", ok: true });
@@ -574,7 +911,7 @@ function createShortlinksHandler(options = {}) {
574
911
  return json({ error: "Missing Host header." }, 400);
575
912
  let link = null;
576
913
  try {
577
- link = store.resolve(host, slug);
914
+ link = await store.resolve(host, slug);
578
915
  } catch {
579
916
  return json({ error: "Shortlink not found.", slug, host }, 404);
580
917
  }
@@ -584,7 +921,7 @@ function createShortlinksHandler(options = {}) {
584
921
  return json({ error: "Shortlink is disabled.", slug, host }, 410);
585
922
  if (isExpired(link))
586
923
  return json({ error: "Shortlink is expired.", slug, host }, 410);
587
- store.recordClick(link, {
924
+ await store.recordClick(link, {
588
925
  ip: getClientIp(request),
589
926
  userAgent: request.headers.get("user-agent"),
590
927
  referer: request.headers.get("referer"),
@@ -873,5 +1210,6 @@ export {
873
1210
  ShortlinksStore,
874
1211
  ShortlinksDatabase,
875
1212
  SQLITE_MIGRATIONS,
1213
+ PgShortlinksStore,
876
1214
  PG_MIGRATIONS
877
1215
  };
@@ -0,0 +1,38 @@
1
+ import type { AddDomainInput, Click, ClickInput, CreateLinkInput, Domain, Link, LinkStats } from "./types.js";
2
+ type PgAdapterLike = {
3
+ get(sql: string, ...params: unknown[]): Promise<any>;
4
+ all(sql: string, ...params: unknown[]): Promise<any[]>;
5
+ run(sql: string, ...params: unknown[]): Promise<unknown>;
6
+ close?: () => Promise<void>;
7
+ };
8
+ export declare class PgShortlinksStore {
9
+ private readonly pg;
10
+ constructor(pg: PgAdapterLike);
11
+ static fromConnectionString(connectionString: string): Promise<PgShortlinksStore>;
12
+ static fromCloud(service?: string): Promise<PgShortlinksStore>;
13
+ close(): Promise<void>;
14
+ addDomain(input: AddDomainInput): Promise<Domain>;
15
+ listDomains(): Promise<Domain[]>;
16
+ getDomain(hostnameOrId: string): Promise<Domain | null>;
17
+ getDefaultDomain(): Promise<Domain | null>;
18
+ createLink(input: CreateLinkInput): Promise<Link>;
19
+ listLinks(options?: {
20
+ domain?: string;
21
+ activeOnly?: boolean;
22
+ limit?: number;
23
+ }): Promise<Link[]>;
24
+ getLink(domainOrSlug: string, maybeSlug?: string): Promise<Link | null>;
25
+ totalStats(): Promise<{
26
+ domains: number;
27
+ links: number;
28
+ clicks: number;
29
+ }>;
30
+ resolve(hostname: string, slug: string): Promise<Link | null>;
31
+ setLinkActive(domainOrSlug: string, maybeSlugOrActive: string | boolean, maybeActive?: boolean): Promise<Link>;
32
+ deleteLink(domainOrSlug: string, maybeSlug?: string): Promise<Link>;
33
+ recordClick(link: Link, input?: ClickInput): Promise<Click>;
34
+ getStats(domainOrSlug: string, maybeSlug?: string): Promise<LinkStats>;
35
+ private hashIp;
36
+ private generateAvailableSlug;
37
+ }
38
+ export {};
package/dist/server.d.ts CHANGED
@@ -1,6 +1,19 @@
1
- import { ShortlinksStore } from "./store.js";
1
+ import type { ClickInput, Link } from "./types.js";
2
+ export interface ShortlinksRuntimeStore {
3
+ totalStats(): {
4
+ domains: number;
5
+ links: number;
6
+ clicks: number;
7
+ } | Promise<{
8
+ domains: number;
9
+ links: number;
10
+ clicks: number;
11
+ }>;
12
+ resolve(hostname: string, slug: string): Link | null | Promise<Link | null>;
13
+ recordClick(link: Link, input?: ClickInput): unknown | Promise<unknown>;
14
+ }
2
15
  export interface ShortlinksHandlerOptions {
3
- store?: ShortlinksStore;
16
+ store?: ShortlinksRuntimeStore;
4
17
  dbPath?: string;
5
18
  defaultHost?: string;
6
19
  redirectStatus?: 301 | 302 | 307 | 308;
package/dist/server.js CHANGED
@@ -558,7 +558,7 @@ function createShortlinksHandler(options = {}) {
558
558
  return async (request) => {
559
559
  const url = new URL(request.url);
560
560
  if (url.pathname === "/healthz") {
561
- return json({ ok: true, service: "shortlinks", stats: store.totalStats() });
561
+ return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
562
562
  }
563
563
  if (url.pathname === "/" || url.pathname === "") {
564
564
  return json({ service: "shortlinks", ok: true });
@@ -576,7 +576,7 @@ function createShortlinksHandler(options = {}) {
576
576
  return json({ error: "Missing Host header." }, 400);
577
577
  let link = null;
578
578
  try {
579
- link = store.resolve(host, slug);
579
+ link = await store.resolve(host, slug);
580
580
  } catch {
581
581
  return json({ error: "Shortlink not found.", slug, host }, 404);
582
582
  }
@@ -586,7 +586,7 @@ function createShortlinksHandler(options = {}) {
586
586
  return json({ error: "Shortlink is disabled.", slug, host }, 410);
587
587
  if (isExpired(link))
588
588
  return json({ error: "Shortlink is expired.", slug, host }, 410);
589
- store.recordClick(link, {
589
+ await store.recordClick(link, {
590
590
  ip: getClientIp(request),
591
591
  userAgent: request.headers.get("user-agent"),
592
592
  referer: request.headers.get("referer"),
@@ -64,9 +64,7 @@ RUNNER
64
64
  chmod 750 /usr/local/bin/shortlinks-env-exec
65
65
  chown root:shortlinks /usr/local/bin/shortlinks-env-exec
66
66
 
67
- su -s /bin/bash shortlinks -c '/usr/local/bin/shortlinks-env-exec shortlinks --json doctor'
68
- su -s /bin/bash shortlinks -c '/usr/local/bin/shortlinks-env-exec shortlinks --json config set default-domain has.na'
69
- su -s /bin/bash shortlinks -c '/usr/local/bin/shortlinks-env-exec shortlinks --json cloud pull --tables domains,links,clicks' || true
67
+ su -s /bin/bash shortlinks -c 'PATH=/var/lib/shortlinks/.bun/bin:$PATH shortlinks --version'
70
68
 
71
69
  caddy_version="$(curl -fsSL https://api.github.com/repos/caddyserver/caddy/releases/latest | jq -r '.tag_name // "v2.10.2"' | sed 's/^v//')"
72
70
  case "$(uname -m)" in
@@ -92,8 +90,8 @@ Group=shortlinks
92
90
  WorkingDirectory=/var/lib/shortlinks
93
91
  Environment=HOME=/var/lib/shortlinks
94
92
  Environment=PATH=/var/lib/shortlinks/.bun/bin:/usr/local/bin:/usr/bin:/bin
95
- ExecStartPre=/usr/local/bin/shortlinks-env-exec shortlinks --json cloud pull --tables domains,links
96
- ExecStart=/usr/local/bin/shortlinks-env-exec shortlinks serve --host 127.0.0.1 --port 8787 --default-host has.na
93
+ Environment=SHORTLINKS_STORE=cloud
94
+ ExecStart=/usr/local/bin/shortlinks-env-exec shortlinks serve --cloud --host 127.0.0.1 --port 8787 --default-host has.na
97
95
  Restart=always
98
96
  RestartSec=5
99
97
 
@@ -101,35 +99,6 @@ RestartSec=5
101
99
  WantedBy=multi-user.target
102
100
  SERVICE
103
101
 
104
- cat > /etc/systemd/system/shortlinks-sync.service <<'SERVICE'
105
- [Unit]
106
- Description=Sync shortlinks SQLite data with RDS
107
- After=network-online.target
108
- Wants=network-online.target
109
-
110
- [Service]
111
- Type=oneshot
112
- User=shortlinks
113
- Group=shortlinks
114
- WorkingDirectory=/var/lib/shortlinks
115
- Environment=HOME=/var/lib/shortlinks
116
- Environment=PATH=/var/lib/shortlinks/.bun/bin:/usr/local/bin:/usr/bin:/bin
117
- ExecStart=/usr/local/bin/shortlinks-env-exec shortlinks --json cloud sync --tables domains,links,clicks
118
- SERVICE
119
-
120
- cat > /etc/systemd/system/shortlinks-sync.timer <<'TIMER'
121
- [Unit]
122
- Description=Run shortlinks cloud sync every minute
123
-
124
- [Timer]
125
- OnBootSec=2min
126
- OnUnitActiveSec=1min
127
- Unit=shortlinks-sync.service
128
-
129
- [Install]
130
- WantedBy=timers.target
131
- TIMER
132
-
133
102
  cat > /etc/systemd/system/caddy.service <<'SERVICE'
134
103
  [Unit]
135
104
  Description=Caddy web server for shortlinks
@@ -162,7 +131,6 @@ has.na {
162
131
  CADDY
163
132
 
164
133
  systemctl daemon-reload
165
- systemctl enable shortlinks.service shortlinks-sync.timer caddy.service
134
+ systemctl enable shortlinks.service caddy.service
166
135
  systemctl start shortlinks.service
167
- systemctl start shortlinks-sync.timer
168
136
  systemctl start caddy.service || true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/shortlinks",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "CLI-only shortlink manager for custom domains, click tracking, Cloudflare setup, and @hasna cloud sync",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,7 +31,7 @@
31
31
  "SECURITY.md"
32
32
  ],
33
33
  "scripts": {
34
- "build": "rm -rf dist && bun build src/cli/index.ts --outdir dist/cli --target bun --external @hasna/cloud && bun build src/index.ts --outdir dist --target bun --external @hasna/cloud && bun build src/server.ts --outdir dist --target bun && bun build src/cloudflare.ts --outdir dist --target bun && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
34
+ "build": "rm -rf dist && bun build src/cli/index.ts --outdir dist/cli --target bun --external @hasna/cloud && bun build src/index.ts --outdir dist --target bun --external @hasna/cloud && bun build src/server.ts --outdir dist --target bun --external @hasna/cloud && bun build src/cloudflare.ts --outdir dist --target bun && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
35
35
  "typecheck": "tsc --noEmit",
36
36
  "test": "bun test",
37
37
  "dev:cli": "bun run src/cli/index.ts",