@ultimat3/core 24.0.0 → 25.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.
Files changed (76) hide show
  1. package/CLAUDE.md +24 -27
  2. package/README.md +71 -34
  3. package/package.json +4 -7
  4. package/src/actor.ts +9 -0
  5. package/src/address-class.ts +40 -4
  6. package/src/assert.ts +9 -5
  7. package/src/audit.ts +144 -0
  8. package/src/aws-sigv4.ts +275 -0
  9. package/src/backoff.ts +16 -0
  10. package/src/bunfs.ts +17 -0
  11. package/src/client-dispatch.ts +24 -3
  12. package/src/client-flight.ts +68 -13
  13. package/src/client-problem.ts +62 -6
  14. package/src/client-retry-after.ts +47 -0
  15. package/src/client-transport.ts +3 -1
  16. package/src/client-wire.ts +27 -3
  17. package/src/config-ai.ts +32 -0
  18. package/src/config-defaults.ts +18 -11
  19. package/src/config-fixes.ts +0 -10
  20. package/src/config-health.ts +9 -2
  21. package/src/config-jobs.ts +51 -0
  22. package/src/config-keys.ts +170 -0
  23. package/src/config-mail.ts +73 -0
  24. package/src/config-merge.ts +1 -1
  25. package/src/config-navigation.ts +1 -25
  26. package/src/config-pwa.ts +42 -5
  27. package/src/config-removed.ts +131 -0
  28. package/src/config-shape.ts +0 -33
  29. package/src/config.ts +71 -94
  30. package/src/context.ts +16 -12
  31. package/src/cookie.ts +267 -3
  32. package/src/core-error-codes.ts +2 -0
  33. package/src/cursor-page.ts +41 -0
  34. package/src/cursor.ts +23 -5
  35. package/src/deprecation.ts +77 -0
  36. package/src/dev-secrets.ts +18 -7
  37. package/src/drain-deadline.ts +43 -0
  38. package/src/env-example.ts +9 -29
  39. package/src/errors.ts +18 -9
  40. package/src/exports/error-contract.ts +0 -1
  41. package/src/exports/observability.ts +1 -1
  42. package/src/exports/secrets.ts +3 -0
  43. package/src/finite-option.ts +1 -1
  44. package/src/flight-gate.ts +29 -14
  45. package/src/generation-fence.ts +1 -1
  46. package/src/ids.ts +7 -7
  47. package/src/image/canvas.ts +76 -5
  48. package/src/image/pipeline.ts +17 -5
  49. package/src/image/raster.ts +24 -1
  50. package/src/index.ts +89 -35
  51. package/src/iso-date.ts +1 -1
  52. package/src/lifecycle-errors.ts +1 -1
  53. package/src/lifecycle-readiness.ts +60 -2
  54. package/src/lifecycle-signals.ts +27 -2
  55. package/src/lifecycle-types.ts +96 -0
  56. package/src/lifecycle.ts +44 -133
  57. package/src/locale-direction.ts +1 -1
  58. package/src/logger.ts +16 -7
  59. package/src/mcp-exposure.ts +70 -8
  60. package/src/measurement-actor.ts +16 -1
  61. package/src/metric-errors.ts +32 -0
  62. package/src/metric-registry.ts +151 -0
  63. package/src/metric-series.ts +94 -0
  64. package/src/metrics.ts +9 -255
  65. package/src/page.ts +4 -2
  66. package/src/registrar.ts +1 -0
  67. package/src/retry.ts +25 -5
  68. package/src/secrets-key-file.ts +139 -0
  69. package/src/secrets-store.ts +32 -16
  70. package/src/service.ts +5 -5
  71. package/src/single-flight.ts +1 -1
  72. package/src/telemetry.ts +1 -1
  73. package/src/theme-storage.ts +12 -0
  74. package/src/type-pins.ts +51 -1
  75. package/src/image/fixtures.ts +0 -263
  76. package/src/time-zone-name.ts +0 -14
@@ -2,16 +2,12 @@
2
2
  // second hand-maintained list. Render it from the declarations, and report drift when the file on
3
3
  // disk has fallen behind. Loading `.env` itself is Bun's job — see `envFileCandidates()`.
4
4
 
5
- import type { EnvSchema, EnvVarDecl } from './env';
6
- import { type CodedErrorInit, UltimateError } from './errors';
5
+ //
6
+ // It REPORTS and never throws. `assertEnvExample` and its `EnvExampleDriftError` were deleted in
7
+ // 25.0.0: a second, weaker gate nothing called. `X_ENV_EXAMPLE_DRIFT` is `x verify`'s `manifest`
8
+ // step's finding (`@ultimat3/cli`'s `app-env.ts`), byte-for-byte against `renderEnvExample`.
7
9
 
8
- export class EnvExampleDriftError extends UltimateError {
9
- static readonly code = 'X_ENV_EXAMPLE_DRIFT';
10
- override readonly name = 'EnvExampleDriftError';
11
- constructor(init: CodedErrorInit) {
12
- super({ ...init, code: EnvExampleDriftError.code });
13
- }
14
- }
10
+ import type { EnvSchema, EnvVarDecl } from './env';
15
11
 
16
12
  const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
17
13
 
@@ -99,11 +95,10 @@ export interface EnvExampleReport {
99
95
  /**
100
96
  * In the file, not in the schema — never fatal, because apps set keys nothing declares.
101
97
  *
102
- * NOT reported on its own, and the comment here said it was. `ok` is `missing.length === 0`, so
103
- * an example carrying only extra keys returns `ok: true` and `assertEnvExample` never builds an
104
- * error: the list reaches a surface only as `meta` on a drift some MISSING key already raised.
105
- * A caller that wants it reads `checkEnvExample(...).extra` itself, which is why this stays
106
- * public. `env-example.test.ts` pins both halves.
98
+ * NOT reported on its own. `ok` is `missing.length === 0`, so an example carrying only extra
99
+ * keys returns `ok: true`; the framework's reporter (`@ultimat3/cli`'s `app-env.ts`) builds its
100
+ * finding from `missing` only. A caller that wants it reads `checkEnvExample(...).extra` itself,
101
+ * which is why this stays public. `env-example.test.ts` pins it.
107
102
  */
108
103
  readonly extra: readonly string[];
109
104
  }
@@ -115,18 +110,3 @@ export function checkEnvExample(schema: EnvSchema, text: string): EnvExampleRepo
115
110
  const extra = [...present].filter((key) => !declared.includes(key));
116
111
  return { ok: missing.length === 0, missing, extra };
117
112
  }
118
-
119
- /**
120
- * Throws `X_ENV_EXAMPLE_DRIFT` when the committed example has fallen behind the schema — the
121
- * failure an agent hits *before* a teammate hits `X_ENV_MISSING` on a variable nobody told them
122
- * about.
123
- */
124
- export function assertEnvExample(schema: EnvSchema, text: string, path = ENV_EXAMPLE_PATH): void {
125
- const report = checkEnvExample(schema, text);
126
- if (report.ok) return;
127
- throw new EnvExampleDriftError({
128
- cause: `${path} does not declare ${report.missing.join(', ')}, declared by defineEnv()`,
129
- fix: `Bun.write('${path}', renderEnvExample(schema)) — regenerate it from the declarations`,
130
- meta: { path, missing: report.missing, extra: report.extra },
131
- });
132
- }
package/src/errors.ts CHANGED
@@ -2,7 +2,14 @@
2
2
  // Stable code + cause + exact fix command, rendered identically in the terminal, the browser
3
3
  // overlay and `--json`. Never throw a bare Error anywhere in the framework.
4
4
 
