@ultimat3/core 5.0.0 → 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.
@@ -1,94 +1,117 @@
1
- // Single responsibility: THE image pipeline. Decode -> resize -> encode, one entry point,
2
- // one capability list. `storage`, `seo` and `pwa` all call this file and none of them owns a
3
- // second copy: responsive variants, blur placeholders and PWA icons are the same three steps
4
- // with different numbers, and a framework that generated them twice would drift twice.
5
-
6
- import { imageUnsupported } from './errors';
7
- import { decodeJpeg } from './jpeg-decode';
8
- import { encodeJpeg } from './jpeg-encode';
9
- import { DEFAULT_JPEG_QUALITY } from './jpeg-tables';
10
- import { decodePng, encodePng } from './png';
11
- import { IMAGE_MIME_TYPES, type ImageFormat, probeImage, sniffImageFormat } from './probe';
12
- import { hasAlpha, type Raster } from './raster';
13
- import { type ResizeSpec, resizeRaster } from './resize';
1
+ // Single responsibility: THE image pipeline. Decode -> resize -> encode, one entry point, one
2
+ // capability list. `storage`, `seo` and `pwa` all call this file and none of them owns a second
3
+ // copy: responsive variants, blur placeholders and PWA icons are the same three steps with
4
+ // different numbers, and a framework that generated them twice would drift twice.
5
+
6
+ import { composeOnto, layOut, type ResizeSpec } from './canvas';
7
+ import { imageFromBunError, imageUnsupported } from './errors';
8
+ import { unshared } from './png-bytes';
9
+ import { decodeImage, encodeImage } from './png-pixels';
10
+ import { IMAGE_MIME_TYPES, type ImageFormat } from './probe';
11
+ import { MAX_IMAGE_PIXELS } from './raster';
14
12
 
15
13
  /**
16
- * What the built-in, zero-dependency pipeline can actually produce. Bun ships no image API and
17
- * the contract forbids `sharp`, so WebP and AVIF are *probed and served*, never synthesised
18
- * here a caller that needs them routes transforms through a driver (see `@ultimat3/seo`'s
19
- * `ImageTransformDriver`). Publishing the real list is what stops `<source type="image/avif">`
20
- * from promising a variant nothing can encode.
14
+ * What the pipeline can produce, on every platform, byte for byte. `Bun.Image` also reaches
15
+ * HEIC and AVIF **through an OS codec** Apple's ImageIO, Windows' WIC which is a variant
16
+ * that exists on the developer's laptop and not on the Linux node that serves it, under a key
17
+ * that says nothing about which machine minted it. `backend = 'bun'` below refuses that trade,
18
+ * so those two formats are refused HERE rather than silently on one deploy out of two: a caller
19
+ * that needs them routes transforms through a driver (`@ultimat3/seo`'s `ImageTransformDriver`).
21
20
  */
22
- export const DECODABLE_FORMATS = ['png', 'jpeg'] as const;
23
- export const ENCODABLE_FORMATS = ['png', 'jpeg'] as const;
21
+ export const ENCODABLE_FORMATS = ['png', 'jpeg', 'webp'] as const;
22
+ /** What the static codecs read. `svg` is markup, and `probeImage` measures it without decoding. */
23
+ export const DECODABLE_FORMATS = ['png', 'jpeg', 'webp', 'gif'] as const;
24
24
 
25
25
  export type DecodableFormat = (typeof DECODABLE_FORMATS)[number];
26
26
  export type EncodableFormat = (typeof ENCODABLE_FORMATS)[number];
27
27
 
28
- export const canDecode = (format: ImageFormat): format is DecodableFormat =>
28
+ export const canDecode = (format: string): format is DecodableFormat =>
29
29
  (DECODABLE_FORMATS as readonly string[]).includes(format);
30
30
 
31
- export const canEncode = (format: ImageFormat): format is EncodableFormat =>
31
+ export const canEncode = (format: string): format is EncodableFormat =>
32
32
  (ENCODABLE_FORMATS as readonly string[]).includes(format);
