react-x11 2.53.0 → 2.54.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/docs/elements.md CHANGED
@@ -1465,7 +1465,12 @@ const logo = <image src={decodeImage(bytes)} />;
1465
1465
  `decodeImageBytes` from `react-x11/ntk` is the same ladder for an element
1466
1466
  that draws its own images (a document's `<img>`, a CSS background): an
1467
1467
  `Image`, or a promise of one where decoding is async; it throws or rejects,
1468
- naming what to convert to, where `<image>` would show nothing.
1468
+ naming what to convert to, where `<image>` would show nothing. One that
1469
+ waits for a promise anyway passes `{ offThread: true }`, and under Node the
1470
+ same JavaScript decoders run on a worker thread for bytes of 8 KB or more —
1471
+ a PNG and a JPEG then land a moment later too, as under Bun, and the
1472
+ JavaScript thread decodes none of a page's photographs. `<image>` does not
1473
+ ask, so the bytes it is handed keep their first frame.
1469
1474
 
1470
1475
  Between the runtimes:
1471
1476
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.53.0",
3
+ "version": "2.54.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "bin": {
@@ -0,0 +1,32 @@
1
+ // The other end of `decodeImageBytes`'s `offThread` (imagedecode.js): the
2
+ // JavaScript rung, run where it holds no frame. A decode's pixels go back
3
+ // handed over, never copied, and its error as the message the caller's
4
+ // rejection carries.
5
+ import { parentPort } from 'node:worker_threads';
6
+
7
+ import { decodeHere } from './imagedecode.js';
8
+
9
+ parentPort.on('message', async ({ id, bytes }) => {
10
+ try {
11
+ const image = await decodeHere(bytes);
12
+ let data = image.data;
13
+ // a view of a larger buffer — a pooled Buffer — is copied out first,
14
+ // so what is handed over is the pixels and nothing beside them
15
+ if (
16
+ !(data.buffer instanceof ArrayBuffer) ||
17
+ data.byteOffset !== 0 ||
18
+ data.byteLength !== data.buffer.byteLength
19
+ ) {
20
+ data = new Uint8Array(data);
21
+ }
22
+ parentPort.postMessage(
23
+ { id, width: image.width, height: image.height, data: data.buffer },
24
+ [data.buffer],
25
+ );
26
+ } catch (err) {
27
+ parentPort.postMessage({
28
+ id,
29
+ error: err instanceof Error ? err.message : String(err),
30
+ });
31
+ }
32
+ });
@@ -13,7 +13,11 @@
13
13
  // 2. JavaScript. ntk's PNG and JPEG decoders (pngjs, jpeg-js), which are
14
14
  // synchronous, and WebP through image-in-browser's decoder — imported
15
15
  // the first time a WebP arrives, because evaluating it takes ~140ms
16
- // that an app showing no WebP should never pay at startup.
16
+ // that an app showing no WebP should never pay at startup. A caller
17
+ // that takes a promise anyway asks for `offThread`, and the same
18
+ // decoders run on a worker thread (`imagedecode-worker.js`) for bytes
19
+ // large enough to pay for the trip: a document's images, which held
20
+ // the JavaScript thread for half of the Guardian's load.
17
21
  //
18
22
  // Two things here are easy to undo:
19
23
  //
@@ -115,17 +119,41 @@ const hex = (bytes) =>
115
119
  * elsewhere). Throws — or rejects — on bytes that are not an image this
116
120
  * runtime can decode, with what to do about it.
117
121
  *
122
+ * `options.offThread` asks for the decode to run on a worker thread where
123
+ * the runtime decodes in JavaScript, which it does for bytes long enough
124
+ * (`OFF_THREAD_BYTES`): a PNG or a JPEG then lands as a promise too. A
125
+ * caller that waits for a promise anyway — a document's images — asks for
126
+ * it; `<image>` does not, so a PNG it is handed still shows in the first
127
+ * frame.
128
+ *
118
129
  * The bytes are read, never written, and under Bun they are read on another
119
130
  * thread after this returns: the caller must not change them meanwhile,
120
131
  * which `<image>`'s contract (a source is immutable content) already says.
121
132
  */
