@ultimat3/core 5.0.1 → 7.0.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/CLAUDE.md +20 -0
- package/README.md +45 -19
- package/package.json +7 -1
- package/src/async-context.ts +79 -0
- package/src/config.ts +30 -13
- package/src/context.ts +13 -8
- package/src/error-codes.ts +1 -0
- package/src/error-reporter-sentry.ts +1 -1
- package/src/image/canvas.ts +170 -0
- package/src/image/errors.ts +35 -3
- package/src/image/pipeline.ts +91 -70
- package/src/image/png-pixels.ts +183 -0
- package/src/impersonate.ts +8 -5
- package/src/index.ts +7 -10
- package/src/logger.ts +1 -1
- package/src/otlp-span-exporter.ts +2 -2
- package/src/roles.ts +1 -1
- package/src/route-vocabulary.ts +23 -0
- package/src/runtime-metrics.ts +1 -1
- package/src/telemetry.ts +7 -4
- package/src/time-zone-name.ts +43 -0
- package/src/type-pins.ts +30 -5
- package/src/image/jpeg-decode.ts +0 -283
- package/src/image/jpeg-encode.ts +0 -463
- package/src/image/jpeg-headers.ts +0 -267
- package/src/image/jpeg-huffman.ts +0 -202
- package/src/image/jpeg-tables.ts +0 -117
- package/src/image/png.ts +0 -433
- package/src/image/resize.ts +0 -320
package/CLAUDE.md
CHANGED
|
@@ -14,6 +14,7 @@ is a change to every package.
|
|
|
14
14
|
| New code | add to `CORE_CODE_TITLES` in `error-codes.ts`, else the title is auto-humanised |
|
|
15
15
|
| Time | take a `Clock`; `Date.now()` / `new Date()` only inside `clock.ts` |
|
|
16
16
|
| Context | never thread `ctx` as a parameter — `useContext()` |
|
|
17
|
+
| A value ambient across an `await` | `asyncContext<T>(subject)` from `async-context.ts`, in **every** package — never `new AsyncLocalStorage` |
|
|
17
18
|
| Exports | add to `src/index.ts` explicitly; no `export *`. Three subjects that each span a dozen modules arrive through `src/exports/` — every name is still written out in `index.ts`, so the public surface is one file to read |
|
|
18
19
|
| Files | < 200 LOC, 500 hard ceiling, one responsibility, `kebab-case.ts`, test beside source |
|
|
19
20
|
| Type claims | `type-pins.ts`, never a `.test.ts` — `tsconfig.json` excludes tests, so `tsc` never reads one |
|
|
@@ -22,6 +23,24 @@ Deliberate cycles (safe — nothing is referenced at module-evaluation time):
|
|
|
22
23
|
`errors.ts ⇄ error-codes.ts`. Keep it that way: no top-level `UltimateError` use in
|
|
23
24
|
`error-codes.ts`.
|
|
24
25
|
|
|
26
|
+
**`async-context.ts` is the framework's ONE `AsyncLocalStorage`, and that is a framework rule
|
|
27
|
+
rather than a core one, `As of 2026-08-20`.** `asyncContext` is exported from `src/index.ts` and
|
|
28
|
+
six modules outside this package opened their own before they adopted it — `@ultimat3/db`'s
|
|
29
|
+
transaction, statement attribution and expected-loop scopes, `@ultimat3/entity`'s `crossTenant`,
|
|
30
|
+
`@ultimat3/ai`'s budget ledger and LLM stream sink. Each was a module-scope `new` a browser bundler
|
|
31
|
+
turns into `TypeError: undefined is not a constructor` at module EVALUATION, so importing any of
|
|
32
|
+
those packages from a client bundle failed before a line of app code ran. Reads degrade to
|
|
33
|
+
`undefined`, writes throw `X_ASYNC_CONTEXT_UNAVAILABLE`; deferring the construction changes nothing
|
|
34
|
+
a server can observe — the storage is built on the first `get()` or `run()` rather than at module
|
|
35
|
+
evaluation, and `getStore()` outside a scope answers `undefined` either way.
|
|
36
|
+
|
|
37
|
+
The mechanical half is `scripts/async-context-guard.ts`, collected by `x verify`'s `unit` step
|
|
38
|
+
through `scripts/async-context-guard.test.ts` — it refuses a `new AsyncLocalStorage` **and** the
|
|
39
|
+
import that binds the class, aliased or namespaced, anywhere but this one file. The browser-barrel
|
|
40
|
+
test in `async-context.test.ts` covers the same defect for core alone and cannot see another
|
|
41
|
+
package; the guard cannot see a runtime `await import('node:async_hooks')`. Neither is the other's
|
|
42
|
+
duplicate.
|
|
43
|
+
|
|
25
44
|
`error-render.ts` imports nothing, including from this package — an error factory that dies
|
|
26
45
|
formatting its own message is the failure it exists to prevent, so it cannot depend on anything
|
|
27
46
|
that could itself throw. The same defect shipped three times (`entity`, `flags`, `cli`) before
|
|
@@ -85,6 +104,7 @@ shape against a locally declared sample interface for exactly that reason.
|
|
|
85
104
|
|---|---|---|
|
|
86
105
|
| which deploy this is | `environment.ts` (`ULTIMATE_ENV`) | the twin of `ROLE`; never declare a second env var for it |
|
|
87
106
|
| what this process does | `roles.ts` (`ROLE`) | |
|
|
107
|
+
| how a route renders, caches offline and hydrates | `route-vocabulary.ts` (`RENDER_MODES`, `OFFLINE_STRATEGIES`, `HYDRATE_STRATEGIES`) | tier 0 because SIX packages name them and imports only go down — `render`, `http`, `seo`, `manifest` and `pwa` each kept a hand-copy until 2026-08, and `'spa'` was deleted from one while five went on admitting it under a green typecheck. Every union is `(typeof ARRAY)[number]`, pinned in `type-pins.ts`; `scripts/render-modes.test.ts` refuses a second declaration anywhere in `packages/*/src`. Re-export it, never restate it |
|
|
88
108
|
| which build of the APP this is | `app-version.ts` (`APP_VERSION`) | one reader, `dev` by default: `db` writes it into `x_migrations` and `jobs` into `x_backfills`, and `jobs` cannot reach `db` for the answer |
|
|
89
109
|
| the values | `env.ts` | `checkEnv().values` holds REAL secrets — anything that prints goes through `maskedEnvValues()` |
|
|
90
110
|
| `.env.example` | `env-example.ts` | a projection of the schema, never hand-maintained |
|
package/README.md
CHANGED
|
@@ -9,7 +9,8 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
9
9
|
| rendering an app's value into a `cause` / `fix` without throwing | `error-render.ts` |
|
|
10
10
|
| code → `{ title, docs }` registry, `registerErrorCodes()` | `error-codes.ts` |
|
|
11
11
|
| `Result<T, E>` for boundaries where throwing is wrong | `result.ts` |
|
|
12
|
-
|
|
|
12
|
+
| the one lazy `AsyncLocalStorage`, every ambient scope in the framework | `async-context.ts` |
|
|
13
|
+
| request context on that seam | `context.ts` |
|
|
13
14
|
| `Actor` (`user \| service \| agent \| anonymous`) | `actor.ts` |
|
|
14
15
|
| acting as another actor, with an origin and a reason | `impersonate.ts` |
|
|
15
16
|
| is an error worth retrying? one classification per code | `error-retry.ts` |
|
|
@@ -20,6 +21,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
20
21
|
| the committed encrypted secrets envelope, AES-256-GCM | `secrets.ts` |
|
|
21
22
|
| the two secrets files, and decrypted values → `defineEnv` | `secrets-store.ts` |
|
|
22
23
|
| `defineConfig()` for `app.config.ts` | `config.ts` |
|
|
24
|
+
| the closed route vocabulary every renderer names | `route-vocabulary.ts` |
|
|
23
25
|
| runtime roles + `ROLE` resolution | `roles.ts` |
|
|
24
26
|
| `Clock` — the only source of "now" | `clock.ts` |
|
|
25
27
|
| UUIDv7, nanoid, branded ids | `ids.ts` |
|
|
@@ -38,7 +40,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
38
40
|
| the sockets this process opened, so a self-request is not egress | `listeners.ts` |
|
|
39
41
|
| `defineService('orgs', …)` → `ctx.orgs`, rebuilt per actor | `service.ts` |
|
|
40
42
|
| the registrar table one same-tier package reaches another through | `registrar.ts` |
|
|
41
|
-
| decode → resize → encode, the one image pipeline | `image/` |
|
|
43
|
+
| decode → resize → encode, the one image pipeline (over `Bun.Image`) | `image/` |
|
|
42
44
|
| `assertNever`, `invariant` | `assert.ts` |
|
|
43
45
|
|
|
44
46
|
## Errors are instructions
|
|
@@ -466,31 +468,55 @@ than in `@ultimat3/time` because `@ultimat3/money` needs it too and tier 1 may n
|
|
|
466
468
|
|
|
467
469
|
```ts
|
|
468
470
|
probeImage(bytes); // { format, width, height, mimeType }
|
|
469
|
-
transformImageBytes(bytes, { width: 640, format: '
|
|
470
|
-
blurDataUrl(bytes);
|
|
471
|
+
await transformImageBytes(bytes, { width: 640, format: 'webp', quality: 80 });
|
|
472
|
+
await blurDataUrl(bytes); // ThumbHash PNG data: URI, the LQIP
|
|
471
473
|
```
|
|
472
474
|
|
|
473
475
|
`storage` variants, `seo` `<picture>` sources and `pwa` icons are the same three steps —
|
|
474
476
|
decode, resize, encode — with different numbers, so there is one implementation and no second
|
|
475
|
-
scaler for an icon to grow a halo in.
|
|
477
|
+
scaler for an icon to grow a halo in. The codecs are **`Bun.Image`** — statically-linked
|
|
478
|
+
libjpeg-turbo / libspng / libwebp with SIMD resize kernels, in the runtime. Still zero
|
|
479
|
+
dependencies: no `sharp`, no native module.
|
|
480
|
+
|
|
481
|
+
Every terminal is `async`, because the pipeline runs on a worker thread. `probeImage` stays
|
|
482
|
+
synchronous: it reads a header and never decodes, which is also why it measures SVG and AVIF that
|
|
483
|
+
no codec here reads.
|
|
476
484
|
|
|
477
485
|
| | |
|
|
478
486
|
|---|---|
|
|
479
|
-
| Decode
|
|
480
|
-
|
|
|
487
|
+
| Decode | PNG, JPEG, WebP, GIF. `canDecode()` publishes the real list |
|
|
488
|
+
| Encode | PNG, JPEG, WebP. `canEncode()` publishes the real list |
|
|
489
|
+
| Probe only | AVIF and SVG — measured from the header so `width`/`height` still inline and CLS stays 0 |
|
|
481
490
|
| Anything else | `X_IMAGE_UNSUPPORTED`, naming the format and pointing at an `ImageTransformDriver` |
|
|
482
|
-
| Ceiling | `MAX_IMAGE_PIXELS` (64MP),
|
|
483
|
-
| Determinism | same bytes + same spec → same output bytes
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
491
|
+
| Ceiling | `MAX_IMAGE_PIXELS` (64MP), passed to the decoder as `maxPixels` and refused from the header **before** a byte is allocated |
|
|
492
|
+
| Determinism | same bytes + same spec → same output bytes, **on every platform** |
|
|
493
|
+
|
|
494
|
+
**AVIF and HEIC are refused everywhere, deliberately.** `Bun.Image` can reach them through an OS
|
|
495
|
+
codec (ImageIO on macOS, WIC on Windows), and this pipeline sets `Bun.Image.backend = 'bun'` on
|
|
496
|
+
every call to forbid exactly that: the static codecs and the Highway geometry kernels are what make
|
|
497
|
+
a laptop and a Linux node produce the same bytes, and `variantKey` is content-addressed. A variant
|
|
498
|
+
that re-encoded differently per platform is a cache that never hits. Producing AVIF means a CDN or
|
|
499
|
+
a custom `ImageTransformDriver`.
|
|
500
|
+
|
|
501
|
+
`transformImageBytes` has two paths and picks by geometry. When the resampled artwork IS the output
|
|
502
|
+
box it is one `Bun.Image` call, source bytes to encoded bytes. When it is not — a letterbox, a
|
|
503
|
+
`padding`, a `cover` crop — the artwork comes back as PNG and `canvas.ts` composites it, because
|
|
504
|
+
`Bun.Image` resamples but has no compositor and the PWA maskable safe zone is a composite.
|
|
505
|
+
`png-pixels.ts` is the raw-pixel seam that hop needs, 8-bit RGBA only; anything else is
|
|
506
|
+
`X_IMAGE_UNSUPPORTED` naming `transformImageBytes`.
|
|
507
|
+
|
|
508
|
+
Adding a format is an entry in `DECODABLE_FORMATS` / `ENCODABLE_FORMATS` and a branch in
|
|
509
|
+
`withFormat` — never a second dispatch. An unencodable `format` is refused from the spec alone,
|
|
510
|
+
before the source is decoded, so a request nothing can write never expands 64 megapixels first.
|
|
511
|
+
|
|
512
|
+
`Bun.Image` rejects with `ERR_IMAGE_*` on `error.code`. `imageFromBunError` is the ONE place that
|
|
513
|
+
is read, mapping it onto `X_IMAGE_UNSUPPORTED` / `X_IMAGE_TOO_LARGE` / `X_IMAGE_DECODE_FAILED`; no
|
|
514
|
+
caller branches on a Bun code.
|
|
515
|
+
|
|
516
|
+
Two files in `image/` are past the 200-line target and neither splits without inventing a seam:
|
|
517
|
+
`probe.ts` is one algorithm per format over header bytes, and `fixtures.ts` is data. The 500-line
|
|
518
|
+
hard ceiling applies to both. Everything else in `image/` is under the target — deleting the
|
|
519
|
+
hand-rolled JPEG and PNG codecs is what put it there.
|
|
494
520
|
|
|
495
521
|
`image/fixtures.ts` is byte-exact output from Pillow and ffmpeg on purpose: a codec that only round
|
|
496
522
|
trips against itself proves nothing. Never regenerate a fixture with our own encoder.
|
package/package.json
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"./src/context.ts",
|
|
9
|
+
"./src/lifecycle-errors.ts",
|
|
10
|
+
"./src/schema-error-codes.ts",
|
|
11
|
+
"./src/secrets-errors.ts"
|
|
12
|
+
],
|
|
7
13
|
"repository": {
|
|
8
14
|
"type": "git",
|
|
9
15
|
"url": "git+https://github.com/developerz-ai/ultimate.git",
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Single responsibility: the one lazily-constructed `AsyncLocalStorage` in the framework. Every
|
|
2
|
+
// ambient value core carries — the request context, the active span, the impersonation reason —
|
|
3
|
+
// opens its scope through this seam, so "what happens where there is no async context" has one
|
|
4
|
+
// answer instead of one per module.
|
|
5
|
+
|
|
6
|
+
// `node:async_hooks` is unavoidable and deliberate: nothing else in Bun makes a value ambient
|
|
7
|
+
// across an `await` without threading it through every signature in the framework.
|
|
8
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
9
|
+
import { UltimateError } from './errors';
|
|
10
|
+
|
|
11
|
+
export interface AsyncContext<T> {
|
|
12
|
+
/** The value in flight, or `undefined` — outside a scope, and in a runtime that has none. */
|
|
13
|
+
get(): T | undefined;
|
|
14
|
+
/** Run `fn` with `value` in flight. `X_ASYNC_CONTEXT_UNAVAILABLE` where that is impossible. */
|
|
15
|
+
run<R>(value: T, fn: () => R): R;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The storage is constructed on the first `get()` or `run()`, never at module scope. That is the
|
|
20
|
+
* whole point of this file: a browser bundler stubs `node:async_hooks` to `{}` — Bun's
|
|
21
|
+
* `target: 'browser'` emits `var { AsyncLocalStorage } = (() => ({}))` — so a module-scope `new` threw
|
|
22
|
+
* `TypeError: undefined is not a constructor` at module EVALUATION, and every package that
|
|
23
|
+
* transitively imports core was dead on arrival in a client bundle. `@ultimat3/ui` calls itself a
|
|
24
|
+
* SolidJS design system and could not be put on a client by the only client bundler the framework
|
|
25
|
+
* has, for this reason and no other.
|
|
26
|
+
*
|
|
27
|
+
* The laziness buys the browser bundle, not a server allocation. `open()` runs on a READ as well as
|
|
28
|
+
* a write, so a server whose first call is `get()` constructs the storage there — it is deferred,
|
|
29
|
+
* never skipped. What deferring changes is nothing observable: `getStore()` outside a scope answers
|
|
30
|
+
* `undefined` whether the storage was ever constructed or not, which is what makes it safe.
|
|
31
|
+
*
|
|
32
|
+
* **Reads degrade, writes throw**, and that split is the doctrine rather than a convenience.
|
|
33
|
+
* `get()` answers `undefined` in a browser because that is TRUE — nothing is in flight there, so
|
|
34
|
+
* "am I inside a scope" has a definite no for an answer and does not deserve an exception. It is
|
|
35
|
+
* the same call `@ultimat3/ui`'s `solid()` makes: inert where the capability is genuinely absent,
|
|
36
|
+
* throwing only where a caller asked for something the runtime cannot deliver. `run()` is that
|
|
37
|
+
* second case, so it names itself with a code and a fix rather than leaving a bare `TypeError`
|
|
38
|
+
* from a stack that mentions no file the caller wrote.
|
|
39
|
+
*
|
|
40
|
+
* **A synchronous save/restore fallback is not the answer here**, and this note exists so that it
|
|
41
|
+
* is not re-proposed: a module-level `current` swapped in a `try`/`finally` serves sync code and
|
|
42
|
+
* is silently WRONG across an `await` — two overlapping scopes interleave and the second one's
|
|
43
|
+
* `finally` restores a value the first is still inside. That is the `jobs: { driver: 'redis' }`
|
|
44
|
+
* failure mode this repo already paid for once: accepted, unwarned, and wrong in the dangerous
|
|
45
|
+
* direction. An error a caller can read beats an ambient value that is occasionally somebody
|
|
46
|
+
* else's.
|
|
47
|
+
*
|
|
48
|
+
* `subject` names what could not be opened, and is a `string` by construction — never an
|
|
49
|
+
* `unknown` reaching a `cause:`, which `bun run error-render` refuses.
|
|
50
|
+
*/
|
|
51
|
+
export function asyncContext<T>(subject: string): AsyncContext<T> {
|
|
52
|
+
let storage: AsyncLocalStorage<T> | undefined;
|
|
53
|
+
|
|
54
|
+
function open(): AsyncLocalStorage<T> | undefined {
|
|
55
|
+
if (storage !== undefined) return storage;
|
|
56
|
+
// The stub is an object with no `AsyncLocalStorage` key, so the binding reads `undefined`.
|
|
57
|
+
if (typeof AsyncLocalStorage !== 'function') return undefined;
|
|
58
|
+
storage = new AsyncLocalStorage<T>();
|
|
59
|
+
return storage;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
get(): T | undefined {
|
|
64
|
+
return open()?.getStore();
|
|
65
|
+
},
|
|
66
|
+
run<R>(value: T, fn: () => R): R {
|
|
67
|
+
const store = open();
|
|
68
|
+
if (store === undefined) {
|
|
69
|
+
throw new UltimateError({
|
|
70
|
+
code: 'X_ASYNC_CONTEXT_UNAVAILABLE',
|
|
71
|
+
cause: `${subject} needs AsyncLocalStorage, and node:async_hooks is stubbed to {} in this runtime`,
|
|
72
|
+
fix: `${subject} is server-only — open it in apps/web/server.ts or a route handler, and keep every module that opens one out of the import graph of a client island entry`,
|
|
73
|
+
meta: { subject },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return store.run(value, fn);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
import { ConfigInvalidError } from './errors';
|
|
6
6
|
import { ROLES, type Role } from './roles';
|
|
7
|
+
// `app.config.ts` CONSUMES the route vocabulary; it does not own it. Declaring `OfflineStrategy`
|
|
8
|
+
// here is what made it copyable — `render`, `manifest` and `pwa` each wrote their own rather than
|
|
9
|
+
// import a name that reads like a config key.
|
|
10
|
+
import type { OfflineStrategy } from './route-vocabulary';
|
|
11
|
+
import { isIanaZoneName } from './time-zone-name';
|
|
7
12
|
|
|
8
13
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
|
9
|
-
export type OfflineStrategy = 'precache' | 'runtime' | 'network-only';
|
|
10
14
|
export type CacheTier = 'memo' | 'lru' | 'shared' | 'isr' | 'cdn';
|
|
11
15
|
export type RealtimeTier = 'channels' | 'live-queries' | 'local-first';
|
|
12
16
|
export type RealtimeTransport = 'memory' | 'nats' | 'redis';
|
|
@@ -205,15 +209,6 @@ const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
|
|
|
205
209
|
*/
|
|
206
210
|
const CURRENCY_RE = /^[A-Z]{3}$/;
|
|
207
211
|
|
|
208
|
-
function isTimeZone(value: string): boolean {
|
|
209
|
-
try {
|
|
210
|
-
new Intl.DateTimeFormat('en', { timeZone: value });
|
|
211
|
-
return true;
|
|
212
|
-
} catch {
|
|
213
|
-
return false;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
212
|
function isLocale(value: string): boolean {
|
|
218
213
|
try {
|
|
219
214
|
return Intl.getCanonicalLocales(value).length === 1;
|
|
@@ -252,8 +247,22 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
|
|
|
252
247
|
};
|
|
253
248
|
}
|
|
254
249
|
|
|
250
|
+
const BASE_FIX = 'edit app.config.ts to fix the fields named in cause, then run: x verify';
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Appended only when the zone is what failed. Axiom 4: an operator holding `'CET'` needs the
|
|
254
|
+
* spelling to write, and the two refused classes have different remedies — a single-label legacy
|
|
255
|
+
* name swaps mechanically, an abbreviation or an offset has no replacement at all because it names
|
|
256
|
+
* no jurisdiction. Deliberately parallel to `@ultimat3/time`'s `X_TIMEZONE_INVALID` fix, since the
|
|
257
|
+
* two refuse the same strings and an operator may meet either first.
|
|
258
|
+
*/
|
|
259
|
+
const TIMEZONE_FIX =
|
|
260
|
+
"set defaultTimeZone to an Area/Location name, or UTC — list every accepted one with bun -e \"console.log(Intl.supportedValuesOf('timeZone').join('\\n'))\" — where a legacy single-label name swaps mechanically (Japan → Asia/Tokyo, GB → Europe/London, Universal → UTC), while an abbreviation or numeric offset (CET, EST5EDT, +01:00) carries no DST rule and has no replacement, so name the city whose clock you mean (Europe/Paris, America/New_York)";
|
|
261
|
+
|
|
255
262
|
function validate(config: AppConfig): void {
|
|
256
263
|
const issues: string[] = [];
|
|
264
|
+
// Zero or one entry: the zone's own remedy, carried only when the zone is what failed.
|
|
265
|
+
const zoneFix: string[] = [];
|
|
257
266
|
|
|
258
267
|
if (!NAME_RE.test(config.name)) {
|
|
259
268
|
issues.push(`name "${config.name}" must match ${String(NAME_RE)}`);
|
|
@@ -265,8 +274,14 @@ function validate(config: AppConfig): void {
|
|
|
265
274
|
if (!config.locales.includes(config.defaultLocale)) {
|
|
266
275
|
issues.push(`defaultLocale "${config.defaultLocale}" is not in locales`);
|
|
267
276
|
}
|
|
268
|
-
|
|
269
|
-
|
|
277
|
+
// `@ultimat3/time`'s rule, restated because tier 0 cannot import tier 1 — see
|
|
278
|
+
// `time-zone-name.ts`. One validator means a zone `app.config.ts` accepts is a zone every
|
|
279
|
+
// `format` call, `task()` and `toZoned` below it can then do arithmetic in.
|
|
280
|
+
if (!isIanaZoneName(config.defaultTimeZone)) {
|
|
281
|
+
issues.push(
|
|
282
|
+
`defaultTimeZone "${config.defaultTimeZone}" is not an IANA Area/Location zone name`,
|
|
283
|
+
);
|
|
284
|
+
zoneFix.push(TIMEZONE_FIX);
|
|
270
285
|
}
|
|
271
286
|
if (!CURRENCY_RE.test(config.defaultCurrency)) {
|
|
272
287
|
issues.push(`defaultCurrency "${config.defaultCurrency}" is not a 3-letter ISO 4217 code`);
|
|
@@ -284,7 +299,9 @@ function validate(config: AppConfig): void {
|
|
|
284
299
|
if (issues.length > 0) {
|
|
285
300
|
throw new ConfigInvalidError({
|
|
286
301
|
cause: issues.join('; '),
|
|
287
|
-
|
|
302
|
+
// The generic instruction goes LAST so the fix line still ends in a command that can be
|
|
303
|
+
// pasted — a trailing `.` after `x verify` is a command nobody can run.
|
|
304
|
+
fix: [...zoneFix, BASE_FIX].join('. '),
|
|
288
305
|
meta: { issues },
|
|
289
306
|
});
|
|
290
307
|
}
|
package/src/context.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// service bag reach every layer through AsyncLocalStorage instead of being threaded as
|
|
3
3
|
// parameters — otherwise every signature in the framework grows a `ctx` argument twice.
|
|
4
4
|
|
|
5
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
6
5
|
import { type Actor, anonymousActor } from './actor';
|
|
6
|
+
import { asyncContext } from './async-context';
|
|
7
7
|
import { type Clock, systemClock } from './clock';
|
|
8
8
|
import { UltimateError } from './errors';
|
|
9
9
|
import { traceId as newTraceId, uuid } from './ids';
|
|
@@ -72,7 +72,12 @@ export interface CtxInit {
|
|
|
72
72
|
|
|
73
73
|
export type CtxPatch = Omit<CtxInit, 'requestId'>;
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
/**
|
|
76
|
+
* `async-context.ts` owns why this is a lazily-opened seam rather than a module-scope
|
|
77
|
+
* `new AsyncLocalStorage()`, and why a browser gets `undefined` from a read and an error from a
|
|
78
|
+
* write. It is the same seam `telemetry.ts` and `impersonate.ts` open, on purpose: one answer.
|
|
79
|
+
*/
|
|
80
|
+
const requestContext = asyncContext<Ctx>('the request context');
|
|
76
81
|
|
|
77
82
|
const neverAborted = new AbortController().signal;
|
|
78
83
|
|
|
@@ -133,16 +138,16 @@ export function createContext(init: CtxInit = {}): Ctx {
|
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
export function runWithContext<T>(ctx: Ctx, fn: () => T): T {
|
|
136
|
-
return
|
|
141
|
+
return requestContext.run(ctx, fn);
|
|
137
142
|
}
|
|
138
143
|
|
|
139
144
|
/** The context, or `undefined` outside a request. Prefer `useContext()` in app code. */
|
|
140
145
|
export function tryUseContext(): Ctx | undefined {
|
|
141
|
-
return
|
|
146
|
+
return requestContext.get();
|
|
142
147
|
}
|
|
143
148
|
|
|
144
149
|
export function useContext(): Ctx {
|
|
145
|
-
const ctx =
|
|
150
|
+
const ctx = tryUseContext();
|
|
146
151
|
if (ctx === undefined) {
|
|
147
152
|
throw new UltimateError({
|
|
148
153
|
code: 'X_NO_CONTEXT',
|
|
@@ -154,7 +159,7 @@ export function useContext(): Ctx {
|
|
|
154
159
|
}
|
|
155
160
|
|
|
156
161
|
export function hasContext(): boolean {
|
|
157
|
-
return
|
|
162
|
+
return tryUseContext() !== undefined;
|
|
158
163
|
}
|
|
159
164
|
|
|
160
165
|
/**
|
|
@@ -183,7 +188,7 @@ export function withChildContext<T>(patch: CtxPatch, fn: () => T): T {
|
|
|
183
188
|
signal: patch.signal ?? parent.signal,
|
|
184
189
|
services: { ...carried, ...(patch.services ?? {}) },
|
|
185
190
|
});
|
|
186
|
-
return
|
|
191
|
+
return requestContext.run(child, fn);
|
|
187
192
|
}
|
|
188
193
|
|
|
189
194
|
/** Resolve a late-bound service. Throws `X_SERVICE_MISSING` rather than returning undefined. */
|
|
@@ -222,7 +227,7 @@ export function throwIfAborted(ctx: Ctx = useContext()): void {
|
|
|
222
227
|
* never mistaken for the customer's own.
|
|
223
228
|
*/
|
|
224
229
|
setLoggerContextFields(() => {
|
|
225
|
-
const ctx =
|
|
230
|
+
const ctx = tryUseContext();
|
|
226
231
|
if (ctx === undefined) return undefined;
|
|
227
232
|
const { actor } = ctx;
|
|
228
233
|
return {
|
package/src/error-codes.ts
CHANGED
|
@@ -28,6 +28,7 @@ export function errorDocsUrl(code: string): string {
|
|
|
28
28
|
/** Codes owned by `@ultimat3/core`. Every other package calls `registerErrorCodes()`. */
|
|
29
29
|
const CORE_CODE_TITLES = {
|
|
30
30
|
X_ABORTED: 'operation aborted',
|
|
31
|
+
X_ASYNC_CONTEXT_UNAVAILABLE: 'async context unavailable',
|
|
31
32
|
X_CONFIG_INVALID: 'app.config.ts is invalid',
|
|
32
33
|
X_CURSOR_INVALID: 'pagination cursor is malformed, tampered with or from another query',
|
|
33
34
|
X_CURSOR_SECRET_DEV: 'cursors are signed with the shipped development key',
|
|
@@ -64,7 +64,7 @@ export function parseSentryDsn(dsn: string): SentryDsn {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/** The protocol's own level names. `warning`/`error`/`fatal` happen to be the same three words. */
|
|
67
|
-
const LEVELS
|
|
67
|
+
const LEVELS = Object.freeze<Record<ErrorSeverity, string>>({
|
|
68
68
|
warning: 'warning',
|
|
69
69
|
error: 'error',
|
|
70
70
|
fatal: 'fatal',
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Single responsibility: the GEOMETRY of a resize — output box, padded inner area, drawn size —
|
|
2
|
+
// and the source-over composite that places the drawn artwork on it. `Bun.Image` resamples but
|
|
3
|
+
// cannot letterbox, pad or crop, so this is the half of a transform it does not do; keeping the
|
|
4
|
+
// arithmetic here is also what lets a caller ask for the box before any pixel exists.
|
|
5
|
+
|
|
6
|
+
import { parseColor } from './color';
|
|
7
|
+
import { imageUnsupported } from './errors';
|
|
8
|
+
import { assertPixelBudget, createRaster, type ImageSize, type Raster } from './raster';
|
|
9
|
+
|
|
10
|
+
export type ImageFit = 'cover' | 'contain';
|
|
11
|
+
|
|
12
|
+
export interface ResizeSpec {
|
|
13
|
+
readonly width?: number | undefined;
|
|
14
|
+
readonly height?: number | undefined;
|
|
15
|
+
/** Default 'contain'. */
|
|
16
|
+
readonly fit?: ImageFit | undefined;
|
|
17
|
+
/** Fraction of the shorter OUTPUT edge left empty on every side. `0 <= padding < 0.5`. */
|
|
18
|
+
readonly padding?: number | undefined;
|
|
19
|
+
/** '#rgb' | '#rgba' | '#rrggbb' | '#rrggbbaa' | 'transparent'. Default transparent. */
|
|
20
|
+
readonly background?: string | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function assertDimension(value: number, field: string): void {
|
|
24
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
25
|
+
throw imageUnsupported(
|
|
26
|
+
`resize ${field} is ${value}, which is not a whole number of pixels above zero`,
|
|
27
|
+
`pass an integer ${field} of 1 or more, or omit it to derive it from the source`,
|
|
28
|
+
{ field, value },
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The output CANVAS size. A single-axis request clamps to the source: asking for `width: 2000`
|
|
35
|
+
* of a 400px original must not invent 1600 pixels of blur, it must hand back the 400.
|
|
36
|
+
*/
|
|
37
|
+
export function fitBox(source: ImageSize, spec: ResizeSpec): ImageSize {
|
|
38
|
+
const { width, height } = spec;
|
|
39
|
+
if (width !== undefined) assertDimension(width, 'width');
|
|
40
|
+
if (height !== undefined) assertDimension(height, 'height');
|
|
41
|
+
if (width !== undefined && height !== undefined) return { width, height };
|
|
42
|
+
if (width !== undefined) {
|
|
43
|
+
const w = Math.min(width, source.width);
|
|
44
|
+
return { width: w, height: Math.max(1, Math.round((w * source.height) / source.width)) };
|
|
45
|
+
}
|
|
46
|
+
if (height !== undefined) {
|
|
47
|
+
const h = Math.min(height, source.height);
|
|
48
|
+
return { width: Math.max(1, Math.round((h * source.width) / source.height)), height: h };
|
|
49
|
+
}
|
|
50
|
+
return { width: source.width, height: source.height };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The size the source is DRAWN at inside `box` — no letterbox, no crop maths. May upscale. */
|
|
54
|
+
export function scaledToFit(source: ImageSize, box: ImageSize, fit: ImageFit): ImageSize {
|
|
55
|
+
const x = box.width / source.width;
|
|
56
|
+
const y = box.height / source.height;
|
|
57
|
+
const scale = fit === 'cover' ? Math.max(x, y) : Math.min(x, y);
|
|
58
|
+
return {
|
|
59
|
+
width: Math.max(1, Math.round(source.width * scale)),
|
|
60
|
+
height: Math.max(1, Math.round(source.height * scale)),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The whole plan for one transform, decided before a pixel is touched. */
|
|
65
|
+
export interface Layout {
|
|
66
|
+
/** The output canvas. */
|
|
67
|
+
readonly box: ImageSize;
|
|
68
|
+
/** Pixels of padding on each edge. */
|
|
69
|
+
readonly pad: number;
|
|
70
|
+
/** The area inside the padding the artwork may occupy. */
|
|
71
|
+
readonly inner: ImageSize;
|
|
72
|
+
/** What the source is resampled to before it is placed. */
|
|
73
|
+
readonly drawn: ImageSize;
|
|
74
|
+
/** Parsed once, here, so an unspellable colour is refused before any pixel is produced. */
|
|
75
|
+
readonly background: readonly [number, number, number, number];
|
|
76
|
+
/** False when `drawn` IS the box and nothing shows through — the resampler's output is the answer. */
|
|
77
|
+
readonly needsCanvas: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function layOut(source: ImageSize, spec: ResizeSpec): Layout {
|
|
81
|
+
const box = fitBox(source, spec);
|
|
82
|
+
assertPixelBudget(box.width, box.height, 'resize');
|
|
83
|
+
const padding = spec.padding ?? 0;
|
|
84
|
+
if (!Number.isFinite(padding) || padding < 0 || padding >= 0.5) {
|
|
85
|
+
throw imageUnsupported(
|
|
86
|
+
`resize padding is ${padding}, outside the 0 <= padding < 0.5 range`,
|
|
87
|
+
'pass a fraction of the shorter output edge, e.g. 0.1 for a 10% border on every side',
|
|
88
|
+
{ padding },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const pad = Math.round(Math.min(box.width, box.height) * padding);
|
|
92
|
+
const inner = { width: box.width - 2 * pad, height: box.height - 2 * pad };
|
|
93
|
+
if (inner.width < 1 || inner.height < 1) {
|
|
94
|
+
throw imageUnsupported(
|
|
95
|
+
`padding ${padding} leaves no room inside a ${box.width}x${box.height} output`,
|
|
96
|
+
'lower the padding or raise the requested width and height',
|
|
97
|
+
{ padding, pad, width: box.width, height: box.height },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const drawn = scaledToFit(source, inner, spec.fit ?? 'contain');
|
|
101
|
+
// Parsed even when the fast path will not use it: 'chartreuse' must be refused whether or not
|
|
102
|
+
// the geometry happens to hide the colour, or the rejection depends on the source's dimensions.
|
|
103
|
+
const background = parseColor(spec.background ?? 'transparent');
|
|
104
|
+
// An opaque background still shows THROUGH a source with alpha, so it needs the canvas even at
|
|
105
|
+
// full bleed. A transparent one does not, and skipping it keeps a plain `srcset` variant out of
|
|
106
|
+
// the RGBA round trip entirely.
|
|
107
|
+
const needsCanvas =
|
|
108
|
+
drawn.width !== box.width || drawn.height !== box.height || background[3] !== 0;
|
|
109
|
+
return { box, pad, inner, drawn, background, needsCanvas };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function fill(canvas: Raster, color: readonly [number, number, number, number]): void {
|
|
113
|
+
const [r, g, b, a] = color;
|
|
114
|
+
// A zero-alpha background is canonicalised to all-zero, matching the composite's own
|
|
115
|
+
// `outA === 0 -> outC = 0`: '#ff000000' and 'transparent' must not produce different bytes.
|
|
116
|
+
if (a === 0) return;
|
|
117
|
+
const { pixels } = canvas;
|
|
118
|
+
for (let i = 0; i < pixels.length; i += 4) {
|
|
119
|
+
pixels[i] = r;
|
|
120
|
+
pixels[i + 1] = g;
|
|
121
|
+
pixels[i + 2] = b;
|
|
122
|
+
pixels[i + 3] = a;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Source-over. `outA === 0` means every contributor was transparent — the colour is nothing. */
|
|
127
|
+
function blend(dst: Uint8ClampedArray, d: number, s: Uint8ClampedArray, p: number): void {
|
|
128
|
+
const sa = s[p + 3] ?? 0;
|
|
129
|
+
if (sa === 0) return;
|
|
130
|
+
const da = dst[d + 3] ?? 0;
|
|
131
|
+
if (sa === 255 || da === 0) {
|
|
132
|
+
dst[d] = s[p] ?? 0;
|
|
133
|
+
dst[d + 1] = s[p + 1] ?? 0;
|
|
134
|
+
dst[d + 2] = s[p + 2] ?? 0;
|
|
135
|
+
dst[d + 3] = sa;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const sf = sa / 255;
|
|
139
|
+
const df = (da / 255) * (1 - sf);
|
|
140
|
+
const outA = sf + df;
|
|
141
|
+
dst[d] = ((s[p] ?? 0) * sf + (dst[d] ?? 0) * df) / outA;
|
|
142
|
+
dst[d + 1] = ((s[p + 1] ?? 0) * sf + (dst[d + 1] ?? 0) * df) / outA;
|
|
143
|
+
dst[d + 2] = ((s[p + 2] ?? 0) * sf + (dst[d + 2] ?? 0) * df) / outA;
|
|
144
|
+
dst[d + 3] = outA * 255;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The artwork, centred in the inner area and clipped to it — that clip is exactly the `cover`
|
|
149
|
+
* crop, so one blit serves both fits.
|
|
150
|
+
*/
|
|
151
|
+
export function composeOnto(art: Raster, layout: Layout): Raster {
|
|
152
|
+
const { box, pad, inner } = layout;
|
|
153
|
+
const canvas = createRaster(box.width, box.height, 'resize');
|
|
154
|
+
fill(canvas, layout.background);
|
|
155
|
+
const ox = pad + Math.round((inner.width - art.width) / 2);
|
|
156
|
+
const oy = pad + Math.round((inner.height - art.height) / 2);
|
|
157
|
+
const x1 = Math.min(pad + inner.width, ox + art.width);
|
|
158
|
+
const y1 = Math.min(pad + inner.height, oy + art.height);
|
|
159
|
+
for (let y = Math.max(pad, oy); y < y1; y += 1) {
|
|
160
|
+
for (let x = Math.max(pad, ox); x < x1; x += 1) {
|
|
161
|
+
blend(
|
|
162
|
+
canvas.pixels,
|
|
163
|
+
(y * canvas.width + x) * 4,
|
|
164
|
+
art.pixels,
|
|
165
|
+
((y - oy) * art.width + (x - ox)) * 4,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return canvas;
|
|
170
|
+
}
|
package/src/image/errors.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
// Single responsibility: the three failure modes of the image pipeline, as coded errors
|
|
2
|
-
//
|
|
3
|
-
// "unsupported" needs to know which format
|
|
1
|
+
// Single responsibility: the three failure modes of the image pipeline, as coded errors, and the
|
|
2
|
+
// one translation of `Bun.Image`'s `ERR_IMAGE_*` rejections into them. Every one names the format
|
|
3
|
+
// AND a runnable way forward, because an agent that hits "unsupported" needs to know which format
|
|
4
|
+
// to ask for instead, not that it lost.
|
|
4
5
|
|
|
6
|
+
import { renderThrowable, stringField } from '../error-render';
|
|
5
7
|
import { UltimateError } from '../errors';
|
|
6
8
|
|
|
7
9
|
export class ImageUnsupportedError extends UltimateError {
|
|
@@ -56,3 +58,33 @@ export const imageTooLarge = (
|
|
|
56
58
|
'downscale the source before it reaches the pipeline, or raise MAX_IMAGE_PIXELS deliberately',
|
|
57
59
|
meta,
|
|
58
60
|
);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `Bun.Image` rejects with a plain `Error` carrying a stable `error.code`. This is the ONE place
|
|
64
|
+
* that code is read: a caller branching on `ERR_IMAGE_*` would be a second vocabulary for the same
|
|
65
|
+
* three failures, and `X_IMAGE_*` is the one the rest of the framework, the wiki and `x errors
|
|
66
|
+
* explain` already know. Unknown codes land on decode-failed rather than on a bare `Error`.
|
|
67
|
+
*/
|
|
68
|
+
const UNSUPPORTED_FIX =
|
|
69
|
+
"request 'png', 'jpeg' or 'webp' — AVIF and HEIC need an OS codec the portable backend never " +
|
|
70
|
+
'uses, so route those through an ImageTransformDriver (a CDN or an external encoder)';
|
|
71
|
+
|
|
72
|
+
const UNKNOWN_FORMAT_FIX =
|
|
73
|
+
're-export the source as PNG, JPEG or WebP: `file <path>` reports what these bytes actually are';
|
|
74
|
+
|
|
75
|
+
export function imageFromBunError(value: unknown, doing: string): UltimateError {
|
|
76
|
+
// `renderThrowable`, never `${value}` — the rejection is Bun's value, not ours, and a cause that
|
|
77
|
+
// throws while formatting itself replaces the refusal with a TypeError nothing catches by code.
|
|
78
|
+
const cause = `${doing}: ${renderThrowable(value)}`;
|
|
79
|
+
const code = stringField(value, 'code');
|
|
80
|
+
if (code === 'ERR_IMAGE_FORMAT_UNSUPPORTED') {
|
|
81
|
+
return new ImageUnsupportedError(cause, UNSUPPORTED_FIX, { bunCode: code });
|
|
82
|
+
}
|
|
83
|
+
if (code === 'ERR_IMAGE_UNKNOWN_FORMAT') {
|
|
84
|
+
return new ImageUnsupportedError(cause, UNKNOWN_FORMAT_FIX, { bunCode: code });
|
|
85
|
+
}
|
|
86
|
+
if (code === 'ERR_IMAGE_TOO_MANY_PIXELS') {
|
|
87
|
+
return imageTooLarge(cause, { bunCode: code });
|
|
88
|
+
}
|
|
89
|
+
return imageDecodeFailed(cause, code === undefined ? {} : { bunCode: code });
|
|
90
|
+
}
|