@absolutejs/deploy 0.9.0 → 0.10.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,180 @@
1
+ /**
2
+ * Preview-deploys fleet for `@absolutejs/deploy`.
3
+ *
4
+ * A `PreviewFleet` is a tenant-aware orchestrator that creates,
5
+ * lists, and tears down ephemeral environments — one per PR /
6
+ * branch / commit. It composes the existing `Deployer` (release
7
+ * dirs + atomic symlink swap + rollback) with a `DnsProvider` and a
8
+ * persistent registry so a deploy bot can:
9
+ *
10
+ * const url = await fleet.create({ previewId: 'pr-42', ... });
11
+ *
12
+ * await fleet.teardown('pr-42');
13
+ *
14
+ * await fleet.gc({ olderThanMs: 7 * 24 * 60 * 60 * 1000 });
15
+ *
16
+ * Substrate responsibilities:
17
+ *
18
+ * - allocate a free port from a configurable pool
19
+ * - build the hostname `<previewId>.<baseDomain>`
20
+ * - upsert an A record for that hostname (DNS provider injected)
21
+ * - call a caller-supplied `makeDeployer({ previewId, port,
22
+ * hostname })` factory, then run `deployer.deploy()` so the
23
+ * preview lands as a normal release on disk
24
+ * - persist the preview registry so we can list / GC later
25
+ * - on teardown, run a caller-supplied stop callback, remove the
26
+ * DNS record, drop the registry entry
27
+ *
28
+ * Everything tenant-specific (secrets snapshotting, db seeding,
29
+ * stopping the process) is a caller-supplied hook. The fleet owns
30
+ * fleet bookkeeping, not application logic.
31
+ */
32
+ import type { Deployer, DeployResult, ReleaseAnnotations } from './deployer';
33
+ import type { DnsProvider } from './dns';
34
+ export type PreviewRecord = {
35
+ previewId: string;
36
+ hostname: string;
37
+ url: string;
38
+ port: number;
39
+ dnsRecordId?: string;
40
+ releaseId: string;
41
+ commitSha?: string;
42
+ createdAt: number;
43
+ annotations?: ReleaseAnnotations;
44
+ };
45
+ export type PreviewStore = {
46
+ list: () => Promise<PreviewRecord[]>;
47
+ get: (previewId: string) => Promise<PreviewRecord | null>;
48
+ put: (record: PreviewRecord) => Promise<void>;
49
+ remove: (previewId: string) => Promise<void>;
50
+ };
51
+ export type PreviewDeployerContext = {
52
+ previewId: string;
53
+ port: number;
54
+ hostname: string;
55
+ url: string;
56
+ };
57
+ export type PreviewFleetOptions = {
58
+ /**
59
+ * Apex used to build preview hostnames: `<previewId>.<baseDomain>`.
60
+ * `previewId` is slugified before use (alphanumeric + `-`).
61
+ */
62
+ baseDomain: string;
63
+ /**
64
+ * Optional URL scheme. Defaults to `'https'`. Set `'http'` for
65
+ * local-loop previews behind a localhost proxy.
66
+ */
67
+ scheme?: 'http' | 'https';
68
+ /**
69
+ * DNS provider — `cloudflareProvider`, `route53Provider`, etc.
70
+ * Optional: when absent, `fleet.create` skips DNS work and the
71
+ * caller is responsible for routing requests at the hostname.
72
+ */
73
+ dns?: DnsProvider;
74
+ /**
75
+ * Public IPv4 the A record should point at. Required when `dns`
76
+ * is set. The fleet calls `dns.upsert({ name, type: 'A',
77
+ * content: ipv4 })`.
78
+ */
79
+ ipv4?: string;
80
+ /**
81
+ * TTL applied to created A records. Default 60s — previews come
82
+ * and go and we want low cache lifetime.
83
+ */
84
+ dnsTtl?: number;
85
+ /**
86
+ * Proxied flag (Cloudflare orange-cloud). Default `false` — most
87
+ * previews want a real IP, not a Cloudflare proxy.
88
+ */
89
+ dnsProxied?: boolean;
90
+ /**
91
+ * Port range that the fleet allocates from. Default `[3100, 3899]`
92
+ * (3000 squat is documented in memory).
93
+ */
94
+ portRange?: {
95
+ start: number;
96
+ end: number;
97
+ };
98
+ /** Override port allocation entirely (e.g. talk to a port-leaser). */
99
+ allocatePort?: (record: {
100
+ previewId: string;
101
+ hostname: string;
102
+ }) => Promise<number>;
103
+ /**
104
+ * REQUIRED. Build a `Deployer` for an individual preview. The
105
+ * factory receives the resolved id, port, hostname, and URL —
106
+ * use them to set the right env, processManager, and target.
107
+ */
108
+ makeDeployer: (ctx: PreviewDeployerContext) => Deployer | Promise<Deployer>;
109
+ /**
110
+ * Stop callback. Called by `teardown` BEFORE DNS removal so the
111
+ * preview process stops accepting connections before the record
112
+ * disappears. Use it to call `processManager.stop()`, kill the
113
+ * port, etc.
114
+ */
115
+ stop?: (record: PreviewRecord) => Promise<void> | void;
116
+ /**
117
+ * After-teardown callback. Called after DNS + registry removal —
118
+ * good place to delete the release directory, drop a tenant
119
+ * schema, clear secrets, etc.
120
+ */
121
+ afterTeardown?: (record: PreviewRecord) => Promise<void> | void;
122
+ /** Registry. Default = filesystem store at `<root>`. */
123
+ store?: PreviewStore;
124
+ /** Directory the file-based default store writes into. */
125
+ registryRoot?: string;
126
+ /** Override `Date.now()` for tests. */
127
+ clock?: () => number;
128
+ };
129
+ /**
130
+ * Single-file JSON registry. Reads + writes atomically (temp-file
131
+ * + rename). Adequate for a deploy bot on one host — swap in a
132
+ * Postgres-backed store for distributed deploy bots.
133
+ */
134
+ export declare const createFilePreviewStore: (root: string) => PreviewStore;
135
+ export declare const createMemoryPreviewStore: () => PreviewStore;
136
+ export type CreatePreviewInput = {
137
+ previewId: string;
138
+ commitSha?: string;
139
+ annotations?: ReleaseAnnotations;
140
+ /**
141
+ * Override hostname. Default = `<slug(previewId)>.<baseDomain>`.
142
+ * Use this for previews with a vanity name that shouldn't echo
143
+ * the PR id directly.
144
+ */
145
+ hostname?: string;
146
+ };
147
+ export type CreatePreviewResult = {
148
+ record: PreviewRecord;
149
+ deploy: DeployResult;
150
+ };
151
+ export type PreviewFleet = {
152
+ /**
153
+ * Spin up a preview. Idempotent on `previewId` — if a record
154
+ * already exists, the fleet re-uses its port + DNS and runs a
155
+ * fresh `deployer.deploy()` (so PRs that push new commits roll
156
+ * forward).
157
+ */
158
+ create: (input: CreatePreviewInput) => Promise<CreatePreviewResult>;
159
+ /** Tear down a single preview (stop → DNS remove → registry remove → afterTeardown). */
160
+ teardown: (previewId: string) => Promise<void>;
161
+ /** List active previews. */
162
+ list: () => Promise<PreviewRecord[]>;
163
+ /** Get one preview by id. */
164
+ get: (previewId: string) => Promise<PreviewRecord | null>;
165
+ /**
166
+ * Tear down all previews older than `olderThanMs`. Returns the
167
+ * list of preview ids torn down. Failures on individual previews
168
+ * are swallowed and reported on `errors`.
169
+ */
170
+ gc: (options: {
171
+ olderThanMs: number;
172
+ }) => Promise<{
173
+ removed: string[];
174
+ errors: {
175
+ previewId: string;
176
+ error: Error;
177
+ }[];
178
+ }>;
179
+ };
180
+ export declare const createPreviewFleet: (options: PreviewFleetOptions) => PreviewFleet;
@@ -0,0 +1,195 @@
1
+ // @bun
2
+ // src/preview.ts
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ writeFileSync
9
+ } from "fs";
10
+ import { dirname, join } from "path";
11
+ var createFilePreviewStore = (root) => {
12
+ const path = join(root, "previews.json");
13
+ const readAll = () => {
14
+ if (!existsSync(path))
15
+ return [];
16
+ try {
17
+ const raw = readFileSync(path, "utf8");
18
+ if (raw.trim() === "")
19
+ return [];
20
+ const parsed = JSON.parse(raw);
21
+ return Array.isArray(parsed.previews) ? parsed.previews : [];
22
+ } catch {
23
+ return [];
24
+ }
25
+ };
26
+ const writeAll = (records) => {
27
+ mkdirSync(dirname(path), { recursive: true });
28
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
29
+ writeFileSync(tmp, JSON.stringify({ previews: records }, null, 2));
30
+ renameSync(tmp, path);
31
+ };
32
+ return {
33
+ get: async (previewId) => readAll().find((r) => r.previewId === previewId) ?? null,
34
+ list: async () => readAll(),
35
+ put: async (record) => {
36
+ const records = readAll().filter((r) => r.previewId !== record.previewId);
37
+ records.push(record);
38
+ writeAll(records);
39
+ },
40
+ remove: async (previewId) => {
41
+ const records = readAll().filter((r) => r.previewId !== previewId);
42
+ writeAll(records);
43
+ }
44
+ };
45
+ };
46
+ var createMemoryPreviewStore = () => {
47
+ const map = new Map;
48
+ return {
49
+ get: async (previewId) => map.get(previewId) ?? null,
50
+ list: async () => Array.from(map.values()),
51
+ put: async (record) => {
52
+ map.set(record.previewId, record);
53
+ },
54
+ remove: async (previewId) => {
55
+ map.delete(previewId);
56
+ }
57
+ };
58
+ };
59
+ var slugify = (id) => id.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
60
+ var defaultAllocatePort = (used, range) => {
61
+ for (let port = range.start;port <= range.end; port += 1) {
62
+ if (!used.has(port))
63
+ return port;
64
+ }
65
+ throw new Error(`preview-fleet: no free ports in [${range.start}, ${range.end}] (${used.size} in use)`);
66
+ };
67
+ var createPreviewFleet = (options) => {
68
+ const scheme = options.scheme ?? "https";
69
+ const portRange = options.portRange ?? { end: 3899, start: 3100 };
70
+ const dnsTtl = options.dnsTtl ?? 60;
71
+ const dnsProxied = options.dnsProxied ?? false;
72
+ const clock = options.clock ?? Date.now;
73
+ const store = options.store ?? createFilePreviewStore(options.registryRoot ?? join(process.cwd(), ".preview-fleet"));
74
+ if (options.dns !== undefined && options.ipv4 === undefined) {
75
+ throw new Error("preview-fleet: `ipv4` is required when `dns` is configured");
76
+ }
77
+ const ensureDns = async (hostname) => {
78
+ if (options.dns === undefined)
79
+ return;
80
+ const spec = {
81
+ content: options.ipv4,
82
+ name: hostname,
83
+ proxied: dnsProxied,
84
+ ttl: dnsTtl,
85
+ type: "A"
86
+ };
87
+ return await options.dns.upsert(spec);
88
+ };
89
+ const removeDns = async (record) => {
90
+ if (options.dns === undefined || record.dnsRecordId === undefined) {
91
+ return;
92
+ }
93
+ try {
94
+ await options.dns.delete(record.dnsRecordId);
95
+ } catch {}
96
+ };
97
+ const buildHostname = (previewId, override) => {
98
+ if (override !== undefined)
99
+ return override;
100
+ const slug = slugify(previewId);
101
+ if (slug === "") {
102
+ throw new Error(`preview-fleet: previewId ${JSON.stringify(previewId)} slugifies to empty`);
103
+ }
104
+ return `${slug}.${options.baseDomain}`;
105
+ };
106
+ const create = async (input) => {
107
+ const existing = await store.get(input.previewId);
108
+ const hostname = buildHostname(input.previewId, input.hostname);
109
+ const url = `${scheme}://${hostname}`;
110
+ let port;
111
+ if (existing !== null) {
112
+ port = existing.port;
113
+ } else if (options.allocatePort !== undefined) {
114
+ port = await options.allocatePort({ hostname, previewId: input.previewId });
115
+ } else {
116
+ const used = new Set((await store.list()).map((record2) => record2.port));
117
+ port = defaultAllocatePort(used, portRange);
118
+ }
119
+ const deployer = await options.makeDeployer({
120
+ hostname,
121
+ port,
122
+ previewId: input.previewId,
123
+ url
124
+ });
125
+ const deployOptions = input.annotations !== undefined ? { annotations: input.annotations } : input.commitSha !== undefined ? { annotations: { commitSha: input.commitSha } } : undefined;
126
+ const deployResult = await deployer.deploy(deployOptions);
127
+ const dnsRecord = await ensureDns(hostname);
128
+ const record = {
129
+ createdAt: existing?.createdAt ?? clock(),
130
+ hostname,
131
+ port,
132
+ previewId: input.previewId,
133
+ releaseId: deployResult.releaseId,
134
+ url,
135
+ ...dnsRecord !== undefined ? { dnsRecordId: dnsRecord.id } : {},
136
+ ...input.commitSha !== undefined ? { commitSha: input.commitSha } : {},
137
+ ...input.annotations !== undefined ? { annotations: input.annotations } : {}
138
+ };
139
+ await store.put(record);
140
+ return { deploy: deployResult, record };
141
+ };
142
+ const teardown = async (previewId) => {
143
+ const record = await store.get(previewId);
144
+ if (record === null)
145
+ return;
146
+ if (options.stop !== undefined) {
147
+ try {
148
+ await options.stop(record);
149
+ } catch {}
150
+ }
151
+ await removeDns(record);
152
+ await store.remove(previewId);
153
+ if (options.afterTeardown !== undefined) {
154
+ try {
155
+ await options.afterTeardown(record);
156
+ } catch {}
157
+ }
158
+ };
159
+ const gc = async ({
160
+ olderThanMs
161
+ }) => {
162
+ const cutoff = clock() - olderThanMs;
163
+ const all = await store.list();
164
+ const expired = all.filter((record) => record.createdAt < cutoff);
165
+ const removed = [];
166
+ const errors = [];
167
+ for (const record of expired) {
168
+ try {
169
+ await teardown(record.previewId);
170
+ removed.push(record.previewId);
171
+ } catch (e) {
172
+ errors.push({
173
+ error: e instanceof Error ? e : new Error(String(e)),
174
+ previewId: record.previewId
175
+ });
176
+ }
177
+ }
178
+ return { errors, removed };
179
+ };
180
+ return {
181
+ create,
182
+ gc,
183
+ get: (previewId) => store.get(previewId),
184
+ list: () => store.list(),
185
+ teardown
186
+ };
187
+ };
188
+ export {
189
+ createPreviewFleet,
190
+ createMemoryPreviewStore,
191
+ createFilePreviewStore
192
+ };
193
+
194
+ //# debugId=8E7013E55C0C6FB764756E2164756E21
195
+ //# sourceMappingURL=preview.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/preview.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * Preview-deploys fleet for `@absolutejs/deploy`.\n *\n * A `PreviewFleet` is a tenant-aware orchestrator that creates,\n * lists, and tears down ephemeral environments — one per PR /\n * branch / commit. It composes the existing `Deployer` (release\n * dirs + atomic symlink swap + rollback) with a `DnsProvider` and a\n * persistent registry so a deploy bot can:\n *\n * const url = await fleet.create({ previewId: 'pr-42', ... });\n *\n * await fleet.teardown('pr-42');\n *\n * await fleet.gc({ olderThanMs: 7 * 24 * 60 * 60 * 1000 });\n *\n * Substrate responsibilities:\n *\n * - allocate a free port from a configurable pool\n * - build the hostname `<previewId>.<baseDomain>`\n * - upsert an A record for that hostname (DNS provider injected)\n * - call a caller-supplied `makeDeployer({ previewId, port,\n * hostname })` factory, then run `deployer.deploy()` so the\n * preview lands as a normal release on disk\n * - persist the preview registry so we can list / GC later\n * - on teardown, run a caller-supplied stop callback, remove the\n * DNS record, drop the registry entry\n *\n * Everything tenant-specific (secrets snapshotting, db seeding,\n * stopping the process) is a caller-supplied hook. The fleet owns\n * fleet bookkeeping, not application logic.\n */\n\nimport {\n\texistsSync,\n\tmkdirSync,\n\treadFileSync,\n\trenameSync,\n\twriteFileSync\n} from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport type {\n\tDeployer,\n\tDeployResult,\n\tReleaseAnnotations\n} from './deployer';\nimport type { DnsProvider, DnsRecord, DnsRecordSpec } from './dns';\n\n// =============================================================================\n// Registry shape\n// =============================================================================\n\nexport type PreviewRecord = {\n\tpreviewId: string;\n\thostname: string;\n\turl: string;\n\tport: number;\n\tdnsRecordId?: string;\n\treleaseId: string;\n\tcommitSha?: string;\n\tcreatedAt: number;\n\tannotations?: ReleaseAnnotations;\n};\n\nexport type PreviewStore = {\n\tlist: () => Promise<PreviewRecord[]>;\n\tget: (previewId: string) => Promise<PreviewRecord | null>;\n\tput: (record: PreviewRecord) => Promise<void>;\n\tremove: (previewId: string) => Promise<void>;\n};\n\n// =============================================================================\n// Hooks + options\n// =============================================================================\n\nexport type PreviewDeployerContext = {\n\tpreviewId: string;\n\tport: number;\n\thostname: string;\n\turl: string;\n};\n\nexport type PreviewFleetOptions = {\n\t/**\n\t * Apex used to build preview hostnames: `<previewId>.<baseDomain>`.\n\t * `previewId` is slugified before use (alphanumeric + `-`).\n\t */\n\tbaseDomain: string;\n\t/**\n\t * Optional URL scheme. Defaults to `'https'`. Set `'http'` for\n\t * local-loop previews behind a localhost proxy.\n\t */\n\tscheme?: 'http' | 'https';\n\t/**\n\t * DNS provider — `cloudflareProvider`, `route53Provider`, etc.\n\t * Optional: when absent, `fleet.create` skips DNS work and the\n\t * caller is responsible for routing requests at the hostname.\n\t */\n\tdns?: DnsProvider;\n\t/**\n\t * Public IPv4 the A record should point at. Required when `dns`\n\t * is set. The fleet calls `dns.upsert({ name, type: 'A',\n\t * content: ipv4 })`.\n\t */\n\tipv4?: string;\n\t/**\n\t * TTL applied to created A records. Default 60s — previews come\n\t * and go and we want low cache lifetime.\n\t */\n\tdnsTtl?: number;\n\t/**\n\t * Proxied flag (Cloudflare orange-cloud). Default `false` — most\n\t * previews want a real IP, not a Cloudflare proxy.\n\t */\n\tdnsProxied?: boolean;\n\t/**\n\t * Port range that the fleet allocates from. Default `[3100, 3899]`\n\t * (3000 squat is documented in memory).\n\t */\n\tportRange?: { start: number; end: number };\n\t/** Override port allocation entirely (e.g. talk to a port-leaser). */\n\tallocatePort?: (record: {\n\t\tpreviewId: string;\n\t\thostname: string;\n\t}) => Promise<number>;\n\t/**\n\t * REQUIRED. Build a `Deployer` for an individual preview. The\n\t * factory receives the resolved id, port, hostname, and URL —\n\t * use them to set the right env, processManager, and target.\n\t */\n\tmakeDeployer: (\n\t\tctx: PreviewDeployerContext\n\t) => Deployer | Promise<Deployer>;\n\t/**\n\t * Stop callback. Called by `teardown` BEFORE DNS removal so the\n\t * preview process stops accepting connections before the record\n\t * disappears. Use it to call `processManager.stop()`, kill the\n\t * port, etc.\n\t */\n\tstop?: (record: PreviewRecord) => Promise<void> | void;\n\t/**\n\t * After-teardown callback. Called after DNS + registry removal —\n\t * good place to delete the release directory, drop a tenant\n\t * schema, clear secrets, etc.\n\t */\n\tafterTeardown?: (record: PreviewRecord) => Promise<void> | void;\n\t/** Registry. Default = filesystem store at `<root>`. */\n\tstore?: PreviewStore;\n\t/** Directory the file-based default store writes into. */\n\tregistryRoot?: string;\n\t/** Override `Date.now()` for tests. */\n\tclock?: () => number;\n};\n\n// =============================================================================\n// File-based PreviewStore (default)\n// =============================================================================\n\n/**\n * Single-file JSON registry. Reads + writes atomically (temp-file\n * + rename). Adequate for a deploy bot on one host — swap in a\n * Postgres-backed store for distributed deploy bots.\n */\nexport const createFilePreviewStore = (root: string): PreviewStore => {\n\tconst path = join(root, 'previews.json');\n\n\tconst readAll = (): PreviewRecord[] => {\n\t\tif (!existsSync(path)) return [];\n\t\ttry {\n\t\t\tconst raw = readFileSync(path, 'utf8');\n\t\t\tif (raw.trim() === '') return [];\n\t\t\tconst parsed = JSON.parse(raw) as { previews?: PreviewRecord[] };\n\t\t\treturn Array.isArray(parsed.previews) ? parsed.previews : [];\n\t\t} catch {\n\t\t\treturn [];\n\t\t}\n\t};\n\n\tconst writeAll = (records: PreviewRecord[]): void => {\n\t\tmkdirSync(dirname(path), { recursive: true });\n\t\tconst tmp = `${path}.tmp-${process.pid}-${Date.now()}`;\n\t\twriteFileSync(tmp, JSON.stringify({ previews: records }, null, 2));\n\t\trenameSync(tmp, path);\n\t};\n\n\treturn {\n\t\tget: async (previewId) =>\n\t\t\treadAll().find((r) => r.previewId === previewId) ?? null,\n\t\tlist: async () => readAll(),\n\t\tput: async (record) => {\n\t\t\tconst records = readAll().filter(\n\t\t\t\t(r) => r.previewId !== record.previewId\n\t\t\t);\n\t\t\trecords.push(record);\n\t\t\twriteAll(records);\n\t\t},\n\t\tremove: async (previewId) => {\n\t\t\tconst records = readAll().filter((r) => r.previewId !== previewId);\n\t\t\twriteAll(records);\n\t\t}\n\t};\n};\n\n// =============================================================================\n// In-memory store (for tests + ephemeral fleets)\n// =============================================================================\n\nexport const createMemoryPreviewStore = (): PreviewStore => {\n\tconst map = new Map<string, PreviewRecord>();\n\treturn {\n\t\tget: async (previewId) => map.get(previewId) ?? null,\n\t\tlist: async () => Array.from(map.values()),\n\t\tput: async (record) => {\n\t\t\tmap.set(record.previewId, record);\n\t\t},\n\t\tremove: async (previewId) => {\n\t\t\tmap.delete(previewId);\n\t\t}\n\t};\n};\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nconst slugify = (id: string): string =>\n\tid\n\t\t.toLowerCase()\n\t\t.replace(/[^a-z0-9-]+/g, '-')\n\t\t.replace(/^-+|-+$/g, '')\n\t\t.slice(0, 50);\n\nconst defaultAllocatePort = (\n\tused: Set<number>,\n\trange: { start: number; end: number }\n): number => {\n\tfor (let port = range.start; port <= range.end; port += 1) {\n\t\tif (!used.has(port)) return port;\n\t}\n\tthrow new Error(\n\t\t`preview-fleet: no free ports in [${range.start}, ${range.end}] (${used.size} in use)`\n\t);\n};\n\n// =============================================================================\n// Fleet\n// =============================================================================\n\nexport type CreatePreviewInput = {\n\tpreviewId: string;\n\tcommitSha?: string;\n\tannotations?: ReleaseAnnotations;\n\t/**\n\t * Override hostname. Default = `<slug(previewId)>.<baseDomain>`.\n\t * Use this for previews with a vanity name that shouldn't echo\n\t * the PR id directly.\n\t */\n\thostname?: string;\n};\n\nexport type CreatePreviewResult = {\n\trecord: PreviewRecord;\n\tdeploy: DeployResult;\n};\n\nexport type PreviewFleet = {\n\t/**\n\t * Spin up a preview. Idempotent on `previewId` — if a record\n\t * already exists, the fleet re-uses its port + DNS and runs a\n\t * fresh `deployer.deploy()` (so PRs that push new commits roll\n\t * forward).\n\t */\n\tcreate: (input: CreatePreviewInput) => Promise<CreatePreviewResult>;\n\t/** Tear down a single preview (stop → DNS remove → registry remove → afterTeardown). */\n\tteardown: (previewId: string) => Promise<void>;\n\t/** List active previews. */\n\tlist: () => Promise<PreviewRecord[]>;\n\t/** Get one preview by id. */\n\tget: (previewId: string) => Promise<PreviewRecord | null>;\n\t/**\n\t * Tear down all previews older than `olderThanMs`. Returns the\n\t * list of preview ids torn down. Failures on individual previews\n\t * are swallowed and reported on `errors`.\n\t */\n\tgc: (options: { olderThanMs: number }) => Promise<{\n\t\tremoved: string[];\n\t\terrors: { previewId: string; error: Error }[];\n\t}>;\n};\n\nexport const createPreviewFleet = (\n\toptions: PreviewFleetOptions\n): PreviewFleet => {\n\tconst scheme = options.scheme ?? 'https';\n\tconst portRange = options.portRange ?? { end: 3899, start: 3100 };\n\tconst dnsTtl = options.dnsTtl ?? 60;\n\tconst dnsProxied = options.dnsProxied ?? false;\n\tconst clock = options.clock ?? Date.now;\n\tconst store =\n\t\toptions.store ??\n\t\tcreateFilePreviewStore(\n\t\t\toptions.registryRoot ?? join(process.cwd(), '.preview-fleet')\n\t\t);\n\n\tif (options.dns !== undefined && options.ipv4 === undefined) {\n\t\tthrow new Error(\n\t\t\t'preview-fleet: `ipv4` is required when `dns` is configured'\n\t\t);\n\t}\n\n\tconst ensureDns = async (\n\t\thostname: string\n\t): Promise<DnsRecord | undefined> => {\n\t\tif (options.dns === undefined) return undefined;\n\t\tconst spec: DnsRecordSpec = {\n\t\t\tcontent: options.ipv4!,\n\t\t\tname: hostname,\n\t\t\tproxied: dnsProxied,\n\t\t\tttl: dnsTtl,\n\t\t\ttype: 'A'\n\t\t};\n\t\treturn await options.dns.upsert(spec);\n\t};\n\n\tconst removeDns = async (record: PreviewRecord): Promise<void> => {\n\t\tif (options.dns === undefined || record.dnsRecordId === undefined) {\n\t\t\treturn;\n\t\t}\n\t\ttry {\n\t\t\tawait options.dns.delete(record.dnsRecordId);\n\t\t} catch {\n\t\t\t// Idempotent teardown — the record may already be gone. Don't\n\t\t\t// block the rest of teardown on a stale id.\n\t\t}\n\t};\n\n\tconst buildHostname = (previewId: string, override?: string): string => {\n\t\tif (override !== undefined) return override;\n\t\tconst slug = slugify(previewId);\n\t\tif (slug === '') {\n\t\t\tthrow new Error(\n\t\t\t\t`preview-fleet: previewId ${JSON.stringify(previewId)} slugifies to empty`\n\t\t\t);\n\t\t}\n\t\treturn `${slug}.${options.baseDomain}`;\n\t};\n\n\tconst create = async (\n\t\tinput: CreatePreviewInput\n\t): Promise<CreatePreviewResult> => {\n\t\tconst existing = await store.get(input.previewId);\n\t\tconst hostname = buildHostname(input.previewId, input.hostname);\n\t\tconst url = `${scheme}://${hostname}`;\n\n\t\t// Allocate a port — reuse existing if we're re-deploying.\n\t\tlet port: number;\n\t\tif (existing !== null) {\n\t\t\tport = existing.port;\n\t\t} else if (options.allocatePort !== undefined) {\n\t\t\tport = await options.allocatePort({ hostname, previewId: input.previewId });\n\t\t} else {\n\t\t\tconst used = new Set(\n\t\t\t\t(await store.list()).map((record) => record.port)\n\t\t\t);\n\t\t\tport = defaultAllocatePort(used, portRange);\n\t\t}\n\n\t\tconst deployer = await options.makeDeployer({\n\t\t\thostname,\n\t\t\tport,\n\t\t\tpreviewId: input.previewId,\n\t\t\turl\n\t\t});\n\n\t\tconst deployOptions =\n\t\t\tinput.annotations !== undefined\n\t\t\t\t? { annotations: input.annotations }\n\t\t\t\t: input.commitSha !== undefined\n\t\t\t\t\t? { annotations: { commitSha: input.commitSha } }\n\t\t\t\t\t: undefined;\n\t\tconst deployResult = await deployer.deploy(deployOptions);\n\n\t\t// DNS only after the deploy succeeded — no point flipping the\n\t\t// record at a half-baked release.\n\t\tconst dnsRecord = await ensureDns(hostname);\n\n\t\tconst record: PreviewRecord = {\n\t\t\tcreatedAt: existing?.createdAt ?? clock(),\n\t\t\thostname,\n\t\t\tport,\n\t\t\tpreviewId: input.previewId,\n\t\t\treleaseId: deployResult.releaseId,\n\t\t\turl,\n\t\t\t...(dnsRecord !== undefined ? { dnsRecordId: dnsRecord.id } : {}),\n\t\t\t...(input.commitSha !== undefined ? { commitSha: input.commitSha } : {}),\n\t\t\t...(input.annotations !== undefined\n\t\t\t\t? { annotations: input.annotations }\n\t\t\t\t: {})\n\t\t};\n\n\t\tawait store.put(record);\n\t\treturn { deploy: deployResult, record };\n\t};\n\n\tconst teardown = async (previewId: string): Promise<void> => {\n\t\tconst record = await store.get(previewId);\n\t\tif (record === null) return;\n\n\t\tif (options.stop !== undefined) {\n\t\t\ttry {\n\t\t\t\tawait options.stop(record);\n\t\t\t} catch {\n\t\t\t\t// Don't block DNS removal on a stop failure. The caller\n\t\t\t\t// can see the error via afterTeardown by re-querying.\n\t\t\t}\n\t\t}\n\n\t\tawait removeDns(record);\n\t\tawait store.remove(previewId);\n\n\t\tif (options.afterTeardown !== undefined) {\n\t\t\ttry {\n\t\t\t\tawait options.afterTeardown(record);\n\t\t\t} catch {\n\t\t\t\t// Caller's own teardown errors are their problem; the\n\t\t\t\t// substrate has already removed the record + DNS.\n\t\t\t}\n\t\t}\n\t};\n\n\tconst gc = async ({\n\t\tolderThanMs\n\t}: {\n\t\tolderThanMs: number;\n\t}): Promise<{\n\t\tremoved: string[];\n\t\terrors: { previewId: string; error: Error }[];\n\t}> => {\n\t\tconst cutoff = clock() - olderThanMs;\n\t\tconst all = await store.list();\n\t\tconst expired = all.filter((record) => record.createdAt < cutoff);\n\t\tconst removed: string[] = [];\n\t\tconst errors: { previewId: string; error: Error }[] = [];\n\t\tfor (const record of expired) {\n\t\t\ttry {\n\t\t\t\tawait teardown(record.previewId);\n\t\t\t\tremoved.push(record.previewId);\n\t\t\t} catch (e) {\n\t\t\t\terrors.push({\n\t\t\t\t\terror: e instanceof Error ? e : new Error(String(e)),\n\t\t\t\t\tpreviewId: record.previewId\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t\treturn { errors, removed };\n\t};\n\n\treturn {\n\t\tcreate,\n\t\tgc,\n\t\tget: (previewId) => store.get(previewId),\n\t\tlist: () => store.list(),\n\t\tteardown\n\t};\n};\n"
6
+ ],
7
+ "mappings": "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",
8
+ "debugId": "8E7013E55C0C6FB764756E2164756E21",
9
+ "names": []
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/deploy",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Generic Bun-project deploy pipeline. A Target (localTarget / sshTarget) is anywhere you can exec + upload — DigitalOcean droplets, Linode, Hetzner, Vultr, your own boxes. Bundled pipeline: prepare → upload → install → build → link → restart → verify. Atomic symlink swap, release history, prune, hooks. SSH shells out to system ssh/rsync — zero ssh2 deps.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,7 +26,7 @@
26
26
  "release"