33
33
 
34
- /** Small enough to inline in HTML, big enough to blur convincingly. */
35
- export const BLUR_PLACEHOLDER_WIDTH = 16;
36
-
37
- const decodeFix =
38
- 'convert the source to PNG or JPEG before it reaches the pipeline, or pass a custom ' +
39
- 'ImageTransformDriver that can read it';
34
+ /** 1-100, lossy formats only. Bun's own default, pinned here so output cannot drift with it. */
35
+ export const DEFAULT_IMAGE_QUALITY = 80;
40
36
 
41
37
  const encodeFix =
42
- "request 'png' or 'jpeg', or route the transform through an ImageTransformDriver (a CDN " +
43
- 'or an external encoder) that can produce it';
44
-
45
- /** Bytes in, RGBA out. The only place a format is turned into pixels. */
46
- export function decodeImage(bytes: Uint8Array): Raster {
47
- const format = sniffImageFormat(bytes);
48
- if (format === null) {
49
- throw imageUnsupported('the bytes match no image format this pipeline knows', decodeFix);
50
- }
51
- if (format === 'png') return decodePng(bytes);
52
- if (format === 'jpeg') return decodeJpeg(bytes);
53
- throw imageUnsupported(`decoding ${format} is not built in`, decodeFix, { format });
54
- }
55
-
56
- /** RGBA in, bytes out. `quality` is ignored by lossless formats. */
57
- export function encodeImage(
58
- raster: Raster,
59
- format: ImageFormat,
60
- quality: number = DEFAULT_JPEG_QUALITY,
61
- ): Uint8Array {
62
- if (format === 'png') return encodePng(raster);
63
- if (format === 'jpeg') return encodeJpeg(raster, quality);
64
- throw imageUnsupported(`encoding ${format} is not built in`, encodeFix, { format });
65
- }
38
+ "request 'png', 'jpeg' or 'webp', or route the transform through an ImageTransformDriver (a " +
39
+ 'CDN or an external encoder) that can produce it';
66
40
 
67
41
  export interface ImageTransformSpec extends ResizeSpec {
68
- /** Defaults to whichever encodable format preserves the source: PNG if it has alpha. */
42
+ /** Defaults to the source's format when the pipeline can write it, PNG otherwise. */
69
43
  readonly format?: ImageFormat | undefined;
70
- /** 1-100, JPEG only. */
44
+ /** 1-100, lossy formats only. */
71
45
  readonly quality?: number | undefined;
72
46
  }
73
47
 
74
48
  /**
75
- * PNG keeps transparency, JPEG does not; picking by the pixels means a logo never silently
76
- * grows a black background because nobody passed `format`.
49
+ * `backend = 'bun'` is set on every call, not once at import. It forces the statically-linked
50
+ * codecs and the Highway geometry kernels on every OS, which is what makes the same source and
51
+ * the same spec the same BYTES on a laptop and on the node — and `variantKey` is content-
52
+ * addressed, so a variant that re-encoded differently per platform would be a cache that never
53
+ * hits and a hash that never agrees. Per call rather than at import because the property is
54
+ * process-global and writable: an app that flips it back would otherwise silently win.
77
55
  */
78
- export const defaultFormatFor = (raster: Raster): EncodableFormat =>
79
- hasAlpha(raster) ? 'png' : 'jpeg';
56
+ function bunImage(bytes: Uint8Array): Bun.Image {
57
+ Bun.Image.backend = 'bun';
58
+ // The decompression-bomb ceiling, enforced by the decoder from the header before it allocates
59
+ // — the same number `probeImage` refuses at, so the two answers cannot disagree.
60
+ return new Bun.Image(unshared(bytes), { maxPixels: MAX_IMAGE_PIXELS });
61
+ }
80
62
 
