galbe 0.14.0 → 0.15.1

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.
@@ -1,11 +1,394 @@
1
- import { Script, createContext } from 'vm'
2
1
  import { Command, Option } from 'commander'
3
- import { resolve, extname } from 'path'
2
+ import { resolve } from 'path'
4
3
  import { transformSync } from '@swc/core'
5
4
  import { CWD, fmtList, instanciateRoutes, silentExec } from '../../util'
6
- import { $T, Galbe, Method, Route, STResponse } from '../../../src'
5
+ import { Galbe, type GalbeClientRoute, type GalbeClientOptions } from '../../../src'
7
6
  import { walkRoutes } from '../../../src/util'
8
- import { schemaToTypeStr, STSchema } from '../../../src/schema'
7
+ import { schemaToTypeStr, Kind, Optional, Stream, type STSchema } from '../../../src/schema'
8
+ import type { STResponse, STResponseEntry, STResponseContent } from '../../../src/types'
9
+
10
+ // ─── MIME / short-name helpers ───────────────────────────────────────────────
11
+
12
+ const MIME_SHORT: Record<string, string> = {
13
+ 'application/json': 'json',
14
+ 'application/x-www-form-urlencoded': 'urlForm',
15
+ 'multipart/form-data': 'multipart',
16
+ 'application/octet-stream': 'byteArray',
17
+ 'text/plain': 'text',
18
+ 'text/html': 'text',
19
+ '*/*': 'raw',
20
+ }
21
+ const SHORT_SUFFIX: Record<string, string> = {
22
+ json: 'Json',
23
+ urlForm: 'UrlForm',
24
+ multipart: 'Multipart',
25
+ byteArray: 'ByteArray',
26
+ text: 'Text',
27
+ raw: 'Raw',
28
+ }
29
+
30
+ const mimeToShort = (mime: string) => MIME_SHORT[mime] ?? (mime.startsWith('text/') ? 'text' : 'raw')
31
+
32
+ // ─── Operaion-id derivation ───────────────────────────────────────────────────
33
+
34
+ const deriveOperationId = (method: string, path: string): string =>
35
+ `${method}-${path
36
+ .replace(/\//g, '-')
37
+ .replace(/:/g, '')
38
+ .replace(/^-/, '')
39
+ .replace(/-+/g, '-')
40
+ .replace(/-$/, '')}`
41
+
42
+ // ─── Response-entry helpers ───────────────────────────────────────────────────
43
+
44
+ const isResponseValue = (entry: STResponseEntry): boolean =>
45
+ Kind in (entry as any) && typeof (entry as any)[Kind] === 'string'
46
+
47
+ type BodyInfo = { methods: string[]; typeStr: string }
48
+
49
+ const responseBodyInfo = (entry: STResponseEntry): BodyInfo => {
50
+ if (isResponseValue(entry)) {
51
+ const s = entry as STSchema
52
+ const kind = s[Kind]
53
+ const isStream = Stream in s && (s as any)[Stream]
54
+ if (isStream) return { methods: ['stream'], typeStr: schemaToTypeStr(s) }
55
+ if (kind === 'byteArray') return { methods: ['byteArray'], typeStr: 'Uint8Array' }
56
+ if (kind === 'string') return { methods: ['text'], typeStr: 'string' }
57
+ if (kind === 'null') return { methods: [], typeStr: 'null' }
58
+ return { methods: ['json'], typeStr: schemaToTypeStr(s) }
59
+ }
60
+ // STResponseContent
61
+ const content = entry as STResponseContent
62
+ const methods: string[] = []
63
+ let typeStr = 'unknown'
64
+ for (const [mime, s] of Object.entries(content)) {
65
+ if (!mime.includes('/') || !s) continue
66
+ const schema = s as STSchema
67
+ if (Stream in schema && (schema as any)[Stream]) { methods.push('stream'); continue }
68
+ const short = mimeToShort(mime)
69
+ methods.push(short === 'byteArray' ? 'byteArray' : short === 'text' ? 'text' : 'json')
70
+ typeStr = schemaToTypeStr(schema)
71
+ }
72
+ return { methods: [...new Set(methods)], typeStr }
73
+ }
74
+
75
+ const responseHeadersType = (entry: STResponseEntry): string => {
76
+ const rh: Record<string, any> | undefined = isResponseValue(entry)
77
+ ? (entry as any).responseHeaders
78
+ : (entry as STResponseContent).responseHeaders
79
+ if (!rh || !Object.keys(rh).length) return 'Headers'
80
+ const keys = Object.keys(rh).map(k => `'${k}'`).join('|')
81
+ return `{get<K extends string>(name:K):K extends ${keys}?string:string|null}&Omit<Headers,'get'>`
82
+ }
83
+
84
+ // ─── Code-generation helpers ──────────────────────────────────────────────────
85
+
86
+ const OKS = new Set([200, 201, 202, 203, 204, 205, 206, 207, 208, 226])
87
+ // prettier-ignore
88
+ const HTTP_CODES = [100,101,102,103,200,201,202,203,204,205,206,207,208,226,300,301,302,303,304,305,307,308,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,421,422,423,424,426,428,429,431,451,500,501,502,503,504,505,506,507,508,510,511]
89
+
90
+ const STATIC_TYPES = `
91
+ // prettier-ignore
92
+ type _OKStatus = ${[...OKS].join('|')}
93
+ // prettier-ignore
94
+ type _HttpStatus = ${HTTP_CODES.join('|')}
95
+ `.trim()
96
+
97
+ // Convert operationId to a safe TypeScript identifier (for type names)
98
+ const safeTypeId = (id: string) => id.replace(/[^a-zA-Z0-9_$]/g, '_')
99
+ // Quote a property key if it is not a valid bare identifier
100
+ const safePropKey = (id: string) => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(id) ? id : `'${id}'`
101
+
102
+ const buildBodyObjType = (methods: string[], typeStr: string): string => {
103
+ const parts: string[] = []
104
+ if (methods.includes('json')) parts.push(`json():Promise<${typeStr}>`)
105
+ if (methods.includes('text')) parts.push(`text():Promise<string>`)
106
+ if (methods.includes('byteArray')) parts.push(`byteArray():Promise<Uint8Array>`)
107
+ if (methods.includes('stream')) parts.push(`stream():AsyncGenerator<Uint8Array,void,unknown>`)
108
+ if (!parts.length) parts.push(`text():Promise<string>`)
109
+ return `{${parts.join(';')}}`
110
+ }
111
+
112
+ const FALLBACK_BODY = `{json():Promise<unknown>;text():Promise<string>;byteArray():Promise<Uint8Array>;stream():AsyncGenerator<Uint8Array,void,unknown>}`
113
+
114
+ /** Generates the `type _GR_OpId = ...` discriminated union for $raw */
115
+ const buildRawResponseType = (operationId: string, response: STResponse | null): string => {
116
+ const typeName = `_GR_${safeTypeId(operationId)}`
117
+ if (!response || !Object.keys(response).length) {
118
+ return `type ${typeName} = {status:_HttpStatus;ok:boolean;headers:Headers;body:${FALLBACK_BODY}}`
119
+ }
120
+
121
+ const declared: string[] = []
122
+ const arms: string[] = []
123
+
124
+ for (const [rawKey, entry] of Object.entries(response)) {
125
+ if (!entry) continue
126
+ const status = rawKey === 'default' ? null : Number(rawKey)
127
+ if (status === null) continue // handled as fallback
128
+ declared.push(String(status))
129
+ const ok = OKS.has(status)
130
+ const { methods, typeStr } = responseBodyInfo(entry)
131
+ const headersT = responseHeadersType(entry)
132
+ arms.push(
133
+ `{status:${status};ok:${ok};headers:${headersT};body:${buildBodyObjType(methods, typeStr)}}`
134
+ )
135
+ }
136
+
137
+ // fallback arm
138
+ const defaultEntry = (response as any)['default'] as STResponseEntry | undefined
139
+ const fallbackBody = defaultEntry ? buildBodyObjType(...Object.values(responseBodyInfo(defaultEntry)) as [string[], string]) : FALLBACK_BODY
140
+ const excludeStr = declared.length ? `Exclude<_HttpStatus,${declared.join('|')}>` : '_HttpStatus'
141
+ arms.push(`{status:${excludeStr};ok:boolean;headers:Headers;body:${fallbackBody}}`)
142
+
143
+ return `type ${typeName} = \n | ${arms.join('\n | ')}`
144
+ }
145
+
146
+ /** Build the 2xx union body type (return type of the simple API) */
147
+ const buildSuccessType = (response: STResponse | null): string => {
148
+ if (!response) return 'unknown'
149
+ const types: string[] = []
150
+ for (const [rawKey, entry] of Object.entries(response)) {
151
+ if (!entry) continue
152
+ const status = rawKey === 'default' ? null : Number(rawKey)
153
+ if (status === null || !OKS.has(status)) continue
154
+ const { typeStr } = responseBodyInfo(entry)
155
+ types.push(typeStr)
156
+ }
157
+ return types.length ? [...new Set(types)].join('|') : 'unknown'
158
+ }
159
+
160
+ /** Build the error union type (E param of GalbeRequest) */
161
+ const buildErrorType = (operationId: string, response: STResponse | null): string => {
162
+ const typeName = `_Err_${safeTypeId(operationId)}`
163
+ if (!response || !Object.keys(response).length) {
164
+ return `type ${typeName} = {status:number;headers:Headers;body:any}`
165
+ }
166
+
167
+ const declared: string[] = []
168
+ const arms: string[] = []
169
+
170
+ for (const [rawKey, entry] of Object.entries(response)) {
171
+ if (!entry) continue
172
+ const status = rawKey === 'default' ? null : Number(rawKey)
173
+ if (status === null || OKS.has(status)) continue
174
+ declared.push(String(status))
175
+ const { typeStr } = responseBodyInfo(entry)
176
+ const headersT = responseHeadersType(entry)
177
+ arms.push(`{status:${status};headers:${headersT};body:${typeStr}}`)
178
+ }
179
+
180
+ const defaultEntry = (response as any)['default'] as STResponseEntry | undefined
181
+ const fallbackBody = defaultEntry ? responseBodyInfo(defaultEntry).typeStr : 'any'
182
+ // also exclude all declared 2xx
183
+ const declared2xx = Object.keys(response)
184
+ .filter(k => k !== 'default' && OKS.has(Number(k)))
185
+ .map(String)
186
+ const allDeclared = [...declared, ...declared2xx]
187
+ const errExcludeStr = allDeclared.length ? `Exclude<_HttpStatus,${allDeclared.join('|')}>` : '_HttpStatus'
188
+ arms.push(`{status:${errExcludeStr};headers:Headers;body:${fallbackBody}}`)
189
+
190
+ return `type ${typeName} = \n | ${arms.join('\n | ')}`
191
+ }
192
+
193
+ /** One logical route expanded into 1..N variants (one per body content-type) */
194
+ interface RouteVariant {
195
+ operationId: string // final method name (may include suffix)
196
+ method: string
197
+ path: string
198
+ pathTemplate: string // with ${param} interpolation
199
+ params: { name: string; typeStr: string; description?: string }[]
200
+ bodyShortName: string | null // null = no body
201
+ bodyTypeStr: string | null
202
+ query: Record<string, { typeStr: string; optional: boolean; description?: string }>
203
+ reqHeaders: Record<string, { typeStr: string; optional: boolean; description?: string }>
204
+ response: STResponse | null
205
+ rawTypeName: string
206
+ errTypeName: string
207
+ }
208
+
209
+ const expandRoute = (r: GalbeClientRoute): RouteVariant[] => {
210
+ const pathTemplate = r.path.replaceAll(/:([^/]+)/g, '${$1}')
211
+ const params = Object.entries(r.params).map(([name, p]) => ({
212
+ name,
213
+ typeStr: p.type,
214
+ description: p.description,
215
+ }))
216
+ const query = Object.fromEntries(
217
+ Object.entries(r.query).map(([k, v]) => [k, { typeStr: v.type, optional: v.optional, description: v.description }])
218
+ )
219
+ const reqHeaders = Object.fromEntries(
220
+ Object.entries(r.headers).map(([k, v]) => [k, { typeStr: v.type, optional: v.optional, description: v.description }])
221
+ )
222
+
223
+ const bodyEntries = r.body ? Object.entries(r.body) : []
224
+ const multiBody = bodyEntries.length > 1
225
+
226
+ if (!bodyEntries.length) {
227
+ return [
228
+ {
229
+ operationId: r.operationId,
230
+ method: r.method,
231
+ path: r.path,
232
+ pathTemplate,
233
+ params,
234
+ bodyShortName: null,
235
+ bodyTypeStr: null,
236
+ query,
237
+ reqHeaders,
238
+ response: r.response,
239
+ rawTypeName: `_GR_${safeTypeId(r.operationId)}`,
240
+ errTypeName: `_Err_${safeTypeId(r.operationId)}`,
241
+ },
242
+ ]
243
+ }
244
+
245
+ return bodyEntries.map(([shortName, schema]) => {
246
+ const suffix = multiBody ? (SHORT_SUFFIX[shortName] ?? shortName[0].toUpperCase() + shortName.slice(1)) : ''
247
+ const id = r.operationId + suffix
248
+ return {
249
+ operationId: id,
250
+ method: r.method,
251
+ path: r.path,
252
+ pathTemplate,
253
+ params,
254
+ bodyShortName: shortName,
255
+ bodyTypeStr: schemaToTypeStr(schema),
256
+ query,
257
+ reqHeaders,
258
+ response: r.response,
259
+ rawTypeName: `_GR_${safeTypeId(id)}`,
260
+ errTypeName: `_Err_${safeTypeId(id)}`,
261
+ }
262
+ })
263
+ }
264
+
265
+ // ─── Method signature builders ────────────────────────────────────────────────
266
+
267
+ const buildParamList = (v: RouteVariant): string => {
268
+ const parts: string[] = []
269
+ for (const p of v.params) parts.push(`${p.name}:${p.typeStr}`)
270
+ if (v.bodyShortName !== null) parts.push(`body:${v.bodyTypeStr}`)
271
+ const queryFields = Object.entries(v.query)
272
+ .map(([k, p]) => `${k}${p.optional ? '?' : ''}:${p.typeStr}`)
273
+ .join(';')
274
+ const headerFields = Object.keys(v.reqHeaders).length
275
+ ? Object.entries(v.reqHeaders)
276
+ .map(([k, p]) => `'${k}'${p.optional ? '?' : ''}:${p.typeStr}`)
277
+ .join(';')
278
+ : null
279
+
280
+ const optParts: string[] = []
281
+ if (queryFields) optParts.push(`query?:{${queryFields}}`)
282
+ if (headerFields) optParts.push(`headers?:{${headerFields}}`)
283
+ else optParts.push(`headers?:Record<string,string>`)
284
+ parts.push(`options?:{${optParts.join(';')}}`)
285
+
286
+ return parts.join(',')
287
+ }
288
+
289
+ const buildFetchCall = (v: RouteVariant, fnName: string): string => {
290
+ const pathExpr = v.params.length ? `\`${v.pathTemplate}\`` : `'${v.path}'`
291
+ const bodyArg = v.bodyShortName !== null ? 'body' : 'undefined'
292
+ const optionsArg = v.bodyShortName !== null
293
+ ? `{...(options??{}),contentType:'${v.bodyShortName}'}`
294
+ : 'options'
295
+ return `${fnName}(this.#config,'${v.method.toUpperCase()}',${pathExpr},${bodyArg},${optionsArg})`
296
+ }
297
+
298
+ const buildMethod = (v: RouteVariant): string => {
299
+ const successType = buildSuccessType(v.response)
300
+ const paramList = buildParamList(v)
301
+ const call = buildFetchCall(v, '_createRequest')
302
+ return ` ${safePropKey(v.operationId)}(${paramList}):GalbeRequest<${successType},${v.errTypeName}>{return ${call}}`
303
+ }
304
+
305
+ const buildRawDecl = (v: RouteVariant): string => {
306
+ const paramList = buildParamList(v)
307
+ return ` ${safePropKey(v.operationId)}(${paramList}):Promise<${v.rawTypeName}>`
308
+ }
309
+
310
+ const buildRawImpl = (v: RouteVariant): string => {
311
+ const paramList = buildParamList(v)
312
+ const call = buildFetchCall(v, '_createRawRequest')
313
+ return ` ${safePropKey(v.operationId)}:(${paramList})=>${call}`
314
+ }
315
+
316
+ // ─── Top-level code generator ─────────────────────────────────────────────────
317
+
318
+ export const generateClientCode = async (opts: {
319
+ routes: GalbeClientRoute[]
320
+ namedTypes: Record<string, string>
321
+ className: string
322
+ version: string
323
+ runtimeContent: string
324
+ }): Promise<string> => {
325
+ const { routes, namedTypes, className, version, runtimeContent } = opts
326
+
327
+ // Expand each route into per-content-type variants
328
+ const variants = routes.flatMap(expandRoute)
329
+
330
+ // Collect response type declarations (deduplicated by raw/err type name)
331
+ const rawTypeDecls = new Map<string, string>()
332
+ const errTypeDecls = new Map<string, string>()
333
+ for (const r of routes) {
334
+ const baseId = r.operationId
335
+ const bodyEntries = r.body ? Object.entries(r.body) : []
336
+ const multiBody = bodyEntries.length > 1
337
+
338
+ if (!bodyEntries.length) {
339
+ rawTypeDecls.set(`_GR_${safeTypeId(baseId)}`, buildRawResponseType(baseId, r.response))
340
+ errTypeDecls.set(`_Err_${safeTypeId(baseId)}`, buildErrorType(baseId, r.response))
341
+ } else {
342
+ for (const [shortName] of bodyEntries) {
343
+ const suffix = multiBody ? (SHORT_SUFFIX[shortName] ?? '') : ''
344
+ const id = baseId + suffix
345
+ rawTypeDecls.set(`_GR_${safeTypeId(id)}`, buildRawResponseType(id, r.response))
346
+ errTypeDecls.set(`_Err_${safeTypeId(id)}`, buildErrorType(id, r.response))
347
+ }
348
+ }
349
+ }
350
+
351
+ const namedTypesStr = Object.entries(namedTypes)
352
+ .map(([name, t]) => `export type ${name} = ${t}`)
353
+ .join('\n')
354
+
355
+ const rawDeclStr = variants.map(buildRawDecl).join('\n')
356
+ const rawImplStr = variants.map(buildRawImpl).join(',\n')
357
+ const methodsStr = variants.map(buildMethod).join('\n')
358
+
359
+ return [
360
+ `// Generated by galbe generate client — v${version}`,
361
+ `// Do not edit manually.`,
362
+ ``,
363
+ runtimeContent,
364
+ ``,
365
+ STATIC_TYPES,
366
+ ``,
367
+ namedTypesStr ? `// Named types\n${namedTypesStr}\n` : '',
368
+ [...rawTypeDecls.values(), ...errTypeDecls.values()].join('\n'),
369
+ ``,
370
+ `export class ${className} {`,
371
+ ` readonly #config:GalbeClientConfig`,
372
+ ` readonly $raw:{`,
373
+ rawDeclStr,
374
+ ` }`,
375
+ ` constructor(config?:GalbeClientConfig){`,
376
+ ` this.#config=config??{}`,
377
+ ` this.$raw={`,
378
+ rawImplStr,
379
+ ` }`,
380
+ ` }`,
381
+ methodsStr,
382
+ `}`,
383
+ `export {GalbeClientError}`,
384
+ `export type {GalbeClientConfig}`,
385
+ `export default ${className}`,
386
+ ]
387
+ .filter(s => s !== '')
388
+ .join('\n')
389
+ }
390
+
391
+ // ─── CLI command ──────────────────────────────────────────────────────────────
9
392
 