27
27
  ],
28
28
  "scripts": {
29
- "build": "rm -rf dist && bun build src/index.ts src/digitalocean.ts src/hetzner.ts src/linode.ts src/vultr.ts src/dns.ts src/cloudflare.ts src/digitaloceanDns.ts src/hetznerDns.ts src/route53.ts src/tls.ts src/env.ts --outdir dist --root src --sourcemap --target=bun && tsc --project tsconfig.build.json",
29
+ "build": "rm -rf dist && bun build src/index.ts src/digitalocean.ts src/hetzner.ts src/linode.ts src/vultr.ts src/dns.ts src/cloudflare.ts src/digitaloceanDns.ts src/hetznerDns.ts src/route53.ts src/tls.ts src/env.ts src/preview.ts --outdir dist --root src --sourcemap --target=bun && tsc --project tsconfig.build.json",
30
30
  "test": "bun test tests/",
31
31
  "typecheck": "tsc --noEmit",
32
32
  "format": "prettier --write \"./**/*.{ts,json,md}\"",
@@ -101,6 +101,11 @@
101
101
  "types": "./dist/route53.d.ts",
102
102
  "import": "./dist/route53.js",
103
103
  "default": "./dist/route53.js"
104
+ },
105
+ "./preview": {
106
+ "types": "./dist/preview.d.ts",
107
+ "import": "./dist/preview.js",
108
+ "default": "./dist/preview.js"
104
109
  }
105
110
  },
106
111
  "files": ["dist", "README.md"]