@zerotal/core 1.8.1 → 1.9.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/api-surface.md +56 -355
  3. package/package.json +2 -1
  4. package/src/application/Application.ts +6 -0
  5. package/src/application/currentApp.ts +7 -1
  6. package/src/command/OutputWriter.ts +5 -1
  7. package/src/command/builtin/DeployCommand.ts +72 -1
  8. package/src/command/builtin/TestCommand.ts +9 -2
  9. package/src/command/startZerotal.ts +34 -0
  10. package/src/config/ConfigLoader.ts +5 -1
  11. package/src/config/DeployConfig.ts +33 -0
  12. package/src/config/index.ts +6 -1
  13. package/src/config/registry.ts +5 -1
  14. package/src/config/validation.ts +10 -2
  15. package/src/conventions/ConventionLoader.ts +5 -1
  16. package/src/dev/CssPlugins.ts +11 -1
  17. package/src/dev/DevBuildHook.ts +10 -2
  18. package/src/dev/DevDeck.ts +6 -0
  19. package/src/dev/DevOrchestrator.ts +2 -0
  20. package/src/dev/DevProcess.ts +17 -3
  21. package/src/dev/DevReloadMiddleware.ts +7 -1
  22. package/src/dev/DevSupervisor.ts +22 -4
  23. package/src/dev/bootBuild.ts +9 -1
  24. package/src/dev/reloadClient.ts +2 -0
  25. package/src/dev/startDevMode.ts +7 -1
  26. package/src/doctor/AppDoctor.ts +43 -1
  27. package/src/doctor/throttleIdentity.ts +114 -0
  28. package/src/errors/RuntimeMismatchError.ts +21 -0
  29. package/src/errors/index.ts +1 -0
  30. package/src/events/Emitter.ts +5 -1
  31. package/src/helpers/html.ts +2 -0
  32. package/src/helpers/markdown.ts +7 -1
  33. package/src/helpers/pageElements.ts +2 -0
  34. package/src/http/HttpClient.ts +5 -1
  35. package/src/http/Uri.ts +5 -1
  36. package/src/http/negotiate.ts +5 -1
  37. package/src/http/originGuard.ts +2 -0
  38. package/src/http/sniffContentType.ts +7 -1
  39. package/src/macros/config.macro.ts +2 -0
  40. package/src/metrics/HttpMetrics.ts +10 -2
  41. package/src/middleware/BaseMiddleware.ts +20 -0
  42. package/src/pipeline/ContextRegistry.ts +12 -2
  43. package/src/pipeline/HttpContext.ts +4 -0
  44. package/src/pipeline/currentPage.ts +4 -0
  45. package/src/pipeline/types.ts +2 -0
  46. package/src/router/FileRouter.ts +20 -2
  47. package/src/router/Route.ts +3 -0
  48. package/src/router/Router.ts +2 -0
  49. package/src/router/domain.ts +1 -0
  50. package/src/router/registry.ts +12 -2
  51. package/src/shared/format.ts +135 -0
  52. package/src/shared/index.ts +34 -0
  53. package/src/storage/StorageFilesMiddleware.ts +2 -0
  54. package/src/support/cookie.ts +7 -1
  55. package/src/support/runtime.ts +136 -0
  56. package/src/view/FileRouteResolver.ts +2 -0
  57. package/src/view/jsx-runtime.ts +4 -0
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Formatting both sides of the wire can agree on.
3
+ *
4
+ * The problem this solves is not that formatting is hard. `Intl` is in every
5
+ * runtime the framework targets and does the work. The problem is that a server
6
+ * helper and a browser helper are two places to make the same decision, and a
7
+ * total that reads `R 39 147` on screen and `R39,147.00` on the invoice looks like
8
+ * two different numbers to the person paying it.
9
+ *
10
+ * So these are deliberately thin — they fix the options, not the arithmetic. The
11
+ * value is that there is one definition of "how this app writes money" and both
12
+ * the controller and the component import it.
13
+ *
14
+ * Everything here is pure and dependency-free, and this module is reachable from
15
+ * `zerotal/shared`, so it bundles into a browser build without pulling the
16
+ * framework in behind it.
17
+ *
18
+ * @module
19
+ */
20
+
21
+ /** How a value should be written. */
22
+ export interface FormatOptions {
23
+ /**
24
+ * BCP-47 locale. Defaults to the runtime's — which on a server is the machine's
25
+ * and in a browser is the reader's, and those are not the same. Pass one
26
+ * explicitly wherever the two sides must match, and they usually must.
27
+ */
28
+ locale?: string;
29
+ }
30
+
31
+ /** How money should be written. */
32
+ export interface MoneyOptions extends FormatOptions {
33
+ /** ISO 4217 code — `ZAR`, `USD`, `EUR`. */
34
+ currency: string;
35
+ /**
36
+ * Whether the amount is in the currency's minor unit (cents), which is how a
37
+ * database column that must not lose a cent stores it. Default `true`, because
38
+ * an app that stores money in a float has a different problem.
39
+ */
40
+ minorUnits?: boolean;
41
+ /** Digits after the decimal point. Defaults to whatever the currency uses. */
42
+ fractionDigits?: number;
43
+ }
44
+
45
+ /**
46
+ * Write an amount of money.
47
+ *
48
+ * @param amount - The amount, in minor units unless `minorUnits: false`.
49
+ * @param options - Currency, and how to write it.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * formatMoney(3_914_700, { currency: "ZAR", locale: "en-ZA" }); // "R 39 147,00"
54
+ * formatMoney(39_147, { currency: "USD", minorUnits: false }); // "$39,147.00"
55
+ * ```
56
+ */
57
+ export function formatMoney(amount: number, options: MoneyOptions): string {
58
+ const { currency, locale, minorUnits = true, fractionDigits } = options;
59
+ const value = minorUnits ? amount / 100 : amount;
60
+ return new Intl.NumberFormat(locale, {
61
+ style: "currency",
62
+ currency,
63
+ ...(fractionDigits !== undefined
64
+ ? { minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits }
65
+ : {}),
66
+ }).format(value);
67
+ }
68
+
69
+ /** How a plain number should be written. */
70
+ export interface NumberOptions extends FormatOptions {
71
+ /** Smallest number of digits after the decimal point. */
72
+ minimumFractionDigits?: number;
73
+ /** Largest number of digits after the decimal point. */
74
+ maximumFractionDigits?: number;
75
+ }
76
+
77
+ /**
78
+ * Write a number with the reader's group and decimal separators.
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * formatNumber(39147.5, { locale: "en-GB", maximumFractionDigits: 1 }); // "39,147.5"
83
+ * ```
84
+ */
85
+ export function formatNumber(value: number, options: NumberOptions = {}): string {
86
+ const { locale, ...rest } = options;
87
+ return new Intl.NumberFormat(locale, rest).format(value);
88
+ }
89
+
90
+ /** How a date should be written. */
91
+ export interface DateOptions extends FormatOptions {
92
+ /** Length of the date part. Omit for none. */
93
+ dateStyle?: "full" | "long" | "medium" | "short";
94
+ /** Length of the time part. Omit for none. */
95
+ timeStyle?: "full" | "long" | "medium" | "short";
96
+ /**
97
+ * IANA zone — `Africa/Johannesburg`, `UTC`.
98
+ *
99
+ * Worth passing on the server. A machine set to UTC and a reader in Cape Town
100
+ * disagree about which day an 11pm booking happened on, and that is the shape
101
+ * the bug takes: not a wrong time, a wrong date.
102
+ */
103
+ timeZone?: string;
104
+ }
105
+
106
+ /**
107
+ * Write a date or timestamp.
108
+ *
109
+ * @param value - A `Date`, an epoch-milliseconds number, or an ISO string.
110
+ * @param options - Which parts to write, and in whose zone.
111
+ *
112
+ * @example
113
+ * ```ts
114
+ * formatDate("2026-08-28T21:00:00Z", {
115
+ * locale: "en-ZA",
116
+ * dateStyle: "medium",
117
+ * timeZone: "Africa/Johannesburg",
118
+ * });
119
+ * ```
120
+ */
121
+ export function formatDate(value: Date | number | string, options: DateOptions = {}): string {
122
+ const { locale, ...rest } = options;
123
+ const date = value instanceof Date ? value : new Date(value);
124
+ if (Number.isNaN(date.getTime())) return "";
125
+ // `Intl` given neither style writes the date and nothing else, which is the
126
+ // useful default for a helper named `formatDate`. Spread rather than replaced:
127
+ // the default has to keep `timeZone`, or a caller who passed a zone and no style
128
+ // silently gets the machine's — which is the exact wrong-day bug the option is
129
+ // there to prevent.
130
+ const noStyle = rest.dateStyle === undefined && rest.timeStyle === undefined;
131
+ return new Intl.DateTimeFormat(locale, {
132
+ ...rest,
133
+ ...(noStyle ? { dateStyle: "medium" as const } : {}),
134
+ }).format(date);
135
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The helpers that are safe to import from a browser bundle.
3
+ *
4
+ * Every function reachable from this module is pure: no `node:` imports, no `Bun`
5
+ * globals, no config, no container, no request context. Importing it from a
6
+ * component pulls in these functions and nothing else — the framework does not
7
+ * come with them.
8
+ *
9
+ * **Why this exists.** Without it, an app that wants the framework's `pluralize`
10
+ * on a page has two options, and both are bad. Importing `zerotal` into the client
11
+ * bundle drags the server in. Writing a second implementation means maintaining
12
+ * the same rule twice — and the second copy is always the worse one, because the
13
+ * irregulars and the inflect-the-last-word behaviour are exactly what somebody
14
+ * re-deriving it by hand leaves out. `"supplier line"` pluralises to
15
+ * `"supplier lines"`; the naive rule gives `"suppliers line"`.
16
+ *
17
+ * The same argument applies to money. Two formatters that must agree are a bug
18
+ * waiting for a rounding difference, and the person who finds it is the one paying
19
+ * the invoice.
20
+ *
21
+ * @example
22
+ * ```tsx
23
+ * // resources/js/pages/Trips/Index.tsx — a browser bundle
24
+ * import { pluralize, formatMoney } from "zerotal/shared";
25
+ *
26
+ * <p>{trips.length} {pluralize("trip")} — {formatMoney(total, { currency: "ZAR" })}</p>
27
+ * ```
28
+ *
29
+ * @packageDocumentation
30
+ */
31
+ export { pluralize, singularize, snakeCase, camelCase, tableNameFor } from "../support/str.ts";
32
+ export { Str } from "../helpers/str.ts";
33
+ export { formatMoney, formatNumber, formatDate } from "./format.ts";
34
+ export type { FormatOptions, MoneyOptions, NumberOptions, DateOptions } from "./format.ts";
@@ -132,6 +132,8 @@ export class StorageFilesMiddleware extends BaseMiddleware<StorageFilesOptions>
132
132
  * accepts a request rather than the first time someone guesses a path.
133
133
  *
134
134
  * @throws {@link UnsafePublicMountError}
135
+ *
136
+ * @internal
135
137
  */
136
138
  export function mountsFrom(config: StorageConfigShape): Mount[] {
137
139
  const mounts: Mount[] = [];
@@ -35,6 +35,8 @@ export interface CookieOptions {
35
35
  * @example
36
36
  * buildCookie({ name: "session", value, maxAge: 86400, secure: true });
37
37
  * // "session=…; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400; Secure"
38
+ *
39
+ * @internal
38
40
  */
39
41
  export function buildCookie(options: CookieOptions): string {
40
42
  const parts = [`${options.name}=${options.value}`, `Path=${options.path ?? "/"}`];
@@ -59,7 +61,11 @@ export function readCookie(request: Request, name: string): string | undefined {
59
61
  return parseCookieHeader(request.headers.get("Cookie") ?? "", name);
60
62
  }
61
63
 
62
- /** Extract a single cookie value from a raw `Cookie` header string. */
64
+ /**
65
+ * Extract a single cookie value from a raw `Cookie` header string.
66
+ *
67
+ * @internal
68
+ */
63
69
  export function parseCookieHeader(header: string, name: string): string | undefined {
64
70
  for (const part of header.split(";")) {
65
71
  const eqIdx = part.indexOf("=");
@@ -0,0 +1,136 @@
1
+ /**
2
+ * One project, one Bun.
3
+ *
4
+ * `engines.bun` is a floor, and nothing enforces it. A project can therefore end
5
+ * up with two runtimes in play at once — the shell's `bun` and the one sitting in
6
+ * `node_modules/bun`, put there by a transitive peer dependency nobody declared —
7
+ * and split its work between them: the server served by one, the suite run by the
8
+ * other. Nothing announces that. The suite stays green, and it is green about a
9
+ * runtime the app is not served by.
10
+ *
11
+ * What makes it expensive is that the difference is real but narrow. The SQLite
12
+ * bindings, `node:` compatibility and the test runner itself all differ between
13
+ * releases, so a handful of assertions happen to be runtime-sensitive and the rest
14
+ * are not. When two of them fail you go looking for a bug in the code they touch,
15
+ * because nothing in the failure says "different binary". And a suite that passes
16
+ * is not evidence: it only means no test happened to stand on a difference.
17
+ *
18
+ * This module is the check. It is not a pin — the version to agree on is whichever
19
+ * one the project installed, so `bun update bun` moves it and nothing here needs
20
+ * editing. What it enforces is that there is only one.
21
+ *
22
+ * @module
23
+ */
24
+ import { readFileSync } from "node:fs";
25
+ import { dirname, join } from "node:path";
26
+
27
+ /** Set to `1`/`true` to downgrade a runtime mismatch from a refusal to a warning. */
28
+ export const RUNTIME_MISMATCH_ESCAPE = "ZT_ALLOW_RUNTIME_MISMATCH";
29
+
30
+ /** A running runtime and the installed one it disagrees with. */
31
+ export interface RuntimeMismatch {
32
+ /** `Bun.version` — the binary this process is executing under. */
33
+ running: string;
34
+ /** The version in `node_modules/bun/package.json`. */
35
+ installed: string;
36
+ /** Absolute path of the manifest `installed` was read from. */
37
+ manifest: string;
38
+ }
39
+
40
+ /**
41
+ * The Bun version a project has installed, by walking up from `cwd` looking for
42
+ * `node_modules/bun/package.json`.
43
+ *
44
+ * Walking rather than reading one fixed path because hoisting decides where the
45
+ * package lands: in a workspace it is at the repo root, not beside the app.
46
+ *
47
+ * @param cwd - Directory to start from.
48
+ * @returns `{ version, manifest }`, or `null` when the project does not install
49
+ * Bun as a package — which is the common case and not a finding.
50
+ */
51
+ export function installedBunVersion(cwd: string): { version: string; manifest: string } | null {
52
+ let dir = cwd;
53
+ // Bounded by the filesystem root: dirname("/") === "/" and dirname("C:\\") === "C:\\".
54
+ for (;;) {
55
+ const manifest = join(dir, "node_modules", "bun", "package.json");
56
+ try {
57
+ const { version } = JSON.parse(readFileSync(manifest, "utf8")) as { version?: string };
58
+ if (version) return { version, manifest };
59
+ } catch {
60
+ // Not here — keep climbing.
61
+ }
62
+ const parent = dirname(dir);
63
+ if (parent === dir) return null;
64
+ dir = parent;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Compare the running runtime against the installed one.
70
+ *
71
+ * The comparison is exact. A patch difference is still two binaries, and the
72
+ * failures this guards against do not respect semver — a change to the SQLite
73
+ * bindings is a patch release to Bun and a behaviour change to an app that stores
74
+ * money in SQLite. "Close enough" is the belief that cost the weeks.
75
+ *
76
+ * @param cwd - Project root to look under. Defaults to the working directory.
77
+ * @returns The mismatch, or `null` when the versions agree or nothing is installed
78
+ * to disagree with.
79
+ */
80
+ export function runtimeMismatch(cwd: string = process.cwd()): RuntimeMismatch | null {
81
+ const running = typeof Bun === "undefined" ? "" : Bun.version;
82
+ if (!running) return null;
83
+ const installed = installedBunVersion(cwd);
84
+ if (!installed) return null;
85
+ if (installed.version === running) return null;
86
+ return { running, installed: installed.version, manifest: installed.manifest };
87
+ }
88
+
89
+ /**
90
+ * The message shown for a mismatch — the two versions, where the second came from,
91
+ * and the two ways out.
92
+ *
93
+ * @param mismatch - The disagreement to describe.
94
+ */
95
+ export function runtimeMismatchMessage(mismatch: RuntimeMismatch): string {
96
+ return (
97
+ `Two Bun runtimes are in play. This process is Bun ${mismatch.running}, but the ` +
98
+ `project installs Bun ${mismatch.installed} (${mismatch.manifest}).\n\n` +
99
+ ` Whichever one is not running this command is still running something else — ` +
100
+ `the server, the suite, a deploy step — and a test that passes under one is not ` +
101
+ `evidence about the other.\n\n` +
102
+ ` Fix it by picking one:\n` +
103
+ ` bun update bun # move the installed one to match your shell\n` +
104
+ ` node_modules/.bin/bun # or run everything through the installed one\n\n` +
105
+ ` To boot anyway, set ${RUNTIME_MISMATCH_ESCAPE}=1 — it downgrades this to a warning.`
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Whether the escape hatch is set. Deliberately an env var and not a config key:
111
+ * the situation it covers is a project mid-upgrade, where the thing you want is to
112
+ * get one command through, not to write the exception down.
113
+ */
114
+ export function runtimeMismatchAllowed(): boolean {
115
+ const raw = (globalThis as { Bun?: { env: Record<string, string | undefined> } }).Bun?.env[
116
+ RUNTIME_MISMATCH_ESCAPE
117
+ ];
118
+ return raw === "1" || raw === "true";
119
+ }
120
+
121
+ /**
122
+ * The Bun binary to spawn a child process with.
123
+ *
124
+ * `process.execPath` rather than `"bun"`, because `"bun"` is resolved by the OS
125
+ * against `PATH` and the parent process was not necessarily started from `PATH`.
126
+ * A command that exists to run *this app's* tests, spawning whichever binary the
127
+ * shell happens to offer, is how the suite ends up on a different runtime from the
128
+ * server — and it is invisible, because the child prints a version nobody reads.
129
+ *
130
+ * @returns An absolute path to the running binary, or `"bun"` when there is none to
131
+ * read (a compiled binary, an unusual embed) and PATH is all that is left.
132
+ */
133
+ export function bunBinary(): string {
134
+ const path = process.execPath;
135
+ return path && path.length > 0 ? path : "bun";
136
+ }
@@ -23,6 +23,8 @@ function isClass(fn: Function): boolean {
23
23
  * export that is a view component (or any function tagged via the view marker)
24
24
  * is rendered to HTML, wrapped in the nearest `_layout`, and registered as a GET
25
25
  * route. Call from a provider's `onRegister()` before file routes are scanned.
26
+ *
27
+ * @internal
26
28
  */
27
29
  export function registerViewFileRouteResolver(): void {
28
30
  enableFileRouteLayouts();
@@ -7,12 +7,16 @@ import { escapeHtml as escHtml } from "../helpers/html.ts";
7
7
  * letting the renderer distinguish page/layout components from ordinary
8
8
  * functions. Registered via `Symbol.for` so the marker survives across module
9
9
  * realms.
10
+ *
11
+ * @internal
10
12
  */
11
13
  export const VIEW_COMPONENT_SYMBOL = Symbol.for("zerotal.view.component");
12
14
 
13
15
  /**
14
16
  * String-keyed twin of {@link VIEW_COMPONENT_SYMBOL}. Set alongside the symbol
15
17
  * as a fallback for tooling or environments that can't read symbol-keyed props.
18
+ *
19
+ * @internal
16
20
  */
17
21
  export const VIEW_COMPONENT_PROP = "__zerotalViewComponent";
18
22