diff --git a/apps/web/src/state/npmRelease.test.ts b/apps/web/src/state/npmRelease.test.ts new file mode 100644 index 000000000000..062d0b040c7f --- /dev/null +++ b/apps/web/src/state/npmRelease.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { FetchHttpClient } from "effect/http"; + +import { checkNpmReleasePublished } from "./npmRelease"; + +const VERSION = "0.0.46-nightly.20261013.3042"; + +// Acts like a browser talking to registry.npmjs.org: a simple GET succeeds, +// but any non-safelisted header forces a preflight the registry rejects. +const registryFetch: typeof globalThis.fetch = async (_url, init) => { + const headers = new Headers(init?.headers); + if (headers.has("traceparent") || headers.has("b3")) { + throw new TypeError("Failed to fetch"); + } + return Response.json({ version: VERSION }); +}; + +const RegistryHttpClient = FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, registryFetch)), +); + +describe("checkNpmReleasePublished", () => { + it.effect("sends a request the registry accepts from a browser", () => + Effect.gen(function* () { + expect(yield* checkNpmReleasePublished(VERSION)).toBe(true); + }).pipe(Effect.provide(RegistryHttpClient)), + ); +}); diff --git a/apps/web/src/state/npmRelease.ts b/apps/web/src/state/npmRelease.ts index e2820089075f..11d58d9dacdd 100644 --- a/apps/web/src/state/npmRelease.ts +++ b/apps/web/src/state/npmRelease.ts @@ -5,24 +5,30 @@ import { AsyncResult, Atom } from "effect/reactivity"; /** * Whether `t3@` is published on npm. A 404 is false; network and - * registry errors stay unknown. Both re-check every few minutes, so a release - * that lands later shows up without a reload. + * registry errors fail, and callers treat that as unknown. + * + * Trace propagation stays off: its `b3` and `traceparent` headers turn this + * into a CORS preflight, which the registry rejects, so browsers could never + * complete the check. */ +export const checkNpmReleasePublished = (version: string) => + Effect.gen(function* () { + const response = yield* HttpClient.get( + `https://registry.npmjs.org/t3/${encodeURIComponent(version)}`, + ); + if (response.status === 404) return false; + const body = yield* HttpClientResponse.filterStatusOk(response).pipe( + Effect.flatMap((ok) => ok.json), + ); + return typeof body === "object" && body !== null && "version" in body + ? body.version === version + : false; + }).pipe(Effect.provideService(HttpClient.TracerPropagationEnabled, false)); + +/** Re-checks every few minutes, so a release that lands later shows up + without a reload. */ const npmReleasePublishedAtom = Atom.family((version: string) => - Atom.make( - Effect.gen(function* () { - const response = yield* HttpClient.get( - `https://registry.npmjs.org/t3/${encodeURIComponent(version)}`, - ); - if (response.status === 404) return false; - const body = yield* HttpClientResponse.filterStatusOk(response).pipe( - Effect.flatMap((ok) => ok.json), - ); - return typeof body === "object" && body !== null && "version" in body - ? body.version === version - : false; - }).pipe(Effect.provide(FetchHttpClient.layer)), - ).pipe( + Atom.make(checkNpmReleasePublished(version).pipe(Effect.provide(FetchHttpClient.layer))).pipe( Atom.withRefresh("5 minutes"), Atom.keepAlive, Atom.withLabel(`npm-release-published:${version}`),