@hasna/shortlinks 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,156 @@
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
+ async function cloudflareRequest(token, path, init = {}) {
91
+ const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
92
+ ...init,
93
+ headers: {
94
+ authorization: `Bearer ${token}`,
95
+ "content-type": "application/json",
96
+ ...init.headers || {}
97
+ }
98
+ });
99
+ const body = await response.json();
100
+ if (!response.ok || body.success === false) {
101
+ const message = body.errors?.map((e) => e.message).join("; ") || response.statusText;
102
+ throw new Error(`Cloudflare API failed: ${message}`);
103
+ }
104
+ return body.result;
105
+ }
106
+ function candidateZones(hostname) {
107
+ const parts = normalizeHostname(hostname).split(".");
108
+ const candidates = [];
109
+ for (let i = 0;i < parts.length - 1; i += 1) {
110
+ candidates.push(parts.slice(i).join("."));
111
+ }
112
+ return candidates;
113
+ }
114
+ async function findCloudflareZoneId(hostname, token) {
115
+ for (const zone of candidateZones(hostname)) {
116
+ const result = await cloudflareRequest(token, `/zones?name=${encodeURIComponent(zone)}`);
117
+ if (result[0]?.id)
118
+ return result[0].id;
119
+ }
120
+ throw new Error(`Could not find a Cloudflare zone for ${hostname}. Pass --zone-id explicitly.`);
121
+ }
122
+ async function upsertCloudflareDnsRecord(options) {
123
+ const token = options.token || process.env.CLOUDFLARE_API_TOKEN;
124
+ const plan = createCloudflarePlan({
125
+ hostname: options.hostname,
126
+ target: options.target,
127
+ origin: process.env.SHORTLINKS_ORIGIN || "https://shortlinks.example.com",
128
+ proxied: options.proxied
129
+ });
130
+ if (options.dryRun)
131
+ return plan;
132
+ if (!token)
133
+ throw new Error("CLOUDFLARE_API_TOKEN is required unless --dry-run is used.");
134
+ const zoneId = options.zoneId || await findCloudflareZoneId(plan.hostname, token);
135
+ const existing = await cloudflareRequest(token, `/zones/${zoneId}/dns_records?type=CNAME&name=${encodeURIComponent(plan.hostname)}`);
136
+ const payload = JSON.stringify(plan.dnsRecord);
137
+ if (existing[0]?.id) {
138
+ const updated = await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${existing[0].id}`, {
139
+ method: "PUT",
140
+ body: payload
141
+ });
142
+ return { id: updated.id, action: "updated" };
143
+ }
144
+ const created = await cloudflareRequest(token, `/zones/${zoneId}/dns_records`, {
145
+ method: "POST",
146
+ body: payload
147
+ });
148
+ return { id: created.id, action: "created" };
149
+ }
150
+ export {
151
+ writeWorkerFiles,
152
+ upsertCloudflareDnsRecord,
153
+ generateWorkerScript,
154
+ findCloudflareZoneId,
155
+ createCloudflarePlan
156
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -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,8 @@
1
+ export { ShortlinksDatabase, SQLITE_MIGRATIONS, makeId, now } from "./database.js";
2
+ export { ShortlinksStore } from "./store.js";
3
+ export { createShortlinksHandler, serveShortlinks } from "./server.js";
4
+ export { createCloudflarePlan, generateWorkerScript, writeWorkerFiles, upsertCloudflareDnsRecord } from "./cloudflare.js";
5
+ export { PG_MIGRATIONS } from "./pg-migrations.js";
6
+ export { formatShortUrl, getConfigPath, getDataDir, getDatabasePath, loadConfig, normalizeHostname, saveConfig } from "./config.js";
7
+ export { normalizeSlug, randomToken } from "./slug.js";
8
+ export type { AddDomainInput, Click, ClickInput, CreateLinkInput, Domain, Link, LinkStats } from "./types.js";