diff --git a/.changeset/node-development-update-warning.md b/.changeset/node-development-update-warning.md new file mode 100644 index 00000000..b85271fe --- /dev/null +++ b/.changeset/node-development-update-warning.md @@ -0,0 +1,5 @@ +--- +"@commet/node": minor +--- + +Warn during local development when a newer `@commet/node` version is available. diff --git a/packages/node/README.md b/packages/node/README.md index 08957df7..b30f8e00 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -110,6 +110,10 @@ commet pull This generates type-safe autocomplete for your plan codes, feature codes, and seat types. +## Update notifications + +During local development, the SDK checks npm once per process and warns when a newer `@commet/node` version is available. The check never runs in production or CI and does not block SDK initialization. Set `COMMET_NO_UPDATE_CHECK=1` to disable it. + ## Documentation Visit [commet.co/docs](https://commet.co/docs) for: diff --git a/packages/node/src/__tests__/update-check.test.ts b/packages/node/src/__tests__/update-check.test.ts new file mode 100644 index 00000000..4308d85e --- /dev/null +++ b/packages/node/src/__tests__/update-check.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + checkForSdkUpdate, + isNewerVersion, + shouldCheckForSdkUpdates, +} from "../utils/update-check"; + +function distTagsResponse(latest: unknown, status = 200): Response { + return new Response(JSON.stringify({ latest }), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +afterEach(() => { + Reflect.deleteProperty(globalThis, Symbol.for("@commet/node.update-check")); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("SDK update check", () => { + it("compares stable versions", () => { + expect(isNewerVersion("7.7.0", "7.1.0")).toBe(true); + expect(isNewerVersion("7.7.1", "7.7.0")).toBe(true); + expect(isNewerVersion("8.0.0", "7.7.0")).toBe(true); + expect(isNewerVersion("7.7.0", "7.7.0-beta.1")).toBe(true); + expect(isNewerVersion("7.7.0-beta.10", "7.7.0-beta.2")).toBe(true); + expect(isNewerVersion("7.7.0", "7.7.0")).toBe(false); + expect(isNewerVersion("7.6.0", "7.7.0")).toBe(false); + expect(isNewerVersion("canary", "7.7.0")).toBe(false); + }); + + it("only enables checks during local development", () => { + vi.stubEnv("NODE_ENV", "development"); + expect(shouldCheckForSdkUpdates()).toBe(true); + + vi.stubEnv("CI", "true"); + expect(shouldCheckForSdkUpdates()).toBe(false); + + vi.stubEnv("CI", ""); + vi.stubEnv("COMMET_NO_UPDATE_CHECK", "1"); + expect(shouldCheckForSdkUpdates()).toBe(false); + + vi.stubEnv("COMMET_NO_UPDATE_CHECK", ""); + vi.stubEnv("NODE_ENV", "production"); + expect(shouldCheckForSdkUpdates()).toBe(false); + }); + + it("warns when npm reports a newer version", async () => { + const fetchMock = vi.fn().mockResolvedValue(distTagsResponse("7.7.0")); + const warnMock = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubGlobal("fetch", fetchMock); + + await checkForSdkUpdate("7.1.0"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://registry.npmjs.org/-/package/%40commet%2Fnode/dist-tags", + { signal: expect.any(AbortSignal) }, + ); + expect(warnMock).toHaveBeenCalledWith( + "[Commet] @commet/node 7.1.0 is out of date. The latest version is 7.7.0.\nUpdate with: npm install @commet/node@latest", + ); + }); + + it("does not warn for the latest or a newer installed version", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(distTagsResponse("7.7.0")) + .mockResolvedValueOnce(distTagsResponse("7.7.0")); + const warnMock = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubGlobal("fetch", fetchMock); + + await checkForSdkUpdate("7.7.0"); + await checkForSdkUpdate("8.0.0"); + + expect(warnMock).not.toHaveBeenCalled(); + }); + + it("ignores registry failures and invalid responses", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(distTagsResponse("7.8.0", 500)) + .mockResolvedValueOnce(distTagsResponse(null)) + .mockRejectedValueOnce(new Error("registry unavailable")); + const warnMock = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubGlobal("fetch", fetchMock); + + await expect(checkForSdkUpdate("7.7.0")).resolves.toBeUndefined(); + await expect(checkForSdkUpdate("7.7.0")).resolves.toBeUndefined(); + await expect(checkForSdkUpdate("7.7.0")).resolves.toBeUndefined(); + + expect(warnMock).not.toHaveBeenCalled(); + }); + + it("schedules at most one registry request per process", async () => { + vi.resetModules(); + vi.stubEnv("NODE_ENV", "development"); + const fetchMock = vi.fn().mockResolvedValue(distTagsResponse("7.7.0")); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const firstSdkModule = await import("../utils/update-check"); + + firstSdkModule.scheduleSdkUpdateCheck(); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + + vi.resetModules(); + const secondSdkModule = await import("../utils/update-check"); + secondSdkModule.scheduleSdkUpdateCheck(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index ac5a5fc4..2cfa6be8 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -4,6 +4,7 @@ import { Webhooks } from "./resources/webhooks"; import type { CommetClientOptions } from "./types/common"; import type { BillingConfig } from "./types/config"; import { CommetHTTPClient } from "./utils/http"; +import { scheduleSdkUpdateCheck } from "./utils/update-check"; export class Commet<_TConfig = unknown> extends GeneratedResources { private httpClient: CommetHTTPClient; @@ -28,6 +29,7 @@ export class Commet<_TConfig = unknown> extends GeneratedResources { this.initResources(this.httpClient); this.usage = new UsageResource(this.httpClient); this.webhooks = new Webhooks(this.httpClient); + scheduleSdkUpdateCheck(); } } diff --git a/packages/node/src/utils/update-check.ts b/packages/node/src/utils/update-check.ts new file mode 100644 index 00000000..1643f8be --- /dev/null +++ b/packages/node/src/utils/update-check.ts @@ -0,0 +1,153 @@ +import { SDK_VERSION } from "../version"; + +const NPM_DIST_TAGS_URL = + "https://registry.npmjs.org/-/package/%40commet%2Fnode/dist-tags"; +const UPDATE_CHECK_TIMEOUT_MS = 3000; +const UPDATE_CHECK_STATE_KEY = Symbol.for("@commet/node.update-check"); + +type VersionNumbers = readonly [number, number, number]; + +interface ParsedVersion { + numbers: VersionNumbers; + prereleaseIdentifiers: string[]; +} + +function parseVersion(version: string): ParsedVersion | null { + const match = + /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec( + version, + ); + if (!match) return null; + + const numbers: VersionNumbers = [ + Number(match[1]), + Number(match[2]), + Number(match[3]), + ]; + if (!numbers.every(Number.isSafeInteger)) return null; + + return { + numbers, + prereleaseIdentifiers: match[4] ? match[4].split(".") : [], + }; +} + +function compareNumericIdentifier(latest: string, installed: string): number { + const normalizedLatest = latest.replace(/^0+(?=\d)/, ""); + const normalizedInstalled = installed.replace(/^0+(?=\d)/, ""); + if (normalizedLatest.length > normalizedInstalled.length) return 1; + if (normalizedLatest.length < normalizedInstalled.length) return -1; + if (normalizedLatest > normalizedInstalled) return 1; + if (normalizedLatest < normalizedInstalled) return -1; + return 0; +} + +function comparePrereleaseIdentifier( + latest: string, + installed: string, +): number { + const latestIsNumber = /^\d+$/.test(latest); + const installedIsNumber = /^\d+$/.test(installed); + + if (latestIsNumber && installedIsNumber) { + return compareNumericIdentifier(latest, installed); + } + if (latestIsNumber) return -1; + if (installedIsNumber) return 1; + if (latest > installed) return 1; + if (latest < installed) return -1; + return 0; +} + +function comparePrereleaseVersions( + latest: string[], + installed: string[], +): number { + if (latest.length === 0) return installed.length === 0 ? 0 : 1; + if (installed.length === 0) return -1; + + const identifierCount = Math.max(latest.length, installed.length); + for (let index = 0; index < identifierCount; index++) { + const latestIdentifier = latest[index]; + const installedIdentifier = installed[index]; + if (latestIdentifier === undefined) return -1; + if (installedIdentifier === undefined) return 1; + + const comparison = comparePrereleaseIdentifier( + latestIdentifier, + installedIdentifier, + ); + if (comparison !== 0) return comparison; + } + + return 0; +} + +export function isNewerVersion(latest: string, installed: string): boolean { + const latestVersion = parseVersion(latest); + const installedVersion = parseVersion(installed); + if (!latestVersion || !installedVersion) return false; + + for (let index = 0; index < latestVersion.numbers.length; index++) { + if (latestVersion.numbers[index] > installedVersion.numbers[index]) { + return true; + } + if (latestVersion.numbers[index] < installedVersion.numbers[index]) { + return false; + } + } + + return ( + comparePrereleaseVersions( + latestVersion.prereleaseIdentifiers, + installedVersion.prereleaseIdentifiers, + ) > 0 + ); +} + +function claimSdkUpdateCheck(): boolean { + if (Reflect.get(globalThis, UPDATE_CHECK_STATE_KEY) === true) return false; + Reflect.set(globalThis, UPDATE_CHECK_STATE_KEY, true); + return true; +} + +export function shouldCheckForSdkUpdates(): boolean { + if (process.env.NODE_ENV !== "development") return false; + if (process.env.CI) return false; + return process.env.COMMET_NO_UPDATE_CHECK !== "1"; +} + +export async function checkForSdkUpdate( + installedVersion: string, +): Promise { + try { + const response = await fetch(NPM_DIST_TAGS_URL, { + signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS), + }); + if (!response.ok) return; + + const distTags: unknown = await response.json(); + if ( + typeof distTags !== "object" || + distTags === null || + !("latest" in distTags) || + typeof distTags.latest !== "string" || + !isNewerVersion(distTags.latest, installedVersion) + ) { + return; + } + + console.warn( + `[Commet] @commet/node ${installedVersion} is out of date. The latest version is ${distTags.latest}.\nUpdate with: npm install @commet/node@latest`, + ); + } catch {} +} + +export function scheduleSdkUpdateCheck(): void { + if (!shouldCheckForSdkUpdates() || !claimSdkUpdateCheck()) return; + + const scheduledCheck = setImmediate(() => { + void checkForSdkUpdate(SDK_VERSION); + }); + scheduledCheck.unref(); +}