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
4 changes: 2 additions & 2 deletions components/errors/alias-claim.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
Loader2,
} from "lucide-react"

import { jsonInit, parse } from "@/lib/api/client"
import { apiFetch, jsonInit, parse } from "@/lib/api/client"
import { celebrate } from "@/lib/confetti"
import { trackUiAction } from "@/lib/analytics"
import { normalizeUrl, urlProblem, validDestinationUrl } from "@/lib/validation"
Expand Down Expand Up @@ -56,7 +56,7 @@ export function AliasClaim({
}
setState({ kind: "claiming" })
try {
const res = await fetch(
const res = await apiFetch(
"/api/v1/shorten",
jsonInit("POST", alias ? { url: wire, alias } : { url: wire })
)
Expand Down
3 changes: 2 additions & 1 deletion components/errors/not-found-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { usePathname } from "next/navigation"

import { Button } from "@/components/ui/button"
import { AliasClaim } from "@/components/errors/alias-claim"
import { apiFetch } from "@/lib/api/client"

/** Creatable-alias shape (mirrors the backend + mock validator). */
const ALIAS_RE = /^[a-zA-Z0-9_-]{3,16}$/
Expand Down Expand Up @@ -47,7 +48,7 @@ export function NotFoundBody({ from }: { from?: string }) {
React.useEffect(() => {
if (!alias) return
let cancelled = false
fetch(`/api/v1/shorten/check-alias?alias=${encodeURIComponent(alias)}`)
apiFetch(`/api/v1/shorten/check-alias?alias=${encodeURIComponent(alias)}`)
.then((r) => r.json())
.then((d: { available?: boolean }) => {
if (!cancelled) setAvailable(Boolean(d.available))
Expand Down
3 changes: 2 additions & 1 deletion components/sections/instant-shortener.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
trackResultCardViewed,
} from "@/lib/analytics"
import { addRecentLink } from "@/lib/recent-links"
import { apiFetch } from "@/lib/api/client"

/* The legacy API reports field errors as { AliasError: "..." } etc.
Map them to fields (so a hidden options fold can open itself) and to
Expand Down Expand Up @@ -155,7 +156,7 @@ export function InstantShortener({
try {
// Same-origin proxy (next.config.mjs): dev -> the local backend,
// mock -> the in-repo handler, prod -> spoo.me. No CORS anywhere.
const res = await fetch("/shorten", {
const res = await apiFetch("/shorten", {
Comment thread
Zingzy marked this conversation as resolved.
method: "POST",
headers: {
Accept: "application/json",
Expand Down
12 changes: 6 additions & 6 deletions lib/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { authedFetch, jsonInit, parse } from "./client"
import { apiFetch, authedFetch, jsonInit, parse } from "./client"

export type AuthProvider = {
provider: "google" | "github" | "discord"
Expand All @@ -24,7 +24,7 @@ export function register(input: {
password: string
user_name?: string
}) {
return fetch("/auth/register", jsonInit("POST", input)).then((r) =>
return apiFetch("/auth/register", jsonInit("POST", input)).then((r) =>
parse<{
access_token: string
user: AuthUser
Expand All @@ -35,13 +35,13 @@ export function register(input: {
}

export function login(input: { email: string; password: string }) {
return fetch("/auth/login", jsonInit("POST", input)).then((r) =>
return apiFetch("/auth/login", jsonInit("POST", input)).then((r) =>
parse<{ access_token: string; user: AuthUser }>(r)
)
}

export function logout() {
return fetch("/auth/logout", { method: "POST" }).then((r) =>
return apiFetch("/auth/logout", { method: "POST" }).then((r) =>
parse<{ success: boolean }>(r)
)
}
Expand Down Expand Up @@ -141,7 +141,7 @@ export function removeProfilePicture() {
}

export function requestPasswordReset(email: string) {
return fetch(
return apiFetch(
"/auth/request-password-reset",
jsonInit("POST", { email })
).then((r) => parse<{ success: boolean }>(r))
Expand All @@ -152,7 +152,7 @@ export function resetPassword(input: {
code: string
password: string
}) {
return fetch("/auth/reset-password", jsonInit("POST", input)).then((r) =>
return apiFetch("/auth/reset-password", jsonInit("POST", input)).then((r) =>
parse<{ success: boolean }>(r)
)
}
68 changes: 68 additions & 0 deletions lib/api/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from "vitest"

import { apiFetch } from "./client"

/** Capture what apiFetch hands to the real fetch. */
function stubFetch() {
const calls: { path: string; headers: Headers }[] = []
vi.stubGlobal(
"fetch",
vi.fn((path: string, init?: RequestInit) => {
calls.push({ path, headers: new Headers(init?.headers) })
return Promise.resolve(new Response(null, { status: 200 }))
})
)
return calls
}

function stubPathname(pathname: string) {
vi.stubGlobal("window", { location: { pathname } })
}

afterEach(() => {
vi.unstubAllGlobals()
})

describe("apiFetch — X-Spoo-Client attribution", () => {
it("tags dashboard routes as dashboard", async () => {
const calls = stubFetch()
stubPathname("/dashboard/links")
await apiFetch("/api/v1/urls", { method: "GET" })
expect(calls[0].headers.get("X-Spoo-Client")).toBe("dashboard")
})

it("tags onboarding as the signed-in app too", async () => {
const calls = stubFetch()
stubPathname("/onboarding/recap")
await apiFetch("/auth/onboarding", { method: "GET" })
expect(calls[0].headers.get("X-Spoo-Client")).toBe("dashboard")
})

it("tags everything else as landing", async () => {
const calls = stubFetch()
for (const path of ["/", "/report", "/stats/abc", "/dashboardish"]) {
stubPathname(path)
await apiFetch("/api/v1/shorten", { method: "POST" })
}
for (const call of calls) {
expect(call.headers.get("X-Spoo-Client")).toBe("landing")
}
})

it("defaults to landing without a window (server-side public pages)", async () => {
const calls = stubFetch()
await apiFetch("http://localhost:8000/api/v1/public/preview/abc")
expect(calls[0].headers.get("X-Spoo-Client")).toBe("landing")
})

it("keeps caller-provided headers intact", async () => {
const calls = stubFetch()
stubPathname("/")
await apiFetch("/api/v1/reports", {
method: "POST",
headers: { "Content-Type": "application/json" },
})
expect(calls[0].headers.get("Content-Type")).toBe("application/json")
expect(calls[0].headers.get("X-Spoo-Client")).toBe("landing")
})
})
33 changes: 30 additions & 3 deletions lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,38 @@ export function jsonInit(method: string, body?: unknown): RequestInit {
}
}

/**
* Which app surface this request originates from, for the X-Spoo-Client
* attribution header. Browser-side it's inferred from the current route:
* /dashboard and /onboarding are the signed-in app, everything else is the
* public landing surface. Server-side callers are the public pages
* (/stats/{code}, /{code}+), so no window means "landing".
*/
function clientTag(): "dashboard" | "landing" {
if (typeof window === "undefined") return "landing"
const path = window.location.pathname
return /^\/(dashboard|onboarding)(\/|$)/.test(path) ? "dashboard" : "landing"
}

/**
* fetch with the X-Spoo-Client attribution header stamped on. Every call to
* the spoo.me backend goes through here (directly or via authedFetch) —
* never use it for third-party hosts, the header is ours alone.
*/
export function apiFetch(
path: string,
init: RequestInit = {}
): Promise<Response> {
const headers = new Headers(init.headers)
headers.set("X-Spoo-Client", clientTag())
return fetch(path, { ...init, headers })
}

/** Single-flight refresh: all concurrent 401 handlers share one attempt. */
let refreshInFlight: Promise<boolean> | null = null

function refreshSession(): Promise<boolean> {
refreshInFlight ??= fetch("/auth/refresh", { method: "POST" })
refreshInFlight ??= apiFetch("/auth/refresh", { method: "POST" })
.then((r) => r.ok)
.catch(() => false)
.finally(() => {
Expand All @@ -74,9 +101,9 @@ export async function authedFetch(
path: string,
init: RequestInit
): Promise<Response> {
const res = await fetch(path, init)
const res = await apiFetch(path, init)
if (res.status !== 401) return res
const refreshed = await refreshSession()
if (!refreshed) return res
return fetch(path, init)
return apiFetch(path, init)
}
6 changes: 3 additions & 3 deletions lib/api/links.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { authedFetch, jsonInit, parse } from "./client"
import { apiFetch, authedFetch, jsonInit, parse } from "./client"

export type ShortUrl = {
alias: string
Expand Down Expand Up @@ -148,7 +148,7 @@ export type CheckAliasReason =
export function checkAlias(alias: string, domain?: string) {
const q = new URLSearchParams({ alias })
if (domain) q.set("domain", domain)
return fetch(`/api/v1/shorten/check-alias?${q}`).then((r) =>
return apiFetch(`/api/v1/shorten/check-alias?${q}`).then((r) =>
parse<{ available: boolean; reason: CheckAliasReason | null }>(r)
)
}
Expand Down Expand Up @@ -187,7 +187,7 @@ export type EmojiSet = {
let emojiSetPromise: Promise<EmojiSet> | null = null
export function getEmojiSet(): Promise<EmojiSet> {
if (!emojiSetPromise) {
emojiSetPromise = fetch("/api/v1/emoji-set")
emojiSetPromise = apiFetch("/api/v1/emoji-set")
.then((r) => parse<EmojiSet>(r))
.catch((e) => {
emojiSetPromise = null
Expand Down
4 changes: 2 additions & 2 deletions lib/api/public-preview.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { parse } from "./client"
import { apiFetch, parse } from "./client"

/**
* Public link preview — the contract behind /{code}+
Expand Down Expand Up @@ -46,7 +46,7 @@ export async function getPublicPreview(
code: string,
baseUrl = "/api"
): Promise<PublicPreview> {
const res = await fetch(
const res = await apiFetch(
`${baseUrl}/v1/public/preview/${encodeURIComponent(code)}`,
{ method: "GET", cache: "no-store" }
)
Expand Down
4 changes: 2 additions & 2 deletions lib/api/public-stats.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { jsonInit, parse } from "./client"
import { apiFetch, jsonInit, parse } from "./client"
import { adaptStats, type StatsResponse, type StatsWire } from "./stats"

/**
Expand Down Expand Up @@ -64,7 +64,7 @@ export async function getPublicStats(
// re-buckets the moment the browser's timezone gets a say.
if (params.timezone) q.set("timezone", params.timezone)
const url = `${baseUrl}/v1/public/stats/${encodeURIComponent(code)}?${q}`
const res = await fetch(url, {
const res = await apiFetch(url, {
...(params.password
? jsonInit("POST", { password: params.password })
: { method: "GET" }),
Expand Down
6 changes: 3 additions & 3 deletions lib/api/reports.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { jsonInit, parse, SpooApiError } from "./client"
import { apiFetch, jsonInit, parse, SpooApiError } from "./client"

/**
* Abuse-report intake + contact — the frozen wire contract from
Expand Down Expand Up @@ -79,7 +79,7 @@ export type ReportSubmissionResult = {
export async function submitReports(
input: ReportSubmissionInput
): Promise<ReportSubmissionResult> {
const res = await fetch("/api/v1/reports", jsonInit("POST", input))
const res = await apiFetch("/api/v1/reports", jsonInit("POST", input))
return parse<ReportSubmissionResult>(res)
}

Expand All @@ -92,7 +92,7 @@ export type ContactInput = {
export async function sendContactMessage(
input: ContactInput
): Promise<{ ok: true }> {
const res = await fetch("/api/v1/contact", jsonInit("POST", input))
const res = await apiFetch("/api/v1/contact", jsonInit("POST", input))
return parse<{ ok: true }>(res)
}

Expand Down