@joinremba/beacon 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/src/index.ts ADDED
@@ -0,0 +1,197 @@
1
+ import { z } from "zod";
2
+ import type {
3
+ Beacon as BeaconInterface,
4
+ BeaconOptions,
5
+ FieldDefinition,
6
+ FieldDefinitionWithSchema,
7
+ SchemaEntry,
8
+ FieldType,
9
+ } from "./types";
10
+ import { ConfigError, ConfigValidationError } from "./errors";
11
+
12
+ export type { BeaconOptions, FieldDefinition, FieldDefinitionWithSchema, SchemaEntry, FieldType };
13
+ export { ConfigError, ConfigValidationError };
14
+ export type { BeaconInterface as Beacon };
15
+
16
+ const typeToSchema = (entry: FieldDefinition): { schema: z.ZodType<unknown>; secret: boolean } => {
17
+ const secret = entry.secret ?? false;
18
+ const { type } = entry;
19
+
20
+ let base: z.ZodType<unknown>;
21
+
22
+ switch (type) {
23
+ case "string":
24
+ case "host":
25
+ base = z.string();
26
+ break;
27
+ case "url":
28
+ base = z.string().url();
29
+ break;
30
+ case "number":
31
+ base = z.coerce.number();
32
+ break;
33
+ case "integer":
34
+ base = z.coerce.number().int();
35
+ break;
36
+ case "boolean":
37
+ base = z
38
+ .string()
39
+ .transform((v) => v === "true" || v === "1")
40
+ .pipe(z.boolean());
41
+ break;
42
+ case "enum":
43
+ if (!entry.values || entry.values.length === 0) {
44
+ throw new Error(`Enum field must have values defined`);
45
+ }
46
+ base = z.enum(entry.values as [string, ...string[]]);
47
+ break;
48
+ case "port":
49
+ base = z.coerce.number().int().min(1).max(65535);
50
+ break;
51
+ case "email":
52
+ base = z.string().email();
53
+ break;
54
+ default:
55
+ base = z.string();
56
+ }
57
+
58
+ if (entry.default !== undefined) {
59
+ base = base.default(entry.default);
60
+ }
61
+
62
+ return { schema: base, secret };
63
+ };
64
+
65
+ const resolveEntry = (
66
+ entry: SchemaEntry
67
+ ): {
68
+ schema: z.ZodType<unknown>;
69
+ required: boolean;
70
+ secret: boolean;
71
+ hasDefault: boolean;
72
+ description?: string;
73
+ } => {
74
+ if ("schema" in entry && entry.schema instanceof z.ZodType) {
75
+ return {
76
+ schema: entry.schema,
77
+ required: entry.required ?? true,
78
+ secret: entry.secret ?? false,
79
+ hasDefault: false,
80
+ description: entry.description,
81
+ };
82
+ }
83
+ const field = entry as FieldDefinition;
84
+ const { schema, secret } = typeToSchema(field);
85
+ return {
86
+ schema,
87
+ required: field.required ?? true,
88
+ secret,
89
+ hasDefault: field.default !== undefined,
90
+ description: field.description,
91
+ };
92
+ };
93
+
94
+ const SECRET_CENSOR = "[REDACTED]";
95
+
96
+ export function createBeacon(
97
+ schema: Record<string, SchemaEntry>,
98
+ options?: BeaconOptions
99
+ ): BeaconInterface {
100
+ const mergedSchema: Record<string, SchemaEntry> = { ...schema };
101
+
102
+ if (options?.profile && options?.profiles?.[options.profile]) {
103
+ Object.assign(mergedSchema, options.profiles[options.profile]);
104
+ }
105
+
106
+ const entries = Object.entries(mergedSchema);
107
+ const resolved = entries.map(([key, entry]) => ({
108
+ key,
109
+ ...resolveEntry(entry),
110
+ }));
111
+ const secretKeys = new Set(resolved.filter((e) => e.secret).map((e) => e.key));
112
+
113
+ let validated: Record<string, unknown> | null = null;
114
+
115
+ const beacon: BeaconInterface = {
116
+ ensure(): BeaconInterface {
117
+ const errors: ConfigError[] = [];
118
+
119
+ for (const { key, schema, required, secret, hasDefault } of resolved) {
120
+ const raw = process.env[key];
121
+ const isMissing = raw === undefined || raw === "";
122
+
123
+ if (isMissing && hasDefault) {
124
+ try {
125
+ const parsed = schema.parse(undefined);
126
+ (validated ??= {})[key] = parsed;
127
+ } catch (err) {
128
+ if (err instanceof z.ZodError) {
129
+ errors.push(
130
+ new ConfigError(
131
+ key,
132
+ `Environment variable ${key}: ${err.issues[0]?.message ?? "Invalid value"}`,
133
+ secret
134
+ )
135
+ );
136
+ }
137
+ }
138
+ continue;
139
+ }
140
+
141
+ if (isMissing) {
142
+ if (!required) continue;
143
+ errors.push(
144
+ new ConfigError(key, `Missing required environment variable: ${key}`, secret)
145
+ );
146
+ continue;
147
+ }
148
+
149
+ try {
150
+ const parsed = schema.parse(raw);
151
+ (validated ??= {})[key] = parsed;
152
+ } catch (err) {
153
+ if (err instanceof z.ZodError) {
154
+ const issue = err.issues[0];
155
+ const val = secret ? SECRET_CENSOR : raw;
156
+ errors.push(
157
+ new ConfigError(
158
+ key,
159
+ `Environment variable ${key}=${val}: ${issue?.message ?? "Invalid value"}`,
160
+ secret
161
+ )
162
+ );
163
+ }
164
+ }
165
+ }
166
+
167
+ if (errors.length > 0) {
168
+ throw new ConfigValidationError(errors);
169
+ }
170
+
171
+ validated ??= {};
172
+ return beacon;
173
+ },
174
+
175
+ get<T = unknown>(key: string): T {
176
+ if (validated === null) {
177
+ throw new ConfigError(key, "Call beacon.ensure() before accessing config values");
178
+ }
179
+ if (!(key in validated)) {
180
+ throw new ConfigError(key, `Unknown config key: ${key}`);
181
+ }
182
+ return validated[key] as T;
183
+ },
184
+
185
+ get secret(): Record<string, boolean> {
186
+ const map: Record<string, boolean> = {};
187
+ for (const key of secretKeys) {
188
+ map[key] = true;
189
+ }
190
+ return map;
191
+ },
192
+ };
193
+
194
+ return beacon;
195
+ }
196
+
197
+ export default createBeacon;
package/src/types.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { z } from "zod";
2
+
3
+ export type FieldType =
4
+ | "string"
5
+ | "url"
6
+ | "number"
7
+ | "integer"
8
+ | "boolean"
9
+ | "enum"
10
+ | "port"
11
+ | "host"
12
+ | "email";
13
+
14
+ export interface FieldDefinition {
15
+ type: FieldType;
16
+ required?: boolean;
17
+ default?: unknown;
18
+ secret?: boolean;
19
+ values?: readonly string[];
20
+ description?: string;
21
+ }
22
+
23
+ export interface FieldDefinitionWithSchema {
24
+ schema: z.ZodType;
25
+ required?: boolean;
26
+ secret?: boolean;
27
+ description?: string;
28
+ }
29
+
30
+ export type SchemaEntry = FieldDefinition | FieldDefinitionWithSchema;
31
+
32
+ export interface BeaconOptions {
33
+ profile?: string;
34
+ profiles?: Record<string, Record<string, SchemaEntry>>;
35
+ }
36
+
37
+ export interface Beacon {
38
+ ensure(): Beacon;
39
+ get<T = unknown>(key: string): T;
40
+ readonly secret: Record<string, boolean>;
41
+ }