Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,32 @@ jobs:
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: site
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: snapshot
path: data/snapshot.json
retention-days: 1

# Separate job: a Google failure must not block the ICS deploy, and only this job sees the key.
sync:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
environment: google-calendar
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: snapshot
path: data
- run: npm run sync
env:
GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}

deploy:
needs: build
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ Node 24 runs the TypeScript in `src/` directly (type stripping, no build). Impor

- `npm run fetch`: fetch upcoming launches and those of the last 30 days from Launch Library 2, write `data/snapshot.json`.
- `npm run build`: render `data/snapshot.json` into `site/cal/{all,crewed,starship,no-starlink}.ics`.
- `npm run sync [-- --dry-run] [-- --calendar all=<googleCalendarId>]`: sync `data/snapshot.json` into the Google calendars. Key from env `GOOGLE_SERVICE_ACCOUNT_KEY` (JSON) or `GOOGLE_SERVICE_ACCOUNT_KEY_FILE` (locally `%USERPROFILE%\.launchcal\sa.json`, never in the repo).
- `npm test`, `npm run typecheck`.

## Layout

- `src/launch.ts`: launchcal's own `Launch` model. `src/ll2.ts`: LL2 client and normaliser. `src/fetch.ts`: fetch entry point.
- `src/calendars.ts`: the 4 calendars and their filters (ids are public URLs, never rename). `src/event.ts`: format-neutral event text and timing, shared by ICS and the Google sync. `src/ics.ts`: RFC 5545 writer. `src/build.ts`: build entry point.
- `src/gcal.ts`: Google event mapping and the insert/update/delete plan (launch id + hash in private extended properties; events older than the snapshot window are kept). `src/google.ts`: service account JWT and Calendar API calls. `src/sync.ts`: sync entry point.
- `test/fixtures/ll2-upcoming.json` (5) and `ll2-previous.json` (1): real, unedited LL2 detailed records (recorded 2026-10-01).
- `data/snapshot.json`, `site/cal/*.ics`: generated, git-ignored. `site/` is deployed to Pages by `.github/workflows/publish.yml` (every 2h, on push to main, manual).
- `data/snapshot.json`, `site/cal/*.ics`: generated, git-ignored. `site/` is deployed to Pages by `.github/workflows/publish.yml` (every 2h, on push to main, manual); its `sync` job runs `npm run sync` in the `google-calendar` environment (secret `GOOGLE_SERVICE_ACCOUNT_KEY`).
- `.github/workflows/ci.yml`: typecheck + tests, required check on `main`. Actions are pinned by commit SHA.

## Known facts
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"scripts": {
"fetch": "node src/fetch.ts",
"build": "node src/build.ts",
"sync": "node src/sync.ts",
"test": "node --test \"test/**/*.test.ts\"",
"typecheck": "tsc"
},
Expand Down
18 changes: 16 additions & 2 deletions src/calendars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,37 @@ import type { Launch } from './launch.ts';
export interface CalendarDef {
id: string;
name: string;
/** Public Google Calendar id the sync writes to; public like `id`, never change it once published. */
googleId: string;
includes: (launch: Launch) => boolean;
}

export const CALENDAR_DESCRIPTION =
'Unofficial, auto-updating calendar from https://launchcal.github.io. Not affiliated with SpaceX. Launch data: The Space Devs.';

export const CALENDARS: CalendarDef[] = [
{ id: 'all', name: 'SpaceX launches', includes: () => true },
{
id: 'all',
name: 'SpaceX launches',
googleId: '24f5ae18242c0bd83698f9ee64ec2cc2da185cc5357718efe3bb1d2d800c42bf@group.calendar.google.com',
includes: () => true,
},
{
id: 'crewed',
name: 'SpaceX crewed launches',
googleId: 'cbc8fd4367b790cf6e18fbf78abb919112e54936254d3b3b6bdfdda45ea3786d@group.calendar.google.com',
includes: (launch) => launch.crew.length > 0 || (launch.spacecraft?.startsWith('Crew Dragon') ?? false),
},
{ id: 'starship', name: 'Starship launches', includes: (launch) => launch.rocket === 'Starship' },
{
id: 'starship',
name: 'Starship launches',
googleId: 'a72ea698dbec5c3fc71148b928dd5b1c12fef27f98676bfe7b4fcc4ef444a2fa@group.calendar.google.com',
includes: (launch) => launch.rocket === 'Starship',
},
{
id: 'no-starlink',
name: 'SpaceX launches without Starlink',
googleId: 'c1884006e046fbd621afe8756f6b4ec003f2ac9e9590ca380f511ddeaab24470@group.calendar.google.com',
includes: (launch) => launch.rocket === 'Starship' || !launch.name.startsWith('Starlink'),
},
];
72 changes: 72 additions & 0 deletions src/gcal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { createHash } from 'node:crypto';
import type { CalendarEvent } from './event.ts';

/** A Calendar API v3 event resource, limited to the fields launchcal writes. */
export interface GoogleEvent {
summary: string;
description: string;
location: string;
start: { dateTime: string; timeZone: 'UTC' } | { date: string };
end: { dateTime: string; timeZone: 'UTC' } | { date: string };
status: 'confirmed' | 'tentative';
transparency: 'transparent';
source?: { title: string; url: string };
extendedProperties: { private: { launchcal: '1'; launchId: string; hash: string } };
}