5
- import { describeErrorCode } from './error-codes';
5
+ /**
6
+ * Structural brand. `instanceof` is unreliable across duplicated module instances, so the guard is
7
+ * duck-typed on a well-known symbol instead. It is `@ultimat3/schema`'s — declared once, at the
8
+ * tier both error classes can reach (`core -> schema` is the declared edge) — and imported from
9
+ * there by anything that needs the symbol itself: core does not publish it a second time.
10
+ */
11
+ import { ULTIMATE_ERROR_BRAND } from '@ultimat3/schema';
12
+ import { describeErrorCode, hasErrorCode } from './error-codes';
6
13
  import {
7
14
  isThrownError,
8
15
  renderCauseValue,
@@ -12,13 +19,6 @@ import {
12
19
  } from './error-render';
13
20
  import { DEFAULT_ERROR_RETRY, type ErrorRetry, isErrorRetry, retryFor } from './error-retry';
14
21
 
15
- /**
16
- * Structural brand. `instanceof` is unreliable across duplicated module instances and across
17
- * tier-0 packages that may not import each other (`@ultimat3/schema` cannot import
18
- * `@ultimat3/core`), so the guard is duck-typed on a well-known symbol instead.
19
- */
20
- export const ULTIMATE_ERROR_BRAND: unique symbol = Symbol.for('ultimate.error');
21
-
22
22
  export interface UltimateErrorInit {
23
23
  /** `SCREAMING_SNAKE`, prefixed `X_`. Must exist in the code registry to get a title. */
24
24
  readonly code: string;
@@ -48,6 +48,14 @@ export interface UltimateErrorInit {
48
48
  readonly retry?: ErrorRetry | undefined;
49
49
  /** The underlying thrown value, when this error wraps one. */
50
50
  readonly sourceError?: unknown;
51
+ /**
52
+ * A title off the WIRE — a problem document's `title` — used only when this realm registered
53
+ * none for `code`. A browser rebuilds a server's refusal by code and may never have loaded the
54
+ * package that titles it; the registry still wins wherever it has an answer, so a server can
55
+ * never retitle a code this realm owns. Untrusted display text: the decoder caps it, and the
56
+ * constructor makes it one line like every other field.
57
+ */
58
+ readonly remoteTitle?: string | undefined;
51
59
  }
52
60
 
53
61
  export interface UltimateErrorJSON {
@@ -111,7 +119,8 @@ export class UltimateError extends Error {
111
119
  // Escaping at construction is the one place that covers all of them, and it is what #97 called
112
120
  // the real answer. `singleLine` is idempotent, so a call site that already escaped is unharmed.
113
121
  const code = singleLine(init.code);
114
- const title = singleLine(described.title);
122
+ const remote = init.remoteTitle !== undefined && !hasErrorCode(init.code);
123
+ const title = singleLine(remote ? (init.remoteTitle ?? described.title) : described.title);
115
124
  const cause = singleLine(init.cause);
116
125
  // `message` carries the cause because it is the ONLY field a runtime prints when an
117
126
  // error escapes uncaught — a worker log, a CI transcript, a stack trace. A message of
@@ -63,7 +63,6 @@ export {
63
63
  NotImplementedError,
64
64
  notImplemented,
65
65
  toUltimateError,
66
- ULTIMATE_ERROR_BRAND,
67
66
  UltimateError,
68
67
  } from '../errors';
69
68
  export { SCHEMA_ERROR_CODE_TITLES } from '../schema-error-codes';
@@ -35,7 +35,6 @@ export {
35
35
  } from '../error-reporter-sentry';
36
36
  export type { LogFields, Logger, LoggerOptions, LogLevel } from '../logger';
37
37
  export {
38
- createLogger,
39
38
  isRedactedKey,
40
39
  LOG_LEVELS,
41
40
  logger,
@@ -43,6 +42,7 @@ export {
43
42
  redactKeys,
44
43
  setLoggerContextFields,
45
44
  setLogStream,
45
+ structuredLogger,
46
46
  } from '../logger';
47
47
  export type {
48
48
  Counter,
@@ -47,6 +47,7 @@ export {
47
47
  SecretsPlaintextInvalidError,
48
48
  SecretsTamperedError,
49
49
  } from '../secrets-errors';
50
+ export { SecretsKeyAclError } from '../secrets-key-file';
50
51
  export type {
51
52
  MasterKeyRef,
52
53
  MasterKeySource,
@@ -58,6 +59,7 @@ export {
58
59
  installSecrets,
59
60
  masterKeyIdOf,
60
61
  masterKeyPath,
62
+ promoteStagedMasterKey,
61
63
  readSecretsFile,
62
64
  requireMasterKey,
63
65
  SECRETS_FILE,
@@ -67,6 +69,7 @@ export {
67
69
  secretsFileExists,
68
70
  secretsPath,
69
71
  stagedMasterKeyPath,
72
+ stageMasterKeyFile,
70
73
  writeMasterKeyFile,
71
74
  writeSecretsFile,
72
75
  } from '../secrets-store';
@@ -48,7 +48,7 @@ export function finiteCount(
48
48
  assert(
49
49
  Number.isSafeInteger(value) && value >= min,
50
50
  `${subject} ${option} is ${String(value)}, and it counts things: it must be a whole number of ${min === 1 ? 'at least 1' : '0 or more'}, or the bound it sets is not one`,
51
- `pass a whole ${option} to ${subject} — and parse an environment value before you pass it, because Number(process.env.…) is NaN when the variable is unset and Math.floor does not repair that`,
51
+ `${option}: Number.parseInt(raw, 10) # a whole number ${min === 1 ? '≥ 1' : '≥ 0'} for ${subject}; parse an env value and check it, since Number(process.env.…) is NaN when unset and Math.floor does not repair that`,
52
52
  );
53
53
  return value;
54
54
  }
@@ -35,7 +35,12 @@ export interface FlightGateOptions {
35
35
  }
36
36
 
37
37
  export interface FlightGate {
38
- run<T>(work: () => Promise<T>): Promise<T>;
38
+ /**
39
+ * `signal` makes a QUEUED wait cancellable: on abort the waiter leaves the queue and the call
40
+ * rejects with the abort's reason. Once a slot is handed over the work runs; the signal is then
41
+ * the work's own business.
42
+ */
43
+ run<T>(work: () => Promise<T>, signal?: AbortSignal): Promise<T>;
39
44
  /** Running right now. */
40
45
  readonly active: number;
41
46
  /** Waiting for a slot right now. A count that does not fall back to 0 is a leak. */
@@ -46,23 +51,20 @@ export interface FlightGate {
46
51
  * A slot is HANDED OVER on release rather than released and re-acquired: decrementing first would
47
52
  * let a caller arriving in the same tick past the ceiling while a waiter's continuation is still a
48
53
  * queued microtask, which is how a "bounded" pool goes over its bound under exactly the load it
49
- * exists for. `@ultimat3/auth`'s `createKdfGate` states the same rule; this is that function with
54
+ * exists for. `@ultimat3/auth`'s `boundedKdfGate` states the same rule; this is that function with
50
55
  * the refusal made injectable.
51
56
  */
52
- export function createFlightGate(
53
- limits: FlightGateLimits,
54
- options?: FlightGateOptions,
55
- ): FlightGate {
57
+ export function flightGate(limits: FlightGateLimits, options?: FlightGateOptions): FlightGate {
56
58
  const subject = options?.subject ?? 'in-flight work';
57
59
  // Refused at CONSTRUCTION, because this pair wedges rather than fails: `active < NaN` and
58
60
  // `waiters.length >= NaN` are both false, so every caller parks in a queue with no bound. Zero
59
61
  // is a real value at both — "never wait" and, at the width, "refuse everything".
60
62
  const maxConcurrent = finiteCount(
61
- `createFlightGate (${subject})`,
63
+ `flightGate (${subject})`,
62
64
  'maxConcurrent',
63
65
  limits.maxConcurrent,
64
66
  );
65
- const maxQueued = finiteCount(`createFlightGate (${subject})`, 'maxQueued', limits.maxQueued);
67
+ const maxQueued = finiteCount(`flightGate (${subject})`, 'maxQueued', limits.maxQueued);
66
68
  const waiters: Array<() => void> = [];
67
69
  let active = 0;
68
70
 
@@ -74,7 +76,8 @@ export function createFlightGate(
74
76
  subject,
75
77
  });
76
78
 
77
- const acquire = async (): Promise<void> => {
79
+ const acquire = async (signal: AbortSignal | undefined): Promise<void> => {
80
+ if (signal?.aborted === true) throw signal.reason;
78
81
  if (active < maxConcurrent) {
79
82
  active += 1;
80
83
  return;
@@ -85,8 +88,20 @@ export function createFlightGate(
85
88
  const current = state();
86
89
  throw options?.overflow?.(current) ?? gateOverloaded(current);
87
90
  }
88
- await new Promise<void>((resume) => {
89
- waiters.push(resume);
91
+ // A waiter that only stored its resolver could not be taken back: a superseded or abandoned
92
+ // call stayed pending and kept its queue place until a slot reached it. The abort removes it.
93
+ await new Promise<void>((resume, refuse) => {
94
+ const onAbort = (): void => {
95
+ const at = waiters.indexOf(waiter);
96
+ if (at !== -1) waiters.splice(at, 1);
97
+ refuse(signal?.reason);
98
+ };
99
+ const waiter = (): void => {
100
+ signal?.removeEventListener('abort', onAbort);
101
+ resume();
102
+ };
103
+ waiters.push(waiter);
104
+ signal?.addEventListener('abort', onAbort, { once: true });
90
105
  });
91
106
  };
92
107
 
@@ -103,8 +118,8 @@ export function createFlightGate(
103
118
  get queued(): number {
104
119
  return waiters.length;
105
120
  },
106
- async run<T>(work: () => Promise<T>): Promise<T> {
107
- await acquire();
121
+ async run<T>(work: () => Promise<T>, signal?: AbortSignal): Promise<T> {
122
+ await acquire(signal);
108
123
  try {
109
124
  return await work();
110
125
  } finally {
@@ -124,7 +139,7 @@ export function gateOverloaded(state: FlightGateState): UltimateError {
124
139
  return new UltimateError({
125
140
  code: 'X_FLIGHT_GATE_OVERLOADED',
126
141
  cause: `${state.active} of ${state.subject} are running at the ceiling of ${state.maxConcurrent} and ${state.queued} more are queued at the limit of ${state.maxQueued}`,
127
- fix: 'retry after the Retry-After header, or widen the ceiling at the createFlightGate({ maxConcurrent, maxQueued }) call site — only if the box has the capacity the extra slots buy',
142
+ fix: 'retry after the Retry-After header, or widen the ceiling at the flightGate({ maxConcurrent, maxQueued }) call site — only if the box has the capacity the extra slots buy',
128
143
  meta: {
129
144
  active: state.active,
130
145
  queued: state.queued,
@@ -18,7 +18,7 @@ export interface GenerationFence {
18
18
  * `subject` names what the generation counts, and it reaches the `cause:` — "the live window was
19
19
  * superseded" is actionable where "generation 3 != 4" is a puzzle.
20
20
  */
21
- export function createFence(subject: string): GenerationFence {
21
+ export function generationFence(subject: string): GenerationFence {
22
22
  let current = 0;
23
23
  return {
24
24
  generation: (): number => current,
package/src/ids.ts CHANGED
@@ -33,7 +33,7 @@ function randomBytes(length: number): Uint8Array {
33
33
  /**
34
34
  * A full 10 bits, from both bytes. Reading `bytes[0]` alone masked an 8-bit value with a 10-bit
35
35
  * mask, so the seed only ever reached 255 while `COUNTER_SEED_MASK` declared 1023 — the constant
36
- * and the code disagreed, and the second byte was allocated on every `uuid()` for nothing.
36
+ * and the code disagreed, and the second byte was allocated on every `uuidV7()` for nothing.
37
37
  */
38
38
  function seedCounter(): number {
39
39
  const bytes = randomBytes(2);
@@ -43,7 +43,7 @@ function seedCounter(): number {
43
43
  /**
44
44
  * `new Uint8Array(NaN)` is a zero-length array, not a throw, so an unscreened length made this
45
45
  * answer `''` — an id that is no id, minted silently. `min: 1`: zero bytes of randomness is the
46
- * same empty string, and every caller here (`uuid`, `traceId`, `spanId`) wants a width.
46
+ * same empty string, and every caller here (`uuidV7`, `traceId`, `spanId`) wants a width.
47
47
  */
48
48
  export function randomHex(byteLength: number): string {
49
49
  const bytes = randomBytes(finiteCount('randomHex', 'byteLength', byteLength, 1));
@@ -60,7 +60,7 @@ export function randomHex(byteLength: number): string {
60
60
  * Strictly increasing lexicographically even within the same millisecond, and never goes
61
61
  * backwards when the wall clock does.
62
62
  */
63
- export function uuid(clock: Clock = systemClock): string {
63
+ export function uuidV7(clock: Clock = systemClock): string {
64
64
  let epochMs = clock.now().getTime();
65
65
  if (epochMs < lastEpochMs) epochMs = lastEpochMs;
66
66
 
@@ -108,7 +108,7 @@ export function uuidTimestamp(id: string): Date {
108
108
  // problem document, and the strings that arrive here wrong are session tokens and API keys
109
109
  // as often as they are typos. The expected shape is the half that helps the reader.
110
110
  cause: `expected a UUIDv7 (${UUID_SHAPE}), received ${describeValue(id)}`,
111
- fix: 'generate ids with uuid() from @ultimat3/core',
111
+ fix: 'generate ids with uuidV7() from @ultimat3/core',
112
112
  meta: { received: describeValue(id) },
113
113
  });
114
114
  }
@@ -131,7 +131,7 @@ export function nanoid(length = 21): string {
131
131
 
132
132
  /** `typedId<'post'>()` — a UUIDv7 branded so it cannot be passed where a user id is wanted. */
133
133
  export function typedId<K extends string>(clock: Clock = systemClock): Id<K> {
134
- return uuid(clock) as Id<K>;
134
+ return uuidV7(clock) as Id<K>;
135
135
  }
136
136
 
137
137
  /** Validate an untrusted string into a branded id. Throws `X_ID_INVALID`. */
@@ -166,9 +166,9 @@ const ALL_ZERO = /^0+$/;
166
166
  * The ONE definition of "is this a W3C trace id" — `traceparent` parsing, and any layer that
167
167
  * accepts an id from outside, ask here rather than carrying a second regex.
168
168
  *
169
- * A dashed UUID is the failure this predicate exists to name: `uuid()` produces 36 characters with
169
+ * A dashed UUID is the failure this predicate exists to name: `uuidV7()` produces 36 characters with
170
170
  * hyphens, every OTLP collector rejects it, and nothing downstream said so — the trace simply
171
- * never appeared. Mint trace ids with `traceId()`, never `uuid()`. All-zero is invalid per the
171
+ * never appeared. Mint trace ids with `traceId()`, never `uuidV7()`. All-zero is invalid per the
172
172
  * spec: it is the wire's spelling of "no trace", not a trace whose id happens to be zero.
173
173
  */
174
174
  export function isTraceId(value: unknown): boolean {
@@ -5,7 +5,13 @@
5
5
 
6
6
  import { parseColor } from './color';
7
7
  import { imageUnsupported } from './errors';
8
- import { assertPixelBudget, createRaster, type ImageSize, type Raster } from './raster';
8
+ import {
9
+ assertPixelBudget,
10
+ blankRaster,
11
+ type ImageRegion,
12
+ type ImageSize,
13
+ type Raster,
14
+ } from './raster';
9
15
 
10
16
  export type ImageFit = 'cover' | 'contain';
11
17
 
@@ -69,7 +75,15 @@ export interface Layout {
69
75
  readonly pad: number;
70
76
  /** The area inside the padding the artwork may occupy. */
71
77
  readonly inner: ImageSize;
72
- /** What the source is resampled to before it is placed. */
78
+ /**
79
+ * The part of the source that is resampled, when it is not all of it: an UPSCALING `cover`
80
+ * whose aspect disagrees with the box. Resampling first would draw the whole source past the
81
+ * box only to clip it away — a 1x200 strip at 1000x200000 for a 1000x1000 box — so the visible
82
+ * region is cut first and `drawn` is the inner area. A downscaling `cover` resamples first: its
83
+ * `drawn` is never larger than the source, and no crop then means no full-size decode.
84
+ */
85
+ readonly crop: ImageRegion | undefined;
86
+ /** What the source (or `crop`) is resampled to before it is placed: within `max(source, box)`. */
73
87
  readonly drawn: ImageSize;
74
88
  /** Parsed once, here, so an unspellable colour is refused before any pixel is produced. */
75
89
  readonly background: readonly [number, number, number, number];
@@ -97,7 +111,13 @@ export function layOut(source: ImageSize, spec: ResizeSpec): Layout {
97
111
  { padding, pad, width: box.width, height: box.height },
98
112
  );
99
113
  }
100
- const drawn = scaledToFit(source, inner, spec.fit ?? 'contain');
114
+ const fit = spec.fit ?? 'contain';
115
+ const scaled = scaledToFit(source, inner, fit);
116
+ const crop = fit === 'cover' ? visibleRegion(source, inner, scaled) : undefined;
117
+ // The crop is whole source pixels covering the exact window, so its resample can overflow the
118
+ // inner area by under one scaled pixel a side; `composeOnto` centre-clips that, as it clips an
119
+ // uncropped cover, so both orders centre the same fractional window.
120
+ const drawn = crop === undefined ? scaled : drawnOf(crop, source, inner);
101
121
  // Parsed even when the fast path will not use it: 'chartreuse' must be refused whether or not
102
122
  // the geometry happens to hide the colour, or the rejection depends on the source's dimensions.
103
123
  const background = parseColor(spec.background ?? 'transparent');
@@ -106,7 +126,58 @@ export function layOut(source: ImageSize, spec: ResizeSpec): Layout {
106
126
  // the RGBA round trip entirely.
107
127
  const needsCanvas =
108
128
  drawn.width !== box.width || drawn.height !== box.height || background[3] !== 0;
109
- return { box, pad, inner, drawn, background, needsCanvas };
129
+ return { box, pad, inner, crop, drawn, background, needsCanvas };
130
+ }
131
+
132
+ /**
133
+ * The source region a `cover` shows, or `undefined` when resampling the whole source first costs
134
+ * no more than the source itself (`scaled` within it) or nothing overflows the inner area.
135
+ * Centred as `composeOnto` centres an overflowing draw, so both orders crop the same window.
136
+ */
137
+ function visibleRegion(
138
+ source: ImageSize,
139
+ inner: ImageSize,
140
+ scaled: ImageSize,
141
+ ): ImageRegion | undefined {
142
+ const overflows = scaled.width > inner.width || scaled.height > inner.height;
143
+ if (!overflows || scaled.width * scaled.height <= source.width * source.height) return undefined;
144
+ const scale = coverScale(source, inner);
145
+ // The exact window is fractional. The whole pixels that COVER it (floor to ceil) centre exactly
146
+ // once `composeOnto` clips the overflow; a ROUNDED window shifts a .5 offset half a source pixel
147
+ // off centre, which shows on a small source upscaled a lot. Covering costs up to one scaled pixel
148
+ // a side, so it is taken only while that stays under twice the inner area — a degenerate strip
149
+ // (1x200 into a square), where half a source pixel is invisible, keeps the rounded window.
150
+ const span = (total: number, wanted: number, cover: boolean): { start: number; size: number } => {
151
+ const exact = Math.min(total, wanted / scale);
152
+ const from = (total - exact) / 2;
153
+ if (!cover) {
154
+ const size = Math.min(total, Math.max(1, Math.round(exact)));
155
+ return { start: Math.round((total - size) / 2), size };
156
+ }
157
+ const start = Math.max(0, Math.floor(from));
158
+ return { start, size: Math.min(total, Math.ceil(from + exact)) - start };
159
+ };
160
+ const across = span(source.width, inner.width, true);
161
+ const down = span(source.height, inner.height, true);
162
+ const covered = { x: across.start, y: down.start, width: across.size, height: down.size };
163
+ const cost = drawnOf(covered, source, inner);
164
+ if (cost.width * cost.height < 2 * inner.width * inner.height) return covered;
165
+ const a = span(source.width, inner.width, false);
166
+ const d = span(source.height, inner.height, false);
167
+ return { x: a.start, y: d.start, width: a.size, height: d.size };
168
+ }
169
+
170
+ function coverScale(source: ImageSize, inner: ImageSize): number {
171
+ return Math.max(inner.width / source.width, inner.height / source.height);
172
+ }
173
+
174
+ /** The cropped region at the cover scale: the inner area plus under one scaled pixel a side. */
175
+ function drawnOf(crop: ImageRegion, source: ImageSize, inner: ImageSize): ImageSize {
176
+ const scale = coverScale(source, inner);
177
+ return {
178
+ width: Math.max(inner.width, Math.round(crop.width * scale)),
179
+ height: Math.max(inner.height, Math.round(crop.height * scale)),
180
+ };
110
181
  }
111
182
 
112
183
  function fill(canvas: Raster, color: readonly [number, number, number, number]): void {
@@ -150,7 +221,7 @@ function blend(dst: Uint8ClampedArray, d: number, s: Uint8ClampedArray, p: numbe
150
221
  */
151
222
  export function composeOnto(art: Raster, layout: Layout): Raster {
152
223
  const { box, pad, inner } = layout;
153
- const canvas = createRaster(box.width, box.height, 'resize');
224
+ const canvas = blankRaster(box.width, box.height, 'resize');
154
225
  fill(canvas, layout.background);
155
226
  const ox = pad + Math.round((inner.width - art.width) / 2);
156
227
  const oy = pad + Math.round((inner.height - art.height) / 2);
@@ -9,7 +9,7 @@ import { imageFromBunError, imageUnsupported } from './errors';
9
9
  import { unshared } from './png-bytes';
10
10
  import { decodeImage, encodeImage } from './png-pixels';
11
11
  import { IMAGE_MIME_TYPES, type ImageFormat } from './probe';
12
- import { MAX_IMAGE_PIXELS } from './raster';
12
+ import { cropRaster, type ImageRegion, MAX_IMAGE_PIXELS } from './raster';
13
13
 
14
14
  /**
15
15
  * What the pipeline can produce, on every platform, byte for byte. `Bun.Image` also reaches
@@ -97,6 +97,15 @@ async function run<T>(doing: string, work: () => Promise<T>): Promise<T> {
97
97
  }
98
98
  }
99
99
 
100
+ /**
101
+ * `region` of the source as PNG. `Bun.Image` has no crop, so the source is decoded to a raster
102
+ * (within the same `MAX_IMAGE_PIXELS` its header was held to) and the region copied out of it.
103
+ */
104
+ async function croppedPng(bytes: Uint8Array, region: ImageRegion): Promise<Uint8Array> {
105
+ const whole = await run('decoding the image', () => bunImage(bytes).png().bytes());
106
+ return encodeImage(cropRaster(decodeImage(whole), region));
107
+ }
108
+
100
109
  /** The whole pipeline in one call: decode, resize, compose, encode. */
101
110
  export async function transformImageBytes(
102
111
  bytes: Uint8Array,
@@ -112,12 +121,15 @@ export async function transformImageBytes(
112
121
  const source = await run('reading the image header', () => bunImage(bytes).metadata());
113
122
  const output: EncodableFormat = format ?? (canEncode(source.format) ? source.format : 'png');
114
123
  const layout = layOut(source, spec);
115
- const { box, drawn } = layout;
124
+ const { box, drawn, crop } = layout;
125
+ // The cropped region stands in for the source from here on: what is resampled is what shows.
126
+ const input = crop === undefined ? bytes : await croppedPng(bytes, crop);
127
+ const inputSize = crop ?? source;
116
128
 
117
129
  if (!layout.needsCanvas) {
118
130
  return run('transforming the image', () => {
119
- const image = bunImage(bytes);
120
- if (box.width !== source.width || box.height !== source.height) {
131
+ const image = bunImage(input);
132
+ if (box.width !== inputSize.width || box.height !== inputSize.height) {
121
133
  image.resize(box.width, box.height, { fit: 'fill' });
122
134
  }
123
135
  return withFormat(image, output, quality).bytes();
@@ -130,7 +142,7 @@ export async function transformImageBytes(
130
142
  // is 1.2-1.8x libspng's bytes on a real icon (measured), and one writer for everything this
131
143
  // function returns is also what makes "same input, same bytes" rest on the static codecs alone.
132
144
  const art = await run('resampling the image', () =>
133
- bunImage(bytes).resize(drawn.width, drawn.height, { fit: 'fill' }).png().bytes(),
145
+ bunImage(input).resize(drawn.width, drawn.height, { fit: 'fill' }).png().bytes(),
134
146
  );
135
147
  const composed = encodeImage(composeOnto(decodeImage(art), layout));
136
148
  return run('encoding the image', () => withFormat(bunImage(composed), output, quality).bytes());
@@ -43,7 +43,7 @@ export function assertPixelBudget(width: number, height: number, source: string)
43
43
  }
44
44
 
45
45
  /** A transparent canvas of the given size, budget already checked. */
46
- export function createRaster(width: number, height: number, source = 'raster'): Raster {
46
+ export function blankRaster(width: number, height: number, source = 'raster'): Raster {
47
47
  assertPixelBudget(width, height, source);
48
48
  return { width, height, pixels: new Uint8ClampedArray(width * height * 4) };
49
49
  }
@@ -63,6 +63,29 @@ export function rasterFrom(width: number, height: number, pixels: Uint8ClampedAr
63
63
  return { width, height, pixels };
64
64
  }
65
65
 
66
+ /** A rectangle of a raster, in its pixels. */
67
+ export interface ImageRegion extends ImageSize {
68
+ readonly x: number;
69
+ readonly y: number;
70
+ }
71
+
72
+ /** The pixels of `region`, copied row by row. The region must lie inside the raster. */
73
+ export function cropRaster(raster: Raster, region: ImageRegion): Raster {
74
+ const { x, y, width, height } = region;
75
+ if (x < 0 || y < 0 || x + width > raster.width || y + height > raster.height) {
76
+ throw imageDecodeFailed(
77
+ `crop ${width}x${height}+${x}+${y} lies outside the ${raster.width}x${raster.height} raster`,
78
+ { x, y, width, height, rasterWidth: raster.width, rasterHeight: raster.height },
79
+ );
80
+ }
81
+ const out = blankRaster(width, height, 'crop');
82
+ for (let row = 0; row < height; row += 1) {
83
+ const from = ((y + row) * raster.width + x) * 4;
84
+ out.pixels.set(raster.pixels.subarray(from, from + width * 4), row * width * 4);
85
+ }
86
+ return out;
87
+ }
88
+
66
89
  /** Whether any pixel is not fully opaque — decides PNG vs JPEG when nobody asked. */
67
90
  export function hasAlpha(raster: Raster): boolean {
68
91
  const { pixels } = raster;