Spaces:
Configuration error
Configuration error
File size: 14,214 Bytes
9f21d0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 | #!/usr/bin/env node
// Merge the committed core config with every data/custom/*/satvis.yaml plugin
// config, inline each group's extraRecordsFile TLE text into extraRecords,
// validate, and write worker/src/config/satvis.generated.json.
//
// A config contributes two independent sections: `groups` (what is served, as
// which unit) and `satellites` (static per-satellite facts, keyed by NORAD id
// and attached to records at refresh time). With no plugin configs present this
// still produces a valid generated file from the core config alone (so lint/CI
// stay green).
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import YAML from "yaml";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const workerDir = path.resolve(scriptDir, "..");
const repoRoot = path.resolve(workerDir, "..");
const coreConfigPath = path.join(workerDir, "src", "config", "satvis.core.yaml");
const customDir = path.join(repoRoot, "data", "custom");
const outPath = path.join(workerDir, "src", "config", "satvis.generated.json");
const PLUGIN_CONFIG_NAME = "satvis.yaml";
// Pre-YAML plugin config name. Detected only to fail loudly: silently skipping
// it would make a plugin's groups vanish from the build without a word.
const LEGACY_PLUGIN_CONFIG_NAME = "groups.json";
const GROUP_NAME_RE = /^[a-zA-Z0-9_-]+$/;
function readYaml(file) {
return YAML.parse(fs.readFileSync(file, "utf8"));
}
// Parse TLE text into TleRecord objects. Supports 3-line blocks (optional
// leading "0 " on the name line), and bare 2-line blocks (no name).
//
// Deliberate near-duplicate of src/modules/util/gp.ts parseTleText with an
// intentionally opposite error policy: this build tool fails loud (throws) so
// bad input never ships, while the browser path warns and skips. Do not
// "unify" them.
function parseTleText(text) {
const lines = text
.split(/\r?\n/)
.map((line) => line.replace(/\s+$/, ""))
.filter((line) => line.length > 0);
const records = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.startsWith("1 ") && i + 1 < lines.length && lines[i + 1].startsWith("2 ")) {
// Bare 2-line block.
records.push({ TLE_LINE1: line, TLE_LINE2: lines[i + 1] });
i += 2;
} else if (i + 2 < lines.length && lines[i + 1].startsWith("1 ") && lines[i + 2].startsWith("2 ")) {
// 3-line block; strip an optional "0 " name prefix.
const name = line.startsWith("0 ") ? line.slice(2) : line;
records.push({ OBJECT_NAME: name, TLE_LINE1: lines[i + 1], TLE_LINE2: lines[i + 2] });
i += 3;
} else {
throw new Error(`unrecognized TLE block at line ${i + 1}: ${JSON.stringify(line)}`);
}
}
return records;
}
// Load and normalize one config file. `extraRecordsFile` (generator-only) is
// resolved relative to the config's directory and inlined into extraRecords.
function loadConfig(configPath) {
const config = readYaml(configPath);
const dir = path.dirname(configPath);
const groups = (config.groups ?? []).map((group) => {
const { extraRecordsFile, ...rest } = group;
if (extraRecordsFile) {
const txtPath = path.join(dir, extraRecordsFile);
const parsed = parseTleText(fs.readFileSync(txtPath, "utf8"));
rest.extraRecords = [...(rest.extraRecords ?? []), ...parsed];
}
return rest;
});
return { groups, satellites: config.satellites ?? [] };
}
function discoverPluginConfigs() {
if (!fs.existsSync(customDir)) {
return [];
}
const configs = [];
for (const entry of fs.readdirSync(customDir, { withFileTypes: true }).toSorted((a, b) => a.name.localeCompare(b.name))) {
if (!entry.isDirectory()) {
continue;
}
const dir = path.join(customDir, entry.name);
const candidate = path.join(dir, PLUGIN_CONFIG_NAME);
if (fs.existsSync(candidate)) {
configs.push(candidate);
continue;
}
// Fail loudly rather than silently dropping a plugin that has not migrated.
if (fs.existsSync(path.join(dir, LEGACY_PLUGIN_CONFIG_NAME))) {
throw new Error(
`${path.relative(repoRoot, path.join(dir, LEGACY_PLUGIN_CONFIG_NAME))} is the pre-YAML config format; ` +
`rename it to ${PLUGIN_CONFIG_NAME} and convert it to YAML (see worker/src/config/satvis.core.yaml)`,
);
}
}
return configs;
}
// Validate a group's `satellites` rows (if any). Each row must select by
// `noradId` (number) or `upstreamName` (string); `name`/`metadata` optional
// with checked types. Throws with the group name on any violation.
function validateSatellites(group) {
if (group.satellites === undefined) {
return;
}
if (!Array.isArray(group.satellites)) {
throw new Error(`group ${JSON.stringify(group.name)}: "satellites" must be an array`);
}
group.satellites.forEach((row, i) => {
const where = `group ${JSON.stringify(group.name)} satellites[${i}]`;
if (row === null || typeof row !== "object" || Array.isArray(row)) {
throw new Error(`${where}: must be an object`);
}
if (row.noradId !== undefined && typeof row.noradId !== "number") {
throw new Error(`${where}: "noradId" must be a number`);
}
if (row.upstreamName !== undefined && typeof row.upstreamName !== "string") {
throw new Error(`${where}: "upstreamName" must be a string`);
}
if (row.noradId === undefined && row.upstreamName === undefined) {
throw new Error(`${where}: must have a "noradId" or "upstreamName"`);
}
if (row.name !== undefined && typeof row.name !== "string") {
throw new Error(`${where}: "name" must be a string`);
}
if (row.metadata !== undefined) {
validateMetadata(row.metadata, where);
if (row.noradId === undefined) {
// Metadata is keyed by NORAD id in the merged table; a name-only row has
// no key to lift it under.
throw new Error(`${where}: "metadata" requires a "noradId" (matching is by NORAD id only)`);
}
// Same rule as a top-level table entry: an empty bag would attach a
// meaningless `metadata` key to the served record, which is supposed to be
// present only when the satellite actually has facts to carry.
if (Object.keys(row.metadata).length === 0) {
throw new Error(`${where}: "metadata" is empty — remove it or give it a field`);
}
}
if (row.decayed !== undefined && typeof row.decayed !== "boolean") {
throw new Error(`${where}: "decayed" must be a boolean`);
}
});
}
// Validate a metadata bag. The worker treats it as opaque, so only the fields
// whose shape the frontend depends on are checked here: the swath extents, which
// must be positive numbers and must be given for both sides or neither (see
// swathExtentsOf in src/config/satelliteMetadata.ts, which reads them as a pair
// and treats a half-specified swath as absent).
function validateMetadata(metadata, where) {
if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
throw new Error(`${where}: "metadata" must be an object`);
}
const sides = ["swathStarboardKm", "swathPortKm"];
for (const side of sides) {
const value = metadata[side];
if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value) || value <= 0)) {
throw new Error(`${where}: "${side}" must be a positive number`);
}
}
const given = sides.filter((side) => metadata[side] !== undefined);
if (given.length === 1) {
throw new Error(`${where}: swath needs both "swathStarboardKm" and "swathPortKm" (got only ${JSON.stringify(given[0])})`);
}
}
// The keys of a satellite-table entry that are bookkeeping rather than payload.
// Everything else in the entry IS the metadata bag, which is what keeps adding a
// field a data-only edit.
const TABLE_ENTRY_KEYS = new Set(["noradId", "name", "decayed"]);
function metadataFields(entry) {
return Object.fromEntries(Object.entries(entry).filter(([key]) => !TABLE_ENTRY_KEYS.has(key)));
}
// Validate a config's top-level `satellites` table. Every entry keys on a
// numeric `noradId`; `name` is documentation only.
function validateSatelliteTable(entries, source) {
if (!Array.isArray(entries)) {
throw new Error(`${source}: "satellites" must be an array`);
}
entries.forEach((entry, i) => {
const where = `${source} satellites[${i}]`;
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
throw new Error(`${where}: must be an object`);
}
if (typeof entry.noradId !== "number") {
throw new Error(`${where}: "noradId" must be a number`);
}
if (entry.name !== undefined && typeof entry.name !== "string") {
throw new Error(`${where}: "name" must be a string`);
}
if (entry.decayed !== undefined && typeof entry.decayed !== "boolean") {
throw new Error(`${where}: "decayed" must be a boolean`);
}
const metadata = metadataFields(entry);
validateMetadata(metadata, where);
if (Object.keys(metadata).length === 0) {
throw new Error(`${where}: has no metadata fields — remove the entry or give it something to attach`);
}
});
}
function validate(groups) {
const names = new Set();
for (const group of groups) {
if (typeof group.name !== "string" || !GROUP_NAME_RE.test(group.name)) {
throw new Error(`invalid group name ${JSON.stringify(group.name)} (must match ${GROUP_NAME_RE})`);
}
if (names.has(group.name)) {
throw new Error(`duplicate group name ${JSON.stringify(group.name)}`);
}
names.add(group.name);
validateSatellites(group);
}
// include targets must exist.
for (const group of groups) {
for (const dep of group.include ?? []) {
if (!names.has(dep)) {
throw new Error(`group ${JSON.stringify(group.name)} includes unknown group ${JSON.stringify(dep)}`);
}
}
}
// no include cycles (DFS).
const byName = new Map(groups.map((g) => [g.name, g]));
const state = new Map();
const visit = (name, stack) => {
if (state.get(name) === "done") {
return;
}
if (state.get(name) === "visiting") {
throw new Error(`include cycle detected: ${[...stack, name].join(" -> ")}`);
}
state.set(name, "visiting");
for (const dep of byName.get(name)?.include ?? []) {
visit(dep, [...stack, name]);
}
state.set(name, "done");
};
for (const group of groups) {
visit(group.name, []);
}
}
// Accumulator for the merged satellite table, keyed by NORAD id. Contributions
// arrive from two kinds of place — a config's top-level `satellites` table and a
// group's `satellites[].metadata` rows — and are merged field-wise in arrival
// order, so a later, more specific contribution wins per field.
//
// Conflicts (two places giving one field different values for one satellite) are
// a config bug: whichever won would depend on file discovery order, so we fail
// with both origins instead of picking. Identical values merge silently, which
// is what makes it safe to repeat a satellite across groups.
function createSatelliteTable() {
const byNoradId = new Map();
return {
add(noradId, fields, origin) {
const existing = byNoradId.get(noradId);
if (existing === undefined) {
byNoradId.set(noradId, { noradId, metadata: { ...fields.metadata }, origins: [origin], name: fields.name, decayed: fields.decayed });
return;
}
for (const [key, value] of Object.entries(fields.metadata)) {
const previous = existing.metadata[key];
if (previous !== undefined && previous !== value) {
throw new Error(
`conflicting metadata for noradId ${noradId}: ${origin} sets ${key}=${JSON.stringify(value)}, ` +
`but ${existing.origins.join(", ")} set ${key}=${JSON.stringify(previous)}`,
);
}
existing.metadata[key] = value;
}
existing.name ??= fields.name;
existing.decayed ||= fields.decayed;
existing.origins.push(origin);
},
// Strip the bookkeeping (`origins`) that only the merge needed, and drop
// absent optional keys so the generated JSON stays free of nulls.
entries() {
const out = [];
for (const { noradId, name, decayed, metadata } of byNoradId.values()) {
const entry = { noradId };
if (name !== undefined) {
entry.name = name;
}
if (decayed) {
entry.decayed = true;
}
entry.metadata = metadata;
out.push(entry);
}
return out.toSorted((a, b) => a.noradId - b.noradId);
},
};
}
function main() {
const configs = [
{ path: coreConfigPath, config: loadConfig(coreConfigPath) },
...discoverPluginConfigs().map((configPath) => ({ path: configPath, config: loadConfig(configPath) })),
];
const groups = configs.flatMap(({ config }) => config.groups);
validate(groups);
const table = createSatelliteTable();
for (const { path: configPath, config } of configs) {
const source = path.relative(repoRoot, configPath);
validateSatelliteTable(config.satellites, source);
for (const entry of config.satellites) {
table.add(entry.noradId, { metadata: metadataFields(entry), name: entry.name, decayed: entry.decayed }, `${source} satellites`);
}
}
// Group rows contribute after every table. Order only decides which origin is
// named first in a conflict message — it does NOT establish precedence: two
// places giving one satellite different values for a field is a build failure,
// because whichever won would depend on config discovery order.
for (const group of groups) {
for (const row of group.satellites ?? []) {
if (row.metadata !== undefined) {
table.add(row.noradId, { metadata: row.metadata, name: row.name, decayed: row.decayed }, `group ${JSON.stringify(group.name)}`);
}
}
}
const satellites = table.entries();
const generated = { groups, satellites };
fs.writeFileSync(outPath, `${JSON.stringify(generated, null, 2)}\n`);
process.stdout.write(`Wrote ${path.relative(repoRoot, outPath)} (${groups.length} groups, ${satellites.length} satellites)\n`);
}
main();
|