@spfn/core 0.1.0-alpha.40 → 0.1.0-alpha.42
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/README.md +53 -183
- package/dist/client/index.d.ts +5 -1
- package/dist/client/index.js +6 -2
- package/dist/client/index.js.map +1 -1
- package/dist/codegen/index.js +1 -43
- package/dist/codegen/index.js.map +1 -1
- package/dist/db/index.js +20 -43
- package/dist/db/index.js.map +1 -1
- package/dist/env/index.d.ts +15 -4
- package/dist/env/index.js +20 -43
- package/dist/env/index.js.map +1 -1
- package/dist/index.js +20 -43
- package/dist/index.js.map +1 -1
- package/dist/route/index.js +0 -42
- package/dist/route/index.js.map +1 -1
- package/dist/server/index.js +20 -43
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ export const getUsersContract = {
|
|
|
58
58
|
// src/server/routes/users/index.ts
|
|
59
59
|
import { createApp } from '@spfn/core/route';
|
|
60
60
|
import { getUsersContract } from './contract.js';
|
|
61
|
-
import {
|
|
61
|
+
import { Repository } from '@spfn/core/db';
|
|
62
62
|
import { users } from '../../entities/users.js';
|
|
63
63
|
|
|
64
64
|
const app = createApp();
|
|
@@ -66,14 +66,15 @@ const app = createApp();
|
|
|
66
66
|
app.bind(getUsersContract, async (c) => {
|
|
67
67
|
const { page = 1, limit = 10 } = c.query;
|
|
68
68
|
|
|
69
|
-
//
|
|
70
|
-
const repo =
|
|
69
|
+
// Create repository instance
|
|
70
|
+
const repo = new Repository(users);
|
|
71
71
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
const offset = (page - 1) * limit;
|
|
73
|
+
const result = await repo.select()
|
|
74
|
+
.limit(limit)
|
|
75
|
+
.offset(offset);
|
|
75
76
|
|
|
76
|
-
return c.json(result);
|
|
77
|
+
return c.json({ users: result, total: result.length });
|
|
77
78
|
});
|
|
78
79
|
|
|
79
80
|
export default app;
|
|
@@ -147,131 +148,37 @@ export type NewPost = typeof posts.$inferInsert;
|
|
|
147
148
|
**2. Repository Layer** - Data access with custom methods
|
|
148
149
|
|
|
149
150
|
```typescript
|
|
150
|
-
// src/server/
|
|
151
|
+
// src/server/entities/posts.ts (continued)
|
|
151
152
|
import { eq } from 'drizzle-orm';
|
|
152
153
|
import { Repository } from '@spfn/core/db';
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
return this.findOne(eq(this.table.slug, slug));
|
|
154
|
+
|
|
155
|
+
export class PostRepository extends Repository<typeof posts> {
|
|
156
|
+
async findBySlug(slug: string) {
|
|
157
|
+
const results = await this.select()
|
|
158
|
+
.where(eq(this.table.slug, slug))
|
|
159
|
+
.limit(1);
|
|
160
|
+
return results[0] ?? null;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
async findPublished()
|
|
164
|
-
|
|
165
|
-
const results = await this.db
|
|
166
|
-
.select()
|
|
167
|
-
.from(this.table)
|
|
163
|
+
async findPublished() {
|
|
164
|
+
return this.select()
|
|
168
165
|
.where(eq(this.table.status, 'published'))
|
|
169
166
|
.orderBy(this.table.createdAt);
|
|
170
|
-
|
|
171
|
-
return results;
|
|
172
167
|
}
|
|
173
|
-
}
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
**3. Service Layer** - Business logic (Function-based pattern)
|
|
177
|
-
|
|
178
|
-
```typescript
|
|
179
|
-
// src/server/services/posts.ts
|
|
180
|
-
import { getRepository } from '@spfn/core/db';
|
|
181
|
-
import { ValidationError, DatabaseError, NotFoundError } from '@spfn/core';
|
|
182
|
-
import { posts } from '../entities';
|
|
183
|
-
import { PostRepository } from '../repositories/posts.repository';
|
|
184
|
-
import type { NewPost, Post } from '../entities';
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Create a new post
|
|
188
|
-
*/
|
|
189
|
-
export async function createPost(data: {
|
|
190
|
-
title: string;
|
|
191
|
-
content: string;
|
|
192
|
-
}): Promise<Post> {
|
|
193
|
-
try {
|
|
194
|
-
// Get repository singleton
|
|
195
|
-
const repo = getRepository(posts, PostRepository);
|
|
196
|
-
|
|
197
|
-
// Business logic: Generate slug from title
|
|
198
|
-
const slug = generateSlug(data.title);
|
|
199
|
-
|
|
200
|
-
// Validation: Check if slug already exists
|
|
201
|
-
const existing = await repo.findBySlug(slug);
|
|
202
|
-
if (existing) {
|
|
203
|
-
throw new ValidationError('Post with this title already exists', {
|
|
204
|
-
fields: [{
|
|
205
|
-
path: '/title',
|
|
206
|
-
message: 'A post with this title already exists',
|
|
207
|
-
value: data.title
|
|
208
|
-
}]
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Create post
|
|
213
|
-
return await repo.save({
|
|
214
|
-
...data,
|
|
215
|
-
slug,
|
|
216
|
-
status: 'draft',
|
|
217
|
-
});
|
|
218
|
-
} catch (error) {
|
|
219
|
-
// Re-throw ValidationError as-is
|
|
220
|
-
if (error instanceof ValidationError) {
|
|
221
|
-
throw error;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Wrap unexpected errors
|
|
225
|
-
throw new DatabaseError('Failed to create post', 500, {
|
|
226
|
-
originalError: error instanceof Error ? error.message : String(error)
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Publish a post
|
|
233
|
-
*/
|
|
234
|
-
export async function publishPost(id: string): Promise<Post> {
|
|
235
|
-
try {
|
|
236
|
-
const repo = getRepository(posts, PostRepository);
|
|
237
|
-
const post = await repo.update(id, { status: 'published' });
|
|
238
|
-
|
|
239
|
-
if (!post) {
|
|
240
|
-
throw new NotFoundError('Post not found');
|
|
241
|
-
}
|
|
242
168
|
|
|
169
|
+
async create(data: NewPost) {
|
|
170
|
+
const [post] = await this.insert()
|
|
171
|
+
.values(data)
|
|
172
|
+
.returning();
|
|
243
173
|
return post;
|
|
244
|
-
} catch (error) {
|
|
245
|
-
if (error instanceof NotFoundError) {
|
|
246
|
-
throw error;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
throw new DatabaseError('Failed to publish post', 500, {
|
|
250
|
-
originalError: error instanceof Error ? error.message : String(error)
|
|
251
|
-
});
|
|
252
174
|
}
|
|
253
175
|
}
|
|
254
176
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
*/
|
|
258
|
-
export async function getPublishedPosts(): Promise<Post[]> {
|
|
259
|
-
const repo = getRepository(posts, PostRepository);
|
|
260
|
-
return repo.findPublished();
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Helper: Generate URL-friendly slug from title
|
|
265
|
-
*/
|
|
266
|
-
function generateSlug(title: string): string {
|
|
267
|
-
return title
|
|
268
|
-
.toLowerCase()
|
|
269
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
270
|
-
.replace(/(^-|-$)/g, '');
|
|
271
|
-
}
|
|
177
|
+
// Export repository instance for reuse
|
|
178
|
+
export const postRepository = new PostRepository(posts);
|
|
272
179
|
```
|
|
273
180
|
|
|
274
|
-
**
|
|
181
|
+
**3. Routes Layer** - HTTP API
|
|
275
182
|
|
|
276
183
|
```typescript
|
|
277
184
|
// src/server/routes/posts/contracts.ts
|
|
@@ -306,7 +213,7 @@ export const listPostsContract = {
|
|
|
306
213
|
// src/server/routes/posts/index.ts
|
|
307
214
|
import { createApp } from '@spfn/core/route';
|
|
308
215
|
import { Transactional } from '@spfn/core/db';
|
|
309
|
-
import {
|
|
216
|
+
import { postRepository } from '../../entities/posts';
|
|
310
217
|
import { createPostContract, listPostsContract } from './contracts';
|
|
311
218
|
|
|
312
219
|
const app = createApp();
|
|
@@ -314,14 +221,32 @@ const app = createApp();
|
|
|
314
221
|
// POST /posts - Create new post (with transaction)
|
|
315
222
|
app.bind(createPostContract, Transactional(), async (c) => {
|
|
316
223
|
const body = await c.data();
|
|
317
|
-
|
|
224
|
+
|
|
225
|
+
// Generate slug from title
|
|
226
|
+
const slug = body.title.toLowerCase()
|
|
227
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
228
|
+
.replace(/(^-|-$)/g, '');
|
|
229
|
+
|
|
230
|
+
// Check if slug exists
|
|
231
|
+
const existing = await postRepository.findBySlug(slug);
|
|
232
|
+
if (existing) {
|
|
233
|
+
return c.json({ error: 'Post with this title already exists' }, 409);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Create post
|
|
237
|
+
const post = await postRepository.create({
|
|
238
|
+
...body,
|
|
239
|
+
slug,
|
|
240
|
+
status: 'draft'
|
|
241
|
+
});
|
|
242
|
+
|
|
318
243
|
// ✅ Auto-commit on success, auto-rollback on error
|
|
319
244
|
return c.json(post, 201);
|
|
320
245
|
});
|
|
321
246
|
|
|
322
247
|
// GET /posts - List published posts (no transaction needed)
|
|
323
248
|
app.bind(listPostsContract, async (c) => {
|
|
324
|
-
const posts = await
|
|
249
|
+
const posts = await postRepository.findPublished();
|
|
325
250
|
return c.json(posts);
|
|
326
251
|
});
|
|
327
252
|
|
|
@@ -368,17 +293,10 @@ export default app;
|
|
|
368
293
|
|
|
369
294
|
**Repository Layer:**
|
|
370
295
|
- ✅ Extend `Repository<typeof table>` for custom methods
|
|
371
|
-
- ✅
|
|
296
|
+
- ✅ Export repository instance from entity file: `export const repo = new MyRepository(table)`
|
|
372
297
|
- ✅ Add domain-specific query methods
|
|
373
298
|
- ✅ Return typed results
|
|
374
299
|
|
|
375
|
-
**Service Layer:**
|
|
376
|
-
- ✅ Use function-based pattern (export async functions)
|
|
377
|
-
- ✅ Get repositories via `getRepository()` (singleton)
|
|
378
|
-
- ✅ Implement business logic and validation
|
|
379
|
-
- ✅ Throw descriptive errors
|
|
380
|
-
- ✅ Keep functions focused and small
|
|
381
|
-
|
|
382
300
|
**Routes Layer:**
|
|
383
301
|
- ✅ Keep handlers thin (delegate to services)
|
|
384
302
|
- ✅ Define contracts with TypeBox
|
|
@@ -404,56 +322,10 @@ Drizzle ORM integration with repository pattern and pagination.
|
|
|
404
322
|
|
|
405
323
|
**[→ Read Database Documentation](./src/db/README.md)**
|
|
406
324
|
|
|
407
|
-
**
|
|
408
|
-
-
|
|
409
|
-
-
|
|
410
|
-
-
|
|
411
|
-
|
|
412
|
-
#### Choosing a Repository Pattern
|
|
413
|
-
|
|
414
|
-
SPFN offers two repository patterns. Choose based on your needs:
|
|
415
|
-
|
|
416
|
-
**Global Singleton (`getRepository`)**
|
|
417
|
-
```typescript
|
|
418
|
-
import { getRepository } from '@spfn/core/db';
|
|
419
|
-
|
|
420
|
-
const repo = getRepository(users);
|
|
421
|
-
```
|
|
422
|
-
|
|
423
|
-
- ✅ Simple API, minimal setup
|
|
424
|
-
- ✅ Maximum memory efficiency
|
|
425
|
-
- ⚠️ Requires manual `clearRepositoryCache()` in tests
|
|
426
|
-
- ⚠️ Global state across all requests
|
|
427
|
-
- 📝 **Use for:** Simple projects, prototypes, single-instance services
|
|
428
|
-
|
|
429
|
-
**Request-Scoped (`getScopedRepository` + `RepositoryScope()`)** ⭐ Recommended
|
|
430
|
-
```typescript
|
|
431
|
-
import { getScopedRepository, RepositoryScope } from '@spfn/core/db';
|
|
432
|
-
|
|
433
|
-
// Add middleware once (in server setup)
|
|
434
|
-
app.use(RepositoryScope());
|
|
435
|
-
|
|
436
|
-
// Use in routes/services
|
|
437
|
-
const repo = getScopedRepository(users);
|
|
438
|
-
```
|
|
439
|
-
|
|
440
|
-
- ✅ Automatic per-request isolation
|
|
441
|
-
- ✅ No manual cache clearing needed
|
|
442
|
-
- ✅ Test-friendly (each test gets fresh instances)
|
|
443
|
-
- ✅ Production-ready with graceful degradation
|
|
444
|
-
- 📝 **Use for:** Production apps, complex testing, team projects
|
|
445
|
-
|
|
446
|
-
**Comparison:**
|
|
447
|
-
|
|
448
|
-
| Feature | `getRepository` | `getScopedRepository` |
|
|
449
|
-
|---------|----------------|----------------------|
|
|
450
|
-
| Setup | Zero config | Add middleware |
|
|
451
|
-
| Test isolation | Manual | Automatic |
|
|
452
|
-
| Memory | Shared cache | Per-request cache |
|
|
453
|
-
| State | Global | Request-scoped |
|
|
454
|
-
| Best for | Prototypes | Production |
|
|
455
|
-
|
|
456
|
-
[→ See full request-scoped documentation](./src/db/repository/request-scope.ts)
|
|
325
|
+
**Key Features:**
|
|
326
|
+
- Repository pattern with automatic transaction handling
|
|
327
|
+
- Read/Write database separation
|
|
328
|
+
- Schema helpers: `id()`, `timestamps()`, `foreignKey()`
|
|
457
329
|
|
|
458
330
|
### 🔄 Transactions
|
|
459
331
|
Automatic transaction management with async context propagation.
|
|
@@ -525,11 +397,9 @@ import type { RouteContext, RouteContract } from '@spfn/core/route';
|
|
|
525
397
|
### Database
|
|
526
398
|
```typescript
|
|
527
399
|
import {
|
|
528
|
-
|
|
529
|
-
Repository
|
|
530
|
-
getRepository
|
|
400
|
+
getDatabase,
|
|
401
|
+
Repository
|
|
531
402
|
} from '@spfn/core/db';
|
|
532
|
-
import type { Pageable, Page } from '@spfn/core/db';
|
|
533
403
|
```
|
|
534
404
|
|
|
535
405
|
### Transactions
|
package/dist/client/index.d.ts
CHANGED
|
@@ -59,8 +59,12 @@ declare class ContractClient {
|
|
|
59
59
|
use(interceptor: RequestInterceptor): void;
|
|
60
60
|
/**
|
|
61
61
|
* Make a type-safe API call using a contract
|
|
62
|
+
*
|
|
63
|
+
* @param basePath - Base path from file-based routing (e.g., '/organizations')
|
|
64
|
+
* @param contract - Route contract
|
|
65
|
+
* @param options - Call options (params, query, body, headers)
|
|
62
66
|
*/
|
|
63
|
-
call<TContract extends RouteContract>(contract: TContract, options?: CallOptions<TContract>): Promise<InferContract<TContract>['response']>;
|
|
67
|
+
call<TContract extends RouteContract>(basePath: string, contract: TContract, options?: CallOptions<TContract>): Promise<InferContract<TContract>['response']>;
|
|
64
68
|
/**
|
|
65
69
|
* Create a new client with merged configuration
|
|
66
70
|
*/
|
package/dist/client/index.js
CHANGED
|
@@ -28,11 +28,15 @@ var ContractClient = class _ContractClient {
|
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
30
|
* Make a type-safe API call using a contract
|
|
31
|
+
*
|
|
32
|
+
* @param basePath - Base path from file-based routing (e.g., '/organizations')
|
|
33
|
+
* @param contract - Route contract
|
|
34
|
+
* @param options - Call options (params, query, body, headers)
|
|
31
35
|
*/
|
|
32
|
-
async call(contract, options) {
|
|
36
|
+
async call(basePath, contract, options) {
|
|
33
37
|
const baseUrl = options?.baseUrl || this.config.baseUrl;
|
|
34
38
|
const urlPath = _ContractClient.buildUrl(
|
|
35
|
-
contract.path,
|
|
39
|
+
basePath + contract.path,
|
|
36
40
|
options?.params
|
|
37
41
|
);
|
|
38
42
|
const queryString = _ContractClient.buildQuery(
|
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.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 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 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
|
+
{"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.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 const urlPath = ContractClient.buildUrl(\n basePath + 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"]}
|
package/dist/codegen/index.js
CHANGED
|
@@ -352,7 +352,7 @@ function generateMethodCode(mapping, options) {
|
|
|
352
352
|
code += `options: { ${params.join(", ")} }`;
|
|
353
353
|
}
|
|
354
354
|
code += `) => `;
|
|
355
|
-
code += `client.call(${mapping.contractName}`;
|
|
355
|
+
code += `client.call('${mapping.path}', ${mapping.contractName}`;
|
|
356
356
|
if (params.length > 0) {
|
|
357
357
|
code += `, options`;
|
|
358
358
|
}
|
|
@@ -416,45 +416,8 @@ function countUniqueContractFiles(mappings) {
|
|
|
416
416
|
var PinoAdapter = class _PinoAdapter {
|
|
417
417
|
logger;
|
|
418
418
|
constructor(config) {
|
|
419
|
-
const isProduction = process.env.NODE_ENV === "production";
|
|
420
|
-
const isDevelopment = process.env.NODE_ENV === "development";
|
|
421
|
-
const fileLoggingEnabled = process.env.LOGGER_FILE_ENABLED === "true";
|
|
422
|
-
const targets = [];
|
|
423
|
-
if (!isProduction && isDevelopment) {
|
|
424
|
-
targets.push({
|
|
425
|
-
target: "pino-pretty",
|
|
426
|
-
level: "debug",
|
|
427
|
-
options: {
|
|
428
|
-
colorize: true,
|
|
429
|
-
translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l",
|
|
430
|
-
ignore: "pid,hostname",
|
|
431
|
-
// 메시지와 필드를 한 줄로 표시
|
|
432
|
-
messageFormat: "{if module}[module={module}] {end}{msg}",
|
|
433
|
-
// context 필드들도 한 줄로 표시
|
|
434
|
-
singleLine: true
|
|
435
|
-
}
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
if (fileLoggingEnabled && isProduction) {
|
|
439
|
-
const logDir = process.env.LOG_DIR || "./logs";
|
|
440
|
-
const maxFileSize = process.env.LOG_MAX_FILE_SIZE || "10M";
|
|
441
|
-
const maxFiles = parseInt(process.env.LOG_MAX_FILES || "10", 10);
|
|
442
|
-
targets.push({
|
|
443
|
-
target: "pino-roll",
|
|
444
|
-
level: "info",
|
|
445
|
-
options: {
|
|
446
|
-
file: `${logDir}/app.log`,
|
|
447
|
-
frequency: "daily",
|
|
448
|
-
size: maxFileSize,
|
|
449
|
-
limit: { count: maxFiles },
|
|
450
|
-
mkdir: true
|
|
451
|
-
}
|
|
452
|
-
});
|
|
453
|
-
}
|
|
454
419
|
this.logger = pino({
|
|
455
420
|
level: config.level,
|
|
456
|
-
// Transport 설정 (targets가 있으면 사용, 없으면 기본 stdout)
|
|
457
|
-
transport: targets.length > 0 ? { targets } : void 0,
|
|
458
421
|
// 기본 필드
|
|
459
422
|
base: config.module ? { module: config.module } : void 0
|
|
460
423
|
});
|
|
@@ -1118,11 +1081,6 @@ function validateEnvironment() {
|
|
|
1118
1081
|
process.stderr.write(
|
|
1119
1082
|
"[Logger] Warning: NODE_ENV is not set. Defaulting to test environment.\n"
|
|
1120
1083
|
);
|
|
1121
|
-
} else if (!["development", "production", "test"].includes(nodeEnv)) {
|
|
1122
|
-
process.stderr.write(
|
|
1123
|
-
`[Logger] Warning: Unknown NODE_ENV="${nodeEnv}". Expected: development, production, or test.
|
|
1124
|
-
`
|
|
1125
|
-
);
|
|
1126
1084
|
}
|
|
1127
1085
|
}
|
|
1128
1086
|
function validateConfig() {
|