@hasna/shortlinks 0.1.22 → 0.1.23
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 +14 -19
- package/dist/cli/index.js +338 -71
- package/dist/index.d.ts +3 -1
- package/dist/index.js +278 -9
- package/dist/pg-store.d.ts +10 -2
- package/dist/pg-store.js +598 -0
- package/dist/runtime.d.ts +65 -0
- package/dist/runtime.js +181 -0
- package/infra/aws-ec2-user-data.sh +32 -31
- package/package.json +15 -6
package/dist/index.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var __require = import.meta.require;
|
|
3
|
-
|
|
4
2
|
// src/database.ts
|
|
5
3
|
import { Database } from "bun:sqlite";
|
|
6
4
|
import { mkdirSync as mkdirSync2 } from "fs";
|
|
@@ -583,6 +581,172 @@ class ShortlinksStore {
|
|
|
583
581
|
}
|
|
584
582
|
// src/pg-store.ts
|
|
585
583
|
import { createHash as createHash2 } from "crypto";
|
|
584
|
+
|
|
585
|
+
// src/runtime.ts
|
|
586
|
+
var SHORTLINKS_RUNTIME_ENV = {
|
|
587
|
+
store: "HASNA_SHORTLINKS_STORE",
|
|
588
|
+
databaseUrl: "HASNA_SHORTLINKS_DATABASE_URL",
|
|
589
|
+
databaseSsl: "HASNA_SHORTLINKS_DATABASE_SSL"
|
|
590
|
+
};
|
|
591
|
+
var SHORTLINKS_RUNTIME_FALLBACK_ENV = {
|
|
592
|
+
store: "SHORTLINKS_STORE",
|
|
593
|
+
databaseUrl: "SHORTLINKS_DATABASE_URL",
|
|
594
|
+
databaseSsl: "SHORTLINKS_DATABASE_SSL"
|
|
595
|
+
};
|
|
596
|
+
var CANONICAL_SHORTLINKS_POSTGRES_CLUSTER = "hasna-xyz-infra-apps-prod-postgres";
|
|
597
|
+
var CANONICAL_SHORTLINKS_POSTGRES_DATABASE = "shortlinks";
|
|
598
|
+
var CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH = "hasna/xyz/opensource/shortlinks/prod/postgres";
|
|
599
|
+
function getCanonicalShortlinksPostgresConfig() {
|
|
600
|
+
return {
|
|
601
|
+
cluster: CANONICAL_SHORTLINKS_POSTGRES_CLUSTER,
|
|
602
|
+
database: CANONICAL_SHORTLINKS_POSTGRES_DATABASE,
|
|
603
|
+
runtimeSecretPath: CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH,
|
|
604
|
+
primaryEnv: SHORTLINKS_RUNTIME_ENV.databaseUrl,
|
|
605
|
+
fallbackEnv: SHORTLINKS_RUNTIME_FALLBACK_ENV.databaseUrl
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
function parseShortlinksStoreMode(value) {
|
|
609
|
+
const normalized = clean(value)?.toLowerCase();
|
|
610
|
+
if (!normalized)
|
|
611
|
+
return "local";
|
|
612
|
+
if (normalized === "local" || normalized === "postgres")
|
|
613
|
+
return normalized;
|
|
614
|
+
if (normalized === "pg")
|
|
615
|
+
return "postgres";
|
|
616
|
+
throw new Error(`${SHORTLINKS_RUNTIME_ENV.store} must be local or postgres`);
|
|
617
|
+
}
|
|
618
|
+
function getShortlinksStoreMode(env = process.env) {
|
|
619
|
+
return parseShortlinksStoreMode(readRuntimeEnv(env, "store").value);
|
|
620
|
+
}
|
|
621
|
+
function getShortlinksDatabaseUrl(env = process.env) {
|
|
622
|
+
return readRuntimeEnv(env, "databaseUrl").value;
|
|
623
|
+
}
|
|
624
|
+
function getShortlinksDatabaseSsl(env = process.env) {
|
|
625
|
+
return parseBoolean(readRuntimeEnv(env, "databaseSsl").value, true);
|
|
626
|
+
}
|
|
627
|
+
function getShortlinksRuntimeEnvName(env, key) {
|
|
628
|
+
const primary = SHORTLINKS_RUNTIME_ENV[key];
|
|
629
|
+
if (clean(env[primary]))
|
|
630
|
+
return primary;
|
|
631
|
+
return SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
|
|
632
|
+
}
|
|
633
|
+
function loadShortlinksRuntimeConfig(env = process.env) {
|
|
634
|
+
const mode = getShortlinksStoreMode(env);
|
|
635
|
+
const databaseUrl = getShortlinksDatabaseUrl(env);
|
|
636
|
+
return {
|
|
637
|
+
service: "shortlinks",
|
|
638
|
+
mode,
|
|
639
|
+
...databaseUrl ? {
|
|
640
|
+
database: {
|
|
641
|
+
provider: "postgres",
|
|
642
|
+
url: databaseUrl,
|
|
643
|
+
ssl: getShortlinksDatabaseSsl(env)
|
|
644
|
+
}
|
|
645
|
+
} : {}
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
function assertShortlinksPostgresConfig(config) {
|
|
649
|
+
if (config.mode !== "postgres")
|
|
650
|
+
return;
|
|
651
|
+
if (!config.database?.url) {
|
|
652
|
+
throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseUrl} is required when ${SHORTLINKS_RUNTIME_ENV.store}=postgres`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
function getShortlinksRuntimeStatus(env = process.env) {
|
|
656
|
+
const issues = [];
|
|
657
|
+
const warnings = [];
|
|
658
|
+
let config;
|
|
659
|
+
try {
|
|
660
|
+
config = loadShortlinksRuntimeConfig(env);
|
|
661
|
+
} catch (error) {
|
|
662
|
+
issues.push(error instanceof Error ? error.message : String(error));
|
|
663
|
+
config = { service: "shortlinks", mode: "local" };
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
assertShortlinksPostgresConfig(config);
|
|
667
|
+
} catch (error) {
|
|
668
|
+
issues.push(error instanceof Error ? error.message : String(error));
|
|
669
|
+
}
|
|
670
|
+
if (config.mode === "local" && config.database?.url) {
|
|
671
|
+
warnings.push(`${SHORTLINKS_RUNTIME_ENV.store}=local ignores configured Postgres database settings`);
|
|
672
|
+
}
|
|
673
|
+
return {
|
|
674
|
+
ok: issues.length === 0,
|
|
675
|
+
service: "shortlinks",
|
|
676
|
+
mode: config.mode,
|
|
677
|
+
local_default: config.mode === "local",
|
|
678
|
+
postgres_enabled: config.mode === "postgres",
|
|
679
|
+
database: {
|
|
680
|
+
configured: Boolean(config.database?.url),
|
|
681
|
+
provider: config.database?.provider ?? null,
|
|
682
|
+
redacted_url: redactDatabaseUrl(config.database?.url),
|
|
683
|
+
ssl: config.database?.ssl ?? null
|
|
684
|
+
},
|
|
685
|
+
env: runtimeEnvStatus(env),
|
|
686
|
+
canonical: getCanonicalShortlinksPostgresConfig(),
|
|
687
|
+
issues,
|
|
688
|
+
warnings,
|
|
689
|
+
no_network: true
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
function redactDatabaseUrl(value) {
|
|
693
|
+
if (!value)
|
|
694
|
+
return null;
|
|
695
|
+
try {
|
|
696
|
+
const url = new URL(value);
|
|
697
|
+
if (url.username)
|
|
698
|
+
url.username = "***";
|
|
699
|
+
if (url.password)
|
|
700
|
+
url.password = "***";
|
|
701
|
+
for (const key of Array.from(url.searchParams.keys())) {
|
|
702
|
+
if (isSensitiveQueryKey(key))
|
|
703
|
+
url.searchParams.set(key, "***");
|
|
704
|
+
}
|
|
705
|
+
return url.toString();
|
|
706
|
+
} catch {
|
|
707
|
+
return "(redacted)";
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
function runtimeEnvStatus(env) {
|
|
711
|
+
return Object.fromEntries(Object.entries(SHORTLINKS_RUNTIME_ENV).map(([key, name]) => {
|
|
712
|
+
const activeName = getShortlinksRuntimeEnvName(env, key);
|
|
713
|
+
return [
|
|
714
|
+
key,
|
|
715
|
+
{
|
|
716
|
+
name,
|
|
717
|
+
active_name: activeName,
|
|
718
|
+
configured: Boolean(clean(env[activeName]))
|
|
719
|
+
}
|
|
720
|
+
];
|
|
721
|
+
}));
|
|
722
|
+
}
|
|
723
|
+
function readRuntimeEnv(env, key) {
|
|
724
|
+
const primary = SHORTLINKS_RUNTIME_ENV[key];
|
|
725
|
+
const primaryValue = clean(env[primary]);
|
|
726
|
+
if (primaryValue)
|
|
727
|
+
return { name: primary, value: primaryValue };
|
|
728
|
+
const fallback = SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
|
|
729
|
+
return { name: fallback, value: clean(env[fallback]) };
|
|
730
|
+
}
|
|
731
|
+
function parseBoolean(value, fallback) {
|
|
732
|
+
const normalized = clean(value)?.toLowerCase();
|
|
733
|
+
if (!normalized)
|
|
734
|
+
return fallback;
|
|
735
|
+
if (["1", "true", "yes", "on"].includes(normalized))
|
|
736
|
+
return true;
|
|
737
|
+
if (["0", "false", "no", "off"].includes(normalized))
|
|
738
|
+
return false;
|
|
739
|
+
throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseSsl} must be true or false`);
|
|
740
|
+
}
|
|
741
|
+
function isSensitiveQueryKey(key) {
|
|
742
|
+
return /(?:^|[_-])(pass(?:word)?|pwd|secret|token|credential|auth|api[_-]?key|access[_-]?key)(?:$|[_-])/i.test(key);
|
|
743
|
+
}
|
|
744
|
+
function clean(value) {
|
|
745
|
+
const trimmed = value?.trim();
|
|
746
|
+
return trimmed ? trimmed : undefined;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// src/pg-store.ts
|
|
586
750
|
function parseJsonObject2(value) {
|
|
587
751
|
if (!value)
|
|
588
752
|
return {};
|
|
@@ -595,6 +759,47 @@ function parseJsonObject2(value) {
|
|
|
595
759
|
return {};
|
|
596
760
|
}
|
|
597
761
|
}
|
|
762
|
+
async function loadPgPool() {
|
|
763
|
+
const importer = new Function("specifier", "return import(specifier)");
|
|
764
|
+
const module = await importer("pg");
|
|
765
|
+
return module.Pool;
|
|
766
|
+
}
|
|
767
|
+
function toPostgresSql(sql) {
|
|
768
|
+
let index = 0;
|
|
769
|
+
return sql.replace(/\?/g, () => `$${++index}`);
|
|
770
|
+
}
|
|
771
|
+
function createPgPoolConfig(connectionString, options = {}) {
|
|
772
|
+
const ssl = options.ssl ?? true;
|
|
773
|
+
return {
|
|
774
|
+
connectionString,
|
|
775
|
+
...ssl ? { ssl: { rejectUnauthorized: true } } : { ssl: false }
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
class PgPoolAdapter {
|
|
780
|
+
pool;
|
|
781
|
+
constructor(pool) {
|
|
782
|
+
this.pool = pool;
|
|
783
|
+
}
|
|
784
|
+
static async create(connectionString, options = {}) {
|
|
785
|
+
const Pool = await loadPgPool();
|
|
786
|
+
return new PgPoolAdapter(new Pool(createPgPoolConfig(connectionString, options)));
|
|
787
|
+
}
|
|
788
|
+
async get(sql, ...params) {
|
|
789
|
+
const result = await this.pool.query(toPostgresSql(sql), params);
|
|
790
|
+
return result.rows[0] ?? null;
|
|
791
|
+
}
|
|
792
|
+
async all(sql, ...params) {
|
|
793
|
+
const result = await this.pool.query(toPostgresSql(sql), params);
|
|
794
|
+
return result.rows;
|
|
795
|
+
}
|
|
796
|
+
async run(sql, ...params) {
|
|
797
|
+
return this.pool.query(toPostgresSql(sql), params);
|
|
798
|
+
}
|
|
799
|
+
async close() {
|
|
800
|
+
await this.pool.end();
|
|
801
|
+
}
|
|
802
|
+
}
|
|
598
803
|
function toIsoString(value) {
|
|
599
804
|
if (value instanceof Date)
|
|
600
805
|
return value.toISOString();
|
|
@@ -663,13 +868,15 @@ class PgShortlinksStore {
|
|
|
663
868
|
constructor(pg) {
|
|
664
869
|
this.pg = pg;
|
|
665
870
|
}
|
|
666
|
-
static async fromConnectionString(connectionString) {
|
|
667
|
-
|
|
668
|
-
return new PgShortlinksStore(new PgAdapterAsync(connectionString));
|
|
871
|
+
static async fromConnectionString(connectionString, options = {}) {
|
|
872
|
+
return new PgShortlinksStore(await PgPoolAdapter.create(connectionString, options));
|
|
669
873
|
}
|
|
670
|
-
static async
|
|
671
|
-
const
|
|
672
|
-
|
|
874
|
+
static async fromEnv(env = process.env) {
|
|
875
|
+
const connectionString = getShortlinksDatabaseUrl(env);
|
|
876
|
+
if (!connectionString) {
|
|
877
|
+
throw new Error("HASNA_SHORTLINKS_DATABASE_URL is required when shortlinks uses the postgres store");
|
|
878
|
+
}
|
|
879
|
+
return PgShortlinksStore.fromConnectionString(connectionString, { ssl: getShortlinksDatabaseSsl(env) });
|
|
673
880
|
}
|
|
674
881
|
async close() {
|
|
675
882
|
await this.pg.close?.();
|
|
@@ -899,6 +1106,52 @@ class PgShortlinksStore {
|
|
|
899
1106
|
throw new Error("Could not generate an unused slug after 32 attempts.");
|
|
900
1107
|
}
|
|
901
1108
|
}
|
|
1109
|
+
async function applyPostgresMigrations(connectionString, migrations, options = {}) {
|
|
1110
|
+
const Pool = await loadPgPool();
|
|
1111
|
+
const pool = new Pool(createPgPoolConfig(connectionString, options));
|
|
1112
|
+
const client = await pool.connect();
|
|
1113
|
+
const run = (sql, ...params) => client.query(toPostgresSql(sql), params);
|
|
1114
|
+
const get = async (sql, ...params) => {
|
|
1115
|
+
const result = await run(sql, ...params);
|
|
1116
|
+
return result.rows[0] ?? null;
|
|
1117
|
+
};
|
|
1118
|
+
const applied = [];
|
|
1119
|
+
const skipped = [];
|
|
1120
|
+
try {
|
|
1121
|
+
await run("BEGIN");
|
|
1122
|
+
await run(`
|
|
1123
|
+
SELECT pg_advisory_xact_lock(hashtext(?))
|
|
1124
|
+
`, "shortlinks:migrations");
|
|
1125
|
+
await run(`
|
|
1126
|
+
CREATE TABLE IF NOT EXISTS _shortlinks_migrations (
|
|
1127
|
+
id INTEGER PRIMARY KEY,
|
|
1128
|
+
service TEXT NOT NULL DEFAULT 'shortlinks',
|
|
1129
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
1130
|
+
)
|
|
1131
|
+
`);
|
|
1132
|
+
for (let i = 0;i < migrations.length; i += 1) {
|
|
1133
|
+
const id = i + 1;
|
|
1134
|
+
const existing = await get("SELECT id FROM _shortlinks_migrations WHERE id = ? LIMIT 1", id);
|
|
1135
|
+
if (existing) {
|
|
1136
|
+
skipped.push(id);
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
await run(migrations[i]);
|
|
1140
|
+
await run("INSERT INTO _shortlinks_migrations (id, service, applied_at) VALUES (?, ?, now())", id, "shortlinks");
|
|
1141
|
+
applied.push(id);
|
|
1142
|
+
}
|
|
1143
|
+
await run("COMMIT");
|
|
1144
|
+
return { service: "shortlinks", applied, skipped };
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
try {
|
|
1147
|
+
await run("ROLLBACK");
|
|
1148
|
+
} catch {}
|
|
1149
|
+
throw error;
|
|
1150
|
+
} finally {
|
|
1151
|
+
client.release();
|
|
1152
|
+
await pool.end();
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
902
1155
|
// src/server.ts
|
|
903
1156
|
var REDIRECT_ALLOW_HEADER = "GET, HEAD";
|
|
904
1157
|
function json(data, status = 200, headers) {
|
|
@@ -1249,23 +1502,39 @@ export {
|
|
|
1249
1502
|
serveShortlinks,
|
|
1250
1503
|
saveConfig,
|
|
1251
1504
|
registerMachinesDns,
|
|
1505
|
+
redactDatabaseUrl,
|
|
1252
1506
|
randomToken,
|
|
1507
|
+
parseShortlinksStoreMode,
|
|
1253
1508
|
now,
|
|
1254
1509
|
normalizeSlug,
|
|
1255
1510
|
normalizeHostname,
|
|
1256
1511
|
makeId,
|
|
1512
|
+
loadShortlinksRuntimeConfig,
|
|
1257
1513
|
loadConfig,
|
|
1514
|
+
getShortlinksStoreMode,
|
|
1515
|
+
getShortlinksRuntimeStatus,
|
|
1516
|
+
getShortlinksRuntimeEnvName,
|
|
1517
|
+
getShortlinksDatabaseUrl,
|
|
1518
|
+
getShortlinksDatabaseSsl,
|
|
1258
1519
|
getDatabasePath,
|
|
1259
1520
|
getDataDir,
|
|
1260
1521
|
getConfigPath,
|
|
1522
|
+
getCanonicalShortlinksPostgresConfig,
|
|
1261
1523
|
generateWorkerScript,
|
|
1262
1524
|
formatShortUrl,
|
|
1263
1525
|
createShortlinksHandler,
|
|
1264
1526
|
createLocalSetupPlan,
|
|
1265
1527
|
createCloudflarePlan,
|
|
1528
|
+
assertShortlinksPostgresConfig,
|
|
1529
|
+
applyPostgresMigrations,
|
|
1266
1530
|
ShortlinksStore,
|
|
1267
1531
|
ShortlinksDatabase,
|
|
1268
1532
|
SQLITE_MIGRATIONS,
|
|
1533
|
+
SHORTLINKS_RUNTIME_FALLBACK_ENV,
|
|
1534
|
+
SHORTLINKS_RUNTIME_ENV,
|
|
1269
1535
|
PgShortlinksStore,
|
|
1270
|
-
PG_MIGRATIONS
|
|
1536
|
+
PG_MIGRATIONS,
|
|
1537
|
+
CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH,
|
|
1538
|
+
CANONICAL_SHORTLINKS_POSTGRES_DATABASE,
|
|
1539
|
+
CANONICAL_SHORTLINKS_POSTGRES_CLUSTER
|
|
1271
1540
|
};
|
package/dist/pg-store.d.ts
CHANGED
|
@@ -5,11 +5,14 @@ type PgAdapterLike = {
|
|
|
5
5
|
run(sql: string, ...params: unknown[]): Promise<unknown>;
|
|
6
6
|
close?: () => Promise<void>;
|
|
7
7
|
};
|
|
8
|
+
export interface PgConnectionOptions {
|
|
9
|
+
ssl?: boolean;
|
|
10
|
+
}
|
|
8
11
|
export declare class PgShortlinksStore {
|
|
9
12
|
private readonly pg;
|
|
10
13
|
constructor(pg: PgAdapterLike);
|
|
11
|
-
static fromConnectionString(connectionString: string): Promise<PgShortlinksStore>;
|
|
12
|
-
static
|
|
14
|
+
static fromConnectionString(connectionString: string, options?: PgConnectionOptions): Promise<PgShortlinksStore>;
|
|
15
|
+
static fromEnv(env?: NodeJS.ProcessEnv): Promise<PgShortlinksStore>;
|
|
13
16
|
close(): Promise<void>;
|
|
14
17
|
addDomain(input: AddDomainInput): Promise<Domain>;
|
|
15
18
|
listDomains(): Promise<Domain[]>;
|
|
@@ -35,4 +38,9 @@ export declare class PgShortlinksStore {
|
|
|
35
38
|
private hashIp;
|
|
36
39
|
private generateAvailableSlug;
|
|
37
40
|
}
|
|
41
|
+
export declare function applyPostgresMigrations(connectionString: string, migrations: string[], options?: PgConnectionOptions): Promise<{
|
|
42
|
+
service: "shortlinks";
|
|
43
|
+
applied: number[];
|
|
44
|
+
skipped: number[];
|
|
45
|
+
}>;
|
|
38
46
|
export {};
|