@hasna/shortlinks 0.1.9 → 0.1.10

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.
@@ -0,0 +1,13 @@
1
+ export type CloudMode = "local" | "hybrid" | "cloud";
2
+ export interface CloudConfig {
3
+ mode: CloudMode;
4
+ rds: {
5
+ host: string;
6
+ port: number;
7
+ username: string;
8
+ password_env: string;
9
+ ssl: boolean;
10
+ };
11
+ }
12
+ export declare function getCloudConfig(): CloudConfig;
13
+ export declare function getConnectionString(dbName?: string): string;
@@ -0,0 +1,28 @@
1
+ import { PgAdapterAsync } from "./remote-storage.js";
2
+ export interface SyncResult {
3
+ table: string;
4
+ direction: "push" | "pull";
5
+ rowsRead: number;
6
+ rowsWritten: number;
7
+ errors: string[];
8
+ }
9
+ export interface CloudStatus {
10
+ mode: string;
11
+ enabled: boolean;
12
+ db_path: string;
13
+ tables: Array<{
14
+ table: string;
15
+ rows: number;
16
+ }>;
17
+ }
18
+ export declare const CLOUD_TABLES: readonly ["domains", "links", "clicks"];
19
+ export declare function getCloudPg(): Promise<PgAdapterAsync>;
20
+ export declare function runCloudMigrations(remote: PgAdapterAsync): Promise<void>;
21
+ export declare function getCloudStatus(dbPath?: string): CloudStatus;
22
+ export declare function pushCloudChanges(dbPath?: string, tables?: string[]): Promise<SyncResult[]>;
23
+ export declare function pullCloudChanges(dbPath?: string, tables?: string[]): Promise<SyncResult[]>;
24
+ export declare function syncCloudChanges(dbPath?: string, tables?: string[]): Promise<{
25
+ push: SyncResult[];
26
+ pull: SyncResult[];
27
+ }>;
28
+ export declare function parseCloudTables(raw?: string): string[];
@@ -0,0 +1,43 @@
1
+ export interface CloudflareSetupPlan {
2
+ hostname: string;
3
+ target: string;
4
+ proxied: boolean;
5
+ workerName: string;
6
+ origin: string;
7
+ dnsRecord: {
8
+ type: "CNAME";
9
+ name: string;
10
+ content: string;
11
+ proxied: boolean;
12
+ };
13
+ wranglerCommand: string;
14
+ }
15
+ export interface CloudflareDnsOptions {
16
+ hostname: string;
17
+ target: string;
18
+ token?: string;
19
+ zoneId?: string;
20
+ proxied?: boolean;
21
+ dryRun?: boolean;
22
+ }
23
+ export declare function createCloudflarePlan(input: {
24
+ hostname: string;
25
+ target: string;
26
+ origin: string;
27
+ workerName?: string;
28
+ proxied?: boolean;
29
+ }): CloudflareSetupPlan;
30
+ export declare function generateWorkerScript(): string;
31
+ export declare function writeWorkerFiles(options?: {
32
+ outDir?: string;
33
+ workerName?: string;
34
+ origin?: string;
35
+ }): {
36
+ workerPath: string;
37
+ wranglerPath: string;
38
+ };
39
+ export declare function findCloudflareZoneId(hostname: string, token?: string): Promise<string>;
40
+ export declare function upsertCloudflareDnsRecord(options: CloudflareDnsOptions): Promise<CloudflareSetupPlan | {
41
+ id: string;
42
+ action: "created" | "updated";
43
+ }>;
@@ -0,0 +1,168 @@
1
+ // @bun
2
+ // src/cloudflare.ts
3
+ import { mkdirSync, writeFileSync } from "fs";
4
+ import { join as join2 } from "path";
5
+
6
+ // src/config.ts
7
+ import { homedir } from "os";
8
+ import { dirname, join, resolve } from "path";
9
+ var SERVICE_NAME = "shortlinks";
10
+ var DEFAULT_DATA_DIR = join(homedir(), ".hasna", SERVICE_NAME);
11
+ function normalizeHostname(input) {
12
+ const raw = input.trim().toLowerCase();
13
+ if (!raw)
14
+ throw new Error("Domain is required.");
15
+ const withProtocol = raw.includes("://") ? raw : `https://${raw}`;
16
+ let hostname;
17
+ try {
18
+ hostname = new URL(withProtocol).hostname;
19
+ } catch {
20
+ throw new Error(`Invalid domain: ${input}`);
21
+ }
22
+ hostname = hostname.replace(/\.$/, "");
23
+ if (!/^[a-z0-9.-]+$/.test(hostname) || hostname.includes("..")) {
24
+ throw new Error(`Invalid domain: ${input}`);
25
+ }
26
+ return hostname;
27
+ }
28
+
29
+ // src/cloudflare.ts
30
+ function createCloudflarePlan(input) {
31
+ const hostname = normalizeHostname(input.hostname);
32
+ const target = normalizeHostname(input.target);
33
+ const workerName = input.workerName || "shortlinks";
34
+ const proxied = input.proxied ?? true;
35
+ return {
36
+ hostname,
37
+ target,
38
+ proxied,
39
+ workerName,
40
+ origin: input.origin,
41
+ dnsRecord: {
42
+ type: "CNAME",
43
+ name: hostname,
44
+ content: target,
45
+ proxied
46
+ },
47
+ wranglerCommand: `wrangler deploy cloudflare/${workerName}.js --name ${workerName}`
48
+ };
49
+ }
50
+ function generateWorkerScript() {
51
+ return `export default {
52
+ async fetch(request, env) {
53
+ const origin = env.SHORTLINKS_ORIGIN;
54
+ if (!origin) {
55
+ return new Response("SHORTLINKS_ORIGIN is not configured", { status: 500 });
56
+ }
57
+
58
+ const incoming = new URL(request.url);
59
+ const upstream = new URL(incoming.pathname + incoming.search, origin);
60
+ const headers = new Headers(request.headers);
61
+ headers.set("x-forwarded-host", incoming.host);
62
+ headers.set("x-shortlinks-worker", "cloudflare");
63
+
64
+ return fetch(upstream.toString(), {
65
+ method: request.method,
66
+ headers,
67
+ body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
68
+ redirect: "manual"
69
+ });
70
+ }
71
+ };
72
+ `;
73
+ }
74
+ function writeWorkerFiles(options = {}) {
75
+ const outDir = options.outDir || "cloudflare";
76
+ const workerName = options.workerName || "shortlinks";
77
+ mkdirSync(outDir, { recursive: true });
78
+ const workerPath = join2(outDir, `${workerName}.js`);
79
+ const wranglerPath = join2(outDir, "wrangler.example.toml");
80
+ writeFileSync(workerPath, generateWorkerScript());
81
+ writeFileSync(wranglerPath, `name = "${workerName}"
82
+ main = "${workerName}.js"
83
+ compatibility_date = "2026-05-01"
84
+
85
+ [vars]
86
+ SHORTLINKS_ORIGIN = "${options.origin || "https://shortlinks.example.com"}"
87
+ `);
88
+ return { workerPath, wranglerPath };
89
+ }
90
+ function cloudflareAuthHeaders(token) {
91
+ const apiToken = token || process.env.CLOUDFLARE_API_TOKEN;
92
+ if (apiToken)
93
+ return { authorization: `Bearer ${apiToken}` };
94
+ const apiKey = process.env.CLOUDFLARE_API_KEY;
95
+ const email = process.env.CLOUDFLARE_EMAIL;
96
+ if (apiKey && email) {
97
+ return {
98
+ "x-auth-key": apiKey,
99
+ "x-auth-email": email
100
+ };
101
+ }
102
+ throw new Error("Cloudflare auth is required: set CLOUDFLARE_API_TOKEN, or CLOUDFLARE_API_KEY plus CLOUDFLARE_EMAIL.");
103
+ }
104
+ async function cloudflareRequest(token, path, init = {}) {
105
+ const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
106
+ ...init,
107
+ headers: {
108
+ ...cloudflareAuthHeaders(token),
109
+ "content-type": "application/json",
110
+ ...init.headers || {}
111
+ }
112
+ });
113
+ const body = await response.json();
114
+ if (!response.ok || body.success === false) {
115
+ const message = body.errors?.map((e) => e.message).join("; ") || response.statusText;
116
+ throw new Error(`Cloudflare API failed: ${message}`);
117
+ }
118
+ return body.result;
119
+ }
120
+ function candidateZones(hostname) {
121
+ const parts = normalizeHostname(hostname).split(".");
122
+ const candidates = [];
123
+ for (let i = 0;i < parts.length - 1; i += 1) {
124
+ candidates.push(parts.slice(i).join("."));
125
+ }
126
+ return candidates;
127
+ }
128
+ async function findCloudflareZoneId(hostname, token) {
129
+ for (const zone of candidateZones(hostname)) {
130
+ const result = await cloudflareRequest(token, `/zones?name=${encodeURIComponent(zone)}`);
131
+ if (result[0]?.id)
132
+ return result[0].id;
133
+ }
134
+ throw new Error(`Could not find a Cloudflare zone for ${hostname}. Pass --zone-id explicitly.`);
135
+ }
136
+ async function upsertCloudflareDnsRecord(options) {
137
+ const token = options.token || process.env.CLOUDFLARE_API_TOKEN;
138
+ const plan = createCloudflarePlan({
139
+ hostname: options.hostname,
140
+ target: options.target,
141
+ origin: process.env.SHORTLINKS_ORIGIN || "https://shortlinks.example.com",
142
+ proxied: options.proxied
143
+ });
144
+ if (options.dryRun)
145
+ return plan;
146
+ const zoneId = options.zoneId || await findCloudflareZoneId(plan.hostname, token);
147
+ const existing = await cloudflareRequest(token, `/zones/${zoneId}/dns_records?type=CNAME&name=${encodeURIComponent(plan.hostname)}`);
148
+ const payload = JSON.stringify(plan.dnsRecord);
149
+ if (existing[0]?.id) {
150
+ const updated = await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${existing[0].id}`, {
151
+ method: "PUT",
152
+ body: payload
153
+ });
154
+ return { id: updated.id, action: "updated" };
155
+ }
156
+ const created = await cloudflareRequest(token, `/zones/${zoneId}/dns_records`, {
157
+ method: "POST",
158
+ body: payload
159
+ });
160
+ return { id: created.id, action: "created" };
161
+ }
162
+ export {
163
+ writeWorkerFiles,
164
+ upsertCloudflareDnsRecord,
165
+ generateWorkerScript,
166
+ findCloudflareZoneId,
167
+ createCloudflarePlan
168
+ };
@@ -0,0 +1,20 @@
1
+ export declare const SERVICE_NAME = "shortlinks";
2
+ export declare const DEFAULT_DATA_DIR: string;
3
+ export interface ShortlinksConfig {
4
+ defaultDomain?: string;
5
+ publicBaseUrl?: string;
6
+ cloudflare?: {
7
+ accountId?: string;
8
+ workerName?: string;
9
+ origin?: string;
10
+ };
11
+ }
12
+ export declare function getDataDir(): string;
13
+ export declare function ensureDataDir(): string;
14
+ export declare function getConfigPath(): string;
15
+ export declare function getDatabasePath(explicitPath?: string): string;
16
+ export declare function loadConfig(): ShortlinksConfig;
17
+ export declare function saveConfig(config: ShortlinksConfig): void;
18
+ export declare function updateConfig(patch: ShortlinksConfig): ShortlinksConfig;
19
+ export declare function normalizeHostname(input: string): string;
20
+ export declare function formatShortUrl(hostname: string, slug: string, publicBaseUrl?: string): string;
@@ -0,0 +1,11 @@
1
+ import { Database } from "bun:sqlite";
2
+ export declare function now(): string;
3
+ export declare function makeId(prefix: string): string;
4
+ export declare const SQLITE_MIGRATIONS: string[];
5
+ export declare class ShortlinksDatabase {
6
+ readonly db: Database;
7
+ readonly path: string;
8
+ constructor(path?: string);
9
+ close(): void;
10
+ private applyMigrations;
11
+ }
@@ -0,0 +1,10 @@
1
+ export type DomainsAction = "check" | "buy" | "setup";
2
+ export declare function buildDomainsArgs(action: DomainsAction, domain: string): string[];
3
+ export declare function runDomains(action: DomainsAction, domain: string, options?: {
4
+ dryRun?: boolean;
5
+ }): {
6
+ command: string;
7
+ status: number | null;
8
+ stdout: string;
9
+ stderr: string;
10
+ };
@@ -0,0 +1,15 @@
1
+ export { ShortlinksDatabase, SQLITE_MIGRATIONS, makeId, now } from "./database.js";
2
+ export { ShortlinksStore } from "./store.js";
3
+ export { PgShortlinksStore } from "./pg-store.js";
4
+ export { getCloudConfig, getConnectionString } from "./cloud-config.js";
5
+ export { PgAdapterAsync } from "./remote-storage.js";
6
+ export { applyPgMigrations } from "./pg-migrate.js";
7
+ export { CLOUD_TABLES, getCloudPg, getCloudStatus, parseCloudTables, pullCloudChanges, pushCloudChanges, runCloudMigrations, syncCloudChanges, } from "./cloud-sync.js";
8
+ export type { CloudStatus, SyncResult } from "./cloud-sync.js";
9
+ export { createShortlinksHandler, serveShortlinks } from "./server.js";
10
+ export { createCloudflarePlan, generateWorkerScript, writeWorkerFiles, upsertCloudflareDnsRecord } from "./cloudflare.js";
11
+ export { createLocalSetupPlan, registerMachinesDns } from "./local.js";
12
+ export { PG_MIGRATIONS } from "./pg-migrations.js";
13
+ export { formatShortUrl, getConfigPath, getDataDir, getDatabasePath, loadConfig, normalizeHostname, saveConfig } from "./config.js";
14
+ export { normalizeSlug, randomToken } from "./slug.js";
15
+ export type { AddDomainInput, Click, ClickInput, CreateLinkInput, Domain, Link, LinkStats } from "./types.js";