@ultimat3/core 5.0.1 → 6.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/README.md CHANGED
@@ -38,7 +38,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
38
38
  | the sockets this process opened, so a self-request is not egress | `listeners.ts` |
39
39
  | `defineService('orgs', …)` → `ctx.orgs`, rebuilt per actor | `service.ts` |
40
40
  | the registrar table one same-tier package reaches another through | `registrar.ts` |
41
- | decode → resize → encode, the one image pipeline | `image/` |
41
+ | decode → resize → encode, the one image pipeline (over `Bun.Image`) | `image/` |
42
42
  | `assertNever`, `invariant` | `assert.ts` |
43
43
 
44
44
  ## Errors are instructions
@@ -466,31 +466,55 @@ than in `@ultimat3/time` because `@ultimat3/money` needs it too and tier 1 may n
466
466
 
467
467
  ```ts
468
468
  probeImage(bytes); // { format, width, height, mimeType }
469
- transformImageBytes(bytes, { width: 640, format: 'jpeg', quality: 80 });
470
- blurDataUrl(bytes); // 16px PNG data: URI, the LQIP
469
+ await transformImageBytes(bytes, { width: 640, format: 'webp', quality: 80 });
470
+ await blurDataUrl(bytes); // ThumbHash PNG data: URI, the LQIP
471
471
  ```
472
472
 
473
473
  `storage` variants, `seo` `<picture>` sources and `pwa` icons are the same three steps —
474
474
  decode, resize, encode — with different numbers, so there is one implementation and no second
475
- scaler for an icon to grow a halo in. Zero dependencies: no `sharp`, no native module.
475
+ scaler for an icon to grow a halo in. The codecs are **`Bun.Image`** statically-linked
476
+ libjpeg-turbo / libspng / libwebp with SIMD resize kernels, in the runtime. Still zero
477
+ dependencies: no `sharp`, no native module.
478
+
479
+ Every terminal is `async`, because the pipeline runs on a worker thread. `probeImage` stays
480
+ synchronous: it reads a header and never decodes, which is also why it measures SVG and AVIF that
481
+ no codec here reads.
476
482
 
477
483
  | | |
478
484
  |---|---|
479
- | Decode / encode | PNG and JPEG. `canDecode()` / `canEncode()` publish the real list |
480
- | Probe only | WebP, AVIF, GIF, SVG — measured from the header so `width`/`height` still inline and CLS stays 0 |
485
+ | Decode | PNG, JPEG, WebP, GIF. `canDecode()` publishes the real list |
486
+ | Encode | PNG, JPEG, WebP. `canEncode()` publishes the real list |
487
+ | Probe only | AVIF and SVG — measured from the header so `width`/`height` still inline and CLS stays 0 |
481
488
  | Anything else | `X_IMAGE_UNSUPPORTED`, naming the format and pointing at an `ImageTransformDriver` |
482
- | Ceiling | `MAX_IMAGE_PIXELS` (64MP), checked from the header **before** a byte is allocated |
483
- | Determinism | same bytes + same spec → same output bytes. No clock, no randomness |
484
-
485
- Adding a format is a decoder plus an entry in `DECODABLE_FORMATS` / `ENCODABLE_FORMATS` never a
486
- second dispatch. An unencodable `format` is refused from the spec alone, before the source is
487
- decoded, so a request nothing can write never expands 64 megapixels first.
488
-
489
- `image/` is the one place in core allowed past the 200-line target, and only there: a JPEG or PNG
490
- codec is a single algorithm that does not split into smaller responsibilities without inventing
491
- seams. Nothing else in it qualifies, which is why the segment headers (`jpeg-headers.ts`), the SVG
492
- text parse (`probe-svg.ts`) and the colour grammar (`color.ts`) are their own files. The 500-line
493
- hard ceiling applies to all of them.
489
+ | Ceiling | `MAX_IMAGE_PIXELS` (64MP), passed to the decoder as `maxPixels` and refused from the header **before** a byte is allocated |
490
+ | Determinism | same bytes + same spec → same output bytes, **on every platform** |
491
+
492
+ **AVIF and HEIC are refused everywhere, deliberately.** `Bun.Image` can reach them through an OS
493
+ codec (ImageIO on macOS, WIC on Windows), and this pipeline sets `Bun.Image.backend = 'bun'` on
494
+ every call to forbid exactly that: the static codecs and the Highway geometry kernels are what make
495
+ a laptop and a Linux node produce the same bytes, and `variantKey` is content-addressed. A variant
496
+ that re-encoded differently per platform is a cache that never hits. Producing AVIF means a CDN or
497
+ a custom `ImageTransformDriver`.
498
+
499
+ `transformImageBytes` has two paths and picks by geometry. When the resampled artwork IS the output
500
+ box it is one `Bun.Image` call, source bytes to encoded bytes. When it is not — a letterbox, a
501
+ `padding`, a `cover` crop — the artwork comes back as PNG and `canvas.ts` composites it, because
502
+ `Bun.Image` resamples but has no compositor and the PWA maskable safe zone is a composite.
503
+ `png-pixels.ts` is the raw-pixel seam that hop needs, 8-bit RGBA only; anything else is
504
+ `X_IMAGE_UNSUPPORTED` naming `transformImageBytes`.
505
+
506
+ Adding a format is an entry in `DECODABLE_FORMATS` / `ENCODABLE_FORMATS` and a branch in
507
+ `withFormat` — never a second dispatch. An unencodable `format` is refused from the spec alone,
508
+ before the source is decoded, so a request nothing can write never expands 64 megapixels first.
509
+
510
+ `Bun.Image` rejects with `ERR_IMAGE_*` on `error.code`. `imageFromBunError` is the ONE place that
511
+ is read, mapping it onto `X_IMAGE_UNSUPPORTED` / `X_IMAGE_TOO_LARGE` / `X_IMAGE_DECODE_FAILED`; no
512
+ caller branches on a Bun code.
513
+
514
+ Two files in `image/` are past the 200-line target and neither splits without inventing a seam:
515
+ `probe.ts` is one algorithm per format over header bytes, and `fixtures.ts` is data. The 500-line
516
+ hard ceiling applies to both. Everything else in `image/` is under the target — deleting the
517
+ hand-rolled JPEG and PNG codecs is what put it there.
494
518
 
