diff --git a/packages/worldcup-live-feed/README.md b/packages/worldcup-live-feed/README.md new file mode 100644 index 0000000..991fd92 --- /dev/null +++ b/packages/worldcup-live-feed/README.md @@ -0,0 +1,55 @@ +# @webhook-objects/worldcup-live-feed + +Polls [football-data.org](https://www.football-data.org/) for World Cup matches +and drives a `status` webhook object: + +- **status**: `working` when idle, `on` while any match is live, briefly `alert` + on a goal (reverts to `on` after `--alert-seconds`) +- **info.name** (on-map, no click needed): `worldcup.town - ⚽ ESP 2-0 AUT`, + `worldcup.town - ⚽ 3 matches live`, or just `worldcup.town` when idle +- **activity feed** (popover, click to open): + - `🔴 LIVE: ...` — one entry per live match, score + minute + - `⚽🎉 GOAL! ...` — one entry per goal, names the scoring team + - `⏰ Next: ...` — countdown to the next scheduled match when idle (or + "No live World Cup matches right now" if none scheduled) + - `🏆 FT: ...` — the moment a live match ends (same day), plus a daily batch + for the previous day + +## Usage + +```sh +pnpm --filter @webhook-objects/worldcup-live-feed start \ + --url https://your-object-url \ + --secret whsec_... \ + --football-token your-football-data-org-token \ + --interval 30 \ + --alert-seconds 60 +``` + +| flag | required | default | meaning | +| ------------------- | -------- | ------- | ---------------------------------------------------- | +| `--url` | yes | — | object's webhook URL | +| `--secret` | yes | — | Standard Webhooks secret | +| `--football-token` | yes\* | — | football-data.org API token | +| `--interval` | no | `30` | poll interval in seconds (min `6`) | +| `--alert-seconds` | no | `60` | how long status stays `alert` after a goal | + +\* `--football-token` can also come from the `FOOTBALL_DATA_API_TOKEN` env var. + +Runs until `Ctrl+C`. + +## Notes + +- Free tier: 12 competitions (World Cup included, code `WC`), basic + fixtures/results/tables, **10 req/min**. No card/booking data on this tier — + checked live, match detail has no `bookings` field. +- `--interval` refuses to go below 6s to protect the rate limit. Steady-state + usage is ~2 req/min live/idle, plus a cached (5 min TTL) lookup for the + idle countdown and one lookup per calendar day for the previous day's + results. +- On `429`, football-data.org calls back off using `Retry-After` and retry. +- Entries only re-dispatch when their text actually changes. The feed's sort + key (`at`) is server-stamped from dispatch time, not sendable via payload — + re-dispatching unchanged text would bump an old entry ahead of genuinely + newer ones. The known-entries cache is seeded from `webhook.ping` on start, + so this holds across restarts too. diff --git a/packages/worldcup-live-feed/package.json b/packages/worldcup-live-feed/package.json new file mode 100644 index 0000000..150cc61 --- /dev/null +++ b/packages/worldcup-live-feed/package.json @@ -0,0 +1,24 @@ +{ + "name": "@webhook-objects/worldcup-live-feed", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "worldcup-live-feed": "src/index.ts" + }, + "scripts": { + "start": "tsx src/index.ts", + "test": "vitest run" + }, + "licenses": [ + { + "type": "Apache-2.0" + }, + { + "type": "MIT" + } + ], + "dependencies": { + "@gathertown/webhook-object-sdk": "^0.1.1" + } +} diff --git a/packages/worldcup-live-feed/src/entries.spec.ts b/packages/worldcup-live-feed/src/entries.spec.ts new file mode 100644 index 0000000..6c6fd48 --- /dev/null +++ b/packages/worldcup-live-feed/src/entries.spec.ts @@ -0,0 +1,89 @@ +import { expect, test } from "vitest"; +import { + countdownText, + goalEntryText, + liveDisplayName, + matchEntryText, + nextMatchEntryText, + resultEntryText, +} from "./entries"; +import type { Match } from "./football-data"; + +function match(overrides: Partial = {}): Match { + return { + id: 1, + utcDate: "2026-07-02T20:00:00Z", + status: "IN_PLAY", + minute: 67, + homeTeam: { name: "Spain", tla: "ESP" }, + awayTeam: { name: "Austria", tla: "AUT" }, + score: { fullTime: { home: 2, away: 0 } }, + ...overrides, + }; +} + +test("resultEntryText formats the final score", () => { + expect(resultEntryText(match())).toBe("🏆 FT: Spain 2-0 Austria"); +}); + +test("matchEntryText includes the minute when present", () => { + expect(matchEntryText(match())).toBe("🔴 LIVE: Spain 2-0 Austria (67')"); +}); + +test("matchEntryText omits the minute suffix when absent", () => { + expect(matchEntryText(match({ minute: null }))).toBe( + "🔴 LIVE: Spain 2-0 Austria", + ); +}); + +test("goalEntryText names the scoring side", () => { + expect(goalEntryText(match(), "home")).toBe( + "⚽🎉 GOAL! Spain — Spain 2-0 Austria", + ); + expect(goalEntryText(match(), "away")).toBe( + "⚽🎉 GOAL! Austria — Spain 2-0 Austria", + ); +}); + +test("liveDisplayName uses team codes for a single match", () => { + expect(liveDisplayName([match()])).toBe("worldcup.town - ⚽ ESP 2-0 AUT"); +}); + +test("liveDisplayName falls back to shortName/name when no tla", () => { + expect( + liveDisplayName([ + match({ + homeTeam: { name: "United States" }, + awayTeam: { name: "Bosnia-Herzegovina" }, + }), + ]), + ).toBe("worldcup.town - ⚽ United States 2-0 Bosnia-Herzegovina"); +}); + +test("liveDisplayName summarizes multiple live matches", () => { + expect(liveDisplayName([match(), match({ id: 2 })])).toBe( + "worldcup.town - ⚽ 2 matches live", + ); +}); + +test("countdownText reports hours and minutes ahead of now", () => { + const now = new Date("2026-07-02T18:00:00Z").getTime(); + expect(countdownText("2026-07-02T20:15:00Z", now)).toBe("in 2h 15m"); +}); + +test("countdownText drops the hours segment under an hour", () => { + const now = new Date("2026-07-02T19:40:00Z").getTime(); + expect(countdownText("2026-07-02T20:00:00Z", now)).toBe("in 20m"); +}); + +test("countdownText reports starting soon once the kickoff has passed", () => { + const now = new Date("2026-07-02T20:00:01Z").getTime(); + expect(countdownText("2026-07-02T20:00:00Z", now)).toBe("starting soon"); +}); + +test("nextMatchEntryText combines team names and the countdown", () => { + const now = new Date("2026-07-02T18:00:00Z").getTime(); + expect( + nextMatchEntryText(match({ utcDate: "2026-07-02T19:00:00Z" }), now), + ).toBe("⏰ Next: ⚽ Spain vs Austria — in 1h 0m"); +}); diff --git a/packages/worldcup-live-feed/src/entries.ts b/packages/worldcup-live-feed/src/entries.ts new file mode 100644 index 0000000..e396d56 --- /dev/null +++ b/packages/worldcup-live-feed/src/entries.ts @@ -0,0 +1,74 @@ +/** + * Pure text/formatting helpers for the worldcup-live-feed `status` object: + * activity feed entries and the on-map `info.name` summary. + * + * @module + */ +import type { Match } from "./football-data"; + +/** Prefix applied to `info.name` while any match is live. */ +export const NAME_PREFIX = "worldcup.town - "; +/** `info.name` value when no match is live. */ +export const IDLE_DISPLAY_NAME = "worldcup.town"; +/** Stable activity entry id for the idle/next-match message. */ +export const IDLE_ENTRY_ID = "idle"; + +function goals(match: Pick) { + return { + home: match.score.fullTime.home ?? 0, + away: match.score.fullTime.away ?? 0, + }; +} + +/** `🏆 FT: Home 2-1 Away` — a finished match, for a same-day conversion or the daily batch. */ +export function resultEntryText(match: Match): string { + const { home, away } = goals(match); + return `🏆 FT: ${match.homeTeam.name} ${home}-${away} ${match.awayTeam.name}`; +} + +/** `🔴 LIVE: Home 2-1 Away (67')` — a currently in-progress match. */ +export function matchEntryText(match: Match): string { + const { home, away } = goals(match); + const minuteSuffix = match.minute != null ? ` (${match.minute}')` : ""; + return `🔴 LIVE: ${match.homeTeam.name} ${home}-${away} ${match.awayTeam.name}${minuteSuffix}`; +} + +/** `⚽🎉 GOAL! Home — Home 1-0 Away` — appended the moment a goal is detected. */ +export function goalEntryText( + match: Match, + scoringSide: "home" | "away", +): string { + const { home, away } = goals(match); + const scorer = + scoringSide === "home" ? match.homeTeam.name : match.awayTeam.name; + return `⚽🎉 GOAL! ${scorer} — ${match.homeTeam.name} ${home}-${away} ${match.awayTeam.name}`; +} + +/** On-map `info.name` while one or more matches are live. */ +export function liveDisplayName(matches: Match[]): string { + if (matches.length === 1) { + const m = matches[0]; + const home = m.homeTeam.tla ?? m.homeTeam.shortName ?? m.homeTeam.name; + const away = m.awayTeam.tla ?? m.awayTeam.shortName ?? m.awayTeam.name; + const { home: h, away: a } = goals(m); + return `${NAME_PREFIX}⚽ ${home} ${h}-${a} ${away}`; + } + return `${NAME_PREFIX}⚽ ${matches.length} matches live`; +} + +/** `in 2h 15m` / `in 40m` / `starting soon`, relative to `now`. */ +export function countdownText(utcDate: string, now = Date.now()): string { + const diffMs = new Date(utcDate).getTime() - now; + if (diffMs <= 0) return "starting soon"; + const hours = Math.floor(diffMs / (60 * 60 * 1000)); + const minutes = Math.floor((diffMs % (60 * 60 * 1000)) / (60 * 1000)); + return hours > 0 ? `in ${hours}h ${minutes}m` : `in ${minutes}m`; +} + +/** `⏰ Next: ⚽ Home vs Away — in 2h 15m` — shown while idle, if a match is scheduled. */ +export function nextMatchEntryText(match: Match, now = Date.now()): string { + return `⏰ Next: ⚽ ${match.homeTeam.name} vs ${match.awayTeam.name} — ${countdownText(match.utcDate, now)}`; +} + +/** Fallback idle message when nothing is live and nothing is scheduled. */ +export const NO_MATCHES_TEXT = "No live World Cup matches right now"; diff --git a/packages/worldcup-live-feed/src/football-data.ts b/packages/worldcup-live-feed/src/football-data.ts new file mode 100644 index 0000000..5e38f24 --- /dev/null +++ b/packages/worldcup-live-feed/src/football-data.ts @@ -0,0 +1,107 @@ +/** + * Minimal client for the football-data.org v4 API, scoped to the World Cup + * competition (`WC`). Free tier: basic fixtures/results/tables, 10 req/min — + * callers are responsible for pacing their own polling. + * + * @module + */ + +const BASE_URL = "https://api.football-data.org/v4"; +const LIVE_STATUSES = new Set(["LIVE", "IN_PLAY", "PAUSED"]); + +export type Team = { + name: string; + tla?: string; + shortName?: string; +}; + +export type Match = { + id: number; + utcDate: string; + status: string; + minute?: number | null; + homeTeam: Team; + awayTeam: Team; + score: { + fullTime: { + home: number | null; + away: number | null; + }; + }; +}; + +/** GET a football-data.org path, retrying once on 429 per `Retry-After`. */ +async function footballDataFetch( + path: string, + apiToken: string, + // biome-ignore lint/suspicious/noExplicitAny: football-data.org's response shape isn't worth hand-typing in full +): Promise { + const res = await fetch(`${BASE_URL}${path}`, { + headers: { "X-Auth-Token": apiToken }, + }); + if (res.status === 429) { + const retryAfter = Number(res.headers.get("Retry-After") ?? "60") * 1000; + await new Promise((r) => setTimeout(r, retryAfter)); + return footballDataFetch(path, apiToken); + } + if (!res.ok) { + throw new Error( + `football-data.org error ${res.status}: ${await res.text()}`, + ); + } + return res.json(); +} + +const byUtcDateAsc = (a: Match, b: Match) => + new Date(a.utcDate).getTime() - new Date(b.utcDate).getTime(); + +/** + * Currently in-progress World Cup matches (`LIVE`/`IN_PLAY`/`PAUSED`). + * + * football-data.org's match `status` field never actually takes the value + * `"LIVE"` (real values are `IN_PLAY`/`PAUSED`/`FINISHED`/etc) — that's only + * a filter shorthand on the cross-competition `/v4/matches` endpoint, not on + * `/v4/competitions/{id}/matches`. Querying `?status=LIVE` here server-side + * filters out real in-progress matches, so we fetch today's matches + * unfiltered and do the live check ourselves. + */ +export async function fetchLiveMatches( + apiToken: string, + now = Date.now(), +): Promise { + const today = new Date(now).toISOString().slice(0, 10); + const json = await footballDataFetch( + `/competitions/WC/matches?dateFrom=${today}&dateTo=${today}`, + apiToken, + ); + return json.matches.filter((m: Match) => LIVE_STATUSES.has(m.status)); +} + +/** Upcoming scheduled World Cup matches, soonest first. */ +export async function fetchUpcomingMatches( + apiToken: string, + now = Date.now(), +): Promise { + const today = new Date(now).toISOString().slice(0, 10); + // dateTo is required alongside dateFrom by the API; 60 days comfortably covers the tournament. + const dateTo = new Date(now + 60 * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + const json = await footballDataFetch( + `/competitions/WC/matches?status=SCHEDULED&dateFrom=${today}&dateTo=${dateTo}`, + apiToken, + ); + return (json.matches as Match[]).sort(byUtcDateAsc); +} + +/** Finished World Cup matches on a given `YYYY-MM-DD` date, earliest first. */ +export async function fetchResultsForDate( + apiToken: string, + dateStr: string, +): Promise { + const json = await footballDataFetch( + `/competitions/WC/matches?dateFrom=${dateStr}&dateTo=${dateStr}&status=FINISHED`, + apiToken, + ); + return (json.matches as Match[]).sort(byUtcDateAsc); +} diff --git a/packages/worldcup-live-feed/src/index.ts b/packages/worldcup-live-feed/src/index.ts new file mode 100644 index 0000000..527a28c --- /dev/null +++ b/packages/worldcup-live-feed/src/index.ts @@ -0,0 +1,260 @@ +#!/usr/bin/env -S npx tsx +/** + * worldcup-live-feed: polls football-data.org for World Cup matches and drives + * a `status` webhook object: + * - status: "working" when idle, "on" while any match is live, briefly + * "alert" on a goal (reverts to "on" after `--alert-seconds`) + * - info.name (on-map, no click needed): an abbreviated summary, e.g. + * "worldcup.town - ⚽ ESP 2-0 AUT", or "worldcup.town - ⚽ 3 matches live", + * or just "worldcup.town" when idle + * - activity feed (popover): "🔴 LIVE: ..." per live match, "⚽🎉 GOAL! ..." + * per goal, "⏰ Next: ..." countdown when idle, "🏆 FT: ..." the moment a + * match ends (same day) plus a daily batch for the previous day + * + * @module + */ +import { parseArgs } from "node:util"; +import { createWebhookObjectClient } from "@gathertown/webhook-object-sdk"; +import { + goalEntryText, + IDLE_DISPLAY_NAME, + IDLE_ENTRY_ID, + liveDisplayName, + matchEntryText, + NO_MATCHES_TEXT, + nextMatchEntryText, + resultEntryText, +} from "./entries"; +import { + fetchLiveMatches, + fetchResultsForDate, + fetchUpcomingMatches, + type Match, +} from "./football-data"; + +const NEXT_MATCH_REFRESH_MS = 5 * 60 * 1000; + +type MatchState = { + home: number; + away: number; + homeTeam: string; + awayTeam: string; +}; + +async function main() { + const { values } = parseArgs({ + options: { + url: { type: "string" }, + secret: { type: "string" }, + "football-token": { type: "string" }, + interval: { type: "string", default: "30" }, + "alert-seconds": { type: "string", default: "60" }, + }, + }); + const footballToken = + values["football-token"] ?? process.env.FOOTBALL_DATA_API_TOKEN; + if (!values.url || !values.secret || !footballToken) { + console.error( + "Usage: worldcup-live-feed --url --secret --football-token [--interval ] [--alert-seconds ]\n" + + "(--football-token can also come from the FOOTBALL_DATA_API_TOKEN env var; get one at https://www.football-data.org)", + ); + process.exit(1); + } + + const intervalMs = Number(values.interval) * 1000; + if (intervalMs < 6_000) { + console.error( + "--interval below 6s risks exceeding football-data.org's free-tier 10 req/min limit.", + ); + process.exit(1); + } + const alertMs = Number(values["alert-seconds"]) * 1000; + + const client = createWebhookObjectClient({ + url: values.url, + secret: values.secret, + }); + + console.log("pinging object..."); + const ping = await client.ping(); + if (ping.preset !== "status") { + console.error(`Expected a "status" preset object, got "${ping.preset}".`); + process.exit(1); + } + + // Seed from the object's actual current entries so a restart doesn't + // re-dispatch unchanged entries: "at" (the feed's sort key) is server-stamped + // from dispatch time, so a needless re-dispatch bumps an entry out of its + // true chronological place relative to genuinely newer ones. + // The SDK leaves ping capabilities untyped (Record), so + // narrow the one slice we read. + const activityState = ping.capabilities.activity as + | { entries?: { id: string; text: string }[] } + | undefined; + const knownEntryText = new Map( + (activityState?.entries ?? []).map((e) => [e.id, e.text]), + ); + const dispatchActivity = async (id: string, text: string) => { + if (knownEntryText.get(id) === text) return; + await client.send("activity.add", { id, text }); + knownEntryText.set(id, text); + }; + const removeActivity = async (id: string) => { + try { + await client.send("activity.remove", { id }); + knownEntryText.delete(id); + } catch { + // already gone — fine + } + }; + + let wasLive: boolean | null = null; // null = not yet polled, forces the first tick to sync state + let lastDisplayName: string | null = null; + let resultsDate: string | null = null; // last calendar date (YYYY-MM-DD) we fetched previous-day results for + const lastMatchState = new Map(); + let nextMatchCache: Match | null = null; + let nextMatchFetchedAt = 0; + + const updatePreviousDayResults = async () => { + const today = new Date().toISOString().slice(0, 10); + if (resultsDate === today) return; + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + console.log(`fetching previous day's results (${yesterday})...`); + const matches = await fetchResultsForDate(footballToken, yesterday); + for (const match of matches) { + await dispatchActivity(`result-${match.id}`, resultEntryText(match)); + } + resultsDate = today; + }; + + const getNextMatch = async () => { + if (Date.now() - nextMatchFetchedAt < NEXT_MATCH_REFRESH_MS) { + return nextMatchCache; + } + console.log("fetching next scheduled match..."); + const upcoming = await fetchUpcomingMatches(footballToken); + nextMatchCache = upcoming[0] ?? null; + nextMatchFetchedAt = Date.now(); + return nextMatchCache; + }; + + const poll = async () => { + try { + await updatePreviousDayResults(); + console.log("polling football-data.org..."); + const liveMatches = await fetchLiveMatches(footballToken); + console.log(`found ${liveMatches.length} live match(es)`); + const live = liveMatches.length > 0; + + // matches present in lastMatchState but no longer live just finished + const currentIds = new Set(liveMatches.map((m) => m.id)); + for (const [id, state] of lastMatchState) { + if (currentIds.has(id)) continue; + console.log(`match ${id} ended -> converting to result entry`); + await removeActivity(String(id)); + await dispatchActivity( + `result-${id}`, + `🏆 FT: ${state.homeTeam} ${state.home}-${state.away} ${state.awayTeam}`, + ); + lastMatchState.delete(id); + } + + if (!live) { + if (wasLive !== false) { + console.log("no live matches -> showing idle message"); + await client.send("status.set", { state: "working" }); + await client.send("info.set", { name: IDLE_DISPLAY_NAME }); + } + const nextMatch = await getNextMatch(); + const idleText = nextMatch + ? nextMatchEntryText(nextMatch) + : NO_MATCHES_TEXT; + await dispatchActivity(IDLE_ENTRY_ID, idleText); + wasLive = false; + lastDisplayName = IDLE_DISPLAY_NAME; + return; + } + + await removeActivity(IDLE_ENTRY_ID); + + let scoredAny = false; + for (const match of liveMatches) { + const homeGoals = match.score.fullTime.home ?? 0; + const awayGoals = match.score.fullTime.away ?? 0; + const prev = lastMatchState.get(match.id); + if (prev) { + // Both teams can score, or one team can score twice, between + // polls — emit one entry per goal keyed on that side's new tally. + for (let n = prev.home + 1; n <= homeGoals; n++) { + scoredAny = true; + await dispatchActivity( + `goal-${match.id}-home-${n}`, + goalEntryText(match, "home"), + ); + } + for (let n = prev.away + 1; n <= awayGoals; n++) { + scoredAny = true; + await dispatchActivity( + `goal-${match.id}-away-${n}`, + goalEntryText(match, "away"), + ); + } + } + + await dispatchActivity(String(match.id), matchEntryText(match)); + + lastMatchState.set(match.id, { + home: homeGoals, + away: awayGoals, + homeTeam: match.homeTeam.name, + awayTeam: match.awayTeam.name, + }); + } + + if (!wasLive) { + await client.send("status.set", { state: "on" }); + } + if (scoredAny) { + await client.send("status.set", { state: "alert" }); + setTimeout(() => { + // If the match ended during the alert window we're now idle + // (`working`); don't clobber that back to `on`. + if (!wasLive) return; + client.send("status.set", { state: "on" }).catch(() => {}); + }, alertMs); + } + + const displayName = liveDisplayName(liveMatches); + if (displayName !== lastDisplayName) { + console.log(`updating display name -> ${displayName}`); + await client.send("info.set", { name: displayName }); + } + + wasLive = true; + lastDisplayName = displayName; + } catch (err) { + console.error("poll failed:", err instanceof Error ? err.message : err); + } + }; + + console.log( + `Watching World Cup matches every ${values.interval}s. Ctrl+C to stop.`, + ); + // Self-scheduling loop (not setInterval) so a slow poll can never let the + // next tick start mid-flight. + let timer: ReturnType; + const tick = async () => { + await poll(); + timer = setTimeout(tick, intervalMs); + }; + process.on("SIGINT", () => { + clearTimeout(timer); + console.log("\nStopped."); + process.exit(0); + }); + tick(); +} + +main(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7bb14f..fd4e5a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,9 +1,5 @@ lockfileVersion: '9.0' -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - importers: .: @@ -83,6 +79,12 @@ importers: specifier: workspace:* version: link:../client + packages/worldcup-live-feed: + dependencies: + '@gathertown/webhook-object-sdk': + specifier: ^0.1.1 + version: 0.1.1 + packages/z-build-config: {} packages: @@ -333,6 +335,13 @@ packages: cpu: [x64] os: [win32] + '@gathertown/webhook-object-sdk@0.1.1': + resolution: {integrity: sha512-lKy/S8bA1Ux0+RAlQAEn6K5FCmQrdIF3QwE+31OBPUAsOqdFeUbb9xvrSuyus2+nqL/JOIQU3QFcFXuHxDLoiA==} + engines: {node: '>=18'} + + '@gathertown/webhook-object-types@0.1.1': + resolution: {integrity: sha512-jJBooZyBW/lRHwY7njRWjr2y1oaZEWBW719sZElV3ipv2a0EymR/AZZtW8jJrkVWP+OhuVSb7EUHVEE0dMb9mA==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -1087,6 +1096,13 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@gathertown/webhook-object-sdk@0.1.1': + dependencies: + '@gathertown/webhook-object-types': 0.1.1 + standardwebhooks: 1.0.0 + + '@gathertown/webhook-object-types@0.1.1': {} + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fec54ef..bf128ae 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,8 @@ minimumReleaseAge: 10080 minimumReleaseAgeExclude: - vite + - "@gathertown/webhook-object-sdk" + - "@gathertown/webhook-object-types" gitBranchLockfile: true enableGlobalVirtualStore: true mergeGitBranchLockfilesBranchPattern: