@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 +42 -18
- package/package.json +1 -1
- package/src/async-context.ts +77 -0
- package/src/config.ts +26 -12
- package/src/context.ts +13 -8
- package/src/error-codes.ts +1 -0
- 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 +4 -9
- package/src/telemetry.ts +7 -4
- package/src/time-zone-name.ts +43 -0
- 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/src/image/pipeline.ts
CHANGED
|
@@ -1,94 +1,117 @@
|
|
|
1
|
-
// Single responsibility: THE image pipeline. Decode -> resize -> encode, one entry point,
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
23
|
-
|
|
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:
|
|
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:
|
|
31
|
+
export const canEncode = (format: string): format is EncodableFormat =>
|
|
32
32
|
(ENCODABLE_FORMATS as readonly string[]).includes(format);
|
|
33
33
|
|
|
34
|
-
/**
|
|
35
|
-
export const
|
|
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 '
|
|
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
|
|
42
|
+
/** Defaults to the source's format when the pipeline can write it, PNG otherwise. */
|
|
69
43
|
readonly format?: ImageFormat | undefined;
|
|
70
|
-
/** 1-100,
|
|
44
|
+
/** 1-100, lossy formats only. */
|
|
71
45
|
readonly quality?: number | undefined;
|
|
72
46
|
}
|
|
73
47
|
|
|
74
48
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
|
90
|
-
const
|
|
91
|
-
|
|
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:
|
|
108
|
-
*
|
|
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
|
|
111
|
-
|
|
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
|
+
}
|
package/src/impersonate.ts
CHANGED
|
@@ -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
|
-
|
|
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 }, () =>
|
|
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
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|