/** An event launchcal wrote earlier, as listed back from Google. */
export interface ExistingEvent {
/** Google's event id. */
id: string;
launchId: string;
hash: string;
/** `start.dateTime` or `start.date`. */
start: string;
}

export interface SyncPlan {
insert: GoogleEvent[];
update: { id: string; event: GoogleEvent }[];
delete: ExistingEvent[];
}

/** Maps an event to Google's format; `hash` covers every written field, so any change triggers an update. */
export function toGoogleEvent(event: CalendarEvent): GoogleEvent {
const body = {
summary: event.title,
description: event.description,
location: event.location,
start: event.timed ? { dateTime: event.start, timeZone: 'UTC' as const } : { date: event.start },
end: event.timed ? { dateTime: event.end, timeZone: 'UTC' as const } : { date: event.end },
status: event.confirmed ? ('confirmed' as const) : ('tentative' as const),
transparency: 'transparent' as const,
...(event.url ? { source: { title: 'Webcast', url: event.url } } : {}),
};
const hash = createHash('sha256').update(JSON.stringify(body)).digest('hex').slice(0, 16);
return { ...body, extendedProperties: { private: { launchcal: '1', launchId: event.id, hash } } };
}

/**
* Diffs the wanted events against Google by launch id. Events starting before `windowStart` are outside
* the snapshot and stay as history; duplicates of one launch are deleted.
*/
export function planSync(wanted: GoogleEvent[], existing: ExistingEvent[], windowStart: string): SyncPlan {
const plan: SyncPlan = { insert: [], update: [], delete: [] };
const byLaunch = new Map<string, ExistingEvent>();
for (const event of existing) {
if (byLaunch.has(event.launchId)) plan.delete.push(event);
else byLaunch.set(event.launchId, event);
}
for (const event of wanted) {
const { launchId, hash } = event.extendedProperties.private;
const current = byLaunch.get(launchId);
byLaunch.delete(launchId);
if (!current) plan.insert.push(event);
else if (current.hash !== hash) plan.update.push({ id: current.id, event });
}
const since = Date.parse(windowStart);
for (const event of byLaunch.values()) {
if (Date.parse(event.start) >= since) plan.delete.push(event);
}
return plan;
}
83 changes: 83 additions & 0 deletions src/google.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { createSign } from 'node:crypto';
import type { ExistingEvent, GoogleEvent } from './gcal.ts';

/** The fields launchcal reads from a service account JSON key. */
export interface ServiceAccountKey {
client_email: string;
private_key: string;
}

const TOKEN_URL = 'https://oauth2.googleapis.com/token';

const SCOPE = 'https://www.googleapis.com/auth/calendar.events';

const API = 'https://www.googleapis.com/calendar/v3';

/** Signs the RS256 assertion a service account exchanges for an access token (valid one hour). */
export function signJwt(key: ServiceAccountKey, now: Date): string {
const iat = Math.floor(now.getTime() / 1000);
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const claims = Buffer.from(JSON.stringify({ iss: key.client_email, scope: SCOPE, aud: TOKEN_URL, iat, exp: iat + 3600 })).toString(
'base64url',
);
const signature = createSign('RSA-SHA256').update(`${header}.${claims}`).sign(key.private_key, 'base64url');
return `${header}.${claims}.${signature}`;
}

export async function accessToken(key: ServiceAccountKey): Promise<string> {
const res = await fetch(TOKEN_URL, {
method: 'POST',
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: signJwt(key, new Date()) }),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) throw new Error(`Google token exchange returned HTTP ${res.status}: ${await res.text()}`);
return ((await res.json()) as { access_token: string }).access_token;
}

/** Lists every event launchcal wrote to the calendar, across all pages. */
export async function listEvents(token: string, calendarId: string): Promise<ExistingEvent[]> {
const events: ExistingEvent[] = [];
let pageToken: string | undefined;
do {
const params = new URLSearchParams({ privateExtendedProperty: 'launchcal=1', maxResults: '2500' });
if (pageToken) params.set('pageToken', pageToken);
const page = (await call(token, 'GET', `${eventsPath(calendarId)}?${params}`)) as {
items: (GoogleEvent & { id: string; start: { dateTime?: string; date?: string } })[];
nextPageToken?: string;
};
for (const item of page.items) {
const { launchId, hash } = item.extendedProperties.private;
events.push({ id: item.id, launchId, hash, start: item.start.dateTime ?? item.start.date! });
}
pageToken = page.nextPageToken;
} while (pageToken);
return events;
}

export async function insertEvent(token: string, calendarId: string, event: GoogleEvent): Promise<void> {
await call(token, 'POST', eventsPath(calendarId), event);
}

export async function updateEvent(token: string, calendarId: string, id: string, event: GoogleEvent): Promise<void> {
await call(token, 'PUT', `${eventsPath(calendarId)}/${encodeURIComponent(id)}`, event);
}

