@flash-dns/core 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +8 -2
- package/dist/index.d.mts +85 -0
- package/dist/index.d.ts +85 -0
- package/dist/index.mjs +391 -0
- package/package.json +44 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 imba97 <https://github.com/imba97>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @flash-dns/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DNS naming, configuration and ownership-aware reconciliation.
|
|
4
|
+
|
|
5
|
+
ESM, Node.js >=22.14.0.
|
|
6
|
+
|
|
7
|
+
See the [project documentation](https://github.com/flash-tunnel/flash-dns#readme) for configuration, SDK examples and ownership semantics.
|
|
8
|
+
|
|
9
|
+
MIT License.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { DnsConfig } from '@flash-dns/config';
|
|
2
|
+
import { DesiredRecord, OperationContext, Zone, DnsRecord, RecordRef, DnsAdapter } from '@flash-dns/types';
|
|
3
|
+
|
|
4
|
+
declare function findConfigFile(cwd: string): string | undefined;
|
|
5
|
+
declare function validateConfig(config: unknown): asserts config is DnsConfig;
|
|
6
|
+
declare function loadDnsConfig(cwd: string, explicit?: string): Promise<{
|
|
7
|
+
config: DnsConfig;
|
|
8
|
+
source?: string;
|
|
9
|
+
configDir: string;
|
|
10
|
+
}>;
|
|
11
|
+
interface CliOverrides {
|
|
12
|
+
provider?: string;
|
|
13
|
+
zone?: string;
|
|
14
|
+
hostname?: string;
|
|
15
|
+
project?: string;
|
|
16
|
+
projectId?: string;
|
|
17
|
+
template?: string;
|
|
18
|
+
type?: string;
|
|
19
|
+
content?: string;
|
|
20
|
+
ttl?: string | number;
|
|
21
|
+
zoneId?: string;
|
|
22
|
+
proxied?: boolean;
|
|
23
|
+
branch?: string;
|
|
24
|
+
session?: string;
|
|
25
|
+
}
|
|
26
|
+
interface ResolvedDnsConfig {
|
|
27
|
+
provider: string;
|
|
28
|
+
zone: string;
|
|
29
|
+
hostname: string;
|
|
30
|
+
projectId?: string;
|
|
31
|
+
type?: string;
|
|
32
|
+
content?: string;
|
|
33
|
+
ttl?: number | 'auto';
|
|
34
|
+
proxied?: boolean;
|
|
35
|
+
zoneId?: string;
|
|
36
|
+
sources: Record<string, string>;
|
|
37
|
+
configDir: string;
|
|
38
|
+
}
|
|
39
|
+
declare function resolveDnsConfig(input: {
|
|
40
|
+
cwd: string;
|
|
41
|
+
config: DnsConfig;
|
|
42
|
+
configFile?: string;
|
|
43
|
+
env?: Record<string, string | undefined>;
|
|
44
|
+
cli?: CliOverrides;
|
|
45
|
+
}): ResolvedDnsConfig;
|
|
46
|
+
declare function desiredFromConfig(config: ReturnType<typeof resolveDnsConfig>): DesiredRecord<{
|
|
47
|
+
proxied?: boolean;
|
|
48
|
+
}>;
|
|
49
|
+
|
|
50
|
+
interface DnsPlan<P = Record<string, unknown>> {
|
|
51
|
+
operation: 'create' | 'update' | 'noop' | 'conflict';
|
|
52
|
+
zone: Zone;
|
|
53
|
+
desired: DesiredRecord<P>;
|
|
54
|
+
existing?: DnsRecord<P>;
|
|
55
|
+
conflict?: {
|
|
56
|
+
code: 'RECORD_CONFLICT' | 'OWNERSHIP_MISMATCH' | 'AMBIGUOUS_RECORD';
|
|
57
|
+
message: string;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
interface EnsureResult<P = Record<string, unknown>> {
|
|
61
|
+
action: 'created' | 'updated' | 'unchanged';
|
|
62
|
+
record: DnsRecord<P>;
|
|
63
|
+
ref: RecordRef;
|
|
64
|
+
}
|
|
65
|
+
interface RemoveResult {
|
|
66
|
+
action: 'removed' | 'already-absent';
|
|
67
|
+
}
|
|
68
|
+
interface DnsManager<P = Record<string, unknown>> {
|
|
69
|
+
plan: (input: DesiredRecord<P>, context?: OperationContext) => Promise<DnsPlan<P>>;
|
|
70
|
+
ensure: (input: DesiredRecord<P>, context?: OperationContext) => Promise<EnsureResult<P>>;
|
|
71
|
+
remove: (target: RecordRef, context?: OperationContext) => Promise<RemoveResult>;
|
|
72
|
+
}
|
|
73
|
+
declare function createDnsManager<P extends object>(options: {
|
|
74
|
+
adapter: DnsAdapter<P>;
|
|
75
|
+
}): DnsManager<P>;
|
|
76
|
+
|
|
77
|
+
declare function normalizeProjectName(input: string): string;
|
|
78
|
+
declare function validateLabel(label: string): string;
|
|
79
|
+
declare function normalizeHostname(input: string, wildcard?: boolean): string;
|
|
80
|
+
declare function assertInZone(hostname: string, zone: string): void;
|
|
81
|
+
declare function renderHostname(template: string, variables: Record<string, string | undefined>): string;
|
|
82
|
+
declare function normalizeContent(type: string, content: string): string;
|
|
83
|
+
|
|
84
|
+
export { assertInZone, createDnsManager, desiredFromConfig, findConfigFile, loadDnsConfig, normalizeContent, normalizeHostname, normalizeProjectName, renderHostname, resolveDnsConfig, validateConfig, validateLabel };
|
|
85
|
+
export type { CliOverrides, DnsManager, DnsPlan, EnsureResult, RemoveResult, ResolvedDnsConfig };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { DnsConfig } from '@flash-dns/config';
|
|
2
|
+
import { DesiredRecord, OperationContext, Zone, DnsRecord, RecordRef, DnsAdapter } from '@flash-dns/types';
|
|
3
|
+
|
|
4
|
+
declare function findConfigFile(cwd: string): string | undefined;
|
|
5
|
+
declare function validateConfig(config: unknown): asserts config is DnsConfig;
|
|
6
|
+
declare function loadDnsConfig(cwd: string, explicit?: string): Promise<{
|
|
7
|
+
config: DnsConfig;
|
|
8
|
+
source?: string;
|
|
9
|
+
configDir: string;
|
|
10
|
+
}>;
|
|
11
|
+
interface CliOverrides {
|
|
12
|
+
provider?: string;
|
|
13
|
+
zone?: string;
|
|
14
|
+
hostname?: string;
|
|
15
|
+
project?: string;
|
|
16
|
+
projectId?: string;
|
|
17
|
+
template?: string;
|
|
18
|
+
type?: string;
|
|
19
|
+
content?: string;
|
|
20
|
+
ttl?: string | number;
|
|
21
|
+
zoneId?: string;
|
|
22
|
+
proxied?: boolean;
|
|
23
|
+
branch?: string;
|
|
24
|
+
session?: string;
|
|
25
|
+
}
|
|
26
|
+
interface ResolvedDnsConfig {
|
|
27
|
+
provider: string;
|
|
28
|
+
zone: string;
|
|
29
|
+
hostname: string;
|
|
30
|
+
projectId?: string;
|
|
31
|
+
type?: string;
|
|
32
|
+
content?: string;
|
|
33
|
+
ttl?: number | 'auto';
|
|
34
|
+
proxied?: boolean;
|
|
35
|
+
zoneId?: string;
|
|
36
|
+
sources: Record<string, string>;
|
|
37
|
+
configDir: string;
|
|
38
|
+
}
|
|
39
|
+
declare function resolveDnsConfig(input: {
|
|
40
|
+
cwd: string;
|
|
41
|
+
config: DnsConfig;
|
|
42
|
+
configFile?: string;
|
|
43
|
+
env?: Record<string, string | undefined>;
|
|
44
|
+
cli?: CliOverrides;
|
|
45
|
+
}): ResolvedDnsConfig;
|
|
46
|
+
declare function desiredFromConfig(config: ReturnType<typeof resolveDnsConfig>): DesiredRecord<{
|
|
47
|
+
proxied?: boolean;
|
|
48
|
+
}>;
|
|
49
|
+
|
|
50
|
+
interface DnsPlan<P = Record<string, unknown>> {
|
|
51
|
+
operation: 'create' | 'update' | 'noop' | 'conflict';
|
|
52
|
+
zone: Zone;
|
|
53
|
+
desired: DesiredRecord<P>;
|
|
54
|
+
existing?: DnsRecord<P>;
|
|
55
|
+
conflict?: {
|
|
56
|
+
code: 'RECORD_CONFLICT' | 'OWNERSHIP_MISMATCH' | 'AMBIGUOUS_RECORD';
|
|
57
|
+
message: string;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
interface EnsureResult<P = Record<string, unknown>> {
|
|
61
|
+
action: 'created' | 'updated' | 'unchanged';
|
|
62
|
+
record: DnsRecord<P>;
|
|
63
|
+
ref: RecordRef;
|
|
64
|
+
}
|
|
65
|
+
interface RemoveResult {
|
|
66
|
+
action: 'removed' | 'already-absent';
|
|
67
|
+
}
|
|
68
|
+
interface DnsManager<P = Record<string, unknown>> {
|
|
69
|
+
plan: (input: DesiredRecord<P>, context?: OperationContext) => Promise<DnsPlan<P>>;
|
|
70
|
+
ensure: (input: DesiredRecord<P>, context?: OperationContext) => Promise<EnsureResult<P>>;
|
|
71
|
+
remove: (target: RecordRef, context?: OperationContext) => Promise<RemoveResult>;
|
|
72
|
+
}
|
|
73
|
+
declare function createDnsManager<P extends object>(options: {
|
|
74
|
+
adapter: DnsAdapter<P>;
|
|
75
|
+
}): DnsManager<P>;
|
|
76
|
+
|
|
77
|
+
declare function normalizeProjectName(input: string): string;
|
|
78
|
+
declare function validateLabel(label: string): string;
|
|
79
|
+
declare function normalizeHostname(input: string, wildcard?: boolean): string;
|
|
80
|
+
declare function assertInZone(hostname: string, zone: string): void;
|
|
81
|
+
declare function renderHostname(template: string, variables: Record<string, string | undefined>): string;
|
|
82
|
+
declare function normalizeContent(type: string, content: string): string;
|
|
83
|
+
|
|
84
|
+
export { assertInZone, createDnsManager, desiredFromConfig, findConfigFile, loadDnsConfig, normalizeContent, normalizeHostname, normalizeProjectName, renderHostname, resolveDnsConfig, validateConfig, validateLabel };
|
|
85
|
+
export type { CliOverrides, DnsManager, DnsPlan, EnsureResult, RemoveResult, ResolvedDnsConfig };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { resolve, join, dirname, parse, basename } from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { DnsError, checkAborted, sameOwner } from '@flash-dns/types';
|
|
7
|
+
import { createJiti } from 'jiti';
|
|
8
|
+
import { loadConfig } from 'unconfig';
|
|
9
|
+
import { isIP } from 'node:net';
|
|
10
|
+
|
|
11
|
+
function normalizeProjectName(input) {
|
|
12
|
+
return validateLabel(input.replace(/^@/, "").replaceAll("/", "-").toLowerCase());
|
|
13
|
+
}
|
|
14
|
+
function validateLabel(label) {
|
|
15
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
|
|
16
|
+
throw new DnsError("NAME_INVALID", `Invalid DNS label: ${label}`);
|
|
17
|
+
return label;
|
|
18
|
+
}
|
|
19
|
+
function normalizeHostname(input, wildcard = false) {
|
|
20
|
+
if (typeof input !== "string")
|
|
21
|
+
throw new DnsError("NAME_INVALID", "Hostname must be a string");
|
|
22
|
+
const name = input.toLowerCase().replace(/\.$/, "");
|
|
23
|
+
const labels = (wildcard && name.startsWith("*.") ? name.slice(2) : name).split(".");
|
|
24
|
+
if (!name || name.length > 253)
|
|
25
|
+
throw new DnsError("NAME_INVALID", "Hostname must contain 1\u2013253 ASCII characters");
|
|
26
|
+
labels.forEach(validateLabel);
|
|
27
|
+
return name;
|
|
28
|
+
}
|
|
29
|
+
function assertInZone(hostname, zone) {
|
|
30
|
+
if (hostname !== zone && !hostname.endsWith(`.${zone}`))
|
|
31
|
+
throw new DnsError("NAME_INVALID", `Hostname ${hostname} is outside Zone ${zone}`);
|
|
32
|
+
}
|
|
33
|
+
function renderHostname(template, variables) {
|
|
34
|
+
const result = template.replace(/\{([^{}]+)\}/g, (_, key) => {
|
|
35
|
+
const value = variables[key];
|
|
36
|
+
if (value === void 0 || value === "")
|
|
37
|
+
throw new DnsError("TEMPLATE_VARIABLE_MISSING", `Missing template variable: ${key}`);
|
|
38
|
+
return validateLabel(value.toLowerCase());
|
|
39
|
+
});
|
|
40
|
+
return normalizeHostname(result, true);
|
|
41
|
+
}
|
|
42
|
+
function normalizeContent(type, content) {
|
|
43
|
+
if (typeof content !== "string" || content.length === 0)
|
|
44
|
+
throw new DnsError("CONFIG_INVALID", "record.content must be a non-empty string");
|
|
45
|
+
if (type === "A" && isIP(content) !== 4 || type === "AAAA" && isIP(content) !== 6)
|
|
46
|
+
throw new DnsError("CONFIG_INVALID", `Invalid ${type} address`);
|
|
47
|
+
if (type === "AAAA")
|
|
48
|
+
return new URL(`http://[${content}]/`).hostname.slice(1, -1);
|
|
49
|
+
return type === "CNAME" ? normalizeHostname(content) : content;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const require$1 = createRequire(import.meta.url);
|
|
53
|
+
const filenames = ["ts", "mts", "js", "mjs", "json"].map((ext) => `flash-dns.config.${ext}`);
|
|
54
|
+
function findConfigFile(cwd) {
|
|
55
|
+
let dir = resolve(cwd);
|
|
56
|
+
while (true) {
|
|
57
|
+
for (const filename of filenames) {
|
|
58
|
+
const candidate = join(dir, filename);
|
|
59
|
+
if (existsSync(candidate))
|
|
60
|
+
return candidate;
|
|
61
|
+
}
|
|
62
|
+
if (existsSync(join(dir, ".git")) || dirname(dir) === dir)
|
|
63
|
+
return void 0;
|
|
64
|
+
dir = dirname(dir);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function object(value, path) {
|
|
68
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
69
|
+
throw new DnsError("CONFIG_INVALID", `${path} must be an object`);
|
|
70
|
+
}
|
|
71
|
+
function validateConfig(config) {
|
|
72
|
+
object(config, "config");
|
|
73
|
+
const schema = {
|
|
74
|
+
provider: "string",
|
|
75
|
+
zone: "string",
|
|
76
|
+
hostname: "string",
|
|
77
|
+
project: { name: "string", id: "string" },
|
|
78
|
+
naming: { template: "string" },
|
|
79
|
+
variables: "variables",
|
|
80
|
+
record: { type: "string", content: "string", ttl: "ttl" },
|
|
81
|
+
providers: { cloudflare: { zoneId: "string", proxied: "boolean" } }
|
|
82
|
+
};
|
|
83
|
+
function visit(value, fields, path) {
|
|
84
|
+
for (const [key, item] of Object.entries(value)) {
|
|
85
|
+
const spec = fields[key];
|
|
86
|
+
const field = `${path}.${key}`;
|
|
87
|
+
if (!spec)
|
|
88
|
+
throw new DnsError("CONFIG_INVALID", `Unknown field: ${field}`);
|
|
89
|
+
if (item === void 0)
|
|
90
|
+
continue;
|
|
91
|
+
if (typeof spec === "object") {
|
|
92
|
+
object(item, field);
|
|
93
|
+
visit(item, spec, field);
|
|
94
|
+
} else if (spec === "variables") {
|
|
95
|
+
object(item, field);
|
|
96
|
+
if (Object.values(item).some((v) => typeof v !== "string"))
|
|
97
|
+
throw new DnsError("CONFIG_INVALID", `${field} values must be strings`);
|
|
98
|
+
} else if (spec === "ttl" ? item !== "auto" && (!Number.isInteger(item) || Number(item) <= 0) : spec === "boolean" ? typeof item !== "boolean" : typeof item !== "string") {
|
|
99
|
+
throw new DnsError("CONFIG_INVALID", `Invalid value for ${field}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
visit(config, schema, "config");
|
|
104
|
+
}
|
|
105
|
+
async function loadDnsConfig(cwd, explicit) {
|
|
106
|
+
const source = explicit ? resolve(cwd, explicit) : findConfigFile(cwd);
|
|
107
|
+
if (!source)
|
|
108
|
+
return { config: {}, configDir: resolve(cwd) };
|
|
109
|
+
if (!existsSync(source))
|
|
110
|
+
throw new DnsError("CONFIG_INVALID", `Config file not found: ${source}`);
|
|
111
|
+
try {
|
|
112
|
+
const file = parse(source);
|
|
113
|
+
const configModule = require$1.resolve("@flash-dns/config");
|
|
114
|
+
const jiti = createJiti(import.meta.url, {
|
|
115
|
+
fsCache: false,
|
|
116
|
+
moduleCache: false,
|
|
117
|
+
alias: {
|
|
118
|
+
"flash-dns/config": configModule,
|
|
119
|
+
"flash-dns": configModule,
|
|
120
|
+
"@flash-dns/config": configModule
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
const loaded = await loadConfig({
|
|
124
|
+
cwd: file.dir,
|
|
125
|
+
merge: false,
|
|
126
|
+
sources: [{
|
|
127
|
+
files: file.name,
|
|
128
|
+
extensions: [file.ext.slice(1)],
|
|
129
|
+
parser: file.ext === ".json" ? "json" : (filepath) => jiti.import(filepath, { default: true })
|
|
130
|
+
}]
|
|
131
|
+
});
|
|
132
|
+
validateConfig(loaded.config);
|
|
133
|
+
return { config: loaded.config, source, configDir: file.dir };
|
|
134
|
+
} catch (cause) {
|
|
135
|
+
throw new DnsError("CONFIG_INVALID", `Invalid configuration: ${source}`, {
|
|
136
|
+
hint: cause instanceof DnsError ? cause.message : "Check the configuration syntax and imports",
|
|
137
|
+
cause
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function resolveDnsConfig(input) {
|
|
142
|
+
validateConfig(input.config);
|
|
143
|
+
const config = input.config;
|
|
144
|
+
const cli = input.cli ?? {};
|
|
145
|
+
const env = input.env ?? process.env;
|
|
146
|
+
const sources = {};
|
|
147
|
+
function pick(path, cliValue, envValue, fileValue, fallback) {
|
|
148
|
+
for (const [source, value] of [["cli", cliValue], ["env", envValue], ["config", fileValue], ["default", fallback]]) {
|
|
149
|
+
if (value !== void 0) {
|
|
150
|
+
sources[path] = source;
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const provider = pick("provider", cli.provider, env.FLASH_DNS_PROVIDER, config.provider, "cloudflare");
|
|
156
|
+
if (provider !== "cloudflare")
|
|
157
|
+
throw new DnsError("CAPABILITY_UNSUPPORTED", "CLI currently supports only cloudflare");
|
|
158
|
+
const zoneValue = pick("zone", cli.zone, env.FLASH_DNS_ZONE, config.zone);
|
|
159
|
+
if (!zoneValue)
|
|
160
|
+
throw new DnsError("CONFIG_INVALID", "zone is required");
|
|
161
|
+
const zone = normalizeHostname(zoneValue);
|
|
162
|
+
const configDir = input.configFile ? dirname(input.configFile) : resolve(input.cwd);
|
|
163
|
+
const projectId = pick("project.id", cli.projectId, env.FLASH_DNS_PROJECT_ID, config.project?.id);
|
|
164
|
+
const template = pick("naming.template", cli.template, env.FLASH_DNS_TEMPLATE, config.naming?.template, `{project}.${zone}`);
|
|
165
|
+
let hostname = pick("hostname", cli.hostname, env.FLASH_DNS_HOSTNAME, config.hostname);
|
|
166
|
+
if (!hostname) {
|
|
167
|
+
const variables = { ...config.variables };
|
|
168
|
+
if (template.includes("{project}")) {
|
|
169
|
+
let project = pick("project.name", cli.project, env.FLASH_DNS_PROJECT, config.project?.name);
|
|
170
|
+
if (project === void 0) {
|
|
171
|
+
const packageFile = join(configDir, "package.json");
|
|
172
|
+
if (existsSync(packageFile)) {
|
|
173
|
+
try {
|
|
174
|
+
const pkg = JSON.parse(readFileSync(packageFile, "utf8"));
|
|
175
|
+
if (pkg.name !== void 0 && typeof pkg.name !== "string")
|
|
176
|
+
throw new Error("Invalid name");
|
|
177
|
+
project = pkg.name;
|
|
178
|
+
sources["project.name"] = packageFile;
|
|
179
|
+
} catch {
|
|
180
|
+
throw new DnsError("CONFIG_INVALID", `Invalid package.json: ${packageFile}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
project ??= basename(configDir);
|
|
184
|
+
sources["project.name"] ??= "directory";
|
|
185
|
+
}
|
|
186
|
+
variables.project = normalizeProjectName(project);
|
|
187
|
+
}
|
|
188
|
+
if (template.includes("{branch}")) {
|
|
189
|
+
variables.branch = cli.branch ?? env.FLASH_DNS_BRANCH ?? variables.branch;
|
|
190
|
+
if (variables.branch === void 0) {
|
|
191
|
+
try {
|
|
192
|
+
variables.branch = execFileSync("git", ["branch", "--show-current"], { cwd: input.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
193
|
+
} catch {
|
|
194
|
+
throw new DnsError("TEMPLATE_VARIABLE_MISSING", "branch must be provided outside a Git checkout");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
variables.session = cli.session ?? env.FLASH_DNS_SESSION ?? variables.session;
|
|
199
|
+
hostname = renderHostname(template, variables);
|
|
200
|
+
}
|
|
201
|
+
hostname = normalizeHostname(hostname, true);
|
|
202
|
+
assertInZone(hostname, zone);
|
|
203
|
+
const rawTtl = pick("record.ttl", cli.ttl, env.FLASH_DNS_TTL, config.record?.ttl);
|
|
204
|
+
const ttl = rawTtl === void 0 ? void 0 : rawTtl === "auto" ? "auto" : Number(rawTtl);
|
|
205
|
+
if (ttl !== void 0 && ttl !== "auto" && (!Number.isInteger(ttl) || ttl <= 0))
|
|
206
|
+
throw new DnsError("CONFIG_INVALID", "record.ttl must be auto or a positive integer");
|
|
207
|
+
const rawProxy = env.FLASH_DNS_PROXIED;
|
|
208
|
+
if (cli.proxied === void 0 && rawProxy !== void 0 && rawProxy !== "true" && rawProxy !== "false")
|
|
209
|
+
throw new DnsError("CONFIG_INVALID", "FLASH_DNS_PROXIED must be true or false");
|
|
210
|
+
const proxied = pick("providers.cloudflare.proxied", cli.proxied, rawProxy === void 0 ? void 0 : rawProxy === "true", config.providers?.cloudflare?.proxied);
|
|
211
|
+
const type = pick("record.type", cli.type, env.FLASH_DNS_TYPE, config.record?.type);
|
|
212
|
+
if (type !== void 0 && !["A", "AAAA", "CNAME", "TXT"].includes(type))
|
|
213
|
+
throw new DnsError("CONFIG_INVALID", "record.type must be A, AAAA, CNAME or TXT");
|
|
214
|
+
return {
|
|
215
|
+
provider,
|
|
216
|
+
zone,
|
|
217
|
+
hostname,
|
|
218
|
+
projectId,
|
|
219
|
+
type,
|
|
220
|
+
content: pick("record.content", cli.content, env.FLASH_DNS_CONTENT, config.record?.content),
|
|
221
|
+
ttl,
|
|
222
|
+
proxied,
|
|
223
|
+
zoneId: pick("providers.cloudflare.zoneId", cli.zoneId, env.CLOUDFLARE_ZONE_ID, config.providers?.cloudflare?.zoneId),
|
|
224
|
+
sources,
|
|
225
|
+
configDir
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function desiredFromConfig(config) {
|
|
229
|
+
if (!config.projectId || !config.type || config.content === void 0)
|
|
230
|
+
throw new DnsError("CONFIG_INVALID", "project.id, record.type and record.content are required; run init or supply explicit values");
|
|
231
|
+
return {
|
|
232
|
+
zone: config.zone,
|
|
233
|
+
name: config.hostname,
|
|
234
|
+
type: config.type,
|
|
235
|
+
content: config.content,
|
|
236
|
+
owner: { application: "flash-dns", id: config.projectId },
|
|
237
|
+
ttl: config.ttl,
|
|
238
|
+
providerOptions: config.proxied === void 0 ? {} : { proxied: config.proxied }
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function fingerprint(value) {
|
|
243
|
+
return JSON.stringify(value, (_, item) => {
|
|
244
|
+
if (item && typeof item === "object" && !Array.isArray(item))
|
|
245
|
+
return Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b)));
|
|
246
|
+
return item;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
function createDnsManager(options) {
|
|
250
|
+
const { adapter } = options;
|
|
251
|
+
const methods = ["resolveZone", "listRecords", "getRecord", "createRecord", "updateRecord", "deleteRecord"];
|
|
252
|
+
const caps = adapter?.capabilities;
|
|
253
|
+
if (!adapter || adapter.apiVersion !== 1 || methods.some((method) => typeof adapter[method] !== "function") || !caps || !Array.isArray(caps.recordTypes) || !Array.isArray(caps.extensions) || !caps.ttl || typeof caps.update !== "boolean" || typeof caps.wildcard !== "boolean" || typeof caps.lookupByName !== "boolean") {
|
|
254
|
+
throw new DnsError("ADAPTER_INCOMPATIBLE", "Adapter must implement the v1 contract");
|
|
255
|
+
}
|
|
256
|
+
const locks = /* @__PURE__ */ new Map();
|
|
257
|
+
async function locked(key, execute) {
|
|
258
|
+
const previous = locks.get(key) ?? Promise.resolve();
|
|
259
|
+
const next = previous.catch(() => {
|
|
260
|
+
}).then(execute);
|
|
261
|
+
locks.set(key, next);
|
|
262
|
+
try {
|
|
263
|
+
return await next;
|
|
264
|
+
} finally {
|
|
265
|
+
if (locks.get(key) === next)
|
|
266
|
+
locks.delete(key);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function normalize(input) {
|
|
270
|
+
const name = normalizeHostname(input.name, true);
|
|
271
|
+
const zone = normalizeHostname(input.zone);
|
|
272
|
+
assertInZone(name, zone);
|
|
273
|
+
const caps2 = adapter.capabilities;
|
|
274
|
+
if (!caps2.recordTypes.includes(input.type) || caps2.ownership !== "metadata" || !caps2.lookupByName || name.startsWith("*.") && !caps2.wildcard) {
|
|
275
|
+
throw new DnsError("CAPABILITY_UNSUPPORTED", "Adapter does not support the requested record or ownership");
|
|
276
|
+
}
|
|
277
|
+
if (!input.owner?.application?.trim() || !input.owner.id?.trim())
|
|
278
|
+
throw new DnsError("CONFIG_INVALID", "A stable owner.application and owner.id are required");
|
|
279
|
+
if (input.ttl !== void 0 && (input.ttl === "auto" ? !caps2.ttl.auto : !Number.isInteger(input.ttl) || input.ttl < caps2.ttl.min || input.ttl > caps2.ttl.max)) {
|
|
280
|
+
throw new DnsError("CAPABILITY_UNSUPPORTED", "Unsupported TTL");
|
|
281
|
+
}
|
|
282
|
+
if (Object.keys(input.providerOptions ?? {}).some((key) => !caps2.extensions.includes(key)))
|
|
283
|
+
throw new DnsError("CAPABILITY_UNSUPPORTED", "Unsupported provider option");
|
|
284
|
+
const desired = { ...input, name, zone, content: normalizeContent(input.type, input.content) };
|
|
285
|
+
return adapter.normalizeDesired?.(desired) ?? desired;
|
|
286
|
+
}
|
|
287
|
+
function matches(record, desired) {
|
|
288
|
+
return normalizeHostname(record.name, true) === desired.name && record.type === desired.type && normalizeContent(record.type, record.content) === desired.content && sameOwner(record.ownership, desired.owner) && (desired.ttl === void 0 || record.ttl === desired.ttl) && Object.entries(desired.providerOptions ?? {}).every(([key, value]) => record.providerOptions?.[key] === value);
|
|
289
|
+
}
|
|
290
|
+
async function plan(input, context) {
|
|
291
|
+
checkAborted(context);
|
|
292
|
+
let desired = normalize(input);
|
|
293
|
+
const zone = await adapter.resolveZone(desired.zone, context);
|
|
294
|
+
assertInZone(desired.name, normalizeHostname(zone.name));
|
|
295
|
+
const records = (await adapter.listRecords(zone.id, desired.name, context)).filter((record) => normalizeHostname(record.name, true) === desired.name);
|
|
296
|
+
const base = { zone, desired };
|
|
297
|
+
const conflict = (code, message) => ({
|
|
298
|
+
...base,
|
|
299
|
+
operation: "conflict",
|
|
300
|
+
conflict: { code, message }
|
|
301
|
+
});
|
|
302
|
+
if (records.some((record) => record.type !== desired.type && (record.type === "CNAME" || desired.type === "CNAME")))
|
|
303
|
+
return conflict("RECORD_CONFLICT", "CNAME cannot coexist with another record type");
|
|
304
|
+
const candidates = records.filter((record) => record.type === desired.type);
|
|
305
|
+
let existing;
|
|
306
|
+
if (desired.recordId) {
|
|
307
|
+
existing = candidates.find((record) => record.id === desired.recordId);
|
|
308
|
+
if (!existing)
|
|
309
|
+
throw new DnsError("RECORD_NOT_FOUND", "Explicit record ID does not match the requested name and type");
|
|
310
|
+
} else {
|
|
311
|
+
const owned = candidates.filter((record) => sameOwner(record.ownership, desired.owner));
|
|
312
|
+
if (owned.length > 1)
|
|
313
|
+
return conflict("AMBIGUOUS_RECORD", "Multiple owned records match; specify recordId");
|
|
314
|
+
existing = owned[0];
|
|
315
|
+
if (!existing && candidates.length) {
|
|
316
|
+
return candidates.some((record) => record.ownership) ? conflict("OWNERSHIP_MISMATCH", "Name is already used by a different owner") : conflict("RECORD_CONFLICT", "Existing records have no matching owner; automatic adoption is disabled");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (existing && !sameOwner(existing.ownership, desired.owner))
|
|
320
|
+
return conflict(existing.ownership ? "OWNERSHIP_MISMATCH" : "RECORD_CONFLICT", "Selected record does not belong to this owner");
|
|
321
|
+
desired = adapter.normalizeDesired?.(desired, existing) ?? desired;
|
|
322
|
+
if (existing && !matches(existing, desired) && !adapter.capabilities.update)
|
|
323
|
+
throw new DnsError("CAPABILITY_UNSUPPORTED", "Adapter does not support updates");
|
|
324
|
+
checkAborted(context);
|
|
325
|
+
return { ...base, desired, existing, operation: existing ? matches(existing, desired) ? "noop" : "update" : "create" };
|
|
326
|
+
}
|
|
327
|
+
function ref(record, owner) {
|
|
328
|
+
return {
|
|
329
|
+
provider: adapter.id,
|
|
330
|
+
zoneId: record.zoneId,
|
|
331
|
+
recordId: record.id,
|
|
332
|
+
owner,
|
|
333
|
+
snapshot: { name: record.name, type: record.type, content: record.content }
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
async function ensure(input, context) {
|
|
337
|
+
const desired = normalize(input);
|
|
338
|
+
return locked("write", async () => {
|
|
339
|
+
const first = await plan(desired, context);
|
|
340
|
+
if (first.conflict)
|
|
341
|
+
throw new DnsError(first.conflict.code, first.conflict.message);
|
|
342
|
+
const current = await plan(desired, context);
|
|
343
|
+
if (fingerprint(first) !== fingerprint(current))
|
|
344
|
+
throw new DnsError("PLAN_STALE", "Records changed during planning; run ensure again");
|
|
345
|
+
checkAborted(context);
|
|
346
|
+
const effective = current.desired;
|
|
347
|
+
let actual = current.existing;
|
|
348
|
+
if (current.operation !== "noop") {
|
|
349
|
+
try {
|
|
350
|
+
actual = current.operation === "create" ? await adapter.createRecord(current.zone.id, effective, context) : await adapter.updateRecord(current.zone.id, current.existing.id, effective, context);
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (!(error instanceof DnsError) || !error.retriable || context?.signal?.aborted)
|
|
353
|
+
throw error;
|
|
354
|
+
const recovered = await plan(desired, context);
|
|
355
|
+
if (recovered.operation !== "noop" || !recovered.existing)
|
|
356
|
+
throw error;
|
|
357
|
+
actual = recovered.existing;
|
|
358
|
+
}
|
|
359
|
+
actual = await adapter.getRecord(current.zone.id, actual.id, context);
|
|
360
|
+
}
|
|
361
|
+
if (!actual || !matches(actual, effective))
|
|
362
|
+
throw new DnsError("PLAN_STALE", "Provider state does not match the requested record after the operation");
|
|
363
|
+
const action = current.operation === "create" ? "created" : current.operation === "update" ? "updated" : "unchanged";
|
|
364
|
+
context?.logger?.({ event: `record:${action}`, requestId: context.requestId, recordId: actual.id });
|
|
365
|
+
return { action, record: actual, ref: ref(actual, desired.owner) };
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
async function remove(target, context) {
|
|
369
|
+
if (target.provider !== adapter.id || !target.zoneId || !target.recordId || !target.owner?.application || !target.owner.id)
|
|
370
|
+
throw new DnsError("CONFIG_INVALID", "A complete record ref for this provider is required");
|
|
371
|
+
return locked("write", async () => {
|
|
372
|
+
checkAborted(context);
|
|
373
|
+
const record = await adapter.getRecord(target.zoneId, target.recordId, context);
|
|
374
|
+
if (!record)
|
|
375
|
+
return { action: "already-absent" };
|
|
376
|
+
if (!sameOwner(record.ownership, target.owner))
|
|
377
|
+
throw new DnsError("OWNERSHIP_MISMATCH", "Record owner changed; deletion refused");
|
|
378
|
+
if (target.snapshot && fingerprint(target.snapshot) !== fingerprint({ name: record.name, type: record.type, content: record.content }))
|
|
379
|
+
throw new DnsError("PLAN_STALE", "Record changed since selection; deletion refused");
|
|
380
|
+
checkAborted(context);
|
|
381
|
+
await adapter.deleteRecord(target.zoneId, target.recordId, context);
|
|
382
|
+
if (await adapter.getRecord(target.zoneId, target.recordId, context))
|
|
383
|
+
throw new DnsError("PLAN_STALE", "Provider still returns the deleted record");
|
|
384
|
+
context?.logger?.({ event: "record:removed", requestId: context.requestId, recordId: target.recordId });
|
|
385
|
+
return { action: "removed" };
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
return { plan, ensure, remove };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export { assertInZone, createDnsManager, desiredFromConfig, findConfigFile, loadDnsConfig, normalizeContent, normalizeHostname, normalizeProjectName, renderHostname, resolveDnsConfig, validateConfig, validateLabel };
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flash-dns/core",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "DNS naming, configuration and ownership-aware reconciliation.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/flash-tunnel/flash-dns#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/flash-tunnel/flash-dns.git",
|
|
11
|
+
"directory": "packages/core"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/flash-tunnel/flash-dns/issues"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.mts",
|
|
19
|
+
"import": "./dist/index.mjs",
|
|
20
|
+
"default": "./dist/index.mjs"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"main": "./dist/index.mjs",
|
|
24
|
+
"types": "./dist/index.d.mts",
|
|
25
|
+
"files": [
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"README.md",
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22.14.0"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@flash-dns/config": "0.1.0",
|
|
38
|
+
"@flash-dns/types": "0.1.0",
|
|
39
|
+
"jiti": "2.6.1",
|
|
40
|
+
"unconfig": "7.5.0"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "unbuild",
|
|
44
|
+
"stub": "unbuild --stub",
|
|
45
|
+
"typecheck": "tsc --noEmit"
|
|
46
|
+
}
|
|
6
47
|
}
|