diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2314932..aa066e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,9 +23,6 @@ jobs: - name: Install dependencies run: pnpm install --merge-git-branch-lockfiles - - name: Install Playwright Chromium - run: pnpm exec playwright install --with-deps chromium - - name: Lint run: pnpm lint diff --git a/package.json b/package.json index 3429af3..6e7ad8d 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,6 @@ "vite": "^8.1", "vite-plugin-node": "^8.0", "vitest": "^4.1", - "@vitest/coverage-v8": "4.1.9", - "@vitest/browser": "^4.1.9", - "@vitest/browser-playwright": "^4.1.9", - "playwright": "^1.61.0" + "@vitest/coverage-v8": "4.1.9" } } diff --git a/packages/claude-status/README.md b/packages/claude-status/README.md index 25d970b..9fa29cb 100644 --- a/packages/claude-status/README.md +++ b/packages/claude-status/README.md @@ -29,7 +29,7 @@ node packages/claude-status/dist/index.js uninstall-hooks `install-hooks` registers a command on a handful of Claude hook events. On each event Claude runs the bundled `hook-entry.js` under bare `node`, which maps the -event to a status and signs & POSTs it via `@webhook-objects/client`: +event to a status and signs & POSTs it via [`@gathertown/webhook-object-sdk`](https://www.npmjs.com/package/@gathertown/webhook-object-sdk): | hook event | status | | ------------------ | ---------- | diff --git a/packages/claude-status/package.json b/packages/claude-status/package.json index d51668f..65c64e4 100644 --- a/packages/claude-status/package.json +++ b/packages/claude-status/package.json @@ -23,9 +23,10 @@ } ], "dependencies": { - "@webhook-objects/client": "workspace:*" + "@gathertown/webhook-object-sdk": "^0.1.1" }, "devDependencies": { + "@gathertown/webhook-object-types": "^0.1.1", "@webhook-objects/z-build-config": "workspace:*" } } diff --git a/packages/claude-status/src/hook-entry.ts b/packages/claude-status/src/hook-entry.ts index a203af3..59bcc6a 100644 --- a/packages/claude-status/src/hook-entry.ts +++ b/packages/claude-status/src/hook-entry.ts @@ -11,7 +11,7 @@ * * @module */ -import { Client } from "@webhook-objects/client/node"; +import { createWebhookObjectClient } from "@gathertown/webhook-object-sdk"; import { eventToState } from "./hook"; const SEND_TIMEOUT_MS = 3000; @@ -40,20 +40,14 @@ const secret = flag("secret"); if (state && url && secret) { try { - // Use the global fetch (Node 18+) so undici is never imported. - const client = new Client({ + // The timeout signal covers the whole send, retries and backoff included, + // so a down/slow receiver can never hold the hook process open. + const client = createWebhookObjectClient({ url, secret, - fetchImpl: (input, init) => fetch(input, init), + signal: AbortSignal.timeout(SEND_TIMEOUT_MS), }); - await client.send( - { - type: "status.set", - timestamp: new Date().toISOString(), - data: { state }, - }, - { signal: AbortSignal.timeout(SEND_TIMEOUT_MS) }, - ); + await client.send("status.set", { state }); } catch { // Best effort only. } diff --git a/packages/claude-status/src/hook.ts b/packages/claude-status/src/hook.ts index c64a224..25f5817 100644 --- a/packages/claude-status/src/hook.ts +++ b/packages/claude-status/src/hook.ts @@ -12,7 +12,7 @@ * * @module */ -import type { StatusState } from "@webhook-objects/client/node"; +import type { StatusSetDataState as StatusState } from "@gathertown/webhook-object-types"; /** The hook events we register, in `settings.json` order. */ export const HOOK_EVENTS = [ diff --git a/packages/claude-status/tsconfig.lib.json b/packages/claude-status/tsconfig.lib.json index 7f66392..2c0a768 100644 --- a/packages/claude-status/tsconfig.lib.json +++ b/packages/claude-status/tsconfig.lib.json @@ -1,6 +1,8 @@ { "extends": "../z-build-config/ts/tsconfig.dom.json", - "compilerOptions": {}, + "compilerOptions": { + "types": ["node"] + }, "include": ["src/**/*.ts"], "exclude": ["src/**/*.spec.ts"] } diff --git a/packages/claude-status/vite.config.ts b/packages/claude-status/vite.config.ts index 05c362c..c282dc7 100644 --- a/packages/claude-status/vite.config.ts +++ b/packages/claude-status/vite.config.ts @@ -14,13 +14,11 @@ export default defineConfig({ }, rolldownOptions: { // Bundle everything except node builtins so the CLI is a self-contained - // artifact (the client and its deps like standardwebhooks live in other - // packages' node_modules and aren't resolvable from ours at runtime). - // undici stays external: it's the client's optional fetch backend (615kB) - // and we use the global `fetch` instead, so it's never imported. + // artifact (the SDK and its standardwebhooks dep live in node_modules, + // which isn't resolvable from dist at runtime). // index.ts keeps its own `#!/usr/bin/env node`; hook-entry.js is always // run via an explicit `node`. - external: (id) => isBuiltin(id) || id === "undici", + external: (id) => isBuiltin(id), }, }, test: { diff --git a/packages/client/README.md b/packages/client/README.md deleted file mode 100644 index 0fe904d..0000000 --- a/packages/client/README.md +++ /dev/null @@ -1 +0,0 @@ -TODO: Remove in favor of 1P SDK. diff --git a/packages/client/package.json b/packages/client/package.json deleted file mode 100644 index 5e75c65..0000000 --- a/packages/client/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "@webhook-objects/client", - "version": "0.1.0-beta.1", - "type": "module", - "files": [ - "dist" - ], - "scripts": { - "test": "vitest run --coverage", - "build": "vite build && tsc -p tsconfig.lib.types.json" - }, - "exports": { - ".": { - "types": "./dist/types/browser.d.ts", - "browser": "./dist/browser.js", - "node": "./dist/node.js", - "default": "./dist/browser.js" - }, - "./browser": { - "types": "./dist/types/browser.d.ts", - "default": "./dist/browser.js" - }, - "./node": { - "types": "./dist/types/node.d.ts", - "default": "./dist/node.js" - }, - "./objects": { - "types": "./dist/types/objects/index.d.ts", - "default": "./dist/objects.js" - } - }, - "licenses": [ - { - "type": "Apache-2.0" - }, - { - "type": "MIT" - } - ], - "dependencies": { - "standardwebhooks": "^1.0.0" - }, - "peerDependencies": { - "undici": ">=8" - }, - "peerDependenciesMeta": { - "undici": { - "optional": true - } - }, - "devDependencies": { - "@webhook-objects/z-build-config": "workspace:*", - "undici": "^8.5.0" - } -} diff --git a/packages/client/src/browser.spec.ts b/packages/client/src/browser.spec.ts deleted file mode 100644 index a07884c..0000000 --- a/packages/client/src/browser.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Client as BrowserClient, PRESET_NAMES } from "./browser"; -import { Client as BaseClient } from "./client"; -import type { WebhookEvent } from "./objects/events"; - -const SECRET = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"; -const URL_ = new URL("https://example.com/hook"); - -const EVENT: WebhookEvent = { - type: "switch.toggle", - timestamp: "2026-06-29T00:00:00.000Z", - data: {}, -}; - -describe("browser entry", () => { - it("re-exports the base Client unchanged", () => { - expect(BrowserClient).toBe(BaseClient); - }); - - it("re-exports the object catalog", () => { - expect(PRESET_NAMES).toContain("inbox"); - }); - - it("sends using the global fetch by default", async () => { - const globalFetch = vi.fn( - async () => new Response(JSON.stringify({ status: "dispatched" })), - ); - vi.stubGlobal("fetch", globalFetch); - - const client = new BrowserClient({ url: URL_, secret: SECRET }); - await client.send(EVENT); - - expect(globalFetch).toHaveBeenCalledOnce(); - vi.unstubAllGlobals(); - }); -}); diff --git a/packages/client/src/browser.ts b/packages/client/src/browser.ts deleted file mode 100644 index cb18dc3..0000000 --- a/packages/client/src/browser.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Browser entry point (`@webhook-objects/client/browser`). The base - * {@link Client} already defaults to the global `fetch`, which is always - * present in browsers, so no environment-specific wiring is needed — this - * module simply re-exports the public API. - * - * @module - */ -export * from "./client"; -export * from "./objects"; diff --git a/packages/client/src/client.spec.ts b/packages/client/src/client.spec.ts deleted file mode 100644 index 72b636a..0000000 --- a/packages/client/src/client.spec.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { Webhook } from "standardwebhooks"; -import { Client, type FetchImpl } from "./client"; -import type { WebhookEvent } from "./objects/events"; - -// Canonical Standard Webhooks test secret (valid base64 after the `whsec_` prefix). -const SECRET = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"; -const URL_ = new URL("https://example.com/hook"); - -const EVENT: WebhookEvent = { - type: "counter.set", - timestamp: "2026-06-29T00:00:00.000Z", - data: { count: 5 }, -}; - -const jsonResponse = (body: unknown, init?: ResponseInit): Response => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - ...init, - }); - -/** A `fetchImpl` mock that resolves to `response` (or a default 200 body). */ -const mockFetch = (response?: Response) => - vi.fn( - async () => response ?? jsonResponse({ status: "dispatched" }), - ); - -/** Pull the `(url, init)` a `fetchImpl` mock was called with, with headers typed. */ -const lastCall = (fetchImpl: ReturnType) => { - const [url, init] = fetchImpl.mock.calls[0]; - return { - url, - init, - headers: (init?.headers ?? {}) as Record, - body: init?.body as string, - }; -}; - -/** Run `fn`, expecting it to reject, and return the thrown error. */ -const captureError = async (fn: () => Promise): Promise => { - try { - await fn(); - } catch (error) { - return error as Error; - } - throw new Error("expected the promise to reject"); -}; - -afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); - -describe("Client.send", () => { - it("POSTs to the configured URL", async () => { - const fetchImpl = mockFetch(); - const client = new Client({ url: URL_, secret: SECRET, fetchImpl }); - - await client.send(EVENT); - - const { url, init } = lastCall(fetchImpl); - expect(fetchImpl).toHaveBeenCalledOnce(); - expect(url).toBe(URL_); - expect(init?.method).toBe("POST"); - }); - - it("serializes the event as the JSON body", async () => { - const fetchImpl = mockFetch(); - const client = new Client({ url: URL_, secret: SECRET, fetchImpl }); - - await client.send(EVENT); - - expect(lastCall(fetchImpl).body).toBe(JSON.stringify(EVENT)); - }); - - it("sets the Standard Webhooks headers", async () => { - const fetchImpl = mockFetch(); - const client = new Client({ - url: URL_, - secret: SECRET, - fetchImpl, - idImpl: () => "msg_test", - }); - - await client.send(EVENT); - - const { headers } = lastCall(fetchImpl); - expect(headers["Content-Type"]).toBe("application/json"); - expect(headers["webhook-id"]).toBe("msg_test"); - expect(headers["webhook-timestamp"]).toMatch(/^\d+$/); - expect(headers["webhook-signature"]).toMatch(/^v1,/); - }); - - it("stamps the timestamp as Unix seconds", async () => { - const fetchImpl = mockFetch(); - const client = new Client({ url: URL_, secret: SECRET, fetchImpl }); - - await client.send(EVENT); - - const ts = Number(lastCall(fetchImpl).headers["webhook-timestamp"]); - expect(ts).toBeCloseTo(Math.floor(Date.now() / 1000), -1); - }); - - it("produces a signature that verifies against the secret", async () => { - const fetchImpl = mockFetch(); - const client = new Client({ url: URL_, secret: SECRET, fetchImpl }); - - await client.send(EVENT); - - const { headers, body } = lastCall(fetchImpl); - const verified = new Webhook(SECRET).verify(body, { - "webhook-id": headers["webhook-id"], - "webhook-timestamp": headers["webhook-timestamp"], - "webhook-signature": headers["webhook-signature"], - }); - expect(verified).toEqual(EVENT); - }); - - it("returns the parsed JSON body on success", async () => { - const fetchImpl = mockFetch(jsonResponse({ status: "space_idle" })); - const client = new Client({ url: URL_, secret: SECRET, fetchImpl }); - - await expect(client.send(EVENT)).resolves.toEqual({ status: "space_idle" }); - }); - - it("uses a unique id per call from the default idImpl", async () => { - const fetchImpl = mockFetch(); - const client = new Client({ url: URL_, secret: SECRET, fetchImpl }); - - await client.send(EVENT); - await client.send(EVENT); - - const ids = fetchImpl.mock.calls.map( - ([, init]) => (init?.headers as Record)["webhook-id"], - ); - expect(ids[0]).toMatch(/^msg_/); - expect(ids[1]).toMatch(/^msg_/); - expect(ids[0]).not.toBe(ids[1]); - }); - - it("merges caller-provided RequestInit but forces method and signing headers", async () => { - const fetchImpl = mockFetch(); - const client = new Client({ - url: URL_, - secret: SECRET, - fetchImpl, - idImpl: () => "msg_real", - }); - - await client.send(EVENT, { - cache: "no-store", - headers: { "X-Custom": "1", "webhook-id": "spoofed" }, - } as Parameters[1]); - - const { init, headers } = lastCall(fetchImpl); - expect(init?.cache).toBe("no-store"); - expect(init?.method).toBe("POST"); - expect(headers["X-Custom"]).toBe("1"); - // Client-controlled headers win over caller-supplied ones. - expect(headers["webhook-id"]).toBe("msg_real"); - }); - - it("preserves caller headers passed as a Headers instance", async () => { - const fetchImpl = mockFetch(); - const client = new Client({ url: URL_, secret: SECRET, fetchImpl }); - - await client.send(EVENT, { - headers: new Headers({ "x-custom": "1" }), - }); - - const { headers } = lastCall(fetchImpl); - expect(headers["x-custom"]).toBe("1"); - expect(headers["webhook-signature"]).toMatch(/^v1,/); - }); - - it("throws with the status and response cause on a non-OK response", async () => { - const failure = jsonResponse({ error: "invalid_args" }, { status: 400 }); - const fetchImpl = mockFetch(failure); - const client = new Client({ url: URL_, secret: SECRET, fetchImpl }); - - const error = await captureError(() => client.send(EVENT)); - expect(error).toBeInstanceOf(Error); - expect(error.message).toContain("Unexpected response status: 400"); - expect((error.cause as { response: Response }).response.status).toBe(400); - }); - - it("throws with the response cause when the body is not JSON", async () => { - const malformed = new Response("not-json", { - status: 200, - headers: { "content-type": "text/plain" }, - }); - const fetchImpl = mockFetch(malformed); - const client = new Client({ url: URL_, secret: SECRET, fetchImpl }); - - const error = await captureError(() => client.send(EVENT)); - expect(error).toBeInstanceOf(Error); - expect(error.message).toContain("Unexpected response body"); - expect((error.cause as { response: Response }).response).toBe(malformed); - }); -}); - -describe("Client.requestMetadata", () => { - const PONG = { - status: "pong", - objectId: "obj_1", - spaceId: "space_1", - preset: "inbox", - capabilities: {}, - }; - - it("POSTs a webhook.ping event with no timestamp or data", async () => { - const fetchImpl = mockFetch(jsonResponse(PONG)); - const client = new Client({ - url: URL_, - secret: SECRET, - fetchImpl, - idImpl: () => "msg_ping", - }); - - await client.requestMetadata(); - - const { url, init, headers, body } = lastCall(fetchImpl); - expect(url).toBe(URL_); - expect(init?.method).toBe("POST"); - expect(JSON.parse(body)).toEqual({ type: "webhook.ping" }); - expect(headers["webhook-id"]).toBe("msg_ping"); - expect(headers["webhook-signature"]).toMatch(/^v1,/); - }); - - it("returns the parsed pong body", async () => { - const fetchImpl = mockFetch(jsonResponse(PONG)); - const client = new Client({ url: URL_, secret: SECRET, fetchImpl }); - - await expect(client.requestMetadata()).resolves.toEqual(PONG); - }); - - it("produces a signature that verifies against the secret", async () => { - const fetchImpl = mockFetch(jsonResponse(PONG)); - const client = new Client({ url: URL_, secret: SECRET, fetchImpl }); - - await client.requestMetadata(); - - const { headers, body } = lastCall(fetchImpl); - const verified = new Webhook(SECRET).verify(body, { - "webhook-id": headers["webhook-id"], - "webhook-timestamp": headers["webhook-timestamp"], - "webhook-signature": headers["webhook-signature"], - }); - expect(verified).toEqual({ type: "webhook.ping" }); - }); - - it("throws with the status and response cause on a non-OK response", async () => { - const failure = jsonResponse({ error: "not_found" }, { status: 404 }); - const fetchImpl = mockFetch(failure); - const client = new Client({ url: URL_, secret: SECRET, fetchImpl }); - - const error = await captureError(() => client.requestMetadata()); - expect(error.message).toContain("Unexpected response status: 404"); - expect((error.cause as { response: Response }).response.status).toBe(404); - }); -}); - -describe("Client fetch resolution", () => { - it("falls back to the global fetch when no fetchImpl is supplied", async () => { - const globalFetch = mockFetch(); - vi.stubGlobal("fetch", globalFetch); - - const client = new Client({ url: URL_, secret: SECRET }); - await client.send(EVENT); - - expect(globalFetch).toHaveBeenCalledOnce(); - }); - - it("throws at construction when no fetch is available", () => { - vi.stubGlobal("fetch", undefined); - - expect(() => new Client({ url: URL_, secret: SECRET })).toThrow( - /No global `fetch` is available/, - ); - }); - - it("prefers an explicit fetchImpl over the global fetch", async () => { - const globalFetch = mockFetch(); - vi.stubGlobal("fetch", globalFetch); - const explicit = mockFetch(); - - const client = new Client({ - url: URL_, - secret: SECRET, - fetchImpl: explicit, - }); - await client.send(EVENT); - - expect(explicit).toHaveBeenCalledOnce(); - expect(globalFetch).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts deleted file mode 100644 index 2990501..0000000 --- a/packages/client/src/client.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * The environment-agnostic webhook client. Signs each request with - * {@link https://www.standardwebhooks.com | Standard Webhooks}, `POST`s it to - * the configured object URL, and parses the typed response body. - * - * @module - */ -import { Webhook, type WebhookOptions } from "standardwebhooks"; -import { - PING_EVENT_TYPE, - type PingEvent, - type PingResponseBody, - type WebhookEvent, - type WebhookEventResponseBody, -} from "./objects"; - -/** - * A {@link RequestInit} with the fields the client owns removed. Callers may - * customize everything except `body`, `method`, and `window` (those are set by - * the client when dispatching). - */ -export type MutableRequestInit = Omit< - RequestInit, - keyof Pick ->; - -/** - * A `fetch`-compatible function. Matches the global `fetch` signature so either - * the platform global or `undici`'s implementation can be supplied. - */ -export type FetchImpl = ( - input: URL | RequestInfo, - init?: RequestInit | undefined, -) => Promise; - -/** Produces the `webhook-id` for an outbound request. Must be unique per call. */ -export type IdImpl = () => string; - -/** Construction options for {@link Client}. */ -export interface ClientOptions { - /** The object's webhook receiver URL (string is parsed to a `URL`). */ - url: URL | string; - /** Standard Webhooks signing secret (the `whsec_...` value). */ - secret: string; - - /** Options forwarded to the underlying `standardwebhooks` `Webhook`. */ - signOptions?: WebhookOptions; - /** - * Fetch implementation to use. Defaults to the global `fetch` (available in - * browsers and Node 18+). Provide a custom implementation to override it — e.g. - * import from `@webhook-objects/client/node` for an undici-backed default. - */ - fetchImpl?: FetchImpl; - /** - * Override the `webhook-id` generator. Defaults to {@link createSimpleIdGenerator}. - */ - idImpl?: IdImpl; -} - -/** - * Resolve the platform global `fetch`, bound to `globalThis`. - * - * @returns The global `fetch` as a {@link FetchImpl}. - * @throws {Error} When no global `fetch` is available. - */ -const resolveGlobalFetch = (): FetchImpl => { - if (typeof globalThis.fetch !== "function") { - throw new Error( - "No global `fetch` is available. Pass `fetchImpl` in ClientOptions, or import the client from `@webhook-objects/client/node`.", - ); - } - return globalThis.fetch.bind(globalThis); -}; - -/** - * Build a simple, collision-resistant id generator. Each call returns a value - * of the form `` `msg_${timestamp}_${counter}` ``; the process-local counter - * guarantees uniqueness even for sends within the same millisecond. - * - * @returns An {@link IdImpl}. - */ -export function createSimpleIdGenerator() { - let idCounter = 0; - - return () => `msg_${Date.now()}_${idCounter++}`; -} - -/** The default {@link IdImpl} used when none is supplied in {@link ClientOptions}. */ -const defaultIdImpl = createSimpleIdGenerator(); - -/** - * Normalize any `HeadersInit` (record, entries array, or `Headers`) to a plain - * record, so caller-supplied headers merge correctly regardless of form. - * - * @param headers - The caller's headers, in any `HeadersInit` form. - * @returns A plain `Record` (empty when `headers` is absent). - */ -const toHeaderRecord = (headers?: HeadersInit): Record => { - if (!headers) { - return {}; - } - if (headers instanceof Headers) { - return Object.fromEntries(headers.entries()); - } - if (Array.isArray(headers)) { - return Object.fromEntries(headers); - } - return { ...headers }; -}; - -/** - * The response body shape for a given outbound event: a {@link PingEvent} - * resolves to {@link PingResponseBody}, any other event to - * {@link WebhookEventResponseBody}. - * - * @typeParam E - The outbound event type. - */ -type SendResult = E extends PingEvent - ? PingResponseBody - : WebhookEventResponseBody; - -/** - * Signs and dispatches webhook events to a single object's receiver URL. - * - * @example - * const client = new Client({ url, secret: "whsec_..." }); - * await client.send({ type: "counter.set", timestamp: new Date().toISOString(), data: { count: 1 } }); - */ -export class Client { - /** Parsed receiver URL. */ - private readonly url: URL; - /** Signer used to produce Standard Webhooks signatures. */ - private readonly webhook: Webhook; - /** Fetch implementation used to dispatch requests. */ - private readonly fetchImpl: FetchImpl; - /** Generator for the per-request `webhook-id`. */ - private readonly idImpl: IdImpl; - - /** - * @param options - See {@link ClientOptions}. - * @throws {Error} When no `fetchImpl` is given and no global `fetch` exists. - */ - constructor(options: ClientOptions) { - this.url = - typeof options.url === "string" ? new URL(options.url) : options.url; - this.webhook = new Webhook(options.secret, options.signOptions); - this.fetchImpl = options.fetchImpl ?? resolveGlobalFetch(); - this.idImpl = options.idImpl ?? defaultIdImpl; - } - - /** - * Send a `webhook.ping` probe and return the object's metadata. - * - * @returns The parsed {@link PingResponseBody} ("pong") describing the - * object's preset and declared capability state. - * @throws {Error} On a non-2xx response or an unparseable body. - */ - async requestMetadata(): Promise { - return this.sendInternal({ - type: PING_EVENT_TYPE, - }); - } - - /** - * Sign and dispatch a capability event. - * - * @param event - The {@link WebhookEvent} to send. Its `type` determines the - * required `data` shape. - * @param init - Optional request overrides (headers, `signal`, etc.); `body`, - * `method`, and `window` are controlled by the client and cannot be set. - * @returns The parsed {@link WebhookEventResponseBody} on success. - * @throws {Error} On a non-2xx response or an unparseable body; `error.cause` - * carries the originating `response` (and `error` for parse failures). - */ - async send( - event: WebhookEvent, - init?: MutableRequestInit, - ): Promise { - return this.sendInternal(event, init); - } - - /** - * Core send routine shared by {@link send} and {@link requestMetadata}: - * serializes the event, signs it, dispatches via {@link fetchImpl}, and - * parses the JSON body. - * - * @typeParam E - The outbound event type, which determines the return type. - * @param event - The event to send. - * @param init - Optional request overrides (see {@link send}). - * @returns The parsed response body, typed per {@link SendResult}. - * @throws {Error} On a non-2xx response or an unparseable body. - */ - private async sendInternal( - event: E, - init?: MutableRequestInit, - ): Promise> { - const id = this.idImpl(); - const timestamp = new Date(); - const body = JSON.stringify(event); - - const signature = this.webhook.sign(id, timestamp, body); - - const response = await this.fetchImpl(this.url, { - ...init, - method: "POST", - body: body, - headers: { - ...toHeaderRecord(init?.headers), - "Content-Type": "application/json", - "webhook-id": id, - "webhook-timestamp": Math.floor(timestamp.getTime() / 1000).toString(), - "webhook-signature": signature, - }, - }); - - if (!response.ok) { - throw new Error(`Unexpected response status: ${response.status}`, { - cause: { - response, - }, - }); - } - - try { - const json = await response.json(); - - // TODO: we could do shape validation here, if we want to be extra strict - return json as SendResult; - } catch (error) { - throw new Error( - `Unexpected response body: ${error instanceof Error ? error.message : String(error)}`, - { - cause: { - error, - response, - }, - }, - ); - } - } -} diff --git a/packages/client/src/node-fallback.spec.ts b/packages/client/src/node-fallback.spec.ts deleted file mode 100644 index df92140..0000000 --- a/packages/client/src/node-fallback.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { WebhookEvent } from "./objects/events"; - -// Simulate `undici` not being installed: its lazy `import("undici")` rejects. -vi.mock("undici", () => { - throw new Error("Cannot find module 'undici'"); -}); - -const SECRET = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"; -const URL_ = new URL("https://example.com/hook"); - -const EVENT: WebhookEvent = { - type: "switch.toggle", - timestamp: "2026-06-29T00:00:00.000Z", - data: {}, -}; - -beforeEach(() => { - // Reset the module-level fetch cache in node.ts between cases. - vi.resetModules(); -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe("node entry — undici unavailable", () => { - it("falls back to the global fetch", async () => { - const globalFetch = vi.fn( - async () => new Response(JSON.stringify({ status: "dispatched" })), - ); - vi.stubGlobal("fetch", globalFetch); - - const { Client } = await import("./node"); - const client = new Client({ url: URL_, secret: SECRET }); - - await expect(client.send(EVENT)).resolves.toEqual({ status: "dispatched" }); - expect(globalFetch).toHaveBeenCalledOnce(); - }); - - it("throws when neither undici nor a global fetch is available", async () => { - vi.stubGlobal("fetch", undefined); - - const { Client } = await import("./node"); - const client = new Client({ url: URL_, secret: SECRET }); - - await expect(client.send(EVENT)).rejects.toThrow( - /No fetch implementation available/, - ); - }); -}); diff --git a/packages/client/src/node.spec.ts b/packages/client/src/node.spec.ts deleted file mode 100644 index 35beabd..0000000 --- a/packages/client/src/node.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { FetchImpl } from "./client"; -import type { WebhookEvent } from "./objects/events"; - -// Mock undici so the Node entry's lazy `import("undici")` resolves without a real network call. -const { undiciFetch } = vi.hoisted(() => ({ undiciFetch: vi.fn() })); -vi.mock("undici", () => ({ fetch: undiciFetch })); - -import { Client } from "./node"; - -const SECRET = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"; -const URL_ = new URL("https://example.com/hook"); - -const EVENT: WebhookEvent = { - type: "switch.toggle", - timestamp: "2026-06-29T00:00:00.000Z", - data: {}, -}; - -beforeEach(() => { - undiciFetch.mockReset(); - undiciFetch.mockImplementation( - async () => new Response(JSON.stringify({ status: "dispatched" })), - ); -}); - -describe("node entry", () => { - it("extends the base Client", async () => { - const { Client: BaseClient } = await import("./client"); - expect(Object.getPrototypeOf(Client)).toBe(BaseClient); - }); - - it("defaults to undici's fetch and caches it across calls", async () => { - const client = new Client({ url: URL_, secret: SECRET }); - - await expect(client.send(EVENT)).resolves.toEqual({ status: "dispatched" }); - await expect(client.send(EVENT)).resolves.toEqual({ status: "dispatched" }); - expect(undiciFetch).toHaveBeenCalledTimes(2); - }); - - it("allows overriding fetchImpl", async () => { - const explicit = vi.fn( - async () => new Response(JSON.stringify({ status: "space_idle" })), - ); - const client = new Client({ - url: URL_, - secret: SECRET, - fetchImpl: explicit, - }); - - await expect(client.send(EVENT)).resolves.toEqual({ status: "space_idle" }); - expect(explicit).toHaveBeenCalledOnce(); - expect(undiciFetch).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/client/src/node.ts b/packages/client/src/node.ts deleted file mode 100644 index 78cb760..0000000 --- a/packages/client/src/node.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Node entry point (`@webhook-objects/client/node`). Re-exports the full public - * API and overrides the default `fetch` to use `undici` when available. - * - * @module - */ -import { - Client as BaseClient, - type ClientOptions, - type FetchImpl, -} from "./client"; - -export type { - ClientOptions, - FetchImpl, - IdImpl, - MutableRequestInit, -} from "./client"; -export * from "./objects"; - -/** Memoized resolved fetch, so `undici` is imported at most once. */ -let cachedFetch: FetchImpl | undefined; - -/** - * Resolve a Node fetch implementation lazily, so `undici` is only loaded at - * runtime in Node and never pulled into a browser bundle. Falls back to the - * built-in global `fetch` (Node 18+) when `undici` isn't installed. - * - * @param input - Request input, forwarded to the resolved fetch. - * @param init - Request init, forwarded to the resolved fetch. - * @returns The fetch `Response`. - * @throws {Error} When neither `undici` nor a global `fetch` is available. - */ -const nodeFetch: FetchImpl = async (input, init) => { - if (cachedFetch === undefined) { - try { - const { fetch } = await import("undici"); - cachedFetch = fetch as unknown as FetchImpl; - } catch { - if (typeof globalThis.fetch !== "function") { - throw new Error( - "No fetch implementation available. Install `undici`, run on Node 18+, or pass a custom `fetchImpl`.", - ); - } - cachedFetch = globalThis.fetch.bind(globalThis); - } - } - return cachedFetch(input, init); -}; - -/** - * Node client. Identical to the base {@link BaseClient}, but defaults `fetchImpl` - * to undici's `fetch`. Callers may still pass their own `fetchImpl` to override it. - */ -export class Client extends BaseClient { - /** - * @param options - See {@link ClientOptions}. `fetchImpl` defaults to an - * undici-backed implementation (with a global-`fetch` fallback). - */ - constructor(options: ClientOptions) { - super({ ...options, fetchImpl: options.fetchImpl ?? nodeFetch }); - } -} diff --git a/packages/client/src/objects/capabilities.ts b/packages/client/src/objects/capabilities.ts deleted file mode 100644 index 69e6881..0000000 --- a/packages/client/src/objects/capabilities.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Capability primitives: the persisted state shapes, the method-argument - * payloads, and the helper types used to derive event/response types from them. - * - * A "capability" is a unit of behavior an object can expose (e.g. `counter`, - * `switch`). Each capability declares both the state it persists and the - * methods (with their argument payloads) that can be invoked against it. - * - * @module - */ - -/** Maximum length (in characters) accepted for an object's `name`. */ -export const WEBHOOK_OBJECT_NAME_MAX_LENGTH = 120; -/** Maximum length (in characters) accepted for an object's `description`. */ -export const WEBHOOK_OBJECT_DESCRIPTION_MAX_LENGTH = 2000; - -/** Maximum number of {@link ActivityEntry} items retained per object (ring buffer). */ -export const ACTIVITY_BUFFER_SIZE = 20; -/** Maximum length (in characters) accepted for an {@link ActivityEntry.id}. */ -export const ACTIVITY_ID_MAX_LENGTH = 128; -/** Maximum length (in characters) accepted for an {@link ActivityEntry.text}. */ -export const ACTIVITY_TEXT_MAX_LENGTH = 500; -/** Maximum length (in characters) accepted for an {@link ActivityEntry.url}. */ -export const ACTIVITY_URL_MAX_LENGTH = 2048; - -/** Every status value the `status` capability can hold, as a readonly tuple. */ -export const STATUS_STATES = [ - "off", - "on", - "question", - "alert", - "working", -] as const; - -/** A single status value — the union derived from {@link STATUS_STATES}. */ -export type StatusState = (typeof STATUS_STATES)[number]; - -/** Persisted state for the `info` capability: human-readable name/description. */ -export type InfoState = { - /** Display name. Capped at {@link WEBHOOK_OBJECT_NAME_MAX_LENGTH}. */ - name?: string; - /** Free-form description. Capped at {@link WEBHOOK_OBJECT_DESCRIPTION_MAX_LENGTH}. */ - description?: string; -}; - -/** Persisted state for the `counter` capability. `null` means "unset". */ -export type CounterState = { - /** Current count, or `null` when the counter has never been set. */ - count: number | null; -}; - -/** Persisted state for the `switch` capability. */ -export type SwitchState = { - /** Whether the switch is currently on. */ - on: boolean; -}; - -/** Persisted state for the `status` capability. */ -export type StatusCapabilityState = { - /** The active {@link StatusState}. */ - state: StatusState; -}; - -/** A single entry in an `activity` feed. */ -export type ActivityEntry = { - /** Caller-supplied stable identifier. Capped at {@link ACTIVITY_ID_MAX_LENGTH}. */ - id: string; - /** Creation time, in Unix milliseconds. */ - at: number; - /** Display text. Capped at {@link ACTIVITY_TEXT_MAX_LENGTH}. */ - text: string; - /** Optional link. Capped at {@link ACTIVITY_URL_MAX_LENGTH}. */ - url?: string; -}; - -/** Persisted state for the `activity` capability: a bounded list of entries. */ -export type ActivityState = { - /** Most-recent-first entries, capped at {@link ACTIVITY_BUFFER_SIZE}. */ - entries: ActivityEntry[]; -}; - -/** - * The argument payload (`data`) for every capability method, keyed first by - * capability name and then by method name. This is the single source of truth - * from which event payload types are derived. - * - * Methods that take no arguments use `Record` (an empty object). - */ -export interface CapabilityMethodArgs { - info: { - /** Update the object's name and/or description. */ - set: { - name?: string; - description?: string; - }; - }; - counter: { - /** Set the counter to an absolute value. */ - set: { - count: number; - }; - /** Add to the counter (defaults to `1` when `by` is omitted). */ - increment: { - by?: number; - }; - /** Clear the counter back to its unset state. Takes no arguments. */ - reset: Record; - }; - switch: { - /** Set the switch to an explicit on/off value. */ - set_state: { - on: boolean; - }; - /** Flip the switch. Takes no arguments. */ - toggle: Record; - }; - status: { - /** Set the active status value. */ - set: { - state: StatusState; - }; - /** Reset the status to its default. Takes no arguments. */ - reset: Record; - }; - activity: { - /** Append an entry to the feed. */ - add: { - id: string; - text: string; - url?: string; - }; - /** Remove a previously-added entry by id. */ - remove: { - id: string; - }; - /** Remove all entries. Takes no arguments. */ - clear: Record; - }; -} - -/** - * The persisted state shape for every capability, keyed by capability name. - * The keys of this interface also define the set of valid capability names. - */ -export interface CapabilityStates { - info: InfoState; - counter: CounterState; - switch: SwitchState; - status: StatusCapabilityState; - activity: ActivityState; -} - -/** Union of all capability names (e.g. `"info" | "counter" | ...`). */ -export type CapabilityName = keyof CapabilityStates; - -/** - * Persisted state for a single capability. - * - * @typeParam N - The capability name. - */ -export type CapabilityState = CapabilityStates[N]; - -/** - * Union of method names available on a capability (e.g. for `counter`: - * `"set" | "increment" | "reset"`). - * - * @typeParam N - The capability name. - */ -export type CapabilityMethod = - keyof CapabilityMethodArgs[N] & string; - -/** - * The `data` payload type for a specific capability method. - * - * @typeParam N - The capability name. - * @typeParam M - The method name on capability `N`. - */ -export type MethodArgs< - N extends CapabilityName, - M extends CapabilityMethod, -> = CapabilityMethodArgs[N][M]; - -/** - * A partial map of capability name to its persisted state — i.e. an object's - * full state where any capability may be absent. - */ -export type CapabilitiesBlob = { - [N in CapabilityName]?: CapabilityState; -}; diff --git a/packages/client/src/objects/events.ts b/packages/client/src/objects/events.ts deleted file mode 100644 index 96a0b99..0000000 --- a/packages/client/src/objects/events.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Webhook event payloads — the JSON bodies posted to an object's webhook URL. - * - * Events are addressed as `"."` and carry a `data` payload - * whose shape is derived from {@link CapabilityMethodArgs}. The `webhook.ping` - * probe is modeled separately as {@link PingEvent}. - * - * @module - */ -import type { - CapabilityMethod, - CapabilityName, - MethodArgs, -} from "./capabilities"; -import type { PresetCapabilityName, PresetName } from "./presets"; - -/** The reserved event type for the metadata/health probe. */ -export const PING_EVENT_TYPE = "webhook.ping" as const; - -/** - * The `webhook.ping` probe event. Carries no meaningful payload; `data`, when - * present, must be an empty object. - */ -export type PingEvent = { - type: typeof PING_EVENT_TYPE; - data?: Record; -}; - -/** - * A single capability event on the wire: `type` is `"."` - * and `data` is the matching method-argument payload. - * - * @typeParam N - The capability name. - * @typeParam M - The method name on capability `N`. - */ -export type CapabilityWebhookEvent< - N extends CapabilityName, - M extends CapabilityMethod, -> = { - /** Wire type, formatted as `` `${N}.${M}` ``. */ - type: `${N}.${M}`; - /** ISO-8601 timestamp of when the event was produced. */ - timestamp: string; - /** Method-argument payload for `N.M`. */ - data: MethodArgs; -}; - -/** - * Union of every event for a single capability (across all of its methods). - * - * @typeParam N - The capability name. - */ -export type CapabilityWebhookEventFor = { - [M in CapabilityMethod]: CapabilityWebhookEvent; -}[CapabilityMethod]; - -/** Any capability event on the wire (excludes reserved `webhook.*` methods). */ -export type CapabilityWebhookEventUnion = { - [N in CapabilityName]: CapabilityWebhookEventFor; -}[CapabilityName]; - -/** Standard Webhooks payload shape posted to the object URL. */ -export type WebhookEvent = CapabilityWebhookEventUnion; - -/** - * Union of capability events for the capabilities a given preset declares. - * - * @typeParam P - The preset name. - */ -export type PresetCapabilityWebhookEvent

= { - [N in PresetCapabilityName

]: CapabilityWebhookEventFor; -}[PresetCapabilityName

]; - -/** Events a placed object of `preset` accepts, plus the universal ping probe. */ -export type PresetWebhookEvent

= - PresetCapabilityWebhookEvent

; - -/** - * Recover the `data` payload type from a wire `type` string. - * - * @typeParam T - A wire event type, e.g. `"counter.set"`. - * @example - * type SetArgs = EventDataForType<"counter.set">; // { count: number } - */ -export type EventDataForType = Extract< - CapabilityWebhookEventUnion, - { type: T } ->["data"]; diff --git a/packages/client/src/objects/index.ts b/packages/client/src/objects/index.ts deleted file mode 100644 index 8d280a2..0000000 --- a/packages/client/src/objects/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Barrel of all webhook-object domain types: capabilities, events, presets, - * and responses. Import from `@webhook-objects/client/objects` for types only - * (no client runtime). - * - * @module - */ -export * from "./capabilities"; -export * from "./events"; -export * from "./presets"; -export * from "./responses"; - -/** The `gather-game-logic` commit these type definitions were generated from. */ -export const VERSION = "83b42349949bf0e0d9c2497d8bbbb016840b2995" as const; diff --git a/packages/client/src/objects/presets.ts b/packages/client/src/objects/presets.ts deleted file mode 100644 index 9f93f5c..0000000 --- a/packages/client/src/objects/presets.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Presets: named bundles of capabilities that an object can be created as. - * - * A preset (e.g. `counter`, `inbox`) fixes which capabilities an object - * exposes. This module derives the per-preset capability sets, declared-state - * shapes, and the visual states an object renders. - * - * @module - */ -import type { - CapabilitiesBlob, - CapabilityName, - CapabilityState, - StatusState, -} from "./capabilities"; - -/** Every available preset name, as a readonly tuple. */ -export const PRESET_NAMES = ["counter", "switch", "inbox", "status"] as const; - -/** A single preset name — the union derived from {@link PRESET_NAMES}. */ -export type PresetName = (typeof PRESET_NAMES)[number]; - -/** Capabilities composed onto every webhook object regardless of preset. */ -export const BASE_CAPABILITY_NAMES = [ - "info", -] as const satisfies readonly CapabilityName[]; - -/** - * The capabilities each preset exposes. Every preset includes the - * {@link BASE_CAPABILITY_NAMES} (`info`) plus its preset-specific ones. - */ -export const PRESET_CAPABILITIES = { - counter: ["info", "counter"], - switch: ["info", "switch"], - inbox: ["info", "activity", "counter"], - status: ["info", "status", "activity"], -} as const satisfies Record; - -/** - * Union of capability names exposed by a given preset. - * - * @typeParam P - The preset name. - */ -export type PresetCapabilityName

= - (typeof PRESET_CAPABILITIES)[P][number]; - -/** - * Partial persisted state for a preset — each declared capability slot is - * optional (any may be absent). - * - * @typeParam P - The preset name. - * @see DeclaredCapabilitiesState for the fully-materialized variant. - */ -export type PresetCapabilitiesState

= { - [N in PresetCapabilityName

]?: CapabilityState; -}; - -/** Rendered visual states for a counter-style object (empty → full). */ -export type CountVisualState = - | "empty" - | "count_1" - | "count_2" - | "count_3" - | "count_4" - | "count_5" - | "count_6" - | "count_7" - | "count_8" - | "count_9" - | "count_10" - | "full"; - -/** Rendered visual states for a switch object. */ -export type SwitchVisualState = "on" | "off"; - -/** Rendered visual states for a status object — mirrors {@link StatusState}. */ -export type StatusVisualState = StatusState; - -/** - * The set of visual states an object of a given preset can render. - * - * - `counter` / `inbox` → {@link CountVisualState} - * - `switch` → {@link SwitchVisualState} - * - `status` → {@link StatusVisualState} - * - * @typeParam P - The preset name. - */ -export type PresetVisualState

= P extends - | "counter" - | "inbox" - ? CountVisualState - : P extends "switch" - ? SwitchVisualState - : P extends "status" - ? StatusVisualState - : never; - -/** - * Full declared capability surface for a preset, with each slot materialized - * (required and non-nullable). This is what a `webhook.ping` echoes back. - * - * @typeParam P - The preset name. - */ -export type DeclaredCapabilitiesState

= { - [N in PresetCapabilityName

]: NonNullable; -}; diff --git a/packages/client/src/objects/responses.ts b/packages/client/src/objects/responses.ts deleted file mode 100644 index f919cd5..0000000 --- a/packages/client/src/objects/responses.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Response shapes returned by the object webhook receiver — both the JSON - * bodies (`*ResponseBody`) and the full HTTP responses with status codes - * (`*Response`). - * - * @module - */ -import type { DeclaredCapabilitiesState, PresetName } from "./presets"; - -/** Applied via the live game server (space loaded). */ -export type DispatchedResponseBody = { - status: "dispatched"; -}; - -/** Applied via direct DB write (space not loaded) — same effect as `dispatched`. */ -export type SpaceIdleResponseBody = { - status: "space_idle"; -}; - -/** Success for capability events. A deduped redelivery also returns HTTP 200 with one of these. */ -export type DispatchSuccessResponseBody = - | DispatchedResponseBody - | SpaceIdleResponseBody; - -/** - * Success for `webhook.ping` only — echoes the object's declared capability state. - * - * @typeParam P - The object's preset, or `null` when unknown. When a concrete - * preset is supplied, `capabilities` is fully materialized; otherwise it is a - * partial map across all presets' capabilities. - */ -export type PingResponseBody

= - { - /** Always `"pong"`. */ - status: "pong"; - /** The responding object's id. */ - objectId: string; - /** The space the object belongs to. */ - spaceId: string; - /** The object's preset, or `null` if not determinable. */ - preset: P; - /** Declared capability state, materialized per {@link DeclaredCapabilitiesState}. */ - capabilities: P extends PresetName - ? DeclaredCapabilitiesState

- : Partial>; - }; - -/** - * All HTTP-200 JSON bodies, discriminated by `status`. - * - * @typeParam P - The object's preset (forwarded to {@link PingResponseBody}). - */ -export type WebhookSuccessResponseBody< - P extends PresetName | null = PresetName | null, -> = DispatchSuccessResponseBody | PingResponseBody

; - -/** The success body returned from {@link Client.send} (a dispatched capability event). */ -export type WebhookEventResponseBody = DispatchSuccessResponseBody; - -/** Every error code the receiver can return, as a readonly tuple. */ -export const WEBHOOK_ERROR_CODES = [ - "invalid_request", - "invalid_args", - "not_found", - "capability_not_found", - "capability_not_declared", - "method_not_found", - "token_revoked", - "unsupported_media_type", - "internal_error", - "service_unavailable", -] as const; - -/** A single error code — the union derived from {@link WEBHOOK_ERROR_CODES}. */ -export type WebhookErrorCode = (typeof WEBHOOK_ERROR_CODES)[number]; - -/** Every error response body: `{ "error": "" }`. */ -export type WebhookErrorResponseBody = { - error: WebhookErrorCode; -}; - -/** - * Post-auth error codes — receiving one of these means HMAC verification already succeeded. - * `not_found` is deliberately excluded: it collapses every pre-auth failure (bad signature, - * unknown object, missing token, etc.) and is indistinguishable from a missing object. - */ -export type PostAuthWebhookErrorCode = Exclude< - WebhookErrorCode, - "not_found" | "unsupported_media_type" ->; - -/** Error responses paired with their HTTP status codes. */ -export type WebhookErrorResponse = - | { - status: 400; - body: { - error: "invalid_request" | "invalid_args"; - }; - } - | { - status: 404; - body: { - error: - | "not_found" - | "capability_not_found" - | "capability_not_declared" - | "method_not_found"; - }; - } - | { status: 410; body: { error: "token_revoked" } } - | { status: 415; body: { error: "unsupported_media_type" } } - | { status: 500; body: { error: "internal_error" } } - | { status: 503; body: { error: "service_unavailable" } }; - -/** - * Rate limit (100 req/min per IP, 60 req/min per space). Emitted by the framework, not the - * webhook handler — body shape is not `{ error: "..." }`. Honor `RateLimit-Reset` (Unix seconds). - */ -export type WebhookRateLimitedResponse = { - status: 429; - body?: unknown; -}; - -/** - * A successful HTTP 200 response (status + body). - * - * @typeParam P - The object's preset (forwarded to the body type). - */ -export type WebhookHttpSuccessResponse< - P extends PresetName | null = PresetName | null, -> = { - status: 200; - body: WebhookSuccessResponseBody

; -}; - -/** - * Full HTTP response union for the object webhook receiver — success, error, - * or rate-limited. - * - * @typeParam P - The object's preset (forwarded to the success body type). - */ -export type WebhookHttpResponse< - P extends PresetName | null = PresetName | null, -> = - | WebhookHttpSuccessResponse

- | WebhookErrorResponse - | WebhookRateLimitedResponse; - -/** - * JSON body only (no HTTP status), success or error. - * - * @typeParam P - The object's preset (forwarded to the success body type). - */ -export type WebhookResponseBody< - P extends PresetName | null = PresetName | null, -> = WebhookSuccessResponseBody

| WebhookErrorResponseBody; diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json deleted file mode 100644 index f5c0ba8..0000000 --- a/packages/client/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": {}, - "include": [], - "exclude": [], - "references": [ - { "path": "./tsconfig.lib.json" }, - { "path": "./tsconfig.lib.test.json" }, - { "path": "./tsconfig.lib.types.json" } - ] -} diff --git a/packages/client/tsconfig.lib.json b/packages/client/tsconfig.lib.json deleted file mode 100644 index 7f66392..0000000 --- a/packages/client/tsconfig.lib.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "../z-build-config/ts/tsconfig.dom.json", - "compilerOptions": {}, - "include": ["src/**/*.ts"], - "exclude": ["src/**/*.spec.ts"] -} diff --git a/packages/client/tsconfig.lib.test.json b/packages/client/tsconfig.lib.test.json deleted file mode 100644 index ac6ba0f..0000000 --- a/packages/client/tsconfig.lib.test.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../z-build-config/ts/tsconfig.dom.test.json", - "compilerOptions": { - "types": ["vitest/globals", "node"] - }, - "include": ["src/**/*.spec.ts"], - "exclude": [] -} diff --git a/packages/client/tsconfig.lib.types.json b/packages/client/tsconfig.lib.types.json deleted file mode 100644 index b27bb1b..0000000 --- a/packages/client/tsconfig.lib.types.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "./tsconfig.lib.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "./dist/types", - "declaration": true, - "declarationMap": true, - "emitDeclarationOnly": true - }, - "include": ["src/**/*.ts"], - "exclude": ["src/**/*.spec.ts"] -} diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts deleted file mode 100644 index 6832d19..0000000 --- a/packages/client/vite.config.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { isAbsolute } from "node:path"; -import { playwright } from "@vitest/browser-playwright"; -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - build: { - lib: { - entry: { - browser: "src/browser.ts", - node: "src/node.ts", - objects: "src/objects/index.ts", - }, - formats: ["es"], - fileName: (_, entryName) => `${entryName}.js`, - }, - rolldownOptions: { - // Externalize bare dependencies (e.g. standardwebhooks, undici, node - // builtins), but bundle internal modules — relative, absolute (resolved - // source paths), and @webhook-objects/* — so the emitted entries never - // reference un-emitted source files. - external: (id) => - !id.startsWith(".") && - !isAbsolute(id) && - !id.startsWith("@webhook-objects"), - }, - }, - test: { - globals: true, - coverage: { - provider: "v8", - include: ["src/**/*.ts"], - }, - projects: [ - { - extends: true, - test: { - name: "node", - environment: "node", - include: ["src/**/*.spec.ts"], - // The browser entry spec only makes sense in the chromium project. - exclude: ["src/browser.spec.ts"], - }, - }, - { - extends: true, - test: { - name: "chromium", - // Only environment-agnostic specs run in the browser. The Node - // entry specs mock `undici`/use `process` and can't run here. - include: ["src/client.spec.ts", "src/browser.spec.ts"], - browser: { - enabled: true, - provider: playwright(), - headless: true, - instances: [{ browser: "chromium" }], - }, - }, - }, - ], - }, -}); diff --git a/packages/gh-prs-inbox/README.md b/packages/gh-prs-inbox/README.md index 4cfbe10..aef3a9d 100644 --- a/packages/gh-prs-inbox/README.md +++ b/packages/gh-prs-inbox/README.md @@ -26,7 +26,7 @@ Runs until `Ctrl+C`. `gh search prs --review-requested=@me --state=open` lists the PRs → each poll reconciles the feed against them (`activity.add` for newly-pending PRs, `activity.remove` for ones now gone, then `counter.set`) and signs & POSTs via -`@webhook-objects/client`. +[`@gathertown/webhook-object-sdk`](https://www.npmjs.com/package/@gathertown/webhook-object-sdk). "PRs pending review" is a *live set* — a PR leaves the list once reviewed or merged. Reconciling (rather than clear-and-rewrite) means a failed send only diff --git a/packages/gh-prs-inbox/package.json b/packages/gh-prs-inbox/package.json index 41026fe..58adb93 100644 --- a/packages/gh-prs-inbox/package.json +++ b/packages/gh-prs-inbox/package.json @@ -19,6 +19,6 @@ } ], "dependencies": { - "@webhook-objects/client": "workspace:*" + "@gathertown/webhook-object-sdk": "^0.1.1" } } diff --git a/packages/gh-prs-inbox/src/index.ts b/packages/gh-prs-inbox/src/index.ts index 3eaf350..a010be9 100644 --- a/packages/gh-prs-inbox/src/index.ts +++ b/packages/gh-prs-inbox/src/index.ts @@ -15,9 +15,12 @@ * @module */ import { parseArgs } from "node:util"; -import { ACTIVITY_BUFFER_SIZE, Client } from "@webhook-objects/client/node"; +import { createWebhookObjectClient } from "@gathertown/webhook-object-sdk"; import { feedEntries, fetchPrs } from "./prs"; +/** The receiver keeps this many activity entries (a ring buffer); older ones are evicted. */ +const ACTIVITY_BUFFER_SIZE = 20; + async function main() { const { values } = parseArgs({ options: { @@ -33,26 +36,26 @@ async function main() { process.exit(1); } - const client = new Client({ url: values.url, secret: values.secret }); + const client = createWebhookObjectClient({ + url: values.url, + secret: values.secret, + }); const intervalMs = Number(values.interval) * 1000; // Reset once at startup so a previous run's stale feed/counter don't linger; // from then on we reconcile incrementally and never wipe the feed mid-poll. - { - const timestamp = new Date().toISOString(); - await client.send({ type: "activity.clear", timestamp, data: {} }); - await client.send({ type: "counter.reset", timestamp, data: {} }); - } + await client.send("activity.clear"); + await client.send("counter.reset"); // Ids currently shown on the object. Mutated as each send lands so a partial // failure leaves it accurate — the next poll retries only the missing ops. const shownIds = new Set(); - // Send one event, isolating its failure: a single bad PR (e.g. a rejected + // Run one send, isolating its failure: a single bad PR (e.g. a rejected // payload) must not abort the rest of the poll or skip the counter update. // Returns whether it landed, so the caller only mutates `shownIds` on success. - const trySend = async (event: Parameters[0]) => { + const trySend = async (send: () => Promise) => { try { - await client.send(event); + await send(); return true; } catch (err) { console.error("send failed:", err instanceof Error ? err.message : err); @@ -74,30 +77,23 @@ async function main() { // cap to its size and let the counter report the true total. const entries = feedEntries(prs, ACTIVITY_BUFFER_SIZE); const currentIds = new Set(entries.map((e) => e.id)); - const timestamp = new Date().toISOString(); // Remove before add so the ring buffer never evicts a still-shown entry. for (const id of [...shownIds]) { if (!currentIds.has(id)) { - if ( - await trySend({ type: "activity.remove", timestamp, data: { id } }) - ) { + if (await trySend(() => client.send("activity.remove", { id }))) { shownIds.delete(id); } } } for (const entry of entries) { if (!shownIds.has(entry.id)) { - if (await trySend({ type: "activity.add", timestamp, data: entry })) { + if (await trySend(() => client.send("activity.add", entry))) { shownIds.add(entry.id); } } } - await trySend({ - type: "counter.set", - timestamp, - data: { count: prs.length }, - }); + await trySend(() => client.send("counter.set", { count: prs.length })); console.log(`synced ${prs.length} PR(s) awaiting review`); }; diff --git a/packages/low-battery-switch/README.md b/packages/low-battery-switch/README.md index ff37269..2197696 100644 --- a/packages/low-battery-switch/README.md +++ b/packages/low-battery-switch/README.md @@ -27,7 +27,7 @@ Runs until `Ctrl+C`. `pmset -g batt` reports charge percent and whether you're on AC → each poll decides "low" (on battery AND at/below `--threshold`) and, only when that -changes, sends `switch.set_state` signed via `@webhook-objects/client`. Plugged +changes, sends `switch.set_state` signed via [`@gathertown/webhook-object-sdk`](https://www.npmjs.com/package/@gathertown/webhook-object-sdk). Plugged in is never low, however empty — you're not about to drop. Sending only on change keeps the receiver quiet between transitions; a failed diff --git a/packages/low-battery-switch/package.json b/packages/low-battery-switch/package.json index bdde30d..6cc9fd6 100644 --- a/packages/low-battery-switch/package.json +++ b/packages/low-battery-switch/package.json @@ -19,6 +19,6 @@ } ], "dependencies": { - "@webhook-objects/client": "workspace:*" + "@gathertown/webhook-object-sdk": "^0.1.1" } } diff --git a/packages/low-battery-switch/src/index.ts b/packages/low-battery-switch/src/index.ts index 61dc7cf..7d5e698 100644 --- a/packages/low-battery-switch/src/index.ts +++ b/packages/low-battery-switch/src/index.ts @@ -10,7 +10,7 @@ */ import { execFile } from "node:child_process"; import { parseArgs, promisify } from "node:util"; -import { Client } from "@webhook-objects/client/node"; +import { createWebhookObjectClient } from "@gathertown/webhook-object-sdk"; import { DEFAULT_THRESHOLD, isLowBattery } from "./battery"; const exec = promisify(execFile); @@ -31,7 +31,10 @@ async function main() { process.exit(1); } - const client = new Client({ url: values.url, secret: values.secret }); + const client = createWebhookObjectClient({ + url: values.url, + secret: values.secret, + }); const intervalMs = Number(values.interval) * 1000; const threshold = Number(values.threshold); @@ -52,11 +55,7 @@ async function main() { if (on === lastOn) return; try { - await client.send({ - type: "switch.set_state", - timestamp: new Date().toISOString(), - data: { on }, - }); + await client.send("switch.set_state", { on }); lastOn = on; console.log(`battery low: ${on}`); } catch (err) { diff --git a/packages/now-playing-inbox/README.md b/packages/now-playing-inbox/README.md index 8d2ac5e..94fdea1 100644 --- a/packages/now-playing-inbox/README.md +++ b/packages/now-playing-inbox/README.md @@ -27,4 +27,4 @@ Music search. ## How it works `osascript` reads the current track → dedup against the last id → sign & POST -via `@webhook-objects/client`. +via [`@gathertown/webhook-object-sdk`](https://www.npmjs.com/package/@gathertown/webhook-object-sdk). diff --git a/packages/now-playing-inbox/package.json b/packages/now-playing-inbox/package.json index c333480..0d46c0c 100644 --- a/packages/now-playing-inbox/package.json +++ b/packages/now-playing-inbox/package.json @@ -22,6 +22,6 @@ } ], "dependencies": { - "@webhook-objects/client": "workspace:*" + "@gathertown/webhook-object-sdk": "^0.1.1" } } diff --git a/packages/now-playing-inbox/src/index.ts b/packages/now-playing-inbox/src/index.ts index c53b0fe..037df58 100644 --- a/packages/now-playing-inbox/src/index.ts +++ b/packages/now-playing-inbox/src/index.ts @@ -8,7 +8,7 @@ * @module */ import { parseArgs } from "node:util"; -import { Client } from "@webhook-objects/client/node"; +import { createWebhookObjectClient } from "@gathertown/webhook-object-sdk"; import { readNowPlaying } from "./now-playing"; async function main() { @@ -32,14 +32,16 @@ async function main() { process.exit(1); } - const client = new Client({ url: values.url, secret: values.secret }); + const client = createWebhookObjectClient({ + url: values.url, + secret: values.secret, + }); const intervalMs = Number(values.interval) * 1000; let lastId: string | undefined; if (values.initialize) { - const timestamp = new Date().toISOString(); - await client.send({ type: "activity.clear", timestamp, data: {} }); - await client.send({ type: "counter.reset", timestamp, data: {} }); + await client.send("activity.clear"); + await client.send("counter.reset"); console.log("Initialized: cleared activity feed and reset counter."); } @@ -47,19 +49,15 @@ async function main() { try { const track = await readNowPlaying(); if (!track || track.id === lastId) return; - const timestamp = new Date().toISOString(); - await client.send({ - type: "activity.add", - timestamp, - data: { id: track.id, text: track.text, url: track.url }, + await client.send("activity.add", { + id: track.id, + text: track.text, + url: track.url, }); // Mark handled as soon as the entry is recorded: a later failure must // not re-add this track (which would duplicate the feed entry). lastId = track.id; console.log(`+ ${track.text}`); - // Best-effort counter bump so the inbox renders as filling up; if it - // fails the feed is still correct (the counter may just lag by one). - await client.send({ type: "counter.increment", timestamp, data: {} }); } catch (err) { console.error("poll failed:", err instanceof Error ? err.message : err); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7bb14f..0451e90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,18 +14,9 @@ importers: '@types/node': specifier: ^26.0.0 version: 26.0.0 - '@vitest/browser': - specifier: ^4.1.9 - version: 4.1.9(vite@8.1.0(@types/node@26.0.0)(esbuild@0.28.1)(tsx@4.22.4))(vitest@4.1.9) - '@vitest/browser-playwright': - specifier: ^4.1.9 - version: 4.1.9(playwright@1.61.0)(vite@8.1.0(@types/node@26.0.0)(esbuild@0.28.1)(tsx@4.22.4))(vitest@4.1.9) '@vitest/coverage-v8': specifier: 4.1.9 version: 4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9) - playwright: - specifier: ^1.61.0 - version: 1.61.0 tsx: specifier: ^4.22 version: 4.22.4 @@ -44,44 +35,34 @@ importers: packages/claude-status: dependencies: - '@webhook-objects/client': - specifier: workspace:* - version: link:../client - devDependencies: - '@webhook-objects/z-build-config': - specifier: workspace:* - version: link:../z-build-config - - packages/client: - dependencies: - standardwebhooks: - specifier: ^1.0.0 - version: 1.0.0 + '@gathertown/webhook-object-sdk': + specifier: ^0.1.1 + version: 0.1.1 devDependencies: + '@gathertown/webhook-object-types': + specifier: ^0.1.1 + version: 0.1.1 '@webhook-objects/z-build-config': specifier: workspace:* version: link:../z-build-config - undici: - specifier: ^8.5.0 - version: 8.5.0 packages/gh-prs-inbox: dependencies: - '@webhook-objects/client': - specifier: workspace:* - version: link:../client + '@gathertown/webhook-object-sdk': + specifier: ^0.1.1 + version: 0.1.1 packages/low-battery-switch: dependencies: - '@webhook-objects/client': - specifier: workspace:* - version: link:../client + '@gathertown/webhook-object-sdk': + specifier: ^0.1.1 + version: 0.1.1 packages/now-playing-inbox: dependencies: - '@webhook-objects/client': - specifier: workspace:* - version: link:../client + '@gathertown/webhook-object-sdk': + specifier: ^0.1.1 + version: 0.1.1 packages/z-build-config: {} @@ -333,6 +314,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'} @@ -825,10 +813,6 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - undici@8.5.0: - resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==} - engines: {node: '>=22.19.0'} - vite-plugin-node@8.0.0: resolution: {integrity: sha512-/jz+hrOULqRfsOwSrA3xU0rm2ED/XLsUkQU3VhuJqaR/gvyGxb0ZRxsamh/U84DCpvIEhYkp2ZwgyDVQ+AmIRQ==} peerDependencies: @@ -991,7 +975,8 @@ snapshots: '@biomejs/cli-win32-x64@2.5.0': optional: true - '@blazediff/core@1.9.1': {} + '@blazediff/core@1.9.1': + optional: true '@emnapi/core@1.11.1': dependencies: @@ -1087,6 +1072,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': {} @@ -1105,7 +1097,8 @@ snapshots: '@oxc-project/types@0.137.0': {} - '@polka/url@1.0.0-next.29': {} + '@polka/url@1.0.0-next.29': + optional: true '@rolldown/binding-android-arm64@1.1.2': optional: true @@ -1192,6 +1185,7 @@ snapshots: - msw - utf-8-validate - vite + optional: true '@vitest/browser@4.1.9(vite@8.1.0(@types/node@26.0.0)(esbuild@0.28.1)(tsx@4.22.4))(vitest@4.1.9)': dependencies: @@ -1209,6 +1203,7 @@ snapshots: - msw - utf-8-validate - vite + optional: true '@vitest/coverage-v8@4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9)': dependencies: @@ -1433,7 +1428,8 @@ snapshots: dependencies: semver: 7.8.5 - mrmime@2.0.1: {} + mrmime@2.0.1: + optional: true ms@2.1.3: {} @@ -1447,15 +1443,18 @@ snapshots: picomatch@4.0.4: {} - playwright-core@1.61.0: {} + playwright-core@1.61.0: + optional: true playwright@1.61.0: dependencies: playwright-core: 1.61.0 optionalDependencies: fsevents: 2.3.2 + optional: true - pngjs@7.0.0: {} + pngjs@7.0.0: + optional: true postcss@8.5.15: dependencies: @@ -1493,6 +1492,7 @@ snapshots: '@polka/url': 1.0.0-next.29 mrmime: 2.0.1 totalist: 3.0.1 + optional: true source-map-js@1.2.1: {} @@ -1520,7 +1520,8 @@ snapshots: tinyrainbow@3.1.0: {} - totalist@3.0.1: {} + totalist@3.0.1: + optional: true tslib@2.8.1: optional: true @@ -1535,8 +1536,6 @@ snapshots: undici-types@8.3.0: {} - undici@8.5.0: {} - vite-plugin-node@8.0.0(vite@8.1.0(@types/node@26.0.0)(esbuild@0.28.1)(tsx@4.22.4)): dependencies: chalk: 4.1.2 @@ -1593,4 +1592,5 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - ws@8.21.0: {} + ws@8.21.0: + optional: true 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: