packages/ flattens into: backends/ server protocol adapters (mizan-django, mizan-ts) frontends/ client kernel + framework adapters (mizan-base, mizan-react, mizan-vue, mizan-svelte) workers/ runtime workers (mizan-ssr) cores/ shared language-level primitives (empty for now; mizan-python forthcoming) The frontend kernel (was packages/mizan-runtime, now frontends/mizan-base) is renamed to reflect its role — it's the shared base that frontend adapters depend on directly. Reflects the substrate position that per-framework adapters wrap a single shared kernel; codegen targets the adapter, not the raw kernel. Path updates landed in: Makefile, two Gitea workflows, Dockerfile.test, four example/harness config files, .claude/settings.local.json, four docs (CLAUDE/ISSUES/ROADMAP/AFI_ARCHITECTURE), four codegen templates (stage1 + react/vue/svelte adapters), and three package.jsons (the mizan-base rename plus mizan-vue/svelte peerDeps). Generated files under examples/django-react-site/harness/src/api/ still reference @mizan/runtime — left as-is; they're regenerated artifacts and the harness is non-functional pending the React wrapper-layer codegen. Also folded in a pre-existing fix: the Gitea workflows had working-directory: react / django pointing at a layout that predates packages/, never updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
525 lines
16 KiB
TypeScript
525 lines
16 KiB
TypeScript
/**
|
|
* mizan/client
|
|
*
|
|
* HTTP client factories for Django backends.
|
|
* Framework-agnostic - works with vanilla JS, React, Vue, etc.
|
|
*
|
|
* ## Quick Start
|
|
*
|
|
* ### Client-Side (CSR)
|
|
* ```ts
|
|
* import { createMizanCSRClient, Auth } from 'mizan/client'
|
|
*
|
|
* // Session-based (cookies + CSRF)
|
|
* const client = createMizanCSRClient(Auth.SESSION)
|
|
*
|
|
* // JWT-based (Bearer token)
|
|
* const client = createMizanCSRClient(Auth.JWT, { getAccessToken })
|
|
*
|
|
* const user = await client.json('GET', '/api/accounts/me/')
|
|
* ```
|
|
*
|
|
* ### Server-Side (SSR)
|
|
* ```ts
|
|
* import { createMizanSSRClient } from 'mizan/client'
|
|
*
|
|
* const client = createMizanSSRClient({
|
|
* cookies: await cookies() // Next.js cookies()
|
|
* })
|
|
*
|
|
* const user = await client.json('GET', '/api/accounts/me/')
|
|
* ```
|
|
*
|
|
* ## React Hooks
|
|
*
|
|
* For React, import from `/react`:
|
|
* ```tsx
|
|
* import { useDjangoCSRClient, Auth } from 'mizan/client/react'
|
|
*
|
|
* const client = useDjangoCSRClient(Auth.SESSION)
|
|
* ```
|
|
*/
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Authentication strategy for client-side requests.
|
|
*/
|
|
export enum Auth {
|
|
/** Session cookies with CSRF token */
|
|
SESSION = 'session',
|
|
/** JWT Bearer token */
|
|
JWT = 'jwt',
|
|
}
|
|
|
|
/**
|
|
* Cookie getter interface (matches Next.js cookies() return type).
|
|
*/
|
|
export interface CookieGetter {
|
|
get(name: string): { name: string; value: string } | undefined
|
|
getAll(): { name: string; value: string }[]
|
|
}
|
|
|
|
/**
|
|
* Cookie configuration for SSR requests.
|
|
* Can be either a cookie getter (like Next.js cookies()) or pre-extracted values.
|
|
*/
|
|
export type SSRCookies = CookieGetter | {
|
|
/** CSRF token value */
|
|
csrf: string
|
|
/** Full cookie header string */
|
|
cookieHeader: string
|
|
}
|
|
|
|
/**
|
|
* The core HTTP client interface for Django requests.
|
|
*/
|
|
export interface MizanHTTPClient {
|
|
/**
|
|
* Make an HTTP request, returning the raw Response.
|
|
*/
|
|
request(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<Response>
|
|
|
|
/**
|
|
* Make an HTTP request, parsing the response as JSON.
|
|
* @throws {HttpError} When response is not ok
|
|
*/
|
|
json<T>(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<T>
|
|
}
|
|
|
|
/**
|
|
* Configuration for CSR client.
|
|
*/
|
|
export interface CSRClientConfig {
|
|
/** Base URL for the Django backend */
|
|
baseUrl?: string | (() => string)
|
|
}
|
|
|
|
/**
|
|
* Configuration for JWT-authenticated CSR client.
|
|
*/
|
|
export interface JWTClientConfig extends CSRClientConfig {
|
|
/** Async function that returns the current access token */
|
|
getAccessToken: () => Promise<string | null>
|
|
}
|
|
|
|
/**
|
|
* Configuration for SSR client.
|
|
*/
|
|
export interface SSRClientConfig {
|
|
/** Cookies for authentication forwarding */
|
|
cookies: SSRCookies
|
|
/** Internal backend URL override (defaults to http://${INTERNAL_BACKEND_HOSTNAME}:8000) */
|
|
baseUrl?: string
|
|
}
|
|
|
|
// =============================================================================
|
|
// Errors
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Details about an HTTP error.
|
|
*/
|
|
export interface HttpErrorDetails {
|
|
status: number
|
|
statusText: string
|
|
url: string
|
|
bodyJson?: unknown
|
|
bodySnippet?: string
|
|
bodyIsHtml?: boolean
|
|
}
|
|
|
|
/**
|
|
* Error thrown when an HTTP request fails.
|
|
*/
|
|
export class HttpError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly details: HttpErrorDetails
|
|
) {
|
|
super(message)
|
|
this.name = 'HttpError'
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Internal Utilities
|
|
// =============================================================================
|
|
|
|
import { getCSRFToken, getCsrfHeaderName, getCsrfCookieName } from '../utils'
|
|
|
|
interface RequestBuild {
|
|
request: RequestInit
|
|
hasBody: boolean
|
|
}
|
|
|
|
function buildRequest(method: string, data?: unknown, headers?: Record<string, string>): RequestBuild {
|
|
const isBodyMethod = !['GET', 'HEAD'].includes(method.toUpperCase())
|
|
const hasBody = isBodyMethod && data !== undefined
|
|
|
|
const requestHeaders: Record<string, string> = {
|
|
'Accept': 'application/json',
|
|
...headers,
|
|
}
|
|
|
|
let body: BodyInit | undefined
|
|
if (hasBody) {
|
|
if (data instanceof FormData) {
|
|
body = data
|
|
} else {
|
|
body = JSON.stringify(data)
|
|
requestHeaders['Content-Type'] = 'application/json'
|
|
}
|
|
}
|
|
|
|
return {
|
|
request: {
|
|
method: method.toUpperCase(),
|
|
headers: requestHeaders,
|
|
body,
|
|
credentials: 'include',
|
|
},
|
|
hasBody,
|
|
}
|
|
}
|
|
|
|
async function buildHttpError(resp: Response, url: URL | string): Promise<HttpError> {
|
|
const urlStr = typeof url === 'string' ? url : url.toString()
|
|
const details: HttpErrorDetails = {
|
|
status: resp.status,
|
|
statusText: resp.statusText,
|
|
url: urlStr,
|
|
}
|
|
|
|
try {
|
|
const contentType = resp.headers.get('content-type') ?? ''
|
|
if (contentType.includes('application/json')) {
|
|
details.bodyJson = await resp.clone().json()
|
|
} else {
|
|
const text = await resp.clone().text()
|
|
details.bodyIsHtml = contentType.includes('text/html')
|
|
details.bodySnippet = text.slice(0, 500)
|
|
}
|
|
} catch {
|
|
// Ignore body parsing errors
|
|
}
|
|
|
|
return new HttpError(`Request failed: ${resp.status} ${resp.statusText}`, details)
|
|
}
|
|
|
|
// =============================================================================
|
|
// CSR Client Factory
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Create a client-side HTTP client for Django.
|
|
*
|
|
* @param auth - Authentication strategy (Auth.SESSION or Auth.JWT)
|
|
* @param config - Client configuration
|
|
* @returns MizanHTTPClient
|
|
*
|
|
* @example
|
|
* // Session-based
|
|
* const client = createMizanCSRClient(Auth.SESSION)
|
|
*
|
|
* @example
|
|
* // JWT-based
|
|
* const client = createMizanCSRClient(Auth.JWT, {
|
|
* getAccessToken: async () => localStorage.getItem('token')
|
|
* })
|
|
*/
|
|
export function createMizanCSRClient(auth: Auth.SESSION, config?: CSRClientConfig): MizanHTTPClient
|
|
export function createMizanCSRClient(auth: Auth.JWT, config: JWTClientConfig): MizanHTTPClient
|
|
export function createMizanCSRClient(
|
|
auth: Auth,
|
|
config?: CSRClientConfig | JWTClientConfig
|
|
): MizanHTTPClient {
|
|
if (!config?.baseUrl) {
|
|
throw new Error(
|
|
'baseUrl is required. Pass it via config or use MizanProvider which provides it automatically.'
|
|
)
|
|
}
|
|
|
|
const getBaseUrl = () => typeof config.baseUrl === 'function' ? config.baseUrl() : config.baseUrl!
|
|
|
|
const getHeaders = async (): Promise<Record<string, string>> => {
|
|
if (auth === Auth.JWT) {
|
|
const jwtConfig = config as JWTClientConfig
|
|
const token = await jwtConfig.getAccessToken()
|
|
if (token) {
|
|
return { Authorization: `Bearer ${token}` }
|
|
}
|
|
return {}
|
|
}
|
|
// Session auth uses CSRF
|
|
return { [getCsrfHeaderName()]: getCSRFToken() ?? '' }
|
|
}
|
|
|
|
function resolveUrl(path: string): string {
|
|
const base = getBaseUrl()
|
|
// Absolute base URL — use URL constructor
|
|
if (base.startsWith('http://') || base.startsWith('https://')) {
|
|
return new URL(path, base).toString()
|
|
}
|
|
// Relative base — path is already usable by fetch in a browser
|
|
return path
|
|
}
|
|
|
|
return {
|
|
request: async (method, path, data?, headers?) => {
|
|
const url = resolveUrl(path)
|
|
const configHeaders = await getHeaders()
|
|
const build = buildRequest(method, data, { ...configHeaders, ...headers })
|
|
return fetch(url, build.request)
|
|
},
|
|
|
|
json: async <T>(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<T> => {
|
|
const url = resolveUrl(path)
|
|
const configHeaders = await getHeaders()
|
|
const build = buildRequest(method, data, { ...configHeaders, ...headers })
|
|
const resp = await fetch(url, build.request)
|
|
|
|
if (!resp.ok) {
|
|
throw await buildHttpError(resp, url)
|
|
}
|
|
|
|
return resp.json()
|
|
},
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Internal Backend URL Resolution
|
|
// =============================================================================
|
|
|
|
function getInternalBackendUrl(override?: string): string {
|
|
if (override) return override
|
|
throw new Error(
|
|
'baseUrl is required for SSR client. Pass it via config.'
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// SSR Client Factory
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Check if cookies is a CookieGetter interface.
|
|
*/
|
|
function isCookieGetter(cookies: SSRCookies): cookies is CookieGetter {
|
|
return typeof (cookies as CookieGetter).get === 'function'
|
|
}
|
|
|
|
/**
|
|
* Extract CSRF token and cookie header from SSRCookies.
|
|
*/
|
|
function extractCookies(cookies: SSRCookies): { csrf: string; cookieHeader: string } {
|
|
if (isCookieGetter(cookies)) {
|
|
return {
|
|
csrf: cookies.get(getCsrfCookieName())?.value ?? '',
|
|
cookieHeader: cookies.getAll().map(c => `${c.name}=${c.value}`).join('; ')
|
|
}
|
|
}
|
|
return cookies
|
|
}
|
|
|
|
/**
|
|
* Create a server-side HTTP client for Django.
|
|
* Used in SSR contexts (Next.js server components, server actions, etc.)
|
|
*
|
|
* @param config - SSR client configuration with cookies
|
|
* @returns MizanHTTPClient
|
|
*
|
|
* @example
|
|
* // Next.js server component
|
|
* import { cookies } from 'next/headers'
|
|
*
|
|
* const client = createMizanSSRClient({ cookies: await cookies() })
|
|
*/
|
|
// Re-export auth types for non-React usage
|
|
export type {
|
|
BaseUser,
|
|
AuthDetails,
|
|
AuthRoutes,
|
|
JWTTokens,
|
|
JWTConfig,
|
|
JWTState,
|
|
} from './types'
|
|
|
|
export function createMizanSSRClient(config: SSRClientConfig): MizanHTTPClient {
|
|
const baseUrl = getInternalBackendUrl(config.baseUrl)
|
|
const { csrf, cookieHeader } = extractCookies(config.cookies)
|
|
|
|
return {
|
|
request: async (method, path, data?, headers?) => {
|
|
const url = new URL(path, baseUrl)
|
|
|
|
const requestHeaders: Record<string, string> = {
|
|
'Accept': 'application/json',
|
|
[getCsrfHeaderName()]: csrf,
|
|
'Cookie': cookieHeader,
|
|
...headers,
|
|
}
|
|
|
|
let body: BodyInit | undefined
|
|
if (data && !['GET', 'HEAD'].includes(method.toUpperCase())) {
|
|
if (data instanceof FormData) {
|
|
body = data
|
|
} else {
|
|
body = JSON.stringify(data)
|
|
requestHeaders['Content-Type'] = 'application/json'
|
|
}
|
|
}
|
|
|
|
return fetch(url, {
|
|
method: method.toUpperCase(),
|
|
headers: requestHeaders,
|
|
body,
|
|
credentials: 'include',
|
|
cache: 'no-store',
|
|
})
|
|
},
|
|
|
|
json: async <T>(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<T> => {
|
|
const url = new URL(path, baseUrl)
|
|
|
|
const requestHeaders: Record<string, string> = {
|
|
'Accept': 'application/json',
|
|
[getCsrfHeaderName()]: csrf,
|
|
'Cookie': cookieHeader,
|
|
...headers,
|
|
}
|
|
|
|
let body: BodyInit | undefined
|
|
if (data && !['GET', 'HEAD'].includes(method.toUpperCase())) {
|
|
if (data instanceof FormData) {
|
|
body = data
|
|
} else {
|
|
body = JSON.stringify(data)
|
|
requestHeaders['Content-Type'] = 'application/json'
|
|
}
|
|
}
|
|
|
|
const resp = await fetch(url, {
|
|
method: method.toUpperCase(),
|
|
headers: requestHeaders,
|
|
body,
|
|
credentials: 'include',
|
|
cache: 'no-store',
|
|
})
|
|
|
|
if (!resp.ok) {
|
|
throw await buildHttpError(resp, url)
|
|
}
|
|
|
|
const contentType = resp.headers.get('content-type') ?? ''
|
|
if (!contentType.includes('application/json')) {
|
|
throw new Error(`Expected JSON response but got ${contentType}`)
|
|
}
|
|
|
|
return resp.json()
|
|
},
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// SSR Session Initialization
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Response from the session initialization endpoint.
|
|
*/
|
|
interface SessionInitResponse {
|
|
csrfToken: string
|
|
}
|
|
|
|
/**
|
|
* Ensure a Django session exists before making SSR requests.
|
|
*
|
|
* On first visit, the user has no cookies. This function pings Django to
|
|
* establish a session and get a CSRF token, which can then be used for
|
|
* subsequent hydration requests in the same SSR request chain.
|
|
*
|
|
* Note: Browser cookie forwarding is handled by Next.js middleware, not this
|
|
* function. This function only ensures cookies exist for SSR data fetching.
|
|
*
|
|
* @param config - SSR client configuration with cookies
|
|
* @returns Object with csrf token and cookie header for use in SSR requests
|
|
*
|
|
* @example
|
|
* // In layout.tsx
|
|
* const cookieStore = await cookies()
|
|
* const session = await ensureMizanSession({ cookies: cookieStore })
|
|
* const client = createMizanSSRClient({
|
|
* cookies: { csrf: session.csrf, cookieHeader: session.cookieHeader }
|
|
* })
|
|
*/
|
|
export async function ensureMizanSession(config: SSRClientConfig): Promise<{
|
|
csrf: string
|
|
cookieHeader: string
|
|
}> {
|
|
const baseUrl = getInternalBackendUrl(config.baseUrl)
|
|
const { csrf: existingCsrf, cookieHeader: existingCookies } = extractCookies(config.cookies)
|
|
|
|
// If we already have a CSRF token, just return existing cookies
|
|
if (existingCsrf) {
|
|
return { csrf: existingCsrf, cookieHeader: existingCookies }
|
|
}
|
|
|
|
// No CSRF token - need to initialize session
|
|
const url = new URL('/api/mizan/session/', baseUrl)
|
|
const resp = await fetch(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'Cookie': existingCookies,
|
|
},
|
|
credentials: 'include',
|
|
cache: 'no-store',
|
|
})
|
|
|
|
if (!resp.ok) {
|
|
console.error('[mizan] Failed to initialize session:', resp.status, resp.statusText)
|
|
return { csrf: '', cookieHeader: existingCookies }
|
|
}
|
|
|
|
// Extract CSRF token from response body
|
|
const data: SessionInitResponse = await resp.json()
|
|
|
|
// Extract Set-Cookie headers to build updated cookie string for SSR chain
|
|
const setCookieHeaders = resp.headers.getSetCookie?.() ?? []
|
|
const newCookies = setCookieHeaders.map(c => c.split(';')[0]).join('; ')
|
|
const combinedCookies = existingCookies
|
|
? `${existingCookies}; ${newCookies}`
|
|
: newCookies
|
|
|
|
return {
|
|
csrf: data.csrfToken,
|
|
cookieHeader: combinedCookies,
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Server Function HTTP Call
|
|
// =============================================================================
|
|
|
|
// Re-export error types from the canonical location
|
|
export type { FunctionErrorResponse } from '../errors'
|
|
import { MizanError, type FunctionErrorResponse } from '../errors'
|
|
|
|
/**
|
|
* Success response from a server function
|
|
*/
|
|
export interface FunctionSuccessResponse<T> {
|
|
result: T
|
|
invalidate?: Array<string | { context: string; params: Record<string, any> }>
|
|
}
|
|
|
|
/**
|
|
* Union type for server function responses
|
|
*/
|
|
export type FunctionResponse<T> = FunctionSuccessResponse<T> | FunctionErrorResponse
|
|
|