@bakery-framework/core 1.2.3 → 2.0.0-alpha.11

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,13 +1,30 @@
1
1
  import { unlinkSync } from 'node:fs'
2
- import { Bakery } from '../core/bakery'
2
+ import { cacheDir } from '../core/context'
3
3
  import { Try } from '../utils/common/try'
4
4
 
5
+ /**
6
+ * **`cacheDir` comes from `core/context`, not from `Bakery` — do not change it
7
+ * back.** `logger.ts` imports this module, so importing `core/bakery` here
8
+ * completed a cycle:
9
+ *
10
+ * logger.ts -> prompt-tracker.ts -> core/bakery.ts -> core/config.ts
11
+ * -> logger/serve-log.ts -> logger.ts
12
+ *
13
+ * `serve-log.ts` runs `new Logger('serve')` at module scope, so whichever
14
+ * import arrived first found `Logger` still in its temporal dead zone. That
15
+ * shipped in 1.2.3 and made `import '@bakery-framework/core'` throw
16
+ * `ReferenceError: Cannot access 'Logger' before initialization` from a clean
17
+ * install — see `tests/module-cycle.test.ts`.
18
+ *
19
+ * `core/context` holds the same single definition of the path and imports
20
+ * nothing that reaches the logger.
21
+ */
5
22
  export const PromptTracker = {
6
23
  getFilePath(pid: number): string {
7
24
  // Derived, not written out: this lands in the cache directory, which the
8
25
  // framework wipes wholesale, and a stale literal here would leave marker
9
26
  // files behind in a directory nothing sweeps.
10
- return `${Bakery.cacheDir}/.prompt-active-${pid}`
27
+ return `${cacheDir()}/.prompt-active-${pid}`
11
28
  },
12
29
 
13
30
  async isActive(pid: number): Promise<boolean> {
@@ -61,6 +61,13 @@ const PROJECT_DIR = fs.resolve(APP_DIR, '.cache/tsconfig')
61
61
  * Before this existed, one config covered everything and `Bun.hash()` in a
62
62
  * client file typechecked clean and failed in the browser.
63
63
  *
64
+ * The includes overlap the app's own tsconfig, and that is fine *because
65
+ * nothing references these projects*: each is a standalone projection of one
66
+ * concern, pointed at directly (`tsc -p .cache/tsconfig/client.json`), and
67
+ * subtracting the app's include would gut them into projects that check
68
+ * nothing. What must never come back is the `references` wiring that composed
69
+ * them with the app project — see {@link syncTSConfigProjects}.
70
+ *
64
71
  * Globs are app-relative here and rewritten to be relative to the generated
65
72
  * file, which sits two levels down.
66
73
  */
@@ -322,22 +329,81 @@ function mapPaths(paths: MapOf<string[]>): MapOf<string[]> {
322
329
  }
323
330
 
324
331
  /**
325
- * The app's root tsconfig with `references` set, and nothing else touched.
332
+ * A `references` entry the generator wrote in a previous release, as opposed
333
+ * to one the developer owns. Ours always pointed into `.cache/tsconfig/`, and
334
+ * nothing else has a reason to: `.cache/` is the disposable runtime directory,
335
+ * and every project inside it is regenerated on boot.
336
+ */
337
+ const RE_GENERATED_REF = /^(\.[\\/])?\.cache[\\/]tsconfig[\\/]/
338
+
339
+ function isGeneratedReference(entry: unknown): boolean {
340
+ if (typeof entry !== 'object' || entry === null) return false
341
+ const path = (entry as { path?: unknown }).path
342
+ return typeof path === 'string' && RE_GENERATED_REF.test(path)
343
+ }
344
+
345
+ /**
346
+ * The root config with the generator's own `references` removed, or `null`
347
+ * when there is nothing to repair.
348
+ *
349
+ * Until 2026-08-27 `syncTSConfigProjects` *added* those references, wiring the
350
+ * generated projects into the app's project graph. That glue is what broke
351
+ * `tsc -p <app>` for every consumer who had booted once. Measured directly
352
+ * (TypeScript 6.0.3):
353
+ *
354
+ * - `tsc -p` verifies every referenced project whenever the referencing
355
+ * program has input files of its own: a reference to a non-composite
356
+ * project is TS6306 and to a `noEmit` one TS6310 — **even when the
357
+ * referenced project's include is disjoint from the root's, and even when
358
+ * it matches zero files**. No include shape survives.
359
+ * - Each root file a referenced project also claims is redirected to that
360
+ * project's declaration output, which `noEmit` guarantees was never built:
361
+ * TS6305, once per overlapping file — `src/**`, `server.config.ts`, every
362
+ * `.tsx` page.
363
+ *
364
+ * The generated projects extend `noEmit` bases and rely on
365
+ * `allowImportingTsExtensions`, so they are unbuildable by design. Making them
366
+ * `composite` instead would trade the errors above for a `tsc -b` build-order
367
+ * requirement no consumer runs — and TS6305 would still fire for any root file
368
+ * importing into one while unbuilt. The only reference shape `tsc -p`
369
+ * tolerates from a root that has files of its own is no reference at all, so
370
+ * the projects stand alone now and this strips what previous releases wrote.
371
+ *
372
+ * Everything the developer owns is preserved: only entries into
373
+ * `.cache/tsconfig/` are removed, a real project reference (say `../shared`)
374
+ * stays, and the `references` key itself survives unless the generator wrote
375
+ * every entry in it.
376
+ *
377
+ * Pure, and exported, so the repair can be tested without a function that
378
+ * writes to `process.cwd()`. The property that matters is negative — *no key
379
+ * the developer wrote is lost* — which a shape assertion on the source cannot
380
+ * check.
381
+ */
382
+ export function stripGeneratedReferences(
383
+ current: Record<string, unknown>,
384
+ ): Record<string, unknown> | null {
385
+ const refs = current.references
386
+ if (!Array.isArray(refs)) return null
387
+
388
+ const kept = refs.filter(entry => !isGeneratedReference(entry))
389
+ if (kept.length === refs.length) return null
390
+
391
+ const repaired = { ...current }
392
+ if (kept.length) repaired.references = kept
393
+ else delete repaired.references
394
+ return repaired
395
+ }
396
+
397
+ /**
398
+ * The root config written when the app has none at all.
326
399
  *
327
- * Pure, and exported, so the merge can be tested without a function that writes
328
- * to `process.cwd()`. The property that matters is negative — *no key the
329
- * developer wrote is lost* which is the kind of thing a shape assertion on the
330
- * source cannot check.
400
+ * The generated projects do not help Bun's runtime it reads
401
+ * `compilerOptions.jsx*` from the root `tsconfig.json` and follows `extends`
402
+ * only into a relative path, never a package specifier so the file this
403
+ * writes has to carry the JSX options itself, inline, exactly as the
404
+ * scaffolder spells them.
331
405
  */
332
- export function mergeRootConfig(
333
- current: Record<string, unknown> | null,
334
- references: { path: string }[],
335
- ): Record<string, unknown> {
336
- // Spread first so `references` is the only key this owns.
337
- if (current) return { ...current, references }
338
-
339
- // No root config at all. The generated projects do not help Bun's runtime, so
340
- // the one this writes has to carry the JSX options itself.
406
+ export function defaultRootConfig(): Record<string, unknown> {
341
407
  return {
342
408
  extends: '@bakery-framework/core/tsconfig.server.json',
343
409
  compilerOptions: {
@@ -345,58 +411,63 @@ export function mergeRootConfig(
345
411
  jsxFactory: 'createElement',
346
412
  jsxFragmentFactory: 'Fragment',
347
413
  },
348
- references,
349
414
  }
350
415
  }
351
416
 
352
417
  /**
353
- * Generate the project configs and add `references` to the app's root tsconfig.
418
+ * Generate the project configs, and keep the app's root tsconfig viable —
419
+ * created with the runtime JSX options when the app has none, and stripped of
420
+ * the `references` a previous release wrote into it.
354
421
  *
355
422
  * Separate from `syncTSConfigPaths` because an app can reasonably want one and
356
423
  * not the other: the paths sync has existed for a long time and rewrites a file
357
424
  * people keep in git, while this owns a directory nobody edits.
358
425
  *
359
- * **It used to replace the root config with a references-only stub, and that
360
- * silently broke every `.tsx` page in the app.** The reasoning was tidy — the
361
- * generated projects carry the JSX options, so the root does not need them — and
362
- * it was wrong about *who reads the root*. `tsc` follows `references`; **Bun's
363
- * runtime does not.** Bun reads `compilerOptions.jsx*` from the root
364
- * `tsconfig.json` and nothing else, so a references-only root means pages get
365
- * transpiled against the automatic JSX runtime instead of Bakery's
366
- * `createElement`.
367
- *
368
- * The symptom is worse than the 500 that mistake usually produces. Measured on a
369
- * scratch app: `GET /` answered **200** with
370
- * `{"type":"html","props":{…},"_owner":null,"_store":{}}` a React element tree,
371
- * JSON-encoded, because the handler received an object where it expects a
372
- * `SafeHtml` string. Nothing logs, nothing throws, the status is fine.
426
+ * **The generated projects are standalone on purpose; the root config does not
427
+ * reference them.** They exist for direct invocation against the one concern
428
+ * each covers: `vue-tsc -p .cache/tsconfig/vue.json` is the only way an SFC
429
+ * typechecks at all, `tsc -p .cache/tsconfig/client.json` proves browser code
430
+ * clean of `Bun.*`, and a plugin's project carries its own ambients the same
431
+ * way. Their `include` deliberately overlaps the app's own project they are
432
+ * alternate projections of the same files, used *instead of* the root for
433
+ * their slice, never composed with it. Wiring them in as `references` broke
434
+ * `tsc -p <app>` for any consumer who had booted once (TS6305/6306/6310 — the
435
+ * measurements are on {@link stripGeneratedReferences}), and what the wiring
436
+ * bought was less than it looked: tsserver routes a file to the project whose
437
+ * `include` claims it, so for everything a scaffolded root claims — `src/**`,
438
+ * `server.config.ts`, `orm/**` the reference walk never ran anyway.
373
439
  *
374
- * So the root is now *merged*, not replaced: every key the developer wrote stays,
375
- * and only `references` is ours. That also settles the contradiction with the
376
- * scaffolder, which writes those JSX options under a comment saying to keep them
377
- * and with the three documentation pages that repeat it.
440
+ * Two earlier lessons still bind the root-config half. It used to be
441
+ * *replaced* with a references-only stub, which silently broke every `.tsx`
442
+ * page: Bun's runtime reads `compilerOptions.jsx*` from the root and does not
443
+ * follow `references`, so pages transpiled against the automatic JSX runtime
444
+ * and `GET /` answered 200 with a JSON-encoded React element tree. Hence
445
+ * {@link defaultRootConfig} when no root exists, and surgical repair — never
446
+ * wholesale rewrite — when one does. And a boot that dirties git every time
447
+ * trains people to ignore the diff, so an already-clean root is not rewritten.
378
448
  */
379
449
  export async function syncTSConfigProjects(): Promise<void> {
380
450
  try {
381
451
  const written = await writeProjects(buildPaths())
382
452
 
383
- const references = written.map(name => ({
384
- path: `./.cache/tsconfig/${name}.json`,
385
- }))
386
-
387
453
  const current = fs.exists(APP_CONFIG_PATH)
388
454
  ? parseJSONC(await Bun.file(APP_CONFIG_PATH).text())
389
455
  : null
390
456
 
391
- // Only rewrite when the reference set actually changed. The root config is
392
- // committed, and a dev boot that dirties git every time trains people to
393
- // ignore the diff.
394
- if (current && Bun.deepEquals(current.references, references)) return
457
+ if (!current) {
458
+ await Bun.write(
459
+ APP_CONFIG_PATH,
460
+ `${JSON.stringify(defaultRootConfig(), null, 2)}\n`,
461
+ )
462
+ serveLog.TSCONFIG_PROJECTS_WRITTEN({ count: String(written.length) })
463
+ return
464
+ }
395
465
 
396
- const root = mergeRootConfig(current, references)
466
+ const repaired = stripGeneratedReferences(current)
467
+ if (!repaired) return
397
468
 
398
- await Bun.write(APP_CONFIG_PATH, `${JSON.stringify(root, null, 2)}\n`)
399
- serveLog.TSCONFIG_PROJECTS_WRITTEN({ count: String(written.length) })
469
+ await Bun.write(APP_CONFIG_PATH, `${JSON.stringify(repaired, null, 2)}\n`)
470
+ serveLog.TSCONFIG_REFERENCES_REMOVED()
400
471
  } catch (err: any) {
401
472
  serveLog.UNHANDLED_ERR({
402
473
  error: `TSConfig project sync error: ${errorMsg(err)}`,
@@ -2,7 +2,7 @@ import { HandlerMap } from '../handlers/core/$registry'
2
2
  import { fs } from '../utils/fs'
3
3
  import { SharedMemoryPool } from '../utils/shared-pool'
4
4
  import { getConfig, resolveHostname } from './config'
5
- import { getAppVersion, hostStore } from './context'
5
+ import { cacheDir, dataDir, getAppVersion, hostStore } from './context'
6
6
 
7
7
  export type { HostContext } from './context'
8
8
  export { hostStore } from './context'
@@ -70,17 +70,16 @@ export const Bakery: globalThis.Bakery = {
70
70
  return getAppVersion()
71
71
  },
72
72
  sharedPool: new SharedMemoryPool(1024 * 1024),
73
- // The disposable directory is the hidden one, and the precious one is not.
74
- // This is the reverse of the old `.bakery/cache` + `.data` pairing, and the
75
- // reversal is the whole point: `.cache` is wiped by the framework itself on
76
- // every version bump and dev<->prod switch, so a `rm -rf .*` or a "clean out
77
- // the dotfiles" sweep does exactly what the framework already does. The
78
- // database is not disposable, so it does not live behind a leading dot where
79
- // such a sweep can reach it.
80
- cacheDir: `${fs.cwd}/.cache`,
81
- // Holds the database and its backups. Visible, and deliberately not under
82
- // `.cache`: clearing a cache must never be able to destroy data.
83
- dataDir: `${fs.cwd}/bakery`,
73
+ // Defined in `core/context.ts`, which is low enough that a module needing a
74
+ // path does not have to import `Bakery` to get one reaching them through
75
+ // here is what closed the logger cycle. These stay the reading surface for
76
+ // application and framework code; context is the single definition.
77
+ //
78
+ // Called here rather than forwarded through a getter, so these remain plain
79
+ // writable properties: `nm.test.ts` repoints them at a fixture tree, which a
80
+ // getter turns into `TypeError: Attempted to assign to readonly property`.
81
+ cacheDir: cacheDir(),
82
+ dataDir: dataDir(),
84
83
  startNs: Bun.nanoseconds(),
85
84
  handlers: {
86
85
  fetch: new HandlerMap(),
@@ -29,6 +29,40 @@ export type HostContext = {
29
29
 
30
30
  export const hostStore = new AsyncLocalStorage<HostContext>()
31
31
 
32
+ /**
33
+ * The two runtime directories, defined here rather than on `Bakery`.
34
+ *
35
+ * `Bakery.cacheDir` / `Bakery.dataDir` remain the way application and framework
36
+ * code reads them — these are the single definition those two forward to, and
37
+ * still the only writer of either path. They live in this module because it is
38
+ * low enough to be imported without pulling in `core/config`, and therefore
39
+ * without pulling in the logger: `compiler/prompt-tracker.ts` needs the cache
40
+ * directory and reaching it through `Bakery` closed a module cycle that made
41
+ * the whole package unimportable. See the note on `prompt-tracker.ts`.
42
+ *
43
+ * **Functions, not constants, and that is not a style choice.** `utils/fs.ts`
44
+ * imports this module for `hostStore`, so the two are themselves a cycle: a
45
+ * top-level `` `${fs.cwd}/.cache` `` here is evaluated with `fs` still
46
+ * uninitialised whenever `core/context` is reached first, and throws
47
+ * `TypeError: undefined is not an object`. Reading `fs.cwd` at call time is
48
+ * what makes the order irrelevant.
49
+ *
50
+ * The disposable directory is the hidden one, and the precious one is not. This
51
+ * is the reverse of the old `.bakery/cache` + `.data` pairing, and the reversal
52
+ * is the whole point: `.cache` is wiped by the framework itself on every version
53
+ * bump and dev<->prod switch, so a `rm -rf .*` or a "clean out the dotfiles"
54
+ * sweep does exactly what the framework already does. The database is not
55
+ * disposable, so it does not live behind a leading dot where such a sweep can
56
+ * reach it, and never under `.cache` — clearing a cache must not destroy data.
57
+ */
58
+ export function cacheDir(): string {
59
+ return `${fs.cwd}/.cache`
60
+ }
61
+
62
+ export function dataDir(): string {
63
+ return `${fs.cwd}/bakery`
64
+ }
65
+
32
66
  /**
33
67
  * `matchBlocked`, deduplicated within the current request.
34
68
  *
@@ -51,14 +85,6 @@ export function matchBlockedCached(
51
85
  return verdict
52
86
  }
53
87
 
54
- /**
55
- * The **application's** version, from `<cwd>/package.json`.
56
- *
57
- * Named `getBakeryVersion` until 2026-08-09, which is exactly the wrong name:
58
- * it reads the package.json of whatever is being served, not the framework's.
59
- * That misnomer hid a real bug for as long as it existed — see
60
- * {@link getFrameworkVersion}.
61
- */
62
88
  /**
63
89
  * What each version reader returns when it cannot read its manifest.
64
90
  *
@@ -78,6 +104,14 @@ const UNKNOWN_APP = '0.0.0-unknown-app'
78
104
  const UNKNOWN_FW = '0.0.0-unknown-framework'
79
105
 
80
106
  let _appVersion: string | null = null
107
+
108
+ /**
109
+ * The **application's** version, from `<cwd>/package.json`.
110
+ *
111
+ * Not the framework's — this reads the manifest of whatever is being served.
112
+ * The distinction is load-bearing for cache invalidation; see
113
+ * {@link getFrameworkVersion}.
114
+ */
81
115
  export function getAppVersion() {
82
116
  if (_appVersion) return _appVersion
83
117
  try {
package/src/core/index.ts CHANGED
@@ -4,6 +4,10 @@ import { Case, is, Math2, match, Try } from '../utils/common'
4
4
  import { encodeSSE, response, sse } from '../utils/http'
5
5
  import Bakery, { getHostname, hostKey, hostStore } from './bakery'
6
6
  import { getConfig, NOOP } from './config'
7
+ // `./context` is already in this barrel's graph — line 5 reaches it through
8
+ // `./bakery`, which re-exports `hostStore` from exactly here — so naming it
9
+ // adds no module edge, only a name.
10
+ import { getFrameworkVersion } from './context'
7
11
  import { createElement, Fragment, html } from './jsx'
8
12
 
9
13
  export const defineConfig = <T extends AppConfig>(config: T): T => config
@@ -19,6 +23,7 @@ export type {
19
23
  MixedPromise,
20
24
  RouteBody,
21
25
  RouteHandler,
26
+ RouteParam,
22
27
  RouteResponse,
23
28
  Wrapped,
24
29
  } from '../types'
@@ -62,6 +67,19 @@ export {
62
67
  encodeSSE,
63
68
  Fragment,
64
69
  getConfig,
70
+ /**
71
+ * The version of `@bakery-framework/core` itself, read from its own manifest.
72
+ *
73
+ * **Not the app's version**, which is what `import.meta.env.BAKERY_VERSION`
74
+ * and `getAppVersion()` report — the compiler reads those from
75
+ * `<cwd>/package.json`, so in an application they answer with the
76
+ * application's number. The name is a long-standing misnomer and this is the
77
+ * one that means what "Bakery version" sounds like it means.
78
+ *
79
+ * Exported because a plugin rendering framework chrome has no other way to
80
+ * ask. The dashboard's footer showed a hardcoded `v3` for want of it.
81
+ */
82
+ getFrameworkVersion,
65
83
  // Multi-host helpers. Documented in docs/configuration/multi-host.md, and
66
84
  // the only reason `./core/bakery` had to be a subpath of its own.
67
85
  getHostname,
package/src/core/init.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomId } from '../utils/isomorphic/misc'
1
2
  import { createElement, Fragment, html } from './jsx'
2
3
 
3
4
  const hasDevWorkerArg = process.argv.includes('--dev-worker')
@@ -26,40 +27,43 @@ const getArgValue = (name: string) => {
26
27
  const threadId = process.env.THREAD_ID ?? getArgValue('--thread-id') ?? '0'
27
28
 
28
29
  /**
29
- * An accessor pair, not a bare getter.
30
+ * The mode flags are **strings on `process.env`** — `'1'` for true, `''` for
31
+ * false. They were booleans behind an accessor pair until Bun 1.4.
30
32
  *
31
- * These are `process.env` properties, and a getter with no setter is readonly:
32
- * in strict-mode ESM an assignment to one throws
33
- * `TypeError: Attempted to assign to readonly property`. `threads.ts` assigns
34
- * `THREAD_ID = '0'` on the single-worker/clamped path (deliberately not
35
- * `THREAD_WORKER` a cluster of one must keep full-size caches). When the
36
- * assignment was getter-only it was wrapped in `Try(...)`, so the throw was
37
- * swallowed and the flags never moved `reusePort`, the per-worker cache
38
- * scaling and the startup banner all silently read the master's values. The
39
- * `Try(...)` was what made a dead code path look deliberate.
33
+ * **Bun 1.4.0 rejects accessor descriptors on `process.env` outright.**
34
+ * `Object.defineProperty(process.env, 'X', { get })` throws
35
+ * `ERR_INVALID_OBJECT_DEFINE_PROPERTY`. This block runs at import time in every
36
+ * entry, so the whole framework died on `import` under current Bun — a
37
+ * scaffolded app could not reach step 2 of its own quick start. Data
38
+ * descriptors are accepted and coerce anyway (`{ value: false }` reads back as
39
+ * `"false"`), so a boolean here is no longer expressible at all; and
40
+ * `import.meta.env === process.env` in Bun, so there is no second object to put
41
+ * one on.
42
+ *
43
+ * **`'1'` / `''`, never `'true'` / `'false'`.** Truthiness has to survive the
44
+ * coercion: `"false"` is a truthy string, so every `if (import.meta.env.DEV)`
45
+ * in the codebase would have inverted silently rather than failed. `''` also
46
+ * keeps the key *present* — `'PROD' in process.env` stays true — which is what
47
+ * separates "explicitly not production" from "never booted", a distinction
48
+ * `utils/http/authorize.ts` depends on to fail closed.
49
+ *
50
+ * Plain assignment, so `threads.ts` can still assign `THREAD_ID = '0'` on the
51
+ * single-worker/clamped path (deliberately not `THREAD_WORKER` — a cluster of
52
+ * one must keep full-size caches). That assignment is why these were an
53
+ * accessor *pair* rather than a bare getter: a getter with no setter is
54
+ * readonly, the assignment threw, and the throw was swallowed by a `Try(...)`
55
+ * that made a dead code path look deliberate.
40
56
  */
41
- const accessor = (initial: any) => {
42
- let value = initial
43
- return {
44
- get: () => value,
45
- set: (next: any) => {
46
- value = next
47
- },
48
- enumerable: true,
49
- configurable: true,
50
- }
51
- }
57
+ const flag = (on: boolean) => (on ? '1' : '')
52
58
 
53
- Object.defineProperties(process.env, {
54
- DEV: accessor(isDev),
55
- TEST: accessor(isTest),
56
- PROD: accessor(!isDev && !hasDevWorkerArg),
57
- WORKER: accessor(hasDevWorkerArg || isThreadWorker),
58
- DEV_WORKER: accessor(hasDevWorkerArg),
59
- THREAD_WORKER: accessor(isThreadWorker),
60
- THREAD_ID: accessor(threadId),
61
- MODE: accessor(mode),
62
- })
59
+ process.env.DEV = flag(isDev)
60
+ process.env.TEST = flag(isTest)
61
+ process.env.PROD = flag(!isDev && !hasDevWorkerArg)
62
+ process.env.WORKER = flag(hasDevWorkerArg || isThreadWorker)
63
+ process.env.DEV_WORKER = flag(hasDevWorkerArg)
64
+ process.env.THREAD_WORKER = flag(isThreadWorker)
65
+ process.env.THREAD_ID = threadId
66
+ process.env.MODE = mode
63
67
 
64
68
  /**
65
69
  * "This process is the worker of a *development* server."
@@ -84,6 +88,11 @@ Object.assign(globalThis, {
84
88
  createElement,
85
89
  Fragment,
86
90
  html,
91
+ // The same value the browser runtime binds (`client/utils.ts`), so code
92
+ // that moves between an SFC's browser script and its server block — where
93
+ // it runs as a bare global either way — does not lose the name. Declared
94
+ // once, in `shared.d.ts`.
95
+ randomId,
87
96
  })
88
97
 
89
98
  process.on('SIGHUP', () => {})
package/src/global.d.ts CHANGED
@@ -342,13 +342,31 @@ declare global {
342
342
  // `ImportMeta.env` with its own incompatible shape, which is TS2687/TS2717 —
343
343
  // suppressed, like everything else in a .d.ts, by skipLibCheck.
344
344
  interface ImportMetaEnv {
345
- readonly DEV: boolean
346
- readonly PROD: boolean
347
- readonly WORKER: boolean
348
- readonly DEV_WORKER: boolean
349
- readonly THREAD_WORKER: boolean
345
+ // `'1'` or `''`, not `true` or `false`. These live on `process.env`, and
346
+ // Bun 1.4 stopped accepting accessor descriptors there — a boolean is no
347
+ // longer expressible on that object at all, since data descriptors coerce
348
+ // too. See the encoding block in `core/init.ts`.
349
+ //
350
+ // Typed as the literal pair rather than `string` on purpose: it makes
351
+ // `import.meta.env.PROD !== false` a *compile* error. That comparison was
352
+ // real, in the dashboard's fail-closed gate, and under the string encoding
353
+ // it is true for every value including the `''` a development server sets —
354
+ // so the gate would have denied on loopback in development while still
355
+ // reading as correct. A type that only said `string` would not have caught
356
+ // it either.
357
+ //
358
+ // In a **browser** bundle these are real booleans: `compiler.ts` substitutes
359
+ // `JSON.stringify(!!import.meta.env.DEV)` at build time, so the literal
360
+ // `true`/`false` is inlined and this declaration is a slight lie there. It
361
+ // is the harmless direction — both encodings agree on truthiness, which is
362
+ // all client code tests — and the server is where the mistakes happen.
363
+ readonly DEV: '1' | ''
364
+ readonly PROD: '1' | ''
365
+ readonly WORKER: '1' | ''
366
+ readonly DEV_WORKER: '1' | ''
367
+ readonly THREAD_WORKER: '1' | ''
350
368
  readonly THREAD_ID: string
351
- readonly TEST: boolean
369
+ readonly TEST: '1' | ''
352
370
  readonly MODE: 'production' | 'development' | 'dev-worker' | 'thread-worker'
353
371
  // `readonly SERVE_ROOT: string` was declared here. Nothing defines it —
354
372
  // not init.ts, not the compiler's `defines` — and nothing reads it, so any