10
393
  const clientTargets = ['ts', 'js']
11
394
 
@@ -23,139 +406,166 @@ export default (cmd: Command) => {
23
406
  process.exit(1)
24
407
  })
25
408
  )
409
+ .addOption(new Option('-c, --config <file>', 'config file (.ts or .js)'))
26
410
  .action(async (index, props) => {
27
- let { target, out } = props
28
- if (!target) target = clientTargets.includes(extname(index)?.slice(1)) ? extname(index)?.slice(1) : 'ts'
29
- if (!out) out = { ts: 'dist/client.ts', js: 'dist/client.js' }[target]
411
+ let { target, out, config } = props
412
+ if (!target) target = clientTargets.includes(index.split('.').pop()) ? index.split('.').pop() : 'ts'
413
+ if (!out) out = { ts: 'dist/client.ts', js: 'dist/client.js' }[target as 'ts' | 'js']
414
+
30
415
  let pckg: any = {}
31
- try {
32
- pckg = await Bun.file(resolve(CWD, 'package.json')).json()
33
- } catch (e) {}
416
+ try { pckg = await Bun.file(resolve(CWD, 'package.json')).json() } catch {}
34
417
 
35
- let error = null
418
+ let error: any = null
36
419
  Bun.write(Bun.stdout, '💻 \x1b[1;30mBuilding \x1b[36mGalbe\x1b[0m\x1b[1;30m client\x1b[0m')
37
- let g: Galbe = await silentExec(async () => {
420
+
421
+ const g: Galbe = await silentExec(async () => {
38
422
  try {
39
- const g = (await import(resolve(CWD, index))).default
40
- await instanciateRoutes(g)
41
- await g.init()
42
- return g
43
- } catch (err) {
44
- error = err
45
- }
423
+ const mod = (await import(resolve(CWD, index))).default
424
+ await instanciateRoutes(mod)
425
+ await mod.init()
426
+ return mod
427
+ } catch (err) { error = err }
46
428
  })
429
+
47
430
  if (error) {
48
431
  console.log(`\nerror: galbe instance import failed`)
49
432
  console.log(error)
50
433
  return process.exit(1)
51
434
  }
52
435
 
53
- const routes: Record<Method, Route[]> = {
54
- get: [],
55
- post: [],
56
- put: [],
57
- patch: [],
58
- delete: [],
59
- options: [],
60
- head: [],
61
- }
62
- const types: Record<string, STResponse> = {}
63
436
  const metaRoutes = g.meta?.reduce(
64
- (routes, c) => ({ ...routes, ...c.routes }),
437
+ (acc, c) => ({ ...acc, ...c.routes }),
65
438
  {} as Record<string, Record<string, Record<string, any>>>
66
439
  )
67
440
 
441
+ // Collect named types from response schemas with ids
442
+ const namedTypes: Record<string, string> = {}
443
+
444
+ const routes: GalbeClientRoute[] = []
445
+ const autoDerivedIds: string[] = []
446
+
68
447
  walkRoutes(g.router.routes, r => {
69
- let meta = metaRoutes?.[r.path]?.[r.method]
70
- let [_, summary, description] = meta?.head?.match(/^([^\n]*)\n\n(.*)/) || []
71
- if (!summary) description = meta?.head
72
- let route = {
73
- ...r,
74
- ...(meta?.operationId ? { alias: meta?.operationId } : {}),
75
- ...(summary ? { summary } : {}),
76
- ...(description ? { description } : {}),
77
- pathT: r.path.replaceAll(/:([^\/]+)/g, '${$1}'),
78
- params:
79
- Object.fromEntries(
80
- [...r.path.matchAll(/:([^\/]+)/g)]?.map(m => [
81
- m?.[1],
82
- {
83
- ...(r.schema?.params?.[m?.[1]]
84
- ? {
85
- type: schemaToTypeStr(r.schema.params[m[1]]),
86
- ...(r.schema.params[m[1]]?.description
87
- ? { description: r.schema.params[m[1]].description as string }
88
- : {}),
89
- }
90
- : { type: 'string' }),
91
- },
92
- ])
93
- ) || {},
94
- contentTypes: Object.keys(r.schema.body || { default: '' })
95
- .map(s => `'${s}'`)
96
- .join('|'),
97
- schemas: {
98
- ...(r.schema.headers ? { headers: schemaToTypeStr($T.object(r.schema.headers)) } : {}),
99
- ...(r.schema.query ? { query: schemaToTypeStr($T.object(r.schema.query)) } : {}),
100
- ...(r.schema.body
101
- ? {
102
- body: `{${Object.entries(r.schema.body)
103
- .map(([ct, s]) => `${ct}: ${schemaToTypeStr(s)}`)
104
- .join(';')}}`,
105
- }
106
- : {}),
107
- ...(r.schema.response
108
- ? {
109
- response: Object.fromEntries(
110
- Object.entries(r.schema.response).map(([k, v]) => [
111
- k === 'default' ? '"default"' : k,
112
- schemaToTypeStr(v as STSchema),
113
- ])
114
- ),
115
- }
116
- : {}),
117
- },
448
+ const meta = metaRoutes?.[r.path]?.[r.method]
449
+ const [, summary, description] = meta?.head?.match(/^([^\n]*)\n\n(.*)/) ?? []
450
+ const explicitId: string | undefined = meta?.operationId
451
+ const autoDerived = !explicitId
452
+ const operationId = explicitId ?? deriveOperationId(r.method, r.path)
453
+ if (autoDerived) autoDerivedIds.push(`${r.method.toUpperCase()} ${r.path} ${operationId}`)
454
+
455
+ // Collect named types
456
+ Object.values(r.schema.response ?? {}).forEach(entry => {
457
+ if (!entry) return
458
+ const s = isResponseValue(entry as STResponseEntry) ? entry as STSchema : null
459
+ if (s?.id) namedTypes[s.id] = schemaToTypeStr(s)
460
+ })
461
+
462
+ // Build body map (MIME → short name)
463
+ let body: Record<string, STSchema> | null = null
464
+ const rawBody = r.schema.body as any
465
+ if (rawBody && (rawBody as STSchema)[Kind] !== 'null') {
466
+ body = {}
467
+ for (const [mime, s] of Object.entries(rawBody)) {
468
+ if (!mime.includes('/') || !s) continue
469
+ body[mimeToShort(mime)] = s as STSchema
470
+ }
471
+ if (!Object.keys(body).length) body = null
472
+ }
473
+
474
+ // Build query schema map
475
+ const query: GalbeClientRoute['query'] = {}
476
+ for (const [k, s] of Object.entries((r.schema.query ?? {}) as Record<string, STSchema>)) {
477
+ query[k] = {
478
+ type: schemaToTypeStr({ ...s, [Optional]: false }),
479
+ optional: !!(s as any)[Optional],
480
+ description: (s as any).description,
481
+ }
482
+ }
483
+
484
+ // Build headers schema map
485
+ const headers: GalbeClientRoute['headers'] = {}
486
+ for (const [k, s] of Object.entries((r.schema.headers ?? {}) as Record<string, STSchema>)) {
487
+ headers[k] = {
488
+ type: schemaToTypeStr({ ...s, [Optional]: false }),
489
+ optional: !!(s as any)[Optional],
490
+ description: (s as any).description,
491
+ }
118
492
  }
119
- Object.values(r.schema.response || {})
120
- .filter(s => s?.id)
121
- .forEach(s => {
122
- //@ts-ignore
123
- types[s.id] = schemaToTypeStr(s)
124
- })
125
- routes[r.method.toLocaleLowerCase()].push(route)
493
+
494
+ routes.push({
495
+ method: r.method,
496
+ path: r.path,
497
+ operationId,
498
+ autoDerived,
499
+ params: Object.fromEntries(
500
+ [...r.path.matchAll(/:([^/]+)/g)].map(m => {
501
+ const ps = (r.schema.params as Record<string, STSchema>)?.[m[1]]
502
+ return [m[1], { type: ps ? schemaToTypeStr(ps) : 'string', description: (ps as any)?.description }]
503
+ })
504
+ ),
505
+ query,
506
+ headers,
507
+ body,
508
+ response: (r.schema.response as STResponse) ?? null,
509
+ summary: summary || undefined,
510
+ description: description || undefined,
511
+ tags: meta?.tags ? (Array.isArray(meta.tags) ? meta.tags : [meta.tags]) : [],
512
+ })
126
513
  })
127
514
 
128
- const file = await Bun.file(resolve(import.meta.dir, '..', '..', 'res', 'client.template.ts')).text()
129
- let filled = file.replaceAll(/\/\*\%([\s\S]*?)\%\*\//g, (_match, p) => {
130
- let idt = p.match(/^\n*([ \t]*)/, p)?.[1] || ''
131
- const script = new Script(p)
132
- const sandbox = {
133
- console,
134
- version: pckg?.version || '0.1.0',
135
- routes,
136
- types,
515
+ // Load user config
516
+ let userTransform: ((routes: GalbeClientRoute[]) => GalbeClientRoute[]) | undefined
517
+ let userOptions: GalbeClientOptions | undefined
518
+ if (config) {
519
+ try {
520
+ const m = await import(resolve(CWD, config))
521
+ userTransform = m.transform
522
+ userOptions = m.options
523
+ } catch (err) {
524
+ console.log(`\nerror: config file import failed`)
525
+ console.log(err)
526
+ return process.exit(1)
137
527
  }
138
- createContext(sandbox)
139
- let res = script.runInNewContext(sandbox)
140
- if (typeof res === 'string') res = res.split('\n')
141
- if (Array.isArray(res)) return res.map((s, i) => (i === 0 ? s : `${idt}${s}`)).join('\n')
142
- return res ?? ''
528
+ }
529
+ const finalRoutes = userTransform ? userTransform(routes) : routes
530
+ const className = userOptions?.className ?? 'Client'
531
+
532
+ // Log generation info
533
+ const allVariants = finalRoutes.flatMap(expandRoute)
534
+ Bun.write(Bun.stdout, '\n')
535
+ for (const v of allVariants) {
536
+ const sourceRoute = finalRoutes.find(r =>
537
+ r.operationId === v.operationId || v.operationId.startsWith(r.operationId)
538
+ )
539
+ const isAuto = sourceRoute?.autoDerived
540
+ Bun.write(
541
+ Bun.stdout,
542
+ ` ${isAuto ? '\x1b[33m~\x1b[0m' : '\x1b[32m+\x1b[0m'} ${v.operationId}${isAuto ? ' \x1b[2m(auto-derived)\x1b[0m' : ''}\n`
543
+ )
544
+ }
545
+ if (autoDerivedIds.length) {
546
+ Bun.write(
547
+ Bun.stdout,
548
+ `\n \x1b[33m!\x1b[0m ${autoDerivedIds.length} auto-derived operationId(s) — add explicit operationIds to stabilise names\n`
549
+ )
550
+ }
551
+
552
+ const runtimeContent = await Bun.file(resolve(import.meta.dir, '..', '..', 'res', 'client.runtime.ts')).text()
553
+
554
+ let code = await generateClientCode({
555
+ routes: finalRoutes,
556
+ namedTypes,
557
+ className,
558
+ version: pckg?.version ?? '0.1.0',
559
+ runtimeContent,
143
560
  })
144
561
 
145
562
  if (target === 'js') {
146
- filled = transformSync(filled, {
147
- jsc: {
148
- parser: {
149
- syntax: 'typescript',
150
- },
151
- preserveAllComments: true,
152
- target: 'esnext',
153
- },
563
+ code = transformSync(code, {
564
+ jsc: { parser: { syntax: 'typescript' }, preserveAllComments: true, target: 'esnext' },
154
565
  }).code
155
566
  }
156
567
 
157
- await Bun.write(out, filled)
158
-
568
+ await Bun.write(out, code)
159
569
  Bun.write(Bun.stdout, ' : \x1b[1;30m\x1b[32mdone\x1b[0m\n')
160
570
  process.exit(0)
161
571
  })