81
- /** The whole pipeline in one call: decode, resize, encode. */
82
- export function transformImageBytes(bytes: Uint8Array, spec: ImageTransformSpec = {}): Uint8Array {
63
+ function withFormat(image: Bun.Image, format: EncodableFormat, quality: number): Bun.Image {
64
+ if (format === 'png') return image.png();
65
+ if (format === 'jpeg') return image.jpeg({ quality });
66
+ return image.webp({ quality });
67
+ }
68
+
69
+ /** Every rejection from `Bun.Image` becomes one of the three `X_IMAGE_*` codes, never a bare one. */
70
+ async function run<T>(doing: string, work: () => Promise<T>): Promise<T> {
71
+ try {
72
+ return await work();
73
+ } catch (error) {
74
+ throw imageFromBunError(error, doing);
75
+ }
76
+ }
77
+
78
+ /** The whole pipeline in one call: decode, resize, compose, encode. */
79
+ export async function transformImageBytes(
80
+ bytes: Uint8Array,
81
+ spec: ImageTransformSpec = {},
82
+ ): Promise<Uint8Array> {
83
83
  const { format } = spec;
84
84
  // The spec alone answers "can this be written?", so answer it here — decoding and resampling
85
85
  // 64 megapixels first, only to refuse at the encoder, is work nobody can use.
86
86
  if (format !== undefined && !canEncode(format)) {
87
87
  throw imageUnsupported(`encoding ${format} is not built in`, encodeFix, { format });
88
88
  }
89
- const source = decodeImage(bytes);
90
- const resized = resizeRaster(source, spec);
91
- return encodeImage(resized, format ?? defaultFormatFor(resized), spec.quality);
89
+ const quality = spec.quality ?? DEFAULT_IMAGE_QUALITY;
90
+ const source = await run('reading the image header', () => bunImage(bytes).metadata());
91
+ const output: EncodableFormat = format ?? (canEncode(source.format) ? source.format : 'png');
92
+ const layout = layOut(source, spec);
93
+ const { box, drawn } = layout;
94
+
95
+ if (!layout.needsCanvas) {
96
+ return run('transforming the image', () => {
97
+ const image = bunImage(bytes);
98
+ if (box.width !== source.width || box.height !== source.height) {
99
+ image.resize(box.width, box.height, { fit: 'fill' });
100
+ }
101
+ return withFormat(image, output, quality).bytes();
102
+ });
103
+ }
104
+
105
+ // The letterbox / padding / crop path. `Bun.Image` resamples but has no compositor, so the
106
+ // artwork comes back as PNG, is placed on the canvas here, and goes back through Bun to be
107
+ // written. Back through Bun even when the output IS png: `png-pixels.ts` writes filter 0, which
108
+ // is 1.2-1.8x libspng's bytes on a real icon (measured), and one writer for everything this
109
+ // function returns is also what makes "same input, same bytes" rest on the static codecs alone.
110
+ const art = await run('resampling the image', () =>
111
+ bunImage(bytes).resize(drawn.width, drawn.height, { fit: 'fill' }).png().bytes(),
112
+ );
113
+ const composed = encodeImage(composeOnto(decodeImage(art), layout));
114
+ return run('encoding the image', () => withFormat(bunImage(composed), output, quality).bytes());
92
115
  }
93
116
 
94
117
  /** Chunked because spreading a whole image into `String.fromCharCode` overflows the stack. */
@@ -104,14 +127,12 @@ export const dataUrl = (bytes: Uint8Array, format: ImageFormat): string =>
104
127
  `data:${IMAGE_MIME_TYPES[format]};base64,${base64Of(bytes)}`;
105
128
 
106
129
  /**
107
- * The LQIP: the source at 16px wide, as a `data:` URI. Always PNG — at this size a JPEG's
108
- * own headers cost more than the pixels, and alpha survives.
130
+ * The LQIP: a ThumbHash of the source as a `data:image/png;base64,` URI — at most 32px on its
131
+ * long edge, with the source's average colour, aspect ratio and rough structure. PNG, so alpha
132
+ * survives and no client-side decoder is needed to show it.
109
133
  */
110
- export function blurDataUrl(bytes: Uint8Array, width: number = BLUR_PLACEHOLDER_WIDTH): string {
111
- const tiny = resizeRaster(decodeImage(bytes), { width });
112
- return dataUrl(encodePng(tiny), 'png');
134
+ export async function blurDataUrl(bytes: Uint8Array): Promise<string> {
135
+ return run('building the blur placeholder', () => bunImage(bytes).placeholder());
113
136
  }
114
137
 
115
138
  export type { ImageFormat };
116
- /** Intrinsic dimensions without decoding — this is what keeps CLS at 0 for every format. */
117
- export { IMAGE_MIME_TYPES, probeImage, sniffImageFormat };
@@ -0,0 +1,183 @@
1
+ // Single responsibility: the raw-pixel seam — 8-bit RGBA in and out of a PNG container. `Bun.Image`
2
+ // owns every real codec now, but it has no compositor and no raw-pixel terminal, and the maskable
3
+ // safe zone `@ultimat3/pwa` promises is a composite. So this file exists for exactly that one hop:
4
+ // Bun re-encodes to PNG, this reads the pixels back, `canvas.ts` blits, this writes them again.
5
+
6
+ import { imageDecodeFailed, imageUnsupported } from './errors';
7
+ import {
8
+ adler32,
9
+ chunk,
10
+ joinBytes,
11
+ PNG_SIGNATURE,
12
+ paeth,
13
+ readU32,
14
+ unshared,
15
+ writeU32,
16
+ } from './png-bytes';
17
+ import { type Raster, rasterFrom } from './raster';
18
+
19
+ /** Truecolour with alpha, 8 bits per channel — the ONE shape `Raster` is. */
20
+ const RGBA_COLOR_TYPE = 8 << 4;
21
+ const BYTES_PER_PIXEL = 4;
22
+
23
+ const RAW_FIX =
24
+ 'run the bytes through `transformImageBytes()` instead — it is backed by Bun.Image, which ' +
25
+ 'reads every real format; the raw-pixel seam is 8-bit RGBA PNG only';
26
+
27
+ // --------------------------------------------------------------------------------- encode
28
+
29
+ /**
30
+ * Always filter 0. An adaptive filter buys a few percent on a placeholder or an icon and costs a
31
+ * second thing to be wrong in; every consumer of these bytes re-encodes through Bun anyway.
32
+ */
33
+ function filterRows(raster: Raster): Uint8Array<ArrayBuffer> {
34
+ const stride = raster.width * BYTES_PER_PIXEL;
35
+ const out = new Uint8Array((stride + 1) * raster.height);
36
+ for (let y = 0; y < raster.height; y += 1) {
37
+ out[y * (stride + 1)] = 0;
38
+ out.set(raster.pixels.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /** RGBA pixels to PNG bytes. Deterministic: the same raster is the same bytes, every run. */
44
+ export function encodeImage(raster: Raster, format: 'png' = 'png'): Uint8Array {
45
+ if (format !== 'png') {
46
+ throw imageUnsupported(`the raw-pixel seam writes PNG, not ${String(format)}`, RAW_FIX, {
47
+ format,
48
+ });
49
+ }
50
+ const header = new Uint8Array(13);
51
+ writeU32(header, 0, raster.width);
52
+ writeU32(header, 4, raster.height);
53
+ header[8] = 8;
54
+ header[9] = 6;
55
+ const filtered = filterRows(raster);
56
+ // `windowBits: -15` asks for RAW deflate: PNG supplies the zlib envelope itself, and Bun's
57
+ // documented default (15, zlib-wrapped) would nest a second one inside it.
58
+ const deflated = Bun.deflateSync(filtered, { windowBits: -15 });
59
+ const idat = new Uint8Array(deflated.length + 6);
60
+ idat[0] = 0x78;
61
+ idat[1] = 0x01;
62
+ idat.set(deflated, 2);
63
+ writeU32(idat, deflated.length + 2, adler32(filtered));
64
+ return joinBytes([
65
+ PNG_SIGNATURE,
66
+ chunk('IHDR', header),
67
+ chunk('IDAT', idat),
68
+ chunk('IEND', new Uint8Array(0)),
69
+ ]);
70
+ }
71
+
72
+ // --------------------------------------------------------------------------------- decode
73
+
74
+ interface PngHeader {
75
+ readonly width: number;
76
+ readonly height: number;
77
+ }
78
+
79
+ function readHeader(bytes: Uint8Array): PngHeader {
80
+ if (bytes.length < 33) {
81
+ throw imageDecodeFailed(`a PNG is at least 33 bytes; these are ${bytes.length}`, {
82
+ length: bytes.length,
83
+ });
84
+ }
85
+ for (let i = 0; i < PNG_SIGNATURE.length; i += 1) {
86
+ if (bytes[i] !== PNG_SIGNATURE[i]) {
87
+ throw imageUnsupported('the raw-pixel seam reads PNG, and these bytes are not one', RAW_FIX, {
88
+ length: bytes.length,
89
+ });
90
+ }
91
+ }
92
+ const depth = bytes[24];
93
+ const colorType = bytes[25];
94
+ const interlace = bytes[28];
95
+ // Bun's encoder emits 8-bit RGBA, non-interlaced, for every source — verified, and the only
96
+ // shape this seam ever has to read. Anything else came from outside and says so.
97
+ if (((depth ?? 0) << 4) + (colorType ?? 0) !== RGBA_COLOR_TYPE + 6 || interlace !== 0) {
98
+ throw imageUnsupported(
99
+ `the PNG is ${String(depth)}-bit colour type ${String(colorType)}` +
100
+ `${interlace === 0 ? '' : ', interlaced'}, not 8-bit RGBA`,
101
+ RAW_FIX,
102
+ { depth, colorType, interlace },
103
+ );
104
+ }
105
+ return { width: readU32(bytes, 16), height: readU32(bytes, 20) };
106
+ }
107
+
108
+ /** Every IDAT concatenated: a PNG may split its stream across any number of them. */
109
+ function idatStream(bytes: Uint8Array): Uint8Array {
110
+ const parts: Uint8Array[] = [];
111
+ let at = 8;
112
+ while (at + 12 <= bytes.length) {
113
+ const length = readU32(bytes, at);
114
+ const type = String.fromCharCode(...bytes.subarray(at + 4, at + 8));
115
+ if (type === 'IDAT') parts.push(bytes.subarray(at + 8, at + 8 + length));
116
+ if (type === 'IEND') break;
117
+ at += 12 + length;
118
+ }
119
+ if (parts.length === 0) {
120
+ throw imageDecodeFailed('the PNG carries no IDAT chunk, so it declares no pixels', {});
121
+ }
122
+ const stream = joinBytes(parts);
123
+ try {
124
+ // The 2-byte zlib header and the 4-byte Adler-32 trailer are PNG's envelope, stripped here
125
+ // so the payload inflates as RAW deflate — see the encoder above for the mirror image.
126
+ return Bun.inflateSync(unshared(stream.subarray(2, stream.length - 4)), { windowBits: -15 });
127
+ } catch {
128
+ throw imageDecodeFailed(`the PNG IDAT stream (${stream.length} bytes) could not be inflated`, {
129
+ length: stream.length,
130
+ });
131
+ }
132
+ }
133
+
134
+ /**
135
+ * The five PNG predictors, undone row by row. `raw` is `[filter, ...pixels]` per row.
136
+ *
137
+ * Reconstructed into a `Uint8Array`, never straight into the `Uint8ClampedArray` a `Raster` holds:
138
+ * every filter is arithmetic MOD 256 and a clamped array saturates instead, so `255 + 1` lands on
139
+ * 255 rather than 0. That is invisible on a filter-0 stream (ours) and wrong on every adaptive one
140
+ * (libspng's) — the alpha channel of a transparent pixel first, which is exactly the case a PWA
141
+ * icon is made of.
142
+ */
143
+ function unfilter(raw: Uint8Array, width: number, height: number): Uint8ClampedArray {
144
+ const stride = width * BYTES_PER_PIXEL;
145
+ const out = new Uint8Array(stride * height);
146
+ for (let y = 0; y < height; y += 1) {
147
+ const type = raw[y * (stride + 1)] ?? 0;
148
+ if (type > 4) {
149
+ throw imageDecodeFailed(`PNG row ${y} declares filter ${type}, and there are only 0-4`, {
150
+ row: y,
151
+ filter: type,
152
+ });
153
+ }
154
+ const from = y * (stride + 1) + 1;
155
+ const to = y * stride;
156
+ for (let i = 0; i < stride; i += 1) {
157
+ const x = raw[from + i] ?? 0;
158
+ const a = i >= BYTES_PER_PIXEL ? (out[to + i - BYTES_PER_PIXEL] ?? 0) : 0;
159
+ const b = y > 0 ? (out[to - stride + i] ?? 0) : 0;
160
+ const c = y > 0 && i >= BYTES_PER_PIXEL ? (out[to - stride + i - BYTES_PER_PIXEL] ?? 0) : 0;
161
+ if (type === 0) out[to + i] = x;
162
+ else if (type === 1) out[to + i] = x + a;
163
+ else if (type === 2) out[to + i] = x + b;
164
+ else if (type === 3) out[to + i] = x + ((a + b) >> 1);
165
+ else out[to + i] = x + paeth(a, b, c);
166
+ }
167
+ }
168
+ return new Uint8ClampedArray(out.buffer, out.byteOffset, out.length);
169
+ }
170
+
171
+ /** PNG bytes to RGBA pixels. Refuses anything but 8-bit RGBA, naming the pipeline that reads it. */
172
+ export function decodeImage(bytes: Uint8Array): Raster {
173
+ const { width, height } = readHeader(bytes);
174
+ const raw = idatStream(bytes);
175
+ const expected = (width * BYTES_PER_PIXEL + 1) * height;
176
+ if (raw.length !== expected) {
177
+ throw imageDecodeFailed(
178
+ `the PNG inflates to ${raw.length} bytes but ${width}x${height} RGBA needs ${expected}`,
179
+ { inflated: raw.length, expected, width, height },
180
+ );
181
+ }
182
+ return rasterFrom(width, height, unfilter(raw, width, height));
183
+ }
@@ -2,13 +2,16 @@
2
2
  // the mechanism; this is the ONE door through it, because a swap with no reason and no origin is
3
3
  // indistinguishable in an audit trail from the customer doing it themselves.
4
4
 
5
- import { AsyncLocalStorage } from 'node:async_hooks';
6
5
  import { type Actor, actorLabel, actorOrigin } from './actor';
7
6
  import { assert } from './assert';
7
+ import { asyncContext } from './async-context';
8
8
  import { useContext, withChildContext } from './context';
9
9
  import { currentSpan } from './telemetry';
10
10
 
11
- const storage = new AsyncLocalStorage<string>();
11
+ // The same lazily-opened seam `context.ts` uses. Its `run()` cannot be reached in a runtime with
12
+ // no async context: `impersonate()` calls `useContext()` first, and that throws `X_NO_CONTEXT`
13
+ // there — so no second refusal is written here for symmetry's sake.
14
+ const impersonation = asyncContext<string>('the impersonation reason');
12
15
 
13
16
  /**
14
17
  * Run `fn` as `actor`, recording who asked and why.
@@ -45,7 +48,7 @@ export function impersonate<T>(actor: Actor, reason: string, fn: () => T): T {
45
48
  'actor.label': label,
46
49
  'impersonation.reason': reason,
47
50
  });
48
- return withChildContext({ actor: impersonated }, () => storage.run(reason, fn));
51
+ return withChildContext({ actor: impersonated }, () => impersonation.run(reason, fn));
49
52
  }
50
53
 
51
54
  /**
@@ -53,10 +56,10 @@ export function impersonate<T>(actor: Actor, reason: string, fn: () => T): T {
53
56
  * an app where nobody is impersonating. Read by an audit sink, and by nothing else.
54
57
  */
55
58
  export function impersonationReason(): string | undefined {
56
- return storage.getStore();
59
+ return impersonation.get();
57
60
  }
58
61
 
59
62
  /** Is the caller acting as somebody else right now? */
60
63
  export function isImpersonating(): boolean {
61
- return storage.getStore() !== undefined;
64
+ return impersonation.get() !== undefined;
62
65
  }
package/src/index.ts CHANGED
@@ -384,37 +384,33 @@ export {
384
384
  uuid,
385
385
  uuidTimestamp,
386
386
  } from './ids';
387
+ export { fitBox, type ImageFit, type ResizeSpec, scaledToFit } from './image/canvas';
387
388
  export { parseColor } from './image/color';
388
389
  export {
389
390
  ImageDecodeFailedError,
390
391
  ImageTooLargeError,
391
392
  ImageUnsupportedError,
392
393
  imageDecodeFailed,
394
+ imageFromBunError,
393
395
  imageTooLarge,
394
396
  imageUnsupported,
395
397
  } from './image/errors';
396
- export { decodeJpeg } from './image/jpeg-decode';
397
- export { encodeJpeg } from './image/jpeg-encode';
398
- export { DEFAULT_JPEG_QUALITY } from './image/jpeg-tables';
399
398
  export type {
400
399
  DecodableFormat,
401
400
  EncodableFormat,
402
401
  ImageTransformSpec,
403
402
  } from './image/pipeline';
404
403
  export {
405
- BLUR_PLACEHOLDER_WIDTH,
406
404
  blurDataUrl,
407
405
  canDecode,
408
406
  canEncode,
409
407
  DECODABLE_FORMATS,
408
+ DEFAULT_IMAGE_QUALITY,
410
409
  dataUrl,
411
- decodeImage,
412
- defaultFormatFor,
413
410
  ENCODABLE_FORMATS,
414
- encodeImage,
415
411
  transformImageBytes,
416
412
  } from './image/pipeline';
417
- export { decodePng, encodePng } from './image/png';
413
+ export { decodeImage, encodeImage } from './image/png-pixels';
418
414
  export type { ImageFormat, ImageInfo } from './image/probe';
419
415
  export { IMAGE_FORMATS, IMAGE_MIME_TYPES, probeImage, sniffImageFormat } from './image/probe';
420
416
  export type { ImageSize, Raster } from './image/raster';
@@ -425,7 +421,6 @@ export {
425
421
  MAX_IMAGE_PIXELS,
426
422
  rasterFrom,
427
423
  } from './image/raster';
428
- export { fitBox, type ImageFit, type ResizeSpec, resizeRaster, scaledToFit } from './image/resize';
429
424
  export { impersonate, impersonationReason, isImpersonating } from './impersonate';
430
425
  export { cachedFormatter, canonicalLocale, MAX_CACHED_FORMATTERS } from './intl-cache';
431
426
  export type {
package/src/telemetry.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // is a no-op so unconfigured apps pay nothing, and trace context is serialised explicitly
3
3
  // (`traceparent`) so a trace survives HTTP -> job -> live query.
4
4
 
5
- import { AsyncLocalStorage } from 'node:async_hooks';
5
+ import { asyncContext } from './async-context';
6
6
  import { type Clock, systemClock } from './clock';
7
7
  import { tryUseContext } from './context';
8
8
  import { renderThrowable } from './error-render';
@@ -118,7 +118,10 @@ export function memoryExporter(): MemoryExporter {
118
118
  };
119
119
  }
120
120
 
121
- const activeSpan = new AsyncLocalStorage<Span>();
121
+ // The same lazily-opened seam `context.ts` uses, and for the same reason: a module-scope
122
+ // `new AsyncLocalStorage()` throws at EVALUATION in a browser bundle, taking every importer of
123
+ // `@ultimat3/core` down with it. `async-context.ts` owns the argument.
124
+ const activeSpan = asyncContext<Span>('the active span');
122
125
 
123
126
  let exporter: SpanExporter = noopExporter;
124
127
  let clock: Clock = systemClock;
@@ -161,12 +164,12 @@ export function serviceResource(): SpanResource {
161
164
  }
162
165
 
163
166
  export function currentSpan(): Span | undefined {
164
- return activeSpan.getStore();
167
+ return activeSpan.get();
165
168
  }
166
169
 
167
170
  /** The trace the caller is inside: active span, else the request context, else a fresh trace. */
168
171
  export function currentSpanContext(): SpanContext | undefined {
169
- const span = activeSpan.getStore();
172
+ const span = activeSpan.get();
170
173
  if (span !== undefined) return span.context;
171
174
  const ctx = tryUseContext();
172
175
  if (ctx === undefined) return undefined;
@@ -0,0 +1,43 @@
1
+ // Single responsibility: is a string an IANA zone NAME? Tier 0's statement of the one rule
2
+ // `@ultimat3/time` enforces everywhere above it — a zone is `Area/Location`, and `UTC` is the one
3
+ // exception. It is stated twice because `core` is tier 0 and may not import `@ultimat3/time`;
4
+ // `packages/time/src/zone-canonical.ts` is where the rule and its reasoning are written down.
5
+
6
+ /**
7
+ * A LEADING sign is a fixed offset, which carries no DST rules. `Etc/GMT+2` keeps its `+`.
8
+ *
9
+ * Unobservable on ICU 78 — `+01:00` resolves to itself, so the slash rule below already refuses it,
10
+ * and deleting this line changes no answer this package can currently produce. It stays because it
11
+ * guards the runtime that folds an offset into `Etc/GMT-1`, which WOULD carry a slash, and because
12
+ * `packages/time/src/zone-canonical.ts` carries the same line: two statements of one rule may not
13
+ * differ, least of all in the half that is hard to test.
14
+ */
15
+ const NUMERIC_OFFSET = /^[+-]/;
16
+
17
+ /**
18
+ * Structural, and never delegated to `Intl` — the reasoning, and why a denylist is not the
19
+ * alternative, is `packages/time/src/zone-canonical.ts`'s and is not re-derived here. What this
20
+ * file enforces is that same rule for `app.config.ts`: an identifier is `Area/Location`, `UTC` is
21
+ * the one legal exception, and a leading sign is an offset rather than a name.
22
+ *
23
+ * It exists because a bare `new Intl.DateTimeFormat(…)` probe was the second answer to that
24
+ * question, and the two stopped agreeing: ICU 78 (Bun 1.4) resolves `CET`, `EST`, `Japan`, `GMT`,
25
+ * `Zulu` and the whole `backward`/abbreviation family, so `defaultTimeZone: 'CET'` passed validation
26
+ * at boot and threw `X_TIMEZONE_INVALID` on the first `format` call — a config file accepting a
27
+ * value nothing downstream can use (issue #257).
28
+ *
29
+ * The judgement is made on the RESOLVED name, not the input, so a casing (`utc`) and a runtime that
30
+ * folds an alias into its target (`Etc/UTC` → `UTC`) both answer correctly. No cache: this is read
31
+ * once per process at config validation, never off a request header — a zone that arrives from a
32
+ * caller goes through `@ultimat3/time`'s `canonicalTimeZone`, which is bounded and canonicalizing.
33
+ */
34
+ export function isIanaZoneName(value: string): boolean {
35
+ if (value === '' || NUMERIC_OFFSET.test(value)) return false;
36
+ try {
37
+ const resolved = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions()
38
+ .timeZone;
39
+ return resolved === 'UTC' || resolved.includes('/');
40
+ } catch {
41
+ return false;
42
+ }
43
+ }