122
- export function decodeImageBytes(bytes) {
133
+ export function decodeImageBytes(bytes, options) {
123
134
  if (bytes.length === 0) {
124
135
  throw new Error('react-x11: an empty buffer is not an image.');
125
136
  }
126
137
  const decoder = runtimeDecoder();
127
138
  if (decoder) return decodeWithRuntime(decoder, bytes);
128
139
  const format = imageFormat(bytes);
140
+ if (
141
+ options?.offThread &&
142
+ bytes.length >= OFF_THREAD_BYTES &&
143
+ (format === 'png' || format === 'jpeg' || format === 'webp')
144
+ ) {
145
+ const pool = decodePool();
146
+ if (pool) return pool.decode(bytes, format);
147
+ }
148
+ return decodeHere(bytes, format);
149
+ }
150
+
151
+ /**
152
+ * The JavaScript rung, on whichever thread runs it: what `decodeImageBytes`
153
+ * does where the runtime has no decoder of its own, and what its worker
154
+ * does with the bytes it is handed.
155
+ */
156
+ export function decodeHere(bytes, format = imageFormat(bytes)) {
129
157
  if (format === 'png' || format === 'jpeg') return decodeImage(bytes);
130
158
  if (format === 'webp') return decodeWebP(bytes);
131
159
  if (format) {
@@ -142,6 +170,116 @@ export function decodeImageBytes(bytes) {
142
170
  );
143
171
  }
144
172
 
173
+ /**
174
+ * How long encoded bytes are before a decode asked to run off the
175
+ * JavaScript thread is handed to a worker: under it, the message costs
176
+ * about what the decode does.
177
+ */
178
+ const OFF_THREAD_BYTES = 8 * 1024;
179
+
180
+ /** The most decode workers a process starts. */
181
+ const MAX_WORKERS = 2;
182
+
183
+ /** The pool `offThread` decodes on: undefined until asked, null where the
184
+ * runtime has no worker threads. */
185
+ let pool;
186
+
187
+ function decodePool() {
188
+ if (pool !== undefined) return pool;
189
+ const threads = globalThis.process?.getBuiltinModule?.('node:worker_threads');
190
+ pool = threads?.Worker ? new DecodePool(threads.Worker) : null;
191
+ return pool;
192
+ }
193
+
194
+ /**
195
+ * Workers that decode with the JavaScript rung (`decodeHere`), started as
196
+ * decodes arrive, up to `MAX_WORKERS`. A worker holds the process open only
197
+ * while it has a decode to answer. One that cannot start, or dies, has its
198
+ * decodes made here, and none starts after it: a bundle that left the
199
+ * worker's file behind decodes as it always did.
200
+ */
201
+ class DecodePool {
202
+ constructor(Worker) {
203
+ this._Worker = Worker;
204
+ this._workers = [];
205
+ this._jobs = new Map();
206
+ this._next = 1;
207
+ this._broken = false;
208
+ }
209
+
210
+ decode(bytes, format) {
211
+ const worker = this._worker();
212
+ if (!worker) return decodeHere(bytes, format);
213
+ const id = this._next++;
214
+ // a copy, handed over: the caller's bytes stay where they are, and a
215
+ // Buffer's `slice` is a view of them
216
+ const copy = new Uint8Array(bytes);
217
+ try {
218
+ worker.thread.postMessage({ id, bytes: copy }, [copy.buffer]);
219
+ } catch {
220
+ return decodeHere(bytes, format);
221
+ }
222
+ if (worker.pending++ === 0) worker.thread.ref();
223
+ return new Promise((resolve, reject) => {
224
+ this._jobs.set(id, { resolve, reject, worker, bytes, format });
225
+ });
226
+ }
227
+
228
+ /** The least busy worker, or a new one while every one is busy. */
229
+ _worker() {
230
+ if (this._broken) return null;
231
+ let best = null;
232
+ for (const w of this._workers) {
233
+ if (!best || w.pending < best.pending) best = w;
234
+ }
235
+ if (best && (best.pending === 0 || this._workers.length >= MAX_WORKERS)) {
236
+ return best;
237
+ }
238
+ let thread;
239
+ try {
240
+ thread = new this._Worker(
241
+ new URL('./imagedecode-worker.js', import.meta.url),
242
+ );
243
+ } catch {
244
+ this._broken = true;
245
+ return best;
246
+ }
247
+ const w = { thread, pending: 0 };
248
+ thread.unref();
249
+ thread.on('message', (message) => this._answer(w, message));
250
+ thread.on('error', () => this._lost(w));
251
+ thread.on('exit', () => this._lost(w));
252
+ this._workers.push(w);
253
+ return w;
254
+ }
255
+
256
+ _answer(w, { id, width, height, data, error }) {
257
+ const job = this._jobs.get(id);
258
+ if (!job) return;
259
+ this._jobs.delete(id);
260
+ if (--w.pending === 0) w.thread.unref();
261
+ if (error !== undefined) job.reject(new Error(error));
262
+ else job.resolve(new Image({ width, height, data: new Uint8Array(data) }));
263
+ }
264
+
265
+ _lost(w) {
266
+ this._broken = true;
267
+ this._workers = this._workers.filter((other) => other !== w);
268
+ for (const [id, job] of this._jobs) {
269
+ if (job.worker !== w) continue;
270
+ this._jobs.delete(id);
271
+ try {
272
+ Promise.resolve(decodeHere(job.bytes, job.format)).then(
273
+ job.resolve,
274
+ job.reject,
275
+ );
276
+ } catch (err) {
277
+ job.reject(err);
278
+ }
279
+ }
280
+ }
281
+ }
282
+
145
283
  /** Read a file path or file URL and decode it. */
146
284
  export async function loadImageFile(path) {
147
285
  const { readFile } = await import('node:fs/promises');
package/src/ntk.d.ts CHANGED
@@ -115,8 +115,20 @@ export interface DecodedImage {
115
115
  */
116
116
  export function decodeImageBytes(
117
117
  bytes: Uint8Array,
118
+ options?: DecodeImageOptions,
118
119
  ): DecodedImage | Promise<DecodedImage>;
119
120
 
121
+ /** How `decodeImageBytes` decodes. */
122
+ export interface DecodeImageOptions {
123
+ /**
124
+ * Decode on a worker thread where the runtime decodes in JavaScript (Node),
125
+ * for bytes long enough to pay for the trip: the answer is then a promise
126
+ * for a PNG and a JPEG too. Under Bun every decode is off the thread
127
+ * already.
128
+ */
129
+ offThread?: boolean;
130
+ }
131
+
120
132
  /** `code` values on a failed GL setup — see `<glarea onError>`. */
121
133
  export const GLXError: {
122
134
  NO_EXTENSION: 'GLX_NO_EXTENSION';