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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/node-development-update-warning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@commet/node": minor
---

Warn during local development when a newer `@commet/node` version is available.
4 changes: 4 additions & 0 deletions packages/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
114 changes: 114 additions & 0 deletions packages/node/src/__tests__/update-check.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 2 additions & 0 deletions packages/node/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
}
}

Expand Down
153 changes: 153 additions & 0 deletions packages/node/src/utils/update-check.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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();
}