495
519
  `image/fixtures.ts` is byte-exact output from Pillow and ffmpeg on purpose: a codec that only round
496
520
  trips against itself proves nothing. Never regenerate a fixture with our own encoder.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "5.0.1",
3
+ "version": "6.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",
@@ -0,0 +1,77 @@
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 first `run()`, never at module scope. That is the whole point of
20
+ * this file: a browser bundler stubs `node:async_hooks` to `{}` — Bun's `target: 'browser'` emits
21
+ * `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 server pays nothing: `getStore()` before any `run()` answers `undefined` whether the storage
28
+ * was ever constructed or not, so deferring the construction changes no observable behaviour.
29
+ *
30
+ * **Reads degrade, writes throw**, and that split is the doctrine rather than a convenience.
31
+ * `get()` answers `undefined` in a browser because that is TRUE — nothing is in flight there, so
32
+ * "am I inside a scope" has a definite no for an answer and does not deserve an exception. It is
33
+ * the same call `@ultimat3/ui`'s `solid()` makes: inert where the capability is genuinely absent,
34
+ * throwing only where a caller asked for something the runtime cannot deliver. `run()` is that
35
+ * second case, so it names itself with a code and a fix rather than leaving a bare `TypeError`
36
+ * from a stack that mentions no file the caller wrote.
37
+ *
38
+ * **A synchronous save/restore fallback is not the answer here**, and this note exists so that it
39
+ * is not re-proposed: a module-level `current` swapped in a `try`/`finally` serves sync code and
40
+ * is silently WRONG across an `await` — two overlapping scopes interleave and the second one's
41
+ * `finally` restores a value the first is still inside. That is the `jobs: { driver: 'redis' }`
42
+ * failure mode this repo already paid for once: accepted, unwarned, and wrong in the dangerous
43
+ * direction. An error a caller can read beats an ambient value that is occasionally somebody
44
+ * else's.
45
+ *
46
+ * `subject` names what could not be opened, and is a `string` by construction — never an
47
+ * `unknown` reaching a `cause:`, which `bun run error-render` refuses.
48
+ */
49
+ export function asyncContext<T>(subject: string): AsyncContext<T> {
50
+ let storage: AsyncLocalStorage<T> | undefined;
51
+
52
+ function open(): AsyncLocalStorage<T> | undefined {
53
+ if (storage !== undefined) return storage;
54
+ // The stub is an object with no `AsyncLocalStorage` key, so the binding reads `undefined`.
55
+ if (typeof AsyncLocalStorage !== 'function') return undefined;
56
+ storage = new AsyncLocalStorage<T>();
57
+ return storage;
58
+ }
59
+
60
+ return {
61
+ get(): T | undefined {
62
+ return open()?.getStore();
63
+ },
64
+ run<R>(value: T, fn: () => R): R {
65
+ const store = open();
66
+ if (store === undefined) {
67
+ throw new UltimateError({
68
+ code: 'X_ASYNC_CONTEXT_UNAVAILABLE',
69
+ cause: `${subject} needs AsyncLocalStorage, and node:async_hooks is stubbed to {} in this runtime`,
70
+ 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`,
71
+ meta: { subject },
72
+ });
73
+ }
74
+ return store.run(value, fn);
75
+ },
76
+ };
77
+ }
package/src/config.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { ConfigInvalidError } from './errors';
6
6
  import { ROLES, type Role } from './roles';
