@spfn/core 0.1.0-alpha.56 → 0.1.0-alpha.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{auto-loader-s1OVKtxe.d.ts → auto-loader-CdsxOceW.d.ts} +11 -11
- package/dist/client/index.d.ts +3 -13
- package/dist/client/index.js +3 -21
- package/dist/client/index.js.map +1 -1
- package/dist/codegen/generators/index.js +92 -20
- package/dist/codegen/generators/index.js.map +1 -1
- package/dist/codegen/index.d.ts +6 -2
- package/dist/codegen/index.js +91 -19
- package/dist/codegen/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +71 -137
- package/dist/index.js.map +1 -1
- package/dist/route/index.d.ts +1 -1
- package/dist/route/index.js +74 -142
- package/dist/route/index.js.map +1 -1
- package/dist/server/index.js +71 -137
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -38,8 +38,7 @@ type RouteStats = {
|
|
|
38
38
|
declare class AutoRouteLoader {
|
|
39
39
|
private routesDir;
|
|
40
40
|
private routes;
|
|
41
|
-
private
|
|
42
|
-
private debug;
|
|
41
|
+
private readonly debug;
|
|
43
42
|
private readonly middlewares;
|
|
44
43
|
constructor(routesDir: string, debug?: boolean, middlewares?: Array<{
|
|
45
44
|
name: string;
|
|
@@ -47,23 +46,24 @@ declare class AutoRouteLoader {
|
|
|
47
46
|
}>);
|
|
48
47
|
load(app: Hono): Promise<RouteStats>;
|
|
49
48
|
/**
|
|
50
|
-
* Load routes from
|
|
51
|
-
*
|
|
49
|
+
* Load routes from an external directory (e.g., from SPFN function packages)
|
|
50
|
+
* Routes use contract-based absolute paths, so no basePath prefix needed
|
|
51
|
+
*
|
|
52
|
+
* @param app - Hono app instance
|
|
53
|
+
* @param routesDir - Directory containing route handlers
|
|
54
|
+
* @param packageName - Name of the package (for logging)
|
|
55
|
+
* @returns Route statistics
|
|
52
56
|
*/
|
|
53
|
-
|
|
57
|
+
loadExternalRoutes(app: Hono, routesDir: string, packageName: string): Promise<RouteStats>;
|
|
54
58
|
getStats(): RouteStats;
|
|
55
59
|
private scanFiles;
|
|
56
60
|
private isValidRouteFile;
|
|
57
61
|
private loadRoute;
|
|
62
|
+
private extractContractPaths;
|
|
63
|
+
private calculateContractPriority;
|
|
58
64
|
private validateModule;
|
|
59
|
-
private checkRouteConflict;
|
|
60
65
|
private registerContractBasedMiddlewares;
|
|
61
|
-
private registerFileBasedMiddlewares;
|
|
62
66
|
private categorizeAndLogError;
|
|
63
|
-
private fileToPath;
|
|
64
|
-
private calculatePriority;
|
|
65
|
-
private normalizePath;
|
|
66
|
-
private logRegistrationOrder;
|
|
67
67
|
private logStats;
|
|
68
68
|
}
|
|
69
69
|
declare function loadRoutes(app: Hono, options?: {
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import '../auto-loader-
|
|
1
|
+
import '../auto-loader-CdsxOceW.js';
|
|
2
2
|
import { R as RouteContract, I as InferContract } from '../types-Bd8YsFSU.js';
|
|
3
3
|
import 'hono';
|
|
4
4
|
import 'hono/utils/http-status';
|
|
@@ -60,24 +60,14 @@ declare class ContractClient {
|
|
|
60
60
|
/**
|
|
61
61
|
* Make a type-safe API call using a contract
|
|
62
62
|
*
|
|
63
|
-
* @param
|
|
64
|
-
* @param contract - Route contract
|
|
63
|
+
* @param contract - Route contract with absolute path
|
|
65
64
|
* @param options - Call options (params, query, body, headers)
|
|
66
65
|
*/
|
|
67
|
-
call<TContract extends RouteContract>(
|
|
66
|
+
call<TContract extends RouteContract>(contract: TContract, options?: CallOptions<TContract>): Promise<InferContract<TContract>['response']>;
|
|
68
67
|
/**
|
|
69
68
|
* Create a new client with merged configuration
|
|
70
69
|
*/
|
|
71
70
|
withConfig(config: Partial<ClientConfig>): ContractClient;
|
|
72
|
-
/**
|
|
73
|
-
* Combine basePath and contract.path, handling trailing/leading slashes
|
|
74
|
-
*
|
|
75
|
-
* @example
|
|
76
|
-
* combinePaths('/organizations', '/') → '/organizations'
|
|
77
|
-
* combinePaths('/organizations', '/:id') → '/organizations/:id'
|
|
78
|
-
* combinePaths('/', '/health') → '/health'
|
|
79
|
-
*/
|
|
80
|
-
private static combinePaths;
|
|
81
71
|
private static buildUrl;
|
|
82
72
|
private static buildQuery;
|
|
83
73
|
private static getHttpMethod;
|
package/dist/client/index.js
CHANGED
|
@@ -29,15 +29,13 @@ var ContractClient = class _ContractClient {
|
|
|
29
29
|
/**
|
|
30
30
|
* Make a type-safe API call using a contract
|
|
31
31
|
*
|
|
32
|
-
* @param
|
|
33
|
-
* @param contract - Route contract
|
|
32
|
+
* @param contract - Route contract with absolute path
|
|
34
33
|
* @param options - Call options (params, query, body, headers)
|
|
35
34
|
*/
|
|
36
|
-
async call(
|
|
35
|
+
async call(contract, options) {
|
|
37
36
|
const baseUrl = options?.baseUrl || this.config.baseUrl;
|
|
38
|
-
const combinedPath = _ContractClient.combinePaths(basePath, contract.path);
|
|
39
37
|
const urlPath = _ContractClient.buildUrl(
|
|
40
|
-
|
|
38
|
+
contract.path,
|
|
41
39
|
options?.params
|
|
42
40
|
);
|
|
43
41
|
const queryString = _ContractClient.buildQuery(
|
|
@@ -113,22 +111,6 @@ var ContractClient = class _ContractClient {
|
|
|
113
111
|
fetch: config.fetch || this.config.fetch
|
|
114
112
|
});
|
|
115
113
|
}
|
|
116
|
-
/**
|
|
117
|
-
* Combine basePath and contract.path, handling trailing/leading slashes
|
|
118
|
-
*
|
|
119
|
-
* @example
|
|
120
|
-
* combinePaths('/organizations', '/') → '/organizations'
|
|
121
|
-
* combinePaths('/organizations', '/:id') → '/organizations/:id'
|
|
122
|
-
* combinePaths('/', '/health') → '/health'
|
|
123
|
-
*/
|
|
124
|
-
static combinePaths(basePath, contractPath) {
|
|
125
|
-
const normalizedBase = basePath === "/" ? "" : basePath.replace(/\/$/, "");
|
|
126
|
-
const normalizedContract = contractPath.replace(/^\//, "");
|
|
127
|
-
if (!normalizedContract || normalizedContract === "") {
|
|
128
|
-
return basePath;
|
|
129
|
-
}
|
|
130
|
-
return `${normalizedBase}/${normalizedContract}`;
|
|
131
|
-
}
|
|
132
114
|
static buildUrl(path, params) {
|
|
133
115
|
if (!params) return path;
|
|
134
116
|
let url = path;
|
package/dist/client/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/client/contract-client.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Contract-Based API Client\n *\n * Type-safe HTTP client that works with RouteContract for full end-to-end type safety\n */\nimport type { RouteContract, InferContract } from '../route';\n\nexport type RequestInterceptor = (\n url: string,\n init: RequestInit\n) => Promise<RequestInit> | RequestInit;\n\nexport interface ClientConfig\n{\n /**\n * API base URL (e.g., http://localhost:4000)\n */\n baseUrl?: string;\n\n /**\n * Default headers to include in all requests\n */\n headers?: Record<string, string>;\n\n /**\n * Request timeout in milliseconds\n */\n timeout?: number;\n\n /**\n * Custom fetch implementation\n */\n fetch?: typeof fetch;\n}\n\nexport interface CallOptions<TContract extends RouteContract>\n{\n params?: InferContract<TContract>['params'];\n query?: InferContract<TContract>['query'];\n body?: InferContract<TContract>['body'];\n headers?: Record<string, string>;\n baseUrl?: string;\n}\n\n/**\n * API Client Error\n */\nexport class ApiClientError extends Error\n{\n constructor(\n message: string,\n public readonly status: number,\n public readonly url: string,\n public readonly response?: unknown,\n public readonly errorType?: 'timeout' | 'network' | 'http'\n )\n {\n super(message);\n this.name = 'ApiClientError';\n }\n}\n\n/**\n * Contract-based API Client\n */\nexport class ContractClient\n{\n private readonly config: Required<ClientConfig>;\n private readonly interceptors: RequestInterceptor[] = [];\n\n constructor(config: ClientConfig = {})\n {\n this.config = {\n baseUrl: config.baseUrl || process.env.SERVER_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',\n headers: config.headers || {},\n timeout: config.timeout || 30000,\n fetch: config.fetch || globalThis.fetch.bind(globalThis),\n };\n }\n\n /**\n * Add request interceptor\n */\n use(interceptor: RequestInterceptor): void\n {\n this.interceptors.push(interceptor);\n }\n\n /**\n * Make a type-safe API call using a contract\n *\n * @param basePath - Base path from file-based routing (e.g., '/organizations')\n * @param contract - Route contract\n * @param options - Call options (params, query, body, headers)\n */\n async call<TContract extends RouteContract>(\n basePath: string,\n contract: TContract,\n options?: CallOptions<TContract>\n ): Promise<InferContract<TContract>['response']>\n {\n const baseUrl = options?.baseUrl || this.config.baseUrl;\n\n // Combine basePath and contract.path, handling trailing/leading slashes\n const combinedPath = ContractClient.combinePaths(basePath, contract.path);\n\n const urlPath = ContractClient.buildUrl(\n combinedPath,\n options?.params as Record<string, string | number> | undefined\n );\n const queryString = ContractClient.buildQuery(\n options?.query as Record<string, string | string[] | number | boolean> | undefined\n );\n const url = `${baseUrl}${urlPath}${queryString}`;\n\n const method = ContractClient.getHttpMethod(contract, options);\n\n const headers: Record<string, string> = {\n ...this.config.headers,\n ...options?.headers,\n };\n\n const isFormData = ContractClient.isFormData(options?.body);\n\n if (options?.body !== undefined && !isFormData && !headers['Content-Type'])\n {\n headers['Content-Type'] = 'application/json';\n }\n\n let init: RequestInit = {\n method,\n headers,\n };\n\n if (options?.body !== undefined)\n {\n init.body = isFormData ? (options.body as FormData) : JSON.stringify(options.body);\n }\n\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);\n init.signal = controller.signal;\n\n for (const interceptor of this.interceptors)\n {\n init = await interceptor(url, init);\n }\n\n const response = await this.config.fetch(url, init).catch((error) =>\n {\n clearTimeout(timeoutId);\n\n if (error instanceof Error && error.name === 'AbortError')\n {\n throw new ApiClientError(\n `Request timed out after ${this.config.timeout}ms`,\n 0,\n url,\n undefined,\n 'timeout'\n );\n }\n\n if (error instanceof Error)\n {\n throw new ApiClientError(\n `Network error: ${error.message}`,\n 0,\n url,\n undefined,\n 'network'\n );\n }\n\n throw error;\n });\n\n clearTimeout(timeoutId);\n\n if (!response.ok)\n {\n const errorBody = await response.json().catch(() => null);\n throw new ApiClientError(\n `${method} ${urlPath} failed: ${response.status} ${response.statusText}`,\n response.status,\n url,\n errorBody,\n 'http'\n );\n }\n\n const data = await response.json();\n return data as InferContract<TContract>['response'];\n }\n\n /**\n * Create a new client with merged configuration\n */\n withConfig(config: Partial<ClientConfig>): ContractClient\n {\n return new ContractClient({\n baseUrl: config.baseUrl || this.config.baseUrl,\n headers: { ...this.config.headers, ...config.headers },\n timeout: config.timeout || this.config.timeout,\n fetch: config.fetch || this.config.fetch,\n });\n }\n\n /**\n * Combine basePath and contract.path, handling trailing/leading slashes\n *\n * @example\n * combinePaths('/organizations', '/') → '/organizations'\n * combinePaths('/organizations', '/:id') → '/organizations/:id'\n * combinePaths('/', '/health') → '/health'\n */\n private static combinePaths(basePath: string, contractPath: string): string\n {\n // Remove trailing slash from basePath (unless it's root '/')\n const normalizedBase = basePath === '/' ? '' : basePath.replace(/\\/$/, '');\n\n // Remove leading slash from contractPath\n const normalizedContract = contractPath.replace(/^\\//, '');\n\n // If contractPath is empty or just '/', return basePath\n if (!normalizedContract || normalizedContract === '')\n {\n return basePath;\n }\n\n // Combine with single slash\n return `${normalizedBase}/${normalizedContract}`;\n }\n\n private static buildUrl(path: string, params?: Record<string, string | number>): string\n {\n if (!params) return path;\n\n let url = path;\n for (const [key, value] of Object.entries(params))\n {\n url = url.replace(`:${key}`, String(value));\n }\n\n return url;\n }\n\n private static buildQuery(query?: Record<string, string | string[] | number | boolean>): string\n {\n if (!query || Object.keys(query).length === 0) return '';\n\n const params = new URLSearchParams();\n for (const [key, value] of Object.entries(query))\n {\n if (Array.isArray(value))\n {\n value.forEach((v) => params.append(key, String(v)));\n }\n else if (value !== undefined && value !== null)\n {\n params.append(key, String(value));\n }\n }\n\n const queryString = params.toString();\n return queryString ? `?${queryString}` : '';\n }\n\n private static getHttpMethod<TContract extends RouteContract>(\n contract: TContract,\n options?: CallOptions<TContract>\n ): string\n {\n if ('method' in contract && typeof contract.method === 'string')\n {\n return contract.method.toUpperCase();\n }\n\n if (options?.body !== undefined)\n {\n return 'POST';\n }\n\n return 'GET';\n }\n\n private static isFormData(body: unknown): body is FormData\n {\n return body instanceof FormData;\n }\n}\n\n/**\n * Create a new contract-based API client\n */\nexport function createClient(config?: ClientConfig): ContractClient\n{\n return new ContractClient(config);\n}\n\n/**\n * Global client singleton instance\n */\nlet _clientInstance: ContractClient = new ContractClient();\n\n/**\n * Configure the global client instance\n *\n * Call this in your app initialization to set default configuration\n * for all auto-generated API calls.\n *\n * @example\n * ```ts\n * // In app initialization (layout.tsx, _app.tsx, etc)\n * import { configureClient } from '@spfn/core/client';\n *\n * configureClient({\n * baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',\n * timeout: 60000,\n * headers: {\n * 'X-App-Version': '1.0.0'\n * }\n * });\n *\n * // Add interceptors\n * import { client } from '@spfn/core/client';\n * client.use(async (url, init) => {\n * // Add auth header\n * return {\n * ...init,\n * headers: {\n * ...init.headers,\n * Authorization: `Bearer ${getToken()}`\n * }\n * };\n * });\n * ```\n */\nexport function configureClient(config: ClientConfig): void\n{\n _clientInstance = new ContractClient(config);\n}\n\n/**\n * Global client singleton with Proxy\n *\n * This client can be configured using configureClient() before use.\n * Used by auto-generated API client code.\n */\nexport const client = new Proxy({} as ContractClient, {\n get(_target, prop)\n {\n return _clientInstance[prop as keyof ContractClient];\n }\n});\n\n/**\n * Type guard for timeout errors\n *\n * @example\n * ```ts\n * try {\n * await api.users.getById({ params: { id: '123' } });\n * } catch (error) {\n * if (isTimeoutError(error)) {\n * console.error('Request timed out, retrying...');\n * // Implement retry logic\n * }\n * }\n * ```\n */\nexport function isTimeoutError(error: unknown): error is ApiClientError\n{\n return error instanceof ApiClientError && error.errorType === 'timeout';\n}\n\n/**\n * Type guard for network errors\n *\n * @example\n * ```ts\n * try {\n * await api.users.list();\n * } catch (error) {\n * if (isNetworkError(error)) {\n * showOfflineMessage();\n * }\n * }\n * ```\n */\nexport function isNetworkError(error: unknown): error is ApiClientError\n{\n return error instanceof ApiClientError && error.errorType === 'network';\n}\n\n/**\n * Type guard for HTTP errors (4xx, 5xx)\n *\n * @example\n * ```ts\n * try {\n * await api.users.create({ body: userData });\n * } catch (error) {\n * if (isHttpError(error)) {\n * if (error.status === 401) {\n * redirectToLogin();\n * } else if (error.status === 404) {\n * showNotFoundMessage();\n * }\n * }\n * }\n * ```\n */\nexport function isHttpError(error: unknown): error is ApiClientError\n{\n return error instanceof ApiClientError && error.errorType === 'http';\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/client/contract-client.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Contract-Based API Client\n *\n * Type-safe HTTP client that works with RouteContract for full end-to-end type safety\n */\nimport type { RouteContract, InferContract } from '../route';\n\nexport type RequestInterceptor = (\n url: string,\n init: RequestInit\n) => Promise<RequestInit> | RequestInit;\n\nexport interface ClientConfig\n{\n /**\n * API base URL (e.g., http://localhost:4000)\n */\n baseUrl?: string;\n\n /**\n * Default headers to include in all requests\n */\n headers?: Record<string, string>;\n\n /**\n * Request timeout in milliseconds\n */\n timeout?: number;\n\n /**\n * Custom fetch implementation\n */\n fetch?: typeof fetch;\n}\n\nexport interface CallOptions<TContract extends RouteContract>\n{\n params?: InferContract<TContract>['params'];\n query?: InferContract<TContract>['query'];\n body?: InferContract<TContract>['body'];\n headers?: Record<string, string>;\n baseUrl?: string;\n}\n\n/**\n * API Client Error\n */\nexport class ApiClientError extends Error\n{\n constructor(\n message: string,\n public readonly status: number,\n public readonly url: string,\n public readonly response?: unknown,\n public readonly errorType?: 'timeout' | 'network' | 'http'\n )\n {\n super(message);\n this.name = 'ApiClientError';\n }\n}\n\n/**\n * Contract-based API Client\n */\nexport class ContractClient\n{\n private readonly config: Required<ClientConfig>;\n private readonly interceptors: RequestInterceptor[] = [];\n\n constructor(config: ClientConfig = {})\n {\n this.config = {\n baseUrl: config.baseUrl || process.env.SERVER_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',\n headers: config.headers || {},\n timeout: config.timeout || 30000,\n fetch: config.fetch || globalThis.fetch.bind(globalThis),\n };\n }\n\n /**\n * Add request interceptor\n */\n use(interceptor: RequestInterceptor): void\n {\n this.interceptors.push(interceptor);\n }\n\n /**\n * Make a type-safe API call using a contract\n *\n * @param contract - Route contract with absolute path\n * @param options - Call options (params, query, body, headers)\n */\n async call<TContract extends RouteContract>(\n contract: TContract,\n options?: CallOptions<TContract>\n ): Promise<InferContract<TContract>['response']>\n {\n const baseUrl = options?.baseUrl || this.config.baseUrl;\n\n // Use contract.path directly (contracts use absolute paths)\n const urlPath = ContractClient.buildUrl(\n contract.path,\n options?.params as Record<string, string | number> | undefined\n );\n const queryString = ContractClient.buildQuery(\n options?.query as Record<string, string | string[] | number | boolean> | undefined\n );\n const url = `${baseUrl}${urlPath}${queryString}`;\n\n const method = ContractClient.getHttpMethod(contract, options);\n\n const headers: Record<string, string> = {\n ...this.config.headers,\n ...options?.headers,\n };\n\n const isFormData = ContractClient.isFormData(options?.body);\n\n if (options?.body !== undefined && !isFormData && !headers['Content-Type'])\n {\n headers['Content-Type'] = 'application/json';\n }\n\n let init: RequestInit = {\n method,\n headers,\n };\n\n if (options?.body !== undefined)\n {\n init.body = isFormData ? (options.body as FormData) : JSON.stringify(options.body);\n }\n\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);\n init.signal = controller.signal;\n\n for (const interceptor of this.interceptors)\n {\n init = await interceptor(url, init);\n }\n\n const response = await this.config.fetch(url, init).catch((error) =>\n {\n clearTimeout(timeoutId);\n\n if (error instanceof Error && error.name === 'AbortError')\n {\n throw new ApiClientError(\n `Request timed out after ${this.config.timeout}ms`,\n 0,\n url,\n undefined,\n 'timeout'\n );\n }\n\n if (error instanceof Error)\n {\n throw new ApiClientError(\n `Network error: ${error.message}`,\n 0,\n url,\n undefined,\n 'network'\n );\n }\n\n throw error;\n });\n\n clearTimeout(timeoutId);\n\n if (!response.ok)\n {\n const errorBody = await response.json().catch(() => null);\n throw new ApiClientError(\n `${method} ${urlPath} failed: ${response.status} ${response.statusText}`,\n response.status,\n url,\n errorBody,\n 'http'\n );\n }\n\n const data = await response.json();\n return data as InferContract<TContract>['response'];\n }\n\n /**\n * Create a new client with merged configuration\n */\n withConfig(config: Partial<ClientConfig>): ContractClient\n {\n return new ContractClient({\n baseUrl: config.baseUrl || this.config.baseUrl,\n headers: { ...this.config.headers, ...config.headers },\n timeout: config.timeout || this.config.timeout,\n fetch: config.fetch || this.config.fetch,\n });\n }\n\n private static buildUrl(path: string, params?: Record<string, string | number>): string\n {\n if (!params) return path;\n\n let url = path;\n for (const [key, value] of Object.entries(params))\n {\n url = url.replace(`:${key}`, String(value));\n }\n\n return url;\n }\n\n private static buildQuery(query?: Record<string, string | string[] | number | boolean>): string\n {\n if (!query || Object.keys(query).length === 0) return '';\n\n const params = new URLSearchParams();\n for (const [key, value] of Object.entries(query))\n {\n if (Array.isArray(value))\n {\n value.forEach((v) => params.append(key, String(v)));\n }\n else if (value !== undefined && value !== null)\n {\n params.append(key, String(value));\n }\n }\n\n const queryString = params.toString();\n return queryString ? `?${queryString}` : '';\n }\n\n private static getHttpMethod<TContract extends RouteContract>(\n contract: TContract,\n options?: CallOptions<TContract>\n ): string\n {\n if ('method' in contract && typeof contract.method === 'string')\n {\n return contract.method.toUpperCase();\n }\n\n if (options?.body !== undefined)\n {\n return 'POST';\n }\n\n return 'GET';\n }\n\n private static isFormData(body: unknown): body is FormData\n {\n return body instanceof FormData;\n }\n}\n\n/**\n * Create a new contract-based API client\n */\nexport function createClient(config?: ClientConfig): ContractClient\n{\n return new ContractClient(config);\n}\n\n/**\n * Global client singleton instance\n */\nlet _clientInstance: ContractClient = new ContractClient();\n\n/**\n * Configure the global client instance\n *\n * Call this in your app initialization to set default configuration\n * for all auto-generated API calls.\n *\n * @example\n * ```ts\n * // In app initialization (layout.tsx, _app.tsx, etc)\n * import { configureClient } from '@spfn/core/client';\n *\n * configureClient({\n * baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',\n * timeout: 60000,\n * headers: {\n * 'X-App-Version': '1.0.0'\n * }\n * });\n *\n * // Add interceptors\n * import { client } from '@spfn/core/client';\n * client.use(async (url, init) => {\n * // Add auth header\n * return {\n * ...init,\n * headers: {\n * ...init.headers,\n * Authorization: `Bearer ${getToken()}`\n * }\n * };\n * });\n * ```\n */\nexport function configureClient(config: ClientConfig): void\n{\n _clientInstance = new ContractClient(config);\n}\n\n/**\n * Global client singleton with Proxy\n *\n * This client can be configured using configureClient() before use.\n * Used by auto-generated API client code.\n */\nexport const client = new Proxy({} as ContractClient, {\n get(_target, prop)\n {\n return _clientInstance[prop as keyof ContractClient];\n }\n});\n\n/**\n * Type guard for timeout errors\n *\n * @example\n * ```ts\n * try {\n * await api.users.getById({ params: { id: '123' } });\n * } catch (error) {\n * if (isTimeoutError(error)) {\n * console.error('Request timed out, retrying...');\n * // Implement retry logic\n * }\n * }\n * ```\n */\nexport function isTimeoutError(error: unknown): error is ApiClientError\n{\n return error instanceof ApiClientError && error.errorType === 'timeout';\n}\n\n/**\n * Type guard for network errors\n *\n * @example\n * ```ts\n * try {\n * await api.users.list();\n * } catch (error) {\n * if (isNetworkError(error)) {\n * showOfflineMessage();\n * }\n * }\n * ```\n */\nexport function isNetworkError(error: unknown): error is ApiClientError\n{\n return error instanceof ApiClientError && error.errorType === 'network';\n}\n\n/**\n * Type guard for HTTP errors (4xx, 5xx)\n *\n * @example\n * ```ts\n * try {\n * await api.users.create({ body: userData });\n * } catch (error) {\n * if (isHttpError(error)) {\n * if (error.status === 401) {\n * redirectToLogin();\n * } else if (error.status === 404) {\n * showNotFoundMessage();\n * }\n * }\n * }\n * ```\n */\nexport function isHttpError(error: unknown): error is ApiClientError\n{\n return error instanceof ApiClientError && error.errorType === 'http';\n}\n"]}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { join, dirname } from 'path';
|
|
2
|
+
import { existsSync, mkdirSync, accessSync, constants, writeFileSync, unlinkSync, createWriteStream, statSync, readdirSync, renameSync, readFileSync } from 'fs';
|
|
2
3
|
import { mkdir, writeFile, readdir, stat } from 'fs/promises';
|
|
3
4
|
import * as ts from 'typescript';
|
|
4
|
-
import { existsSync, mkdirSync, accessSync, constants, writeFileSync, unlinkSync, createWriteStream, statSync, readdirSync, renameSync, readFileSync } from 'fs';
|
|
5
5
|
import pino from 'pino';
|
|
6
6
|
|
|
7
7
|
// src/codegen/generators/contract-generator.ts
|
|
@@ -11,15 +11,24 @@ async function scanContracts(routesDir) {
|
|
|
11
11
|
for (let i = 0; i < contractFiles.length; i++) {
|
|
12
12
|
const filePath = contractFiles[i];
|
|
13
13
|
const exports = extractContractExports(filePath);
|
|
14
|
-
const basePath = getBasePathFromFile(filePath, routesDir);
|
|
15
14
|
for (let j = 0; j < exports.length; j++) {
|
|
16
15
|
const contractExport = exports[j];
|
|
17
|
-
const
|
|
16
|
+
const isAbsolutePath = contractExport.path.startsWith("/") && contractExport.path.length > 1;
|
|
17
|
+
let fullPath;
|
|
18
|
+
let importPath;
|
|
19
|
+
if (isAbsolutePath) {
|
|
20
|
+
fullPath = contractExport.path;
|
|
21
|
+
importPath = getImportPath(filePath, routesDir);
|
|
22
|
+
} else {
|
|
23
|
+
const basePath = getBasePathFromFile(filePath, routesDir);
|
|
24
|
+
fullPath = combinePaths(basePath, contractExport.path);
|
|
25
|
+
importPath = getImportPathFromRoutes(filePath, routesDir);
|
|
26
|
+
}
|
|
18
27
|
mappings.push({
|
|
19
28
|
method: contractExport.method,
|
|
20
29
|
path: fullPath,
|
|
21
30
|
contractName: contractExport.name,
|
|
22
|
-
contractImportPath:
|
|
31
|
+
contractImportPath: importPath,
|
|
23
32
|
routeFile: "",
|
|
24
33
|
// Not needed anymore
|
|
25
34
|
contractFile: filePath,
|
|
@@ -34,14 +43,21 @@ async function scanContracts(routesDir) {
|
|
|
34
43
|
async function scanContractFiles(dir, files = []) {
|
|
35
44
|
try {
|
|
36
45
|
const entries = await readdir(dir);
|
|
46
|
+
const isLibContracts = dir.includes("/lib/contracts");
|
|
37
47
|
for (let i = 0; i < entries.length; i++) {
|
|
38
48
|
const entry = entries[i];
|
|
39
49
|
const fullPath = join(dir, entry);
|
|
40
50
|
const fileStat = await stat(fullPath);
|
|
41
51
|
if (fileStat.isDirectory()) {
|
|
42
52
|
await scanContractFiles(fullPath, files);
|
|
43
|
-
} else if (
|
|
44
|
-
|
|
53
|
+
} else if (isLibContracts) {
|
|
54
|
+
if (entry.endsWith(".ts") && !entry.endsWith(".d.ts") && !entry.endsWith(".test.ts")) {
|
|
55
|
+
files.push(fullPath);
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
if (entry === "contract.ts") {
|
|
59
|
+
files.push(fullPath);
|
|
60
|
+
}
|
|
45
61
|
}
|
|
46
62
|
}
|
|
47
63
|
} catch (error) {
|
|
@@ -66,15 +82,26 @@ function extractContractExports(filePath) {
|
|
|
66
82
|
const declaration = node.declarationList.declarations[0];
|
|
67
83
|
if (ts.isVariableDeclaration(declaration) && ts.isIdentifier(declaration.name) && declaration.initializer) {
|
|
68
84
|
const name = declaration.name.text;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
85
|
+
const hasSatisfiesRouteContract = checkSatisfiesRouteContract(declaration.initializer);
|
|
86
|
+
if (hasSatisfiesRouteContract) {
|
|
87
|
+
const objectLiteral = extractObjectLiteral(declaration.initializer);
|
|
88
|
+
if (objectLiteral) {
|
|
89
|
+
const contractData = extractContractData(objectLiteral);
|
|
90
|
+
if (contractData.method && contractData.path) {
|
|
91
|
+
exports.push({
|
|
92
|
+
name,
|
|
93
|
+
method: contractData.method,
|
|
94
|
+
path: contractData.path,
|
|
95
|
+
hasQuery: contractData.hasQuery,
|
|
96
|
+
hasBody: contractData.hasBody,
|
|
97
|
+
hasParams: contractData.hasParams
|
|
98
|
+
});
|
|
99
|
+
}
|
|
77
100
|
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (isContractName(name)) {
|
|
104
|
+
const objectLiteral = extractObjectLiteral(declaration.initializer);
|
|
78
105
|
if (objectLiteral) {
|
|
79
106
|
const contractData = extractContractData(objectLiteral);
|
|
80
107
|
if (contractData.method && contractData.path) {
|
|
@@ -97,6 +124,28 @@ function extractContractExports(filePath) {
|
|
|
97
124
|
visit(sourceFile);
|
|
98
125
|
return exports;
|
|
99
126
|
}
|
|
127
|
+
function checkSatisfiesRouteContract(initializer) {
|
|
128
|
+
if (!ts.isSatisfiesExpression(initializer)) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
const typeNode = initializer.type;
|
|
132
|
+
if (ts.isTypeReferenceNode(typeNode) && ts.isIdentifier(typeNode.typeName)) {
|
|
133
|
+
return typeNode.typeName.text === "RouteContract";
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
function extractObjectLiteral(initializer) {
|
|
138
|
+
if (ts.isObjectLiteralExpression(initializer)) {
|
|
139
|
+
return initializer;
|
|
140
|
+
}
|
|
141
|
+
if (ts.isSatisfiesExpression(initializer)) {
|
|
142
|
+
return extractObjectLiteral(initializer.expression);
|
|
143
|
+
}
|
|
144
|
+
if (ts.isAsExpression(initializer)) {
|
|
145
|
+
return extractObjectLiteral(initializer.expression);
|
|
146
|
+
}
|
|
147
|
+
return void 0;
|
|
148
|
+
}
|
|
100
149
|
function extractContractData(objectLiteral) {
|
|
101
150
|
const result = {};
|
|
102
151
|
for (let i = 0; i < objectLiteral.properties.length; i++) {
|
|
@@ -187,6 +236,18 @@ function getImportPathFromRoutes(filePath, routesDir) {
|
|
|
187
236
|
}
|
|
188
237
|
return "@/server/routes/" + relativePath;
|
|
189
238
|
}
|
|
239
|
+
function getImportPath(filePath, scanDir) {
|
|
240
|
+
const srcIndex = filePath.indexOf("/src/");
|
|
241
|
+
if (srcIndex === -1) {
|
|
242
|
+
return getImportPathFromRoutes(filePath, scanDir);
|
|
243
|
+
}
|
|
244
|
+
const fromSrc = filePath.substring(srcIndex + 5);
|
|
245
|
+
let cleanPath = fromSrc;
|
|
246
|
+
if (cleanPath.endsWith(".ts")) {
|
|
247
|
+
cleanPath = cleanPath.slice(0, -3);
|
|
248
|
+
}
|
|
249
|
+
return "@/" + cleanPath;
|
|
250
|
+
}
|
|
190
251
|
|
|
191
252
|
// src/codegen/route-scanner.ts
|
|
192
253
|
function groupByResource(mappings) {
|
|
@@ -377,8 +438,7 @@ function generateMethodCode(mapping, options) {
|
|
|
377
438
|
code += `options: { ${params.join(", ")} }`;
|
|
378
439
|
}
|
|
379
440
|
code += `) => `;
|
|
380
|
-
|
|
381
|
-
code += `client.call('${basePath}', ${mapping.contractName}`;
|
|
441
|
+
code += `client.call(${mapping.contractName}`;
|
|
382
442
|
if (params.length > 0) {
|
|
383
443
|
code += `, options`;
|
|
384
444
|
}
|
|
@@ -1210,20 +1270,32 @@ var contractLogger = logger.child("contract-gen");
|
|
|
1210
1270
|
function createContractGenerator(config = {}) {
|
|
1211
1271
|
return {
|
|
1212
1272
|
name: "contract",
|
|
1213
|
-
watchPatterns: [
|
|
1273
|
+
watchPatterns: [
|
|
1274
|
+
config.routesDir ?? "src/server/routes/**/*.ts",
|
|
1275
|
+
"src/lib/contracts/**/*.ts"
|
|
1276
|
+
],
|
|
1214
1277
|
async generate(options) {
|
|
1215
1278
|
const cwd = options.cwd;
|
|
1216
1279
|
const routesDir = config.routesDir ?? join(cwd, "src", "server", "routes");
|
|
1280
|
+
const libContractsDir = join(cwd, "src", "lib", "contracts");
|
|
1217
1281
|
const outputPath = config.outputPath ?? join(cwd, "src", "lib", "api.ts");
|
|
1218
1282
|
try {
|
|
1219
|
-
const
|
|
1220
|
-
if (
|
|
1283
|
+
const contractDirs = [routesDir];
|
|
1284
|
+
if (existsSync(libContractsDir)) {
|
|
1285
|
+
contractDirs.push(libContractsDir);
|
|
1286
|
+
}
|
|
1287
|
+
let allContracts = [];
|
|
1288
|
+
for (const dir of contractDirs) {
|
|
1289
|
+
const contracts = await scanContracts(dir);
|
|
1290
|
+
allContracts = allContracts.concat(contracts);
|
|
1291
|
+
}
|
|
1292
|
+
if (allContracts.length === 0) {
|
|
1221
1293
|
if (options.debug) {
|
|
1222
1294
|
contractLogger.warn("No contracts found");
|
|
1223
1295
|
}
|
|
1224
1296
|
return;
|
|
1225
1297
|
}
|
|
1226
|
-
const stats = await generateClient(
|
|
1298
|
+
const stats = await generateClient(allContracts, {
|
|
1227
1299
|
outputPath});
|
|
1228
1300
|
if (options.debug) {
|
|
1229
1301
|
contractLogger.info("Client generated", {
|