This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 3d6b0e0d4e
commit 5386bc51d1
65 changed files with 4058 additions and 161 deletions
+14 -2
View File
@@ -19,7 +19,8 @@
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"clean": "rm -rf dist .turbo *.tsbuildinfo",
"lint": "eslint src",
"typecheck": "tsc -p tsconfig.json --noEmit"
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "jest --passWithNoTests"
},
"dependencies": {
"@sport/types": "workspace:*"
@@ -29,6 +30,17 @@
"@sport/eslint-config": "workspace:*",
"@types/node": "^22.19.0",
"eslint": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"jest": "^30.2.0",
"ts-jest": "^29.4.6",
"@types/jest": "^30.0.0"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"roots": [
"<rootDir>/src"
],
"testRegex": ".*\\.spec\\.ts$"
}
}
+6
View File
@@ -1,4 +1,6 @@
import { HttpClient, type HttpClientOptions } from './http-client';
import { createAdminResource, type AdminResource } from './resources/admin';
import { createAuthResource, type AuthResource } from './resources/auth';
import { createCatalogResource, type CatalogResource } from './resources/catalog';
import { createHealthResource, type HealthResource } from './resources/health';
@@ -12,6 +14,8 @@ export interface ApiClient {
readonly http: HttpClient;
readonly health: HealthResource;
readonly catalog: CatalogResource;
readonly auth: AuthResource;
readonly admin: AdminResource;
}
export function createApiClient(options: HttpClientOptions): ApiClient {
@@ -21,5 +25,7 @@ export function createApiClient(options: HttpClientOptions): ApiClient {
http,
health: createHealthResource(http),
catalog: createCatalogResource(http),
auth: createAuthResource(http),
admin: createAdminResource(http),
};
}
@@ -0,0 +1,89 @@
import { HttpClient } from './http-client';
/**
* Regression tests for two bugs that only manifested in a browser, and so
* survived every server-side and curl-based check.
*/
describe('HttpClient', () => {
const okResponse = () =>
new Response(JSON.stringify({ success: true, data: { ok: true }, meta: {} }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
it('calls the default fetch with the global receiver, not the client instance', async () => {
// Browsers require `fetch` to be invoked with Window as `this`. Storing
// `globalThis.fetch` on the instance and calling `this.fetchImpl(...)`
// passes the HttpClient as the receiver and throws "Illegal invocation" —
// in the browser only. Node does not care, which is precisely why every
// server-side check and every curl passed while the browser was broken.
const original = globalThis.fetch;
const receivers: unknown[] = [];
globalThis.fetch = function (this: unknown) {
// Pushed rather than assigned to a local: capturing the receiver is the
// point of the test, and a plain alias trips `no-this-alias`.
receivers.push(this);
return Promise.resolve(okResponse());
} as unknown as typeof fetch;
try {
// No fetchImpl — exercise the default path, which is the one that broke.
const client = new HttpClient({ baseUrl: 'http://api.test' });
await client.get('/thing');
} finally {
globalThis.fetch = original;
}
expect(receivers).toHaveLength(1);
expect(receivers[0]).toBe(globalThis);
expect(receivers[0]).not.toBeInstanceOf(HttpClient);
});
it('does not retry a request that opted out, so refresh cannot recurse', async () => {
// `onUnauthorized` refreshes by calling the refresh endpoint. If that call
// is itself retryable, its own 401 triggers another refresh — an unbounded
// loop that hammers the API from the browser.
let calls = 0;
let refreshes = 0;
const fetchImpl = (() => {
calls += 1;
return Promise.resolve(new Response('{}', { status: 401 }));
}) as unknown as typeof fetch;
const client = new HttpClient({
baseUrl: 'http://api.test',
fetchImpl,
onUnauthorized: () => {
refreshes += 1;
return false;
},
});
await expect(
client.post('/auth/refresh', undefined, { skipAuthRetry: true }),
).rejects.toThrow();
expect(calls).toBe(1);
expect(refreshes).toBe(0);
});
it('still offers one retry for ordinary requests', async () => {
let calls = 0;
const fetchImpl = (() => {
calls += 1;
return Promise.resolve(calls === 1 ? new Response('{}', { status: 401 }) : okResponse());
}) as unknown as typeof fetch;
const client = new HttpClient({
baseUrl: 'http://api.test',
fetchImpl,
onUnauthorized: () => true,
});
await expect(client.get('/protected')).resolves.toEqual({ ok: true });
expect(calls).toBe(2);
});
});
+61 -5
View File
@@ -3,7 +3,15 @@ import { API_ERROR_CODES, type ApiResponse } from '@sport/types';
import { ApiClientError } from './errors';
export interface HttpClientOptions {
/** Origin only, e.g. `http://localhost:4000`. The version prefix is added here. */
/**
* Origin, e.g. `http://localhost:4000` — or an empty string to call the
* current page's own origin.
*
* Same-origin is what the browser client uses: the refresh cookie is
* `SameSite=Lax` and therefore only sent first-party, so requests must go
* through the app's own host (Nginx in production, a Next.js rewrite in
* development) rather than directly to the API host.
*/
baseUrl: string;
/** Defaults to `v1`. */
apiVersion?: string;
@@ -13,7 +21,13 @@ export interface HttpClientOptions {
onUnauthorized?: () => boolean | Promise<boolean>;
defaultHeaders?: Record<string, string>;
timeoutMs?: number;
/** Injectable for tests and for runtimes with a patched fetch (Next.js). */
/**
* Injectable for tests and for runtimes with a patched fetch.
*
* Must be independently callable — pass a mock, or `window.fetch.bind(window)`.
* A bare `window.fetch` reference throws "Illegal invocation" in the browser
* because it loses its `Window` receiver.
*/
fetchImpl?: typeof fetch;
}
@@ -26,6 +40,15 @@ export interface RequestOptions {
cache?: RequestCache;
/** Send cookies (used by the refresh-token flow). */
credentials?: RequestCredentials;
/**
* Opts this request out of the automatic re-authentication retry.
*
* MANDATORY on the auth endpoints themselves. `onUnauthorized` refreshes by
* calling `/auth/refresh`; if that call is itself eligible for the retry,
* its own 401 triggers another refresh, which 401s, which triggers another —
* an unbounded loop that hammers the API from the browser. Ask how I know.
*/
skipAuthRetry?: boolean;
}
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
@@ -42,7 +65,17 @@ export class HttpClient {
this.options = options;
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
this.apiVersion = options.apiVersion ?? 'v1';
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
/**
* Wrapped, never stored bare.
*
* `globalThis.fetch` must be invoked with `Window` as its receiver in the
* browser. Assigning it to an instance property and calling
* `this.fetchImpl(...)` invokes it with the HttpClient as `this`, which
* throws "Illegal invocation" — in the browser only. Node does not care,
* so server-side rendering and curl both worked while every click in the
* browser silently failed as a network error.
*/
this.fetchImpl = options.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
}
get<T>(path: string, options?: RequestOptions): Promise<T> {
@@ -106,7 +139,12 @@ export class HttpClient {
throw ApiClientError.network(cause);
}
if (response.status === 401 && !isRetry && this.options.onUnauthorized) {
if (
response.status === 401 &&
!isRetry &&
!options?.skipAuthRetry &&
this.options.onUnauthorized
) {
const shouldRetry = await this.options.onUnauthorized();
if (shouldRetry) {
return this.request<T>(method, path, body, options, true);
@@ -122,6 +160,17 @@ export class HttpClient {
try {
payload = (await response.json()) as ApiResponse<T>;
} catch (cause) {
/**
* A non-JSON body on a 5xx means the request never reached the API —
* a reverse proxy or dev rewrite answered with an HTML error page while
* the upstream was down or restarting. Reporting that as "unreadable
* response" sends people hunting for a serialisation bug; it is an
* availability problem, and the offline message says so.
*/
if (response.status >= 500) {
throw ApiClientError.network(cause);
}
throw new ApiClientError({
code: API_ERROR_CODES.INTERNAL_ERROR,
message: 'The server returned an unreadable response.',
@@ -140,7 +189,14 @@ export class HttpClient {
private buildUrl(path: string, query?: RequestOptions['query']): string {
const normalized = path.startsWith('/') ? path : `/${path}`;
const url = new URL(`${this.baseUrl}/api/${this.apiVersion}${normalized}`);
const url = new URL(
`${this.baseUrl}/api/${this.apiVersion}${normalized}`,
// Only used when baseUrl is relative. On the server a relative baseUrl is
// a configuration error, and `new URL` will say so loudly.
this.baseUrl.startsWith('http')
? undefined
: (globalThis as { location?: { origin: string } }).location?.origin,
);
for (const [key, value] of Object.entries(query ?? {})) {
if (value === undefined || value === null || value === '') continue;
+8
View File
@@ -16,4 +16,12 @@ export { HttpClient } from './http-client';
export type { HttpClientOptions, RequestOptions } from './http-client';
export { createApiClient } from './create-client';
export type { CatalogResource, ProductListQuery } from './resources/catalog';
export type { AuthResource, LoginCredentials } from './resources/auth';
export type {
AdminResource,
CreateUserPayload,
RolePayload,
UpdateUserPayload,
UserListParams,
} from './resources/admin';
export type { ApiClient } from './create-client';
@@ -0,0 +1,84 @@
import type { OffsetPaginated, PermissionGroup, RoleDetail, UserSummary } from '@sport/types';
import type { HttpClient } from '../http-client';
export interface UserListParams {
page?: number;
perPage?: number;
q?: string;
type?: string;
status?: string;
}
export interface CreateUserPayload {
email: string;
password: string;
firstName: string;
lastName: string;
phone?: string;
type: 'STAFF' | 'ADMIN' | 'SUPER_ADMIN';
roleIds: string[];
}
export interface UpdateUserPayload {
firstName?: string;
lastName?: string;
phone?: string | null;
status?: 'ACTIVE' | 'INVITED' | 'SUSPENDED';
type?: 'STAFF' | 'ADMIN' | 'SUPER_ADMIN';
roleIds?: string[];
}
export interface RolePayload {
key?: string;
name?: string;
description?: string | null;
permissions?: string[];
}
/** Back-office administration. Every call requires an admin-audience token. */
export interface AdminResource {
listUsers(params?: UserListParams): Promise<OffsetPaginated<UserSummary>>;
getUser(id: string): Promise<UserSummary>;
createUser(payload: CreateUserPayload): Promise<UserSummary>;
updateUser(id: string, payload: UpdateUserPayload): Promise<UserSummary>;
resetUserPassword(id: string, password: string): Promise<{ ok: true }>;
listRoles(): Promise<RoleDetail[]>;
getRole(id: string): Promise<RoleDetail>;
listPermissions(): Promise<PermissionGroup[]>;
createRole(
payload: Required<Pick<RolePayload, 'key' | 'name'>> & RolePayload,
): Promise<RoleDetail>;
updateRole(id: string, payload: RolePayload): Promise<RoleDetail>;
deleteRole(id: string): Promise<void>;
}
export function createAdminResource(http: HttpClient): AdminResource {
// Administration data is never cached: an operator editing permissions must
// see the result of their own change immediately.
const uncached = { cache: 'no-store' } as const;
return {
listUsers: (params = {}) =>
http.get<OffsetPaginated<UserSummary>>('/admin/users', { ...uncached, query: { ...params } }),
getUser: (id) => http.get<UserSummary>(`/admin/users/${encodeURIComponent(id)}`, uncached),
createUser: (payload) => http.post<UserSummary>('/admin/users', payload, uncached),
updateUser: (id, payload) =>
http.patch<UserSummary>(`/admin/users/${encodeURIComponent(id)}`, payload, uncached),
resetUserPassword: (id, password) =>
http.post<{ ok: true }>(
`/admin/users/${encodeURIComponent(id)}/password`,
{ password },
uncached,
),
listRoles: () => http.get<RoleDetail[]>('/admin/roles', uncached),
getRole: (id) => http.get<RoleDetail>(`/admin/roles/${encodeURIComponent(id)}`, uncached),
listPermissions: () => http.get<PermissionGroup[]>('/admin/roles/permissions', uncached),
createRole: (payload) => http.post<RoleDetail>('/admin/roles', payload, uncached),
updateRole: (id, payload) =>
http.patch<RoleDetail>(`/admin/roles/${encodeURIComponent(id)}`, payload, uncached),
deleteRole: (id) => http.delete<void>(`/admin/roles/${encodeURIComponent(id)}`, uncached),
};
}
+51
View File
@@ -0,0 +1,51 @@
import type { CurrentUser, LoginResult, RefreshResult, SessionSummary } from '@sport/types';
import type { HttpClient } from '../http-client';
export interface LoginCredentials {
email: string;
password: string;
}
/**
* Storefront and admin have separate endpoints because they issue separate
* cookies for separate audiences. Exposing them as one method with a flag would
* make it far too easy to point a customer credential at the admin surface.
*/
export interface AuthResource {
login(credentials: LoginCredentials): Promise<LoginResult>;
adminLogin(credentials: LoginCredentials): Promise<LoginResult>;
refresh(): Promise<RefreshResult>;
adminRefresh(): Promise<RefreshResult>;
logout(): Promise<void>;
adminLogout(): Promise<void>;
me(): Promise<CurrentUser>;
sessions(): Promise<SessionSummary[]>;
}
export function createAuthResource(http: HttpClient): AuthResource {
/**
* `cache: 'no-store'` on every call. An authentication response must never be
* served from a cache — not Next's data cache, not a CDN, not the browser's.
*/
const uncached = {
cache: 'no-store',
/**
* Every auth endpoint opts out of the 401 retry. Refresh is the mechanism
* the retry *uses*, so letting it retry itself recurses without bound;
* login and logout have nothing to re-authenticate with either.
*/
skipAuthRetry: true,
} as const;
return {
login: (credentials) => http.post<LoginResult>('/auth/login', credentials, uncached),
adminLogin: (credentials) => http.post<LoginResult>('/auth/admin/login', credentials, uncached),
refresh: () => http.post<RefreshResult>('/auth/refresh', undefined, uncached),
adminRefresh: () => http.post<RefreshResult>('/auth/admin/refresh', undefined, uncached),
logout: () => http.post<void>('/auth/logout', undefined, uncached),
adminLogout: () => http.post<void>('/auth/admin/logout', undefined, uncached),
me: () => http.get<CurrentUser>('/auth/me', uncached),
sessions: () => http.get<SessionSummary[]>('/auth/sessions', uncached),
};
}