export async function deleteEvent(token: string, calendarId: string, id: string): Promise<void> {
await call(token, 'DELETE', `${eventsPath(calendarId)}/${encodeURIComponent(id)}`);
}

function eventsPath(calendarId: string): string {
return `${API}/calendars/${encodeURIComponent(calendarId)}/events`;
}

/** Throws with Google's error text on any non-2xx, so a sync run stops at the first failure. */
async function call(token: string, method: string, url: string, body?: unknown): Promise<unknown> {
const res = await fetch(url, {
method,
headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) throw new Error(`Google Calendar ${method} ${url} returned HTTP ${res.status}: ${await res.text()}`);
return res.status === 204 ? null : res.json();
}
10 changes: 7 additions & 3 deletions src/ll2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ export const UPCOMING_URL = `https://ll.thespacedevs.com/2.3.0/launches/upcoming
/** How far back launched flights stay in the calendars. */
const RECENT_DAYS = 30;

/** Previous launches since midnight UTC `RECENT_DAYS` ago, so the query only moves once a day. */
/** Midnight UTC `RECENT_DAYS` ago: the start of the snapshot, moving only once a day. */
export function recentSince(now: Date): string {
return `${new Date(now.getTime() - RECENT_DAYS * 86_400_000).toISOString().slice(0, 10)}T00:00:00Z`;
}

/** Previous launches since `recentSince`. */
export function recentUrl(now: Date): string {
const since = new Date(now.getTime() - RECENT_DAYS * 86_400_000).toISOString().slice(0, 10);
return `https://ll.thespacedevs.com/2.3.0/launches/previous/?${QUERY}&net__gte=${since}T00:00:00Z`;
return `https://ll.thespacedevs.com/2.3.0/launches/previous/?${QUERY}&net__gte=${recentSince(now)}`;
}

const USER_AGENT = 'launchcal (+https://github.com/launchcal/launchcal.github.io)';
Expand Down
51 changes: 51 additions & 0 deletions src/sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { readFile } from 'node:fs/promises';
import { parseArgs } from 'node:util';
import { CALENDARS } from './calendars.ts';
import { toEvent, type CalendarEvent } from './event.ts';
import { planSync, toGoogleEvent } from './gcal.ts';
import { accessToken, deleteEvent, insertEvent, listEvents, updateEvent, type ServiceAccountKey } from './google.ts';
import type { Launch } from './launch.ts';
import { recentSince } from './ll2.ts';

// --calendar <id>=<googleCalendarId> syncs only that calendar, into the given Google calendar.
const { values: args } = parseArgs({ options: { 'dry-run': { type: 'boolean' }, calendar: { type: 'string' } } });
const separator = args.calendar?.indexOf('=') ?? -1;
const onlyId = args.calendar?.slice(0, separator);
const overrideGoogleId = args.calendar?.slice(separator + 1);
if (args.calendar && (separator <= 0 || !overrideGoogleId)) throw new Error('Use --calendar <id>=<googleCalendarId>');
if (onlyId && !CALENDARS.some((calendar) => calendar.id === onlyId)) throw new Error(`Unknown calendar ${onlyId}`);

const launches = JSON.parse(await readFile('data/snapshot.json', 'utf8')) as Launch[];
const feeds = CALENDARS.map((calendar) => ({
calendar,
events: launches
.filter(calendar.includes)
.map(toEvent)
.filter((event): event is CalendarEvent => event !== null),
}));
if (feeds.find((feed) => feed.calendar.id === 'all')!.events.length === 0) {
throw new Error('No launch has a usable date; refusing to empty the Google calendars');
}

const keyJson = process.env.GOOGLE_SERVICE_ACCOUNT_KEY
?? (process.env.GOOGLE_SERVICE_ACCOUNT_KEY_FILE && (await readFile(process.env.GOOGLE_SERVICE_ACCOUNT_KEY_FILE, 'utf8')));
if (!keyJson) throw new Error('Set GOOGLE_SERVICE_ACCOUNT_KEY or GOOGLE_SERVICE_ACCOUNT_KEY_FILE');
const token = await accessToken(JSON.parse(keyJson) as ServiceAccountKey);
const windowStart = recentSince(new Date());

for (const { calendar, events } of feeds) {
if (onlyId && calendar.id !== onlyId) continue;
const googleId = overrideGoogleId ?? calendar.googleId;
if (!googleId) throw new Error(`Calendar ${calendar.id} has no Google calendar id`);

const plan = planSync(events.map(toGoogleEvent), await listEvents(token, googleId), windowStart);
console.log(`${calendar.id}: ${plan.insert.length} insert, ${plan.update.length} update, ${plan.delete.length} delete`);
for (const event of plan.insert) console.log(` + ${event.summary}`);
for (const { event } of plan.update) console.log(` ~ ${event.summary}`);
for (const event of plan.delete) console.log(` - ${event.launchId} (${event.start})`);
if (args['dry-run']) continue;

for (const event of plan.insert) await insertEvent(token, googleId, event);
for (const { id, event } of plan.update) await updateEvent(token, googleId, id, event);
for (const event of plan.delete) await deleteEvent(token, googleId, event.id);
}
Loading
Loading