Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
212 lines
6.6 KiB
TypeScript
212 lines
6.6 KiB
TypeScript
import { InvalidInstantError, InvalidTimezoneError } from "./errors.js";
|
|
|
|
/**
|
|
* Wall-clock reading of an instant in some zone. Fields are the raw numeric
|
|
* calendar values (month 1-12, day 1-31, hour 0-23).
|
|
*/
|
|
export interface WallClock {
|
|
readonly year: number;
|
|
readonly month: number;
|
|
readonly day: number;
|
|
readonly hour: number;
|
|
readonly minute: number;
|
|
readonly second: number;
|
|
}
|
|
|
|
/**
|
|
* Nominal local date-time, e.g. `2024-03-10T02:30`. Lexicographic order of
|
|
* these strings is chronological order, which is what the
|
|
* `last_fired_nominal_local` refire gate relies on.
|
|
*/
|
|
export type NominalLocal = string;
|
|
|
|
const WALL_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
|
|
hourCycle: "h23",
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
};
|
|
|
|
const wallFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
|
|
function wallFormatter(zone: string): Intl.DateTimeFormat {
|
|
const cached = wallFormatters.get(zone);
|
|
if (cached) return cached;
|
|
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
...WALL_FORMAT_OPTIONS,
|
|
timeZone: zone,
|
|
});
|
|
wallFormatters.set(zone, formatter);
|
|
return formatter;
|
|
}
|
|
|
|
const IDIOMS_REJECTED = new Set(["local", "system", "server", "server-local", "machine"]);
|
|
|
|
/**
|
|
* Validate a timezone and return its canonical IANA id.
|
|
*
|
|
* Accepts real IANA ids (including `Etc/GMT+5` style fixed zones and `UTC`),
|
|
* canonicalizing whatever Intl resolves (e.g. `US/Eastern` -> `America/New_York`).
|
|
* Rejects unknown ids, offset spellings like `UTC+3`/`+05:30`, empty input and
|
|
* the strings `local`/`system` — the schedule zone is never allowed to fall
|
|
* back to the process zone.
|
|
*/
|
|
export function canonicalTimezone(timezone: string): string {
|
|
if (typeof timezone !== "string") {
|
|
throw new InvalidTimezoneError(`timezone must be a string, got ${typeof timezone}`);
|
|
}
|
|
const trimmed = timezone.trim();
|
|
if (trimmed === "") {
|
|
throw new InvalidTimezoneError("timezone must be an explicit IANA zone id, not an empty string");
|
|
}
|
|
if (IDIOMS_REJECTED.has(trimmed.toLowerCase())) {
|
|
throw new InvalidTimezoneError(
|
|
`timezone "${timezone}" is not an IANA zone id; a schedule must carry an explicit zone (never server-local)`,
|
|
);
|
|
}
|
|
if (/^[+-]?\d{1,2}(:?\d{2})?$/.test(trimmed) || /^UTC[+-]\d/.test(trimmed)) {
|
|
throw new InvalidTimezoneError(
|
|
`timezone "${timezone}" is a UTC offset, not an IANA zone id; store an IANA zone (UTC is allowed)`,
|
|
);
|
|
}
|
|
let resolved: string;
|
|
try {
|
|
resolved = new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).resolvedOptions().timeZone;
|
|
} catch {
|
|
throw new InvalidTimezoneError(`unknown IANA time zone: "${timezone}"`);
|
|
}
|
|
if (!resolved) {
|
|
throw new InvalidTimezoneError(`unknown IANA time zone: "${timezone}"`);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
/** True when `canonicalTimezone` would accept the value. */
|
|
export function isValidTimezone(timezone: string): boolean {
|
|
try {
|
|
canonicalTimezone(timezone);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Validate and canonicalize, throwing `InvalidTimezoneError` on rejection. */
|
|
export function assertValidTimezone(timezone: string): string {
|
|
return canonicalTimezone(timezone);
|
|
}
|
|
|
|
/** Local wall clock struck by an instant, in the given zone. */
|
|
export function wallOfInstant(epochMs: number, zone: string): WallClock {
|
|
if (zone === "UTC") {
|
|
return decodeUtc(epochMs);
|
|
}
|
|
const parts = wallFormatter(zone).formatToParts(new Date(epochMs));
|
|
let year = 0;
|
|
let month = 0;
|
|
let day = 0;
|
|
let hour = 0;
|
|
let minute = 0;
|
|
let second = 0;
|
|
for (const part of parts) {
|
|
switch (part.type) {
|
|
case "year":
|
|
year = Number(part.value);
|
|
break;
|
|
case "month":
|
|
month = Number(part.value);
|
|
break;
|
|
case "day":
|
|
day = Number(part.value);
|
|
break;
|
|
case "hour":
|
|
hour = Number(part.value);
|
|
break;
|
|
case "minute":
|
|
minute = Number(part.value);
|
|
break;
|
|
case "second":
|
|
second = Number(part.value);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
return { year, month, day, hour, minute, second };
|
|
}
|
|
|
|
/** The UTC instant a wall-clock reading would denote if the offset were zero. */
|
|
export function epochOfWall(wall: WallClock): number {
|
|
return Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second);
|
|
}
|
|
|
|
function decodeUtc(epochMs: number): WallClock {
|
|
const d = new Date(epochMs);
|
|
return {
|
|
year: d.getUTCFullYear(),
|
|
month: d.getUTCMonth() + 1,
|
|
day: d.getUTCDate(),
|
|
hour: d.getUTCHours(),
|
|
minute: d.getUTCMinutes(),
|
|
second: d.getUTCSeconds(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Zone offset (ms east of UTC) in effect at an instant. Minute-precision
|
|
* arithmetic against `Intl`; UTC short-circuits without touching a formatter.
|
|
*/
|
|
export function offsetMs(epochMs: number, zone: string): number {
|
|
if (zone === "UTC") return 0;
|
|
return epochOfWall(wallOfInstant(epochMs, zone)) - epochMs;
|
|
}
|
|
|
|
const OFFSET_ISO_TAIL = /(?:Z|[+-]\d{2}:\d{2})$/;
|
|
|
|
/**
|
|
* Coerce an instant input to epoch milliseconds.
|
|
*
|
|
* `Date` and finite numbers pass through. Strings must be ISO-8601; a string
|
|
* that carries a time component must also carry an explicit UTC offset (`Z` or
|
|
* `±hh:mm`) — otherwise `Date.parse` would silently apply the server's local
|
|
* zone, which this library exists to prevent.
|
|
*/
|
|
export function toInstantMs(value: Date | number | string): number {
|
|
if (value instanceof Date) {
|
|
const ms = value.getTime();
|
|
if (Number.isNaN(ms)) throw new InvalidInstantError("invalid Date (NaN)");
|
|
return ms;
|
|
}
|
|
if (typeof value === "number") {
|
|
if (!Number.isFinite(value)) throw new InvalidInstantError(`invalid instant number: ${value}`);
|
|
return value;
|
|
}
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
const hasTime = /[T ]\d/.test(trimmed);
|
|
if (hasTime && !OFFSET_ISO_TAIL.test(trimmed)) {
|
|
throw new InvalidInstantError(
|
|
`instant "${value}" has a time but no UTC offset; supply ...Z or ...±hh:mm (never server-local)`,
|
|
);
|
|
}
|
|
const ms = Date.parse(trimmed);
|
|
if (Number.isNaN(ms)) throw new InvalidInstantError(`unparseable instant: "${value}"`);
|
|
return ms;
|
|
}
|
|
throw new InvalidInstantError(`unsupported instant input: ${String(value)}`);
|
|
}
|
|
|
|
type ProcessVersionsLike = { tz?: string };
|
|
|
|
/**
|
|
* tzdata version backing this process (`process.versions.tz`, e.g. `2026a`),
|
|
* or `"unknown"` outside Node. Recorded per schedule so tzdata-driven
|
|
* re-materialization can detect drift.
|
|
*/
|
|
export function currentTzdataVersion(): string {
|
|
const proc = (globalThis as { process?: { versions?: ProcessVersionsLike } }).process;
|
|
return proc?.versions?.tz ?? "unknown";
|
|
}
|