galbe 0.15.5 → 0.16.0

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 CHANGED
@@ -9,6 +9,9 @@
9
9
 
10
10
  Galbe is a fast, lightweight and highly customizable JavaScript web framework based on [Bun](https://bun.sh).
11
11
 
12
+ > [!NOTE]
13
+ > Galbe is Bun-only (Bun v1.2.21+): it relies on Bun-native APIs and ships TypeScript sources directly. Node.js and Deno are not supported.
14
+
12
15
  > [!IMPORTANT]
13
16
  > Galbe is currently under active development and not guaranteed to be stable. Future releases may potentially introduce breaking changes.
14
17
 
@@ -7,7 +7,7 @@ import { mkdir, rm } from 'fs/promises'
7
7
 
8
8
  import { CWD, fmtVal, silentExec } from '../util'
9
9
  import { Galbe } from '../../src'
10
- import { defineRoutes, GalbeProxy } from '../../src/routes'
10
+ import { defineRoutes } from '../../src/routes'
11
11
  import type { BuildConfig } from 'bun'
12
12
  import { cpSync, existsSync } from 'fs'
13
13
  import { softMerge } from '../../src/util'
@@ -16,45 +16,54 @@ const createBuildIndex = async (indexPath: string, g: Galbe, buildId: string, ou
16
16
  const buildPath = resolve(tmpdir(), buildId)
17
17
  const indexDir = dirname(indexPath)
18
18
 
19
+ // Locate galbe's own modules from the CLI's install location rather than
20
+ // assuming `<app>/node_modules/galbe/src`, which breaks under pnpm,
21
+ // hoisted or global CLI installs.
22
+ const galbeUtilPath = resolve(import.meta.dir, '..', '..', 'src', 'util')
23
+ const galbeIndexPath = resolve(import.meta.dir, '..', '..', 'src', 'index')
24
+
19
25
  let configPath = ''
20
26
  if (existsSync(`${indexDir}/galbe.config.ts`)) configPath = `${indexDir}/galbe.config.ts`
21
27
  else if (existsSync(`${indexDir}/galbe.config.js`)) configPath = `${indexDir}/galbe.config.js`
22
28
 
23
- const routes = new Map<string, { filepath: string; static?: { path: string; root: string } }>()
24
29
  let errors: any[] = []
25
- // Create GalbeProxy here
26
- // use it to define routes
27
- const proxy = new GalbeProxy(g, ({ type, error, filepath, route }) => {
28
- if (!filepath) return
29
- routes.set(filepath, { filepath, static: route?.static })
30
- if (type === 'error') errors.push(error)
31
- })
32
- await defineRoutes({ routes: g?.config?.routes }, proxy)
33
- // call init on it for plugin initialization
34
- await proxy.init()
30
+ const { routeFiles, middlewareFiles } = await defineRoutes(
31
+ { routes: g?.config?.routes, middleware: g?.config?.middleware },
32
+ g,
33
+ ({ type, error }) => {
34
+ if (type === 'error') errors.push(error)
35
+ }
36
+ )
37
+ // plugin initialization
38
+ await g.init()
35
39
 
36
40
  if (errors.length) throw errors
37
41
 
38
42
  // Copy static assets next to the bundle so the runtime can resolve them
39
43
  // via static-${BUILD_ID}/<target> at request time.
40
- for (const { target } of proxy._staticTargets) {
44
+ for (const { target } of g.staticTargets) {
41
45
  cpSync(target, `${outPath}/static-${buildId}/${target}`, { recursive: true, dereference: true })
42
46
  }
43
47
 
44
48
  await mkdir(buildPath, { recursive: true })
45
49
 
50
+ const usesGroup = routeFiles.some(r => r.prefix)
46
51
  let buildIndex =
47
52
  `import galbe from '${relative(buildPath, indexPath)}';\n` +
53
+ (usesGroup ? `import {GalbeGroup} from '${relative(buildPath, galbeIndexPath)}';\n` : '') +
48
54
  (configPath ? `import config from '${relative(buildPath, configPath)}';\n` : '') +
49
- (configPath
50
- ? `import {softMerge} from '${relative(buildPath, `${indexDir}/node_modules/galbe/src/util`)}';\n`
51
- : '') +
55
+ (configPath ? `import {softMerge} from '${relative(buildPath, galbeUtilPath)}';\n` : '') +
52
56
  (configPath ? `let conf = galbe.config;\ngalbe.config = softMerge(config, conf)\n` : '') +
53
- `${[...routes.values()].map((r, idx) => `import _${idx} from '${relative(buildPath, r.filepath)}'`).join(';\n')}\n` +
57
+ `${middlewareFiles.map((m, idx) => `import mw_${idx} from '${relative(buildPath, m.file)}'`).join(';\n')}\n` +
58
+ `${routeFiles.map((r, idx) => `import _${idx} from '${relative(buildPath, r.file)}'`).join(';\n')}\n` +
54
59
  `Bun.env.BUN_ENV = 'production';\n` +
55
60
  `Bun.env.GALBE_BUILD = '${buildId}';\n` +
56
61
  `galbe.meta = ${JSON.stringify(g.meta)};\n` +
57
- `${[...routes].map((_, idx) => `_${idx}(galbe)`).join(';\n')};\n` +
62
+ `galbe.metaMiddleware = ${JSON.stringify(g.metaMiddleware)};\n` +
63
+ `${middlewareFiles.map((m, idx) => `galbe.middleware(${JSON.stringify(m.scope)}, mw_${idx})`).join(';\n')};\n` +
64
+ `${routeFiles
65
+ .map((r, idx) => `_${idx}(${r.prefix ? `new GalbeGroup(galbe, ${JSON.stringify(r.prefix)})` : 'galbe'})`)
66
+ .join(';\n')};\n` +
58
67
  `galbe.listen();\n` +
59
68
  `process.on('SIGTERM', () => galbe.stop());\n` +
60
69
  `process.on('SIGINT', () => galbe.stop());\n`
@@ -114,7 +123,9 @@ export default (cmd: Command) => {
114
123
  buildIndex = await createBuildIndex(index, g, buildID, outPath)
115
124
  } catch (errors) {
116
125
  console.log(`\nerror: build errors`)
117
- for (let error of errors as any[]) console.log(error)
126
+ // route-file errors come as an array; anything else (a boot error, a
127
+ // failed import) throws a single value — don't lose it
128
+ for (let error of [errors].flat()) console.log(error)
118
129
  return process.exit(1)
119
130
  }
120
131
  if (!buildIndex) {
@@ -2,7 +2,7 @@ import { $ } from 'bun'
2
2
  import { Command, Option } from 'commander'
3
3
  import { resolve, dirname } from 'path'
4
4
 
5
- import { CWD, fmtInterval, fmtVal, instanciateRoutes, watchDir } from '../util'
5
+ import { CWD, fmtInterval, fmtVal, instanciateRoutes, resolveReloadStrategy, watchDir } from '../util'
6
6
  import { Galbe } from '../../src'
7
7
  import { softMerge } from '../../src/util'
8
8
  import { existsSync } from 'fs'
@@ -42,22 +42,70 @@ export default (cmd: Command) => {
42
42
 
43
43
  if (!Bun.env.BUN_ENV) Bun.env.BUN_ENV = 'development'
44
44
 
45
+ if (!!watch_dir && resolveReloadStrategy() === 'respawn') {
46
+ // Reload by respawning the app process: a syntax error in an edited file
47
+ // kills the child, not the watcher — the next save reloads.
48
+ const spawnApp = () =>
49
+ Bun.spawn([process.execPath, process.argv[1], 'dev', index, '-p', `${port || DEFAULT_PORT}`], {
50
+ stdio: ['inherit', 'inherit', 'inherit'],
51
+ cwd: CWD,
52
+ })
53
+ const killChild = () => {
54
+ try {
55
+ child?.kill()
56
+ } catch {}
57
+ }
58
+ process.on('SIGINT', () => {
59
+ killChild()
60
+ process.exit(0)
61
+ })
62
+ process.on('SIGTERM', () => {
63
+ killChild()
64
+ process.exit(0)
65
+ })
66
+ if (clear) await $`clear`.nothrow()
67
+ let child = spawnApp()
68
+ let reloading: Promise<void> = Promise.resolve()
69
+ await watchDir(
70
+ watch_dir,
71
+ () => {
72
+ reloading = reloading.then(async () => {
73
+ killChild()
74
+ await child.exited
75
+ if (clear) await $`clear`.nothrow()
76
+ child = spawnApp()
77
+ })
78
+ },
79
+ { ignore: watchignore ? new RegExp(watchignore) : undefined }
80
+ )
81
+ // The watcher is non-persistent and a pending promise alone does not
82
+ // keep the event loop alive — hold it open with an interval.
83
+ setInterval(() => {}, 2 ** 31 - 1)
84
+ return
85
+ }
86
+
45
87
  if (!!watch_dir) {
46
88
  await watchDir(
47
89
  watch_dir,
48
90
  async () => {
49
91
  g.stop()
50
- if (clear) await $`clear`
92
+ if (clear) await $`clear`.nothrow()
51
93
  Loader.registry.clear()
52
- g = (await import(indexPath)).default
94
+ try {
95
+ g = (await import(indexPath)).default
96
+ } catch (e) {
97
+ console.error('\x1b[0;31mReload failed:\x1b[0m', e)
98
+ return
99
+ }
100
+ g.config = softMerge(galbeConfig, g.config)
53
101
  await instanciateRoutes(g)
54
102
  await g.listen(port)
55
103
  },
56
- { ignore: watchignore ? new RegExp(watchignore) : /node_modules/ }
104
+ { ignore: watchignore ? new RegExp(watchignore) : undefined }
57
105
  )
58
106
  }
59
107
 
60
- if (!!watch_dir && clear) await $`clear`
108
+ if (!!watch_dir && clear) await $`clear`.nothrow()
61
109
  g = (await import(indexPath)).default
62
110
  let conf = g.config
63
111
  g.config = softMerge(galbeConfig, conf)
@@ -63,8 +63,11 @@ export default (cmd: Command) => {
63
63
 
64
64
  let commands: GalbeCLICommand[] = []
65
65
 
66
+ // meta keys are relative to basePath; route paths carry it
67
+ const prefix = g.router.prefix || ''
66
68
  walkRoutes(g.router.routes, r => {
67
- let meta = metaRoutes?.[r.path]?.[r.method]
69
+ const rPath = prefix && r.path.startsWith(prefix) ? r.path.slice(prefix.length) || '/' : r.path
70
+ let meta = metaRoutes?.[rPath]?.[r.method]
68
71
  let [_, summary, description] = meta?.head?.match(/^([^\n]*)\n\n(.*)/) || []
69
72
  if (!summary) description = meta?.head
70
73
 
@@ -1,6 +1,6 @@
1
1
  import { Command, Option } from 'commander'
2
2
  import { resolve } from 'path'
3
- import { transformSync } from '@swc/core'
3
+ import { transformSync } from '@swc/wasm'
4
4
  import { CWD, fmtList, instanciateRoutes, silentExec } from '../../util'
5
5
  import { Galbe, type GalbeClientRoute, type GalbeClientOptions } from '../../../src'
6
6
  import { walkRoutes } from '../../../src/util'
@@ -32,12 +32,7 @@ const mimeToShort = (mime: string) => MIME_SHORT[mime] ?? (mime.startsWith('text
32
32
  // ─── Operaion-id derivation ───────────────────────────────────────────────────
33
33
 
34
34
  const deriveOperationId = (method: string, path: string): string =>
35
- `${method}-${path
36
- .replace(/\//g, '-')
37
- .replace(/:/g, '')
38
- .replace(/^-/, '')
39
- .replace(/-+/g, '-')
40
- .replace(/-$/, '')}`
35
+ `${method}-${path.replace(/\//g, '-').replace(/:/g, '').replace(/^-/, '').replace(/-+/g, '-').replace(/-$/, '')}`
41
36
 
42
37
  // ─── Response-entry helpers ───────────────────────────────────────────────────
43
38
 
@@ -64,7 +59,10 @@ const responseBodyInfo = (entry: STResponseEntry): BodyInfo => {
64
59
  for (const [mime, s] of Object.entries(content)) {
65
60
  if (!mime.includes('/') || !s) continue
66
61
  const schema = s as STSchema
67
- if (Stream in schema && (schema as any)[Stream]) { methods.push('stream'); continue }
62
+ if (Stream in schema && (schema as any)[Stream]) {
63
+ methods.push('stream')
64
+ continue
65
+ }
68
66
  const short = mimeToShort(mime)
69
67
  methods.push(short === 'byteArray' ? 'byteArray' : short === 'text' ? 'text' : 'json')
70
68
  typeStr = schemaToTypeStr(schema)
@@ -77,7 +75,9 @@ const responseHeadersType = (entry: STResponseEntry): string => {
77
75
  ? (entry as any).responseHeaders
78
76
  : (entry as STResponseContent).responseHeaders
79
77
  if (!rh || !Object.keys(rh).length) return 'Headers'
80
- const keys = Object.keys(rh).map(k => `'${k}'`).join('|')
78
+ const keys = Object.keys(rh)
79
+ .map(k => `'${k}'`)
80
+ .join('|')
81
81
  return `{get<K extends string>(name:K):K extends ${keys}?string:string|null}&Omit<Headers,'get'>`
82
82
  }
83
83
 
@@ -94,10 +94,32 @@ type _OKStatus = ${[...OKS].join('|')}
94
94
  type _HttpStatus = ${HTTP_CODES.join('|')}
95
95
  `.trim()
96
96
 
97
+ /**
98
+ * Expand `1XX`…`5XX` range keys into the concrete statuses they cover, so the
99
+ * generated types stay per-status. An exact declaration always wins over the
100
+ * range containing it; `default` is left alone as the fallback arm.
101
+ */
102
+ const expandRanges = (response: STResponse): Array<[string, STResponseEntry]> => {
103
+ const exact = new Set(Object.keys(response).filter(k => /^\d+$/.test(k)))
104
+ const out: Array<[string, STResponseEntry]> = []
105
+ for (const [key, entry] of Object.entries(response)) {
106
+ if (!entry) continue
107
+ const range = key.match(/^([1-5])XX$/)
108
+ if (!range) {
109
+ out.push([key, entry])
110
+ continue
111
+ }
112
+ const hundred = Number(range[1]) * 100
113
+ for (const code of HTTP_CODES)
114
+ if (code >= hundred && code < hundred + 100 && !exact.has(String(code))) out.push([String(code), entry])
115
+ }
116
+ return out
117
+ }
118
+
97
119
  // Convert operationId to a safe TypeScript identifier (for type names)
98
120
  const safeTypeId = (id: string) => id.replace(/[^a-zA-Z0-9_$]/g, '_')
99
121
  // 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}'`
122
+ const safePropKey = (id: string) => (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(id) ? id : `'${id}'`)
101
123
 
102
124
  const buildBodyObjType = (methods: string[], typeStr: string): string => {
103
125
  const parts: string[] = []
@@ -121,7 +143,7 @@ const buildRawResponseType = (operationId: string, response: STResponse | null):
121
143
  const declared: string[] = []
122
144
  const arms: string[] = []
123
145
 
124
- for (const [rawKey, entry] of Object.entries(response)) {
146
+ for (const [rawKey, entry] of expandRanges(response)) {
125
147
  if (!entry) continue
126
148
  const status = rawKey === 'default' ? null : Number(rawKey)
127
149
  if (status === null) continue // handled as fallback
@@ -129,14 +151,14 @@ const buildRawResponseType = (operationId: string, response: STResponse | null):
129
151
  const ok = OKS.has(status)
130
152
  const { methods, typeStr } = responseBodyInfo(entry)
131
153
  const headersT = responseHeadersType(entry)
132
- arms.push(
133
- `{status:${status};ok:${ok};headers:${headersT};body:${buildBodyObjType(methods, typeStr)}}`
134
- )
154
+ arms.push(`{status:${status};ok:${ok};headers:${headersT};body:${buildBodyObjType(methods, typeStr)}}`)
135
155
  }
136
156
 
137
157
  // fallback arm
138
158
  const defaultEntry = (response as any)['default'] as STResponseEntry | undefined
139
- const fallbackBody = defaultEntry ? buildBodyObjType(...Object.values(responseBodyInfo(defaultEntry)) as [string[], string]) : FALLBACK_BODY
159
+ const fallbackBody = defaultEntry
160
+ ? buildBodyObjType(...(Object.values(responseBodyInfo(defaultEntry)) as [string[], string]))
161
+ : FALLBACK_BODY
140
162
  const excludeStr = declared.length ? `Exclude<_HttpStatus,${declared.join('|')}>` : '_HttpStatus'
141
163
  arms.push(`{status:${excludeStr};ok:boolean;headers:Headers;body:${fallbackBody}}`)
142
164
 
@@ -147,7 +169,7 @@ const buildRawResponseType = (operationId: string, response: STResponse | null):
147
169
  const buildSuccessType = (response: STResponse | null): string => {
148
170
  if (!response) return 'unknown'
149
171
  const types: string[] = []
150
- for (const [rawKey, entry] of Object.entries(response)) {
172
+ for (const [rawKey, entry] of expandRanges(response)) {
151
173
  if (!entry) continue
152
174
  const status = rawKey === 'default' ? null : Number(rawKey)
153
175
  if (status === null || !OKS.has(status)) continue
@@ -167,7 +189,7 @@ const buildErrorType = (operationId: string, response: STResponse | null): strin
167
189
  const declared: string[] = []
168
190
  const arms: string[] = []
169
191
 
170
- for (const [rawKey, entry] of Object.entries(response)) {
192
+ for (const [rawKey, entry] of expandRanges(response)) {
171
193
  if (!entry) continue
172
194
  const status = rawKey === 'default' ? null : Number(rawKey)
173
195
  if (status === null || OKS.has(status)) continue
@@ -180,9 +202,9 @@ const buildErrorType = (operationId: string, response: STResponse | null): strin
180
202
  const defaultEntry = (response as any)['default'] as STResponseEntry | undefined
181
203
  const fallbackBody = defaultEntry ? responseBodyInfo(defaultEntry).typeStr : 'any'
182
204
  // also exclude all declared 2xx
183
- const declared2xx = Object.keys(response)
205
+ const declared2xx = expandRanges(response)
206
+ .map(([k]) => k)
184
207
  .filter(k => k !== 'default' && OKS.has(Number(k)))
185
- .map(String)
186
208
  const allDeclared = [...declared, ...declared2xx]
187
209
  const errExcludeStr = allDeclared.length ? `Exclude<_HttpStatus,${allDeclared.join('|')}>` : '_HttpStatus'
188
210
  arms.push(`{status:${errExcludeStr};headers:Headers;body:${fallbackBody}}`)
@@ -217,7 +239,10 @@ const expandRoute = (r: GalbeClientRoute): RouteVariant[] => {
217
239
  Object.entries(r.query).map(([k, v]) => [k, { typeStr: v.type, optional: v.optional, description: v.description }])
218
240
  )
219
241
  const reqHeaders = Object.fromEntries(
220
- Object.entries(r.headers).map(([k, v]) => [k, { typeStr: v.type, optional: v.optional, description: v.description }])
242
+ Object.entries(r.headers).map(([k, v]) => [
243
+ k,
244
+ { typeStr: v.type, optional: v.optional, description: v.description },
245
+ ])
221
246
  )
222
247
 
223
248
  const bodyEntries = r.body ? Object.entries(r.body) : []
@@ -289,9 +314,7 @@ const buildParamList = (v: RouteVariant): string => {
289
314
  const buildFetchCall = (v: RouteVariant, fnName: string): string => {
290
315
  const pathExpr = v.params.length ? `\`${v.pathTemplate}\`` : `'${v.path}'`
291
316
  const bodyArg = v.bodyShortName !== null ? 'body' : 'undefined'
292
- const optionsArg = v.bodyShortName !== null
293
- ? `{...(options??{}),contentType:'${v.bodyShortName}'}`
294
- : 'options'
317
+ const optionsArg = v.bodyShortName !== null ? `{...(options??{}),contentType:'${v.bodyShortName}'}` : 'options'
295
318
  return `${fnName}(this.#config,'${v.method.toUpperCase()}',${pathExpr},${bodyArg},${optionsArg})`
296
319
  }
297
320
 
@@ -413,7 +436,9 @@ export default (cmd: Command) => {
413
436
  if (!out) out = { ts: 'dist/client.ts', js: 'dist/client.js' }[target as 'ts' | 'js']
414
437
 
415
438
  let pckg: any = {}
416
- try { pckg = await Bun.file(resolve(CWD, 'package.json')).json() } catch {}
439
+ try {
440
+ pckg = await Bun.file(resolve(CWD, 'package.json')).json()
441
+ } catch {}
417
442
 
418
443
  let error: any = null
419
444
  Bun.write(Bun.stdout, '💻 \x1b[1;30mBuilding \x1b[36mGalbe\x1b[0m\x1b[1;30m client\x1b[0m')
@@ -424,7 +449,9 @@ export default (cmd: Command) => {
424
449
  await instanciateRoutes(mod)
425
450
  await mod.init()
426
451
  return mod
427
- } catch (err) { error = err }
452
+ } catch (err) {
453
+ error = err
454
+ }
428
455
  })
429
456
 
430
457
  if (error) {
@@ -444,8 +471,11 @@ export default (cmd: Command) => {
444
471
  const routes: GalbeClientRoute[] = []
445
472
  const autoDerivedIds: string[] = []
446
473
 
474
+ // meta keys are relative to basePath; route paths carry it
475
+ const prefix = g.router.prefix || ''
447
476
  walkRoutes(g.router.routes, r => {
448
- const meta = metaRoutes?.[r.path]?.[r.method]
477
+ const rPath = prefix && r.path.startsWith(prefix) ? r.path.slice(prefix.length) || '/' : r.path
478
+ const meta = metaRoutes?.[rPath]?.[r.method]
449
479
  const [, summary, description] = meta?.head?.match(/^([^\n]*)\n\n(.*)/) ?? []
450
480
  const explicitId: string | undefined = meta?.operationId
451
481
  const autoDerived = !explicitId
@@ -455,7 +485,7 @@ export default (cmd: Command) => {
455
485
  // Collect named types
456
486
  Object.values(r.schema.response ?? {}).forEach(entry => {
457
487
  if (!entry) return
458
- const s = isResponseValue(entry as STResponseEntry) ? entry as STSchema : null
488
+ const s = isResponseValue(entry as STResponseEntry) ? (entry as STSchema) : null
459
489
  if (s?.id) namedTypes[s.id] = schemaToTypeStr(s)
460
490
  })
461
491
 
@@ -533,8 +563,8 @@ export default (cmd: Command) => {
533
563
  const allVariants = finalRoutes.flatMap(expandRoute)
534
564
  Bun.write(Bun.stdout, '\n')
535
565
  for (const v of allVariants) {
536
- const sourceRoute = finalRoutes.find(r =>
537
- r.operationId === v.operationId || v.operationId.startsWith(r.operationId)
566
+ const sourceRoute = finalRoutes.find(
567
+ r => r.operationId === v.operationId || v.operationId.startsWith(r.operationId)
538
568
  )
539
569
  const isAuto = sourceRoute?.autoDerived
540
570
  Bun.write(
@@ -561,7 +591,8 @@ export default (cmd: Command) => {
561
591
 
562
592
  if (target === 'js') {
563
593
  code = transformSync(code, {
564
- jsc: { parser: { syntax: 'typescript' }, preserveAllComments: true, target: 'esnext' },
594
+ // @swc/wasm's JscTarget typing lags @swc/core's; 'esnext' is supported at runtime
595
+ jsc: { parser: { syntax: 'typescript' }, preserveAllComments: true, target: 'esnext' as any },
565
596
  }).code
566
597
  }
567
598