7
+ import { isIanaZoneName } from './time-zone-name';
7
8
 
8
9
  export type ThemeMode = 'light' | 'dark' | 'system';
9
10
  export type OfflineStrategy = 'precache' | 'runtime' | 'network-only';
@@ -205,15 +206,6 @@ const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
205
206
  */
206
207
  const CURRENCY_RE = /^[A-Z]{3}$/;
207
208
 
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
209
  function isLocale(value: string): boolean {
218
210
  try {
219
211
  return Intl.getCanonicalLocales(value).length === 1;
@@ -252,8 +244,22 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
252
244
  };
253
245
  }
254
246
 
247
+ const BASE_FIX = 'edit app.config.ts to fix the fields named in cause, then run: x verify';
248
+
249
+ /**
250
+ * Appended only when the zone is what failed. Axiom 4: an operator holding `'CET'` needs the
251
+ * spelling to write, and the two refused classes have different remedies — a single-label legacy
252
+ * name swaps mechanically, an abbreviation or an offset has no replacement at all because it names
253
+ * no jurisdiction. Deliberately parallel to `@ultimat3/time`'s `X_TIMEZONE_INVALID` fix, since the
254
+ * two refuse the same strings and an operator may meet either first.
255
+ */
256
+ const TIMEZONE_FIX =
257
+ "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)";
258
+
255
259
  function validate(config: AppConfig): void {
256
260
  const issues: string[] = [];
261
+ // Zero or one entry: the zone's own remedy, carried only when the zone is what failed.
262
+ const zoneFix: string[] = [];
257
263
 
258
264
  if (!NAME_RE.test(config.name)) {
259
265
  issues.push(`name "${config.name}" must match ${String(NAME_RE)}`);
@@ -265,8 +271,14 @@ function validate(config: AppConfig): void {
265
271
  if (!config.locales.includes(config.defaultLocale)) {
266
272
  issues.push(`defaultLocale "${config.defaultLocale}" is not in locales`);
267
273
  }
268
- if (!isTimeZone(config.defaultTimeZone)) {
269
- issues.push(`defaultTimeZone "${config.defaultTimeZone}" is not an IANA time zone`);
274
+ // `@ultimat3/time`'s rule, restated because tier 0 cannot import tier 1 — see
275
+ // `time-zone-name.ts`. One validator means a zone `app.config.ts` accepts is a zone every
276
+ // `format` call, `task()` and `toZoned` below it can then do arithmetic in.
277
+ if (!isIanaZoneName(config.defaultTimeZone)) {
278
+ issues.push(
279
+ `defaultTimeZone "${config.defaultTimeZone}" is not an IANA Area/Location zone name`,
280
+ );
281
+ zoneFix.push(TIMEZONE_FIX);
270
282
  }
271
283
  if (!CURRENCY_RE.test(config.defaultCurrency)) {
272
284
  issues.push(`defaultCurrency "${config.defaultCurrency}" is not a 3-letter ISO 4217 code`);
@@ -284,7 +296,9 @@ function validate(config: AppConfig): void {
284
296
  if (issues.length > 0) {
285
297
  throw new ConfigInvalidError({
286
298
  cause: issues.join('; '),
287
- fix: 'edit app.config.ts to fix the fields named in cause, then run: x verify',
299
+ // The generic instruction goes LAST so the fix line still ends in a command that can be
300
+ // pasted — a trailing `.` after `x verify` is a command nobody can run.
301
+ fix: [...zoneFix, BASE_FIX].join('. '),
288
302
  meta: { issues },
289
303
  });
290
304
  }
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
- const storage = new AsyncLocalStorage<Ctx>();
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 storage.run(ctx, fn);
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 storage.getStore();
146
+ return requestContext.get();
142
147
  }
143
148
 
144
149
  export function useContext(): Ctx {
145
- const ctx = storage.getStore();
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 storage.getStore() !== undefined;
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 storage.run(child, fn);
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 = storage.getStore();
230
+ const ctx = tryUseContext();
226
231
  if (ctx === undefined) return undefined;
227
232
  const { actor } = ctx;
228
233
  return {
@@ -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',
@@ -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
+ }
@@ -1,7 +1,9 @@
1
- // Single responsibility: the three failure modes of the image pipeline, as coded errors.
2
- // Every one names the format AND a runnable way forward, because an agent that hits
3
- // "unsupported" needs to know which format to ask for instead, not that it lost.
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
+ }