@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.
- package/CLAUDE.md +24 -27
- package/README.md +71 -34
- package/package.json +4 -7
- package/src/actor.ts +9 -0
- package/src/address-class.ts +40 -4
- package/src/assert.ts +9 -5
- package/src/audit.ts +144 -0
- package/src/aws-sigv4.ts +275 -0
- package/src/backoff.ts +16 -0
- package/src/bunfs.ts +17 -0
- package/src/client-dispatch.ts +24 -3
- package/src/client-flight.ts +68 -13
- package/src/client-problem.ts +62 -6
- package/src/client-retry-after.ts +47 -0
- package/src/client-transport.ts +3 -1
- package/src/client-wire.ts +27 -3
- package/src/config-ai.ts +32 -0
- package/src/config-defaults.ts +18 -11
- package/src/config-fixes.ts +0 -10
- package/src/config-health.ts +9 -2
- package/src/config-jobs.ts +51 -0
- package/src/config-keys.ts +170 -0
- package/src/config-mail.ts +73 -0
- package/src/config-merge.ts +1 -1
- package/src/config-navigation.ts +1 -25
- package/src/config-pwa.ts +42 -5
- package/src/config-removed.ts +131 -0
- package/src/config-shape.ts +0 -33
- package/src/config.ts +71 -94
- package/src/context.ts +16 -12
- package/src/cookie.ts +267 -3
- package/src/core-error-codes.ts +2 -0
- package/src/cursor-page.ts +41 -0
- package/src/cursor.ts +23 -5
- package/src/deprecation.ts +77 -0
- package/src/dev-secrets.ts +18 -7
- package/src/drain-deadline.ts +43 -0
- package/src/env-example.ts +9 -29
- package/src/errors.ts +18 -9
- package/src/exports/error-contract.ts +0 -1
- package/src/exports/observability.ts +1 -1
- package/src/exports/secrets.ts +3 -0
- package/src/finite-option.ts +1 -1
- package/src/flight-gate.ts +29 -14
- package/src/generation-fence.ts +1 -1
- package/src/ids.ts +7 -7
- package/src/image/canvas.ts +76 -5
- package/src/image/pipeline.ts +17 -5
- package/src/image/raster.ts +24 -1
- package/src/index.ts +89 -35
- package/src/iso-date.ts +1 -1
- package/src/lifecycle-errors.ts +1 -1
- package/src/lifecycle-readiness.ts +60 -2
- package/src/lifecycle-signals.ts +27 -2
- package/src/lifecycle-types.ts +96 -0
- package/src/lifecycle.ts +44 -133
- package/src/locale-direction.ts +1 -1
- package/src/logger.ts +16 -7
- package/src/mcp-exposure.ts +70 -8
- package/src/measurement-actor.ts +16 -1
- package/src/metric-errors.ts +32 -0
- package/src/metric-registry.ts +151 -0
- package/src/metric-series.ts +94 -0
- package/src/metrics.ts +9 -255
- package/src/page.ts +4 -2
- package/src/registrar.ts +1 -0
- package/src/retry.ts +25 -5
- package/src/secrets-key-file.ts +139 -0
- package/src/secrets-store.ts +32 -16
- package/src/service.ts +5 -5
- package/src/single-flight.ts +1 -1
- package/src/telemetry.ts +1 -1
- package/src/theme-storage.ts +12 -0
- package/src/type-pins.ts +51 -1
- package/src/image/fixtures.ts +0 -263
- package/src/time-zone-name.ts +0 -14
package/src/env-example.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -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,
|
package/src/exports/secrets.ts
CHANGED
|
@@ -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';
|
package/src/finite-option.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/flight-gate.ts
CHANGED
|
@@ -35,7 +35,12 @@ export interface FlightGateOptions {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export interface FlightGate {
|
|
38
|
-
|
|
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 `
|
|
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
|
|
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
|
-
`
|
|
63
|
+
`flightGate (${subject})`,
|
|
62
64
|
'maxConcurrent',
|
|
63
65
|
limits.maxConcurrent,
|
|
64
66
|
);
|
|
65
|
-
const maxQueued = finiteCount(`
|
|
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
|
-
|
|
89
|
-
|
|
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
|
|
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
|
|
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,
|
package/src/generation-fence.ts
CHANGED
|
@@ -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
|
|
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 `
|
|
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 (`
|
|
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
|
|
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
|
|
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
|
|
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: `
|
|
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 `
|
|
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 {
|
package/src/image/canvas.ts
CHANGED
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
|
|
6
6
|
import { parseColor } from './color';
|
|
7
7
|
import { imageUnsupported } from './errors';
|
|
8
|
-
import {
|
|
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
|
-
/**
|
|
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
|
|
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 =
|
|
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);
|
package/src/image/pipeline.ts
CHANGED
|
@@ -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(
|
|
120
|
-
if (box.width !==
|
|
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(
|
|
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());
|
package/src/image/raster.ts
CHANGED
|
@@ -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
|
|
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;
|