buninu-browser 0.2.1 → 0.2.2

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 (72) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +74 -9
  3. package/bunfig.toml +1 -0
  4. package/buninu-browser.js +4 -0
  5. package/index.js +5 -0
  6. package/lib/audio/piano.js +24 -1
  7. package/lib/audio/pulse-client.js +120 -12
  8. package/lib/cdp-server.js +16 -11
  9. package/lib/happy-dom/parser.js +24 -4
  10. package/lib/headless-shell.js +66 -19
  11. package/lib/input/hit-test.js +25 -7
  12. package/lib/layout/text-layout.js +196 -49
  13. package/lib/log.js +5 -2
  14. package/lib/media/byte-pipe.js +82 -0
  15. package/lib/media/position-audio.js +42 -0
  16. package/lib/network/network-service.js +161 -14
  17. package/lib/paint/display-list.js +300 -14
  18. package/lib/paint/gradient.js +11 -5
  19. package/lib/render-tree/index.js +17 -2
  20. package/lib/render-tree/svg.js +2 -1
  21. package/lib/renderer/bindings.js +985 -68
  22. package/lib/renderer/canvas-2d.js +92 -0
  23. package/lib/renderer/host.js +158 -8
  24. package/lib/renderer/interfaces.js +215 -0
  25. package/lib/renderer/main.js +160 -12
  26. package/lib/renderer/media-source.js +819 -0
  27. package/lib/renderer/messaging.js +411 -0
  28. package/lib/renderer/native-functions.js +113 -0
  29. package/lib/renderer/page-realm.js +1405 -198
  30. package/lib/renderer/page-renderer.js +1736 -193
  31. package/lib/renderer/rejections.js +31 -0
  32. package/lib/renderer/rewrite-worker.js +22 -0
  33. package/lib/renderer/script-rewrite.js +805 -57
  34. package/lib/renderer/trusted-types.js +129 -0
  35. package/lib/renderer/workers.js +327 -0
  36. package/lib/screenshot-cli.js +2 -0
  37. package/lib/style/computed-style.js +162 -22
  38. package/lib/user-agent.js +14 -2
  39. package/package.json +4 -1
  40. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  41. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  42. package/patches/happy-dom/README.md +26 -0
  43. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  44. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  45. package/patches/ses/README.md +4 -0
  46. package/test/canvas-2d.test.js +55 -0
  47. package/test/compositor.test.js +3 -1
  48. package/test/computed-style.test.js +38 -1
  49. package/test/display-list.test.js +74 -1
  50. package/test/download.test.js +35 -0
  51. package/test/fixtures/long-video.mp4 +0 -0
  52. package/test/fixtures/media-sync.js +57 -0
  53. package/test/happy-dom-platform-adapters.test.js +25 -0
  54. package/test/headless-shell.test.js +1 -1
  55. package/test/hit-test.test.js +25 -1
  56. package/test/media-source.test.js +156 -0
  57. package/test/media-sync.test.js +12 -0
  58. package/test/mobile.test.js +2 -2
  59. package/test/navigation.test.js +61 -0
  60. package/test/network-service.test.js +14 -0
  61. package/test/page-scripts.test.js +1045 -7
  62. package/test/position-audio.test.js +67 -0
  63. package/test/positioning.test.js +95 -1
  64. package/test/pulse-client.test.js +69 -0
  65. package/test/renderer-host.test.js +5 -2
  66. package/test/screenshot-cli.test.js +25 -1
  67. package/test/script-rewrite.test.js +111 -6
  68. package/test/setup.js +5 -0
  69. package/test/user-agent.test.js +60 -4
  70. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  71. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  72. package/vendor/ses/dist/ses.mjs +52 -6
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Page `<canvas>` 2d contexts, drawn by this process's CanvasKit.
3
+ *
4
+ * `loadGraphics` calls `setCanvasKit` with the instance the page is rasterized
5
+ * with. A second CanvasKit cannot share images with that one, so a page canvas
6
+ * is `CanvasKit.MakeCanvas`, not a separate software bitmap.
7
+ *
8
+ * MakeCanvas has no width or height. Setting either on the element disposes
9
+ * that surface and opens another (HTML resets the bitmap and the drawing
10
+ * state). The object the page holds stays the same and forwards to whichever
11
+ * context is current.
12
+ *
13
+ * `getImageData` builds its array on `readPixels().buffer`, which can be the
14
+ * whole WASM heap. The copy handed back is only the requested rectangle.
15
+ */
16
+
17
+ let CanvasKit = null;
18
+
19
+ /** The CanvasKit `loadGraphics` instantiated. Page canvases share it. */
20
+ export function setCanvasKit(kit) {
21
+ CanvasKit = kit;
22
+ }
23
+
24
+ const surfaces = new WeakMap();
25
+
26
+ /** The element's 2d context, or null for any other context id or before CanvasKit loads. */
27
+ export function canvasContext(node, type) {
28
+ if (type !== "2d" || !CanvasKit) return null;
29
+ let entry = surfaces.get(node);
30
+ if (!entry) {
31
+ entry = open(node);
32
+ if (!entry) return null;
33
+ surfaces.set(node, entry);
34
+ }
35
+ return entry.proxy;
36
+ }
37
+
38
+ /** Setting width or height clears the bitmap and the drawing state. */
39
+ export function resetCanvas(node) {
40
+ surfaces.get(node)?.reopen();
41
+ }
42
+
43
+ // A zero bitmap makes MakeSurface fail and getImageData reject the rectangle.
44
+ // The element can still report 0; drawing uses one pixel until it grows.
45
+ function bitmapSize(node) {
46
+ return [Math.max(1, node.width >>> 0), Math.max(1, node.height >>> 0)];
47
+ }
48
+
49
+ function open(node) {
50
+ let surface = makeSurface(node);
51
+ if (!surface) return null;
52
+ let context = surface.getContext("2d");
53
+ const proxy = new Proxy(Object.create(null), {
54
+ get(_target, property) {
55
+ if (property === "getImageData") return (sx, sy, sw, sh) => copyImageData(context.getImageData(sx, sy, sw, sh));
56
+ const value = context[property];
57
+ return typeof value === "function" ? value.bind(context) : value;
58
+ },
59
+ set(_target, property, value) {
60
+ context[property] = value;
61
+ return true;
62
+ },
63
+ has(_target, property) {
64
+ return property in context;
65
+ },
66
+ });
67
+ return {
68
+ proxy,
69
+ reopen() {
70
+ const next = makeSurface(node);
71
+ if (!next) return;
72
+ const previous = surface;
73
+ surface = next;
74
+ context = surface.getContext("2d");
75
+ previous.dispose?.();
76
+ },
77
+ };
78
+ }
79
+
80
+ function makeSurface(node) {
81
+ const [width, height] = bitmapSize(node);
82
+ return CanvasKit.MakeCanvas(width, height);
83
+ }
84
+
85
+ function copyImageData(image) {
86
+ if (!image?.data) throw new DOMException("The source rectangle is empty.", "IndexSizeError");
87
+ const count = image.width * image.height * 4;
88
+ const data = new Uint8ClampedArray(count);
89
+ const source = new Uint8ClampedArray(image.data.buffer, image.data.byteOffset, Math.min(count, image.data.length));
90
+ data.set(source);
91
+ return { width: image.width, height: image.height, data, colorSpace: "srgb" };
92
+ }
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { closeSync } from "node:fs";
16
16
  import { PulseClient } from "../audio/pulse-client.js";
17
- import { logEnabled, pipeToLog, RENDERER_LOG_FLAG } from "../log.js";
17
+ import { logEnabled, pipeToLog, RENDERER_LOG_FLAG, writeLog } from "../log.js";
18
18
  import { NetworkService } from "../network/network-service.js";
19
19
  import { COMPOSITOR_FLAG, FULL_STYLE_FLAG, VERIFY_STYLE_FLAG } from "./flags.js";
20
20
 
@@ -23,8 +23,10 @@ const FETCH_MODES = new Set(["cors", "no-cors", "same-origin"]);
23
23
  const CREDENTIALS_MODES = new Set(["omit", "same-origin", "include"]);
24
24
  const REDIRECT_MODES = new Set(["follow", "error", "manual"]);
25
25
  const MAX_REQUEST_HEADERS = 100;
26
- const RESOURCE_KINDS = new Set(["stylesheet", "image", "script", "metadata", "video", "audio"]);
26
+ const RESOURCE_KINDS = new Set(["stylesheet", "image", "script", "metadata", "video", "audio", "iframe"]);
27
27
  const NAVIGATION_SCHEMES = new Set(["http:", "https:", "about:", "data:"]);
28
+ // The largest form body a page may POST in a navigation.
29
+ const MAX_POST_BYTES = 16 * 1024 * 1024;
28
30
  const MAX_RESOURCE_BYTES = 32 * 1024 * 1024;
29
31
  // Audio output: one PulseAudio native-protocol connection (jspulse or a
30
32
  // PulseAudio daemon on 127.0.0.1:4713) shared by every renderer, opened on
@@ -69,6 +71,8 @@ export class RendererHost {
69
71
  #sockets = new Map();
70
72
  // Audio streams: id -> { child, stream } (PulseAudio playback streams)
71
73
  #audio = new Map();
74
+ // In-flight page fetches, so AbortController reaches the actual HTTP request.
75
+ #pageFetches = new Map();
72
76
  #nextId = 1;
73
77
  #queue = Promise.resolve();
74
78
  #restarting = false;
@@ -86,7 +90,8 @@ export class RendererHost {
86
90
  * args?: string[],
87
91
  * timeout?: number,
88
92
  * onRestart?: (call: (method: string, ...params: unknown[]) => Promise<unknown>) => Promise<void>,
89
- * onNavigate?: (url: string, downloadName: string | null, fragmentFrom: { x: number, y: number } | null) => void,
93
+ * onNavigate?: (url: string, downloadName: string | null, fragmentFrom: { x: number, y: number } | null,
94
+ * post: { method: "POST", body: Uint8Array, contentType: string, referrer: string } | null) => void,
90
95
  * onDownload?: (payload: { url: string, name: string, data: Uint8Array }) => void,
91
96
  * onFrame?: () => void,
92
97
  * onLifecycle?: (name: "DOMContentLoaded" | "load", generation: number) => void,
@@ -149,6 +154,7 @@ export class RendererHost {
149
154
  this.#closeChannel(channel);
150
155
  this.#closeSockets(child);
151
156
  this.#closeAudio(child);
157
+ this.#closePageFetches(child);
152
158
  if (this.#child !== child) return;
153
159
  console.error(`buninu-browser: renderer ${child.pid} exited unexpectedly (${signalCode ?? `code ${exitCode}`})`);
154
160
  this.#child = null;
@@ -301,6 +307,7 @@ export class RendererHost {
301
307
  const child = this.#child;
302
308
  this.#closeSockets(child);
303
309
  this.#closeAudio(child);
310
+ this.#closePageFetches(child);
304
311
  this.#child = null;
305
312
  this.#ready = null;
306
313
  child?.kill();
@@ -360,6 +367,9 @@ export class RendererHost {
360
367
  this.#fetchForRenderer(child, message);
361
368
  } else if (message.type === "page-fetch" && Number.isInteger(message.id)) {
362
369
  this.#pageFetch(child, message);
370
+ } else if (message.type === "page-fetch-abort" && Number.isInteger(message.id)) {
371
+ const entry = this.#pageFetches.get(message.id);
372
+ if (entry?.child === child) entry.controller.abort();
363
373
  } else if (message.type === "ws-open" && Number.isInteger(message.id)) {
364
374
  this.#openSocket(child, message);
365
375
  } else if (message.type === "ws-send" && Number.isInteger(message.id)) {
@@ -391,11 +401,22 @@ export class RendererHost {
391
401
  this.#audio.delete(message.id);
392
402
  send({ event: "ended" });
393
403
  }, () => this.#audio.delete(message.id));
404
+ } else if (message.type === "audio-resync" && Number.isInteger(message.id)) {
405
+ const entry = this.#audio.get(message.id);
406
+ if (entry?.child === child) (entry.opened ?? Promise.resolve()).then(() => entry.stream?.resync()).catch(() => {});
394
407
  } else if (message.type === "audio-close" && Number.isInteger(message.id)) {
395
408
  const entry = this.#audio.get(message.id);
396
409
  if (entry?.child !== child) return;
397
410
  this.#audio.delete(message.id);
398
- (entry.opened ?? Promise.resolve()).then(() => entry.stream?.close()).catch(() => {});
411
+ // Tell the renderer how much PCM reached the server: a server may
412
+ // still play what it holds, so the renderer resumes after it.
413
+ (entry.opened ?? Promise.resolve()).then(() => {
414
+ const sentBytes = entry.stream?.sentBytes ?? 0;
415
+ if (this.#child === child) child.send({ type: "audio-event", id: message.id, event: "closed", sentBytes });
416
+ return entry.stream?.close();
417
+ }).catch(() => {
418
+ if (this.#child === child) child.send({ type: "audio-event", id: message.id, event: "closed", sentBytes: 0 });
419
+ });
399
420
  } else if (message.type === "lifecycle" && LIFECYCLE_NAMES.has(message.name) && Number.isInteger(message.generation)) {
400
421
  this.#onLifecycle?.(message.name, message.generation);
401
422
  } else if (message.type === "cookie" && typeof message.value === "string" && message.value.length <= 4096) {
@@ -412,7 +433,16 @@ export class RendererHost {
412
433
  const download = typeof message.download === "string" ? message.download.slice(0, 200) : null;
413
434
  const from = message.fragmentFrom;
414
435
  const fragmentFrom = from && Number.isFinite(from.x) && Number.isFinite(from.y) ? { x: from.x, y: from.y } : null;
415
- this.#onNavigate?.(target.href, download, fragmentFrom);
436
+ // A form POST: its body and type from the page; Origin and Referer
437
+ // from the document the renderer shows, which the page cannot choose.
438
+ const post = message.post;
439
+ const request = post && post.method === "POST" && post.body instanceof Uint8Array
440
+ && post.body.byteLength <= MAX_POST_BYTES && typeof post.contentType === "string" && post.contentType.length <= 200
441
+ && !/[\r\n]/.test(post.contentType) && ["http:", "https:"].includes(target.protocol)
442
+ ? { method: "POST", body: post.body, contentType: post.contentType, referrer: this.#documentUrl }
443
+ : null;
444
+ if (post && !request) return;
445
+ this.#onNavigate?.(target.href, download, fragmentFrom, request);
416
446
  }
417
447
  } else if (message.type === "download-data" && typeof message.url === "string"
418
448
  && message.data instanceof Uint8Array && message.data.byteLength <= 64 * 1024 * 1024) {
@@ -473,11 +503,14 @@ export class RendererHost {
473
503
  rate, channels, name: String(name ?? "audio").slice(0, 200),
474
504
  leadSeconds: name === "piano" ? 0.12 : undefined,
475
505
  onRequest: (bytes) => send({ event: "request", bytes }),
506
+ onTiming: name === "piano" ? null : (timing) => send({ event: "timing", ...timing }),
507
+ onTimingError: (error) => send({ event: "timing-error", message: String(error?.message ?? error) }),
476
508
  }))
477
509
  .then((stream) => {
478
510
  if (this.#audio.get(id) !== entry) return stream.close();
479
511
  entry.stream = stream;
480
512
  for (const data of entry.early.splice(0)) stream.write(data);
513
+ send({ event: "ready" });
481
514
  }, (error) => {
482
515
  this.#audio.delete(id);
483
516
  send({ event: "error", message: String(error?.message ?? error) });
@@ -493,6 +526,14 @@ export class RendererHost {
493
526
  }
494
527
  }
495
528
 
529
+ #closePageFetches(child) {
530
+ for (const [id, entry] of this.#pageFetches) {
531
+ if (entry.child !== child) continue;
532
+ this.#pageFetches.delete(id);
533
+ entry.controller.abort();
534
+ }
535
+ }
536
+
496
537
  #closeSockets(child) {
497
538
  for (const [id, entry] of this.#sockets) {
498
539
  if (entry.child !== child) continue;
@@ -516,15 +557,59 @@ export class RendererHost {
516
557
  if (!ALLOWED_SCHEMES.has(target.protocol) || !RESOURCE_KINDS.has(kind)) {
517
558
  return reply({ error: `blocked ${kind} request for ${target.protocol}` });
518
559
  }
560
+ // A renderer only ever holds documents of its own origin (site isolation
561
+ // is by origin): an iframe's document is given to it only when it is
562
+ // same-origin with the page, before and after redirects, and allows
563
+ // being framed.
564
+ const frame = kind === "iframe";
565
+ if (frame && !sameOrigin(target.href, this.#documentUrl)) return reply({ error: "cross-origin frame" });
566
+ // Video and audio over http(s) stream. data: stays one response: it is
567
+ // already in hand, and the renderer wraps it in a finished pipe.
568
+ if ((kind === "video" || kind === "audio") && target.protocol !== "data:") {
569
+ const send = (payload) => {
570
+ if (this.#child === child) child.send(payload);
571
+ };
572
+ try {
573
+ await this.#network.streamSubresource(target.href, this.#documentUrl, kind, (event) => {
574
+ if (event.type === "head") {
575
+ send({
576
+ type: "fetch-result", id,
577
+ result: {
578
+ streaming: true,
579
+ ok: event.ok,
580
+ status: event.status,
581
+ statusText: event.statusText ?? "",
582
+ url: event.url || target.href,
583
+ contentType: event.contentType ?? "",
584
+ contentLength: event.contentLength ?? null,
585
+ documentCookie: this.#network.documentCookie(this.#documentUrl),
586
+ },
587
+ });
588
+ } else if (event.type === "chunk" && event.data instanceof Uint8Array) {
589
+ send({ type: "fetch-chunk", id, data: event.data });
590
+ } else if (event.type === "end") {
591
+ send({ type: "fetch-end", id });
592
+ } else if (event.type === "error") {
593
+ send({ type: "fetch-error", id, error: String(event.error ?? "media fetch failed") });
594
+ }
595
+ });
596
+ } catch (error) {
597
+ send({ type: "fetch-error", id, error: String(error?.message ?? error) });
598
+ }
599
+ return;
600
+ }
519
601
  try {
520
602
  const response = target.protocol === "data:"
521
603
  ? await dataResponse(target)
522
604
  : await this.#network.subresource(target.href, this.#documentUrl, kind);
523
605
  if (response.body.byteLength > MAX_RESOURCE_BYTES) return reply({ error: "resource too large" });
606
+ if (frame && !sameOrigin(response.url || target.href, this.#documentUrl)) return reply({ error: "cross-origin frame" });
607
+ if (frame && framingDenied(response.rawHeaders ?? [])) return reply({ error: "framing denied" });
524
608
  reply({
525
609
  result: {
526
610
  ok: response.ok,
527
611
  status: response.status,
612
+ statusText: response.statusText ?? "",
528
613
  url: response.url || target.href,
529
614
  contentType: response.contentType,
530
615
  body: response.body,
@@ -541,17 +626,54 @@ export class RendererHost {
541
626
  const reply = (payload) => {
542
627
  if (this.#child === child) child.send({ type: "fetch-result", id, ...payload });
543
628
  };
629
+ const started = performance.now();
630
+ const controller = new AbortController();
631
+ const entry = { child, controller };
632
+ this.#pageFetches.set(id, entry);
633
+ let streamHead = false;
634
+ let sanitized = null;
544
635
  try {
545
- const sanitized = sanitizePageRequest(request);
546
- const response = await this.#network.pageFetch(sanitized, this.#documentUrl);
636
+ sanitized = sanitizePageRequest(request);
637
+ const response = await this.#network.pageFetch(sanitized, this.#documentUrl, {
638
+ signal: controller.signal,
639
+ onChunk: (event) => {
640
+ if (this.#child !== child) return;
641
+ if (event.type === "head") {
642
+ streamHead = true;
643
+ child.send({ type: "page-fetch-head", id, result: {
644
+ ...event.result, documentCookie: this.#network.documentCookie(this.#documentUrl),
645
+ } });
646
+ }
647
+ else if (event.type === "chunk") child.send({ type: "page-fetch-chunk", id, data: event.data });
648
+ else if (event.type === "end") child.send({ type: "page-fetch-end", id });
649
+ },
650
+ });
651
+ if (response.streaming) {
652
+ logFetch(sanitized, `${response.status} streaming`, started);
653
+ return;
654
+ }
547
655
  const { rawHeaders, ...result } = response;
656
+ logFetch(sanitized, `${response.status} ${response.body?.byteLength ?? 0}B`, started);
548
657
  reply({ result: { ...result, documentCookie: this.#network.documentCookie(this.#documentUrl) } });
549
658
  } catch (error) {
550
- reply({ error: String(error?.message ?? error) });
659
+ logFetch(sanitized ?? request, `failed: ${error?.message ?? error}`, started);
660
+ if (streamHead && this.#child === child) {
661
+ child.send({ type: "page-fetch-error", id, error: String(error?.message ?? error) });
662
+ } else reply({ error: String(error?.message ?? error) });
663
+ } finally {
664
+ if (this.#pageFetches.get(id) === entry) this.#pageFetches.delete(id);
551
665
  }
552
666
  }
553
667
  }
554
668
 
669
+ /** With BUNINU_LOG, one line per page fetch()/XMLHttpRequest: method, URL (shortened), outcome, time. */
670
+ function logFetch(request, outcome, started) {
671
+ if (!logEnabled()) return;
672
+ const url = String(request?.url ?? "");
673
+ const shown = url.length > 160 ? `${url.slice(0, 160)}…` : url;
674
+ writeLog("fetch", `${request?.method ?? "GET"} ${shown} -> ${outcome} in ${Math.round(performance.now() - started)}ms`);
675
+ }
676
+
555
677
  /** Keeps only the expected shape of a renderer's (untrusted) sandbox report. */
556
678
  function sanitizeSandboxReport(report) {
557
679
  const layer = (value) => ({
@@ -602,3 +724,31 @@ function sanitizeCompositorResult(method, result) {
602
724
  const y = Number(result?.y);
603
725
  return { x: Number.isFinite(x) ? x : 0, y: Number.isFinite(y) ? y : 0 };
604
726
  }
727
+
728
+ function sameOrigin(left, right) {
729
+ try {
730
+ const a = new URL(left);
731
+ const b = new URL(right);
732
+ return ["http:", "https:"].includes(a.protocol) && a.origin === b.origin;
733
+ } catch {
734
+ return false;
735
+ }
736
+ }
737
+
738
+ /**
739
+ * Whether a response refuses to be framed (by its own origin, the only case
740
+ * here): X-Frame-Options: DENY, or Content-Security-Policy frame-ancestors 'none'.
741
+ */
742
+ function framingDenied(rawHeaders) {
743
+ for (const [name, value] of rawHeaders) {
744
+ const header = String(name).toLowerCase();
745
+ if (header === "x-frame-options" && String(value).trim().toLowerCase() === "deny") return true;
746
+ if (header === "content-security-policy") {
747
+ for (const directive of String(value).split(";")) {
748
+ const [directiveName, ...sources] = directive.trim().toLowerCase().split(/\s+/);
749
+ if (directiveName === "frame-ancestors" && sources.length === 1 && sources[0] === "'none'") return true;
750
+ }
751
+ }
752
+ }
753
+ return false;
754
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Web IDL interface objects for the engine's singleton platform objects
3
+ * (navigator, screen, performance...).
4
+ *
5
+ * In a browser these objects have no own properties: attributes are
6
+ * accessors and operations are methods on the interface prototype
7
+ * (Navigator.prototype.userAgent is a getter), each throwing
8
+ * "Illegal invocation" for a receiver that is not a platform object of that
9
+ * interface, and Object.prototype.toString reads the interface name from
10
+ * Symbol.toStringTag. The interface object itself throws when called or
11
+ * constructed. Pages and integrity checks inspect exactly this shape.
12
+ */
13
+
14
+ /**
15
+ * @param {string} name interface name
16
+ * @param {object} template members as an object literal: accessors stay
17
+ * accessors, functions become operations, other values read-only attributes
18
+ * @param {{ parent?: object | null, base?: (prototype: object) => object }} [options]
19
+ * `parent` is the prototype's prototype; `base` makes an instance with the
20
+ * given prototype (an event target's constructor, for one)
21
+ * Accessors and operations run with `this` set to the instance's slots
22
+ * object (the template itself unless `create` was given one), so a template
23
+ * may read its other members, or per-instance data, through `this`.
24
+ *
25
+ * @returns {{ Interface: Function, prototype: object, instance: object, create: (slots?: object) => object }}
26
+ */
27
+ export function platformObject(name, template, { parent = Object.prototype, base = (prototype) => Object.create(prototype) } = {}) {
28
+ const instances = new WeakMap();
29
+ const check = (self) => {
30
+ const slots = instances.get(self);
31
+ if (!slots) throw new TypeError("Illegal invocation");
32
+ return slots;
33
+ };
34
+ // An object literal method has no prototype and is not a constructor,
35
+ // as built-in operations and accessors are.
36
+ // Interface objects are constructors that refuse to construct.
37
+ const Interface = { [name]: function () { throw new TypeError("Illegal constructor"); } }[name];
38
+ const prototype = Object.create(parent);
39
+ for (const key of Reflect.ownKeys(template)) {
40
+ const descriptor = Object.getOwnPropertyDescriptor(template, key);
41
+ if (!descriptor) continue;
42
+ if (descriptor.get || descriptor.set) {
43
+ const { get, set } = descriptor;
44
+ const member = {};
45
+ Object.defineProperty(member, key, {
46
+ get: get ? accessor({ get [key]() { return get.call(check(this)); } }, key).get : undefined,
47
+ set: set ? accessor({ set [key](value) { set.call(check(this), value); } }, key).set : undefined,
48
+ });
49
+ Object.defineProperty(prototype, key, { ...Object.getOwnPropertyDescriptor(member, key), enumerable: true, configurable: true });
50
+ } else if (typeof descriptor.value === "function") {
51
+ const operation = descriptor.value;
52
+ const method = { [key](...args) { return Reflect.apply(operation, check(this), args); } }[key];
53
+ Object.defineProperty(method, "length", { value: operation.length });
54
+ Object.defineProperty(prototype, key, { value: method, writable: true, enumerable: true, configurable: true });
55
+ } else {
56
+ const { value } = descriptor;
57
+ Object.defineProperty(prototype, key, {
58
+ get: accessor({ get [key]() { check(this); return value; } }, key).get,
59
+ enumerable: true,
60
+ configurable: true,
61
+ });
62
+ }
63
+ }
64
+ Object.defineProperty(prototype, "constructor", { value: Interface, writable: true, configurable: true });
65
+ Object.defineProperty(prototype, Symbol.toStringTag, { value: name, configurable: true });
66
+ Object.defineProperty(Interface, "prototype", { value: prototype, writable: false, enumerable: false, configurable: false });
67
+ const create = (slots = template) => {
68
+ const object = base(prototype);
69
+ instances.set(object, slots);
70
+ return object;
71
+ };
72
+ return { Interface, prototype, instance: create(), create };
73
+ }
74
+
75
+ const accessor = (object, key) => Object.getOwnPropertyDescriptor(object, key);
76
+
77
+ /**
78
+ * window.localStorage and sessionStorage (HTML §12.2.1): the methods on
79
+ * Storage.prototype, and the stored items as each storage's own properties
80
+ * (`localStorage.theme` reads an item; assigning one stores a string).
81
+ *
82
+ * @param {object} parent Object.prototype of the page
83
+ * @returns {{ Storage: Function, prototype: object, create: (map: Map<string, string>) => object }}
84
+ */
85
+ export function storageInterface(parent = Object.prototype) {
86
+ const maps = new WeakMap();
87
+ const mapOf = (self) => {
88
+ const map = maps.get(self);
89
+ if (!map) throw new TypeError("Illegal invocation");
90
+ return map;
91
+ };
92
+ const Storage = { Storage: function () { throw new TypeError("Illegal constructor"); } }.Storage;
93
+ const members = {
94
+ get length() { return mapOf(this).size; },
95
+ key(index) { return [...mapOf(this).keys()][Number(index)] ?? null; },
96
+ getItem(key) { const map = mapOf(this); return map.has(String(key)) ? map.get(String(key)) : null; },
97
+ setItem(key, value) { mapOf(this).set(String(key), String(value)); },
98
+ removeItem(key) { mapOf(this).delete(String(key)); },
99
+ clear() { mapOf(this).clear(); },
100
+ };
101
+ const prototype = Object.create(parent);
102
+ for (const key of Reflect.ownKeys(members)) {
103
+ const descriptor = Object.getOwnPropertyDescriptor(members, key);
104
+ if (!descriptor) continue;
105
+ Object.defineProperty(prototype, key, { ...descriptor, enumerable: true, configurable: true });
106
+ }
107
+ Object.defineProperty(prototype, "constructor", { value: Storage, writable: true, configurable: true });
108
+ Object.defineProperty(prototype, Symbol.toStringTag, { value: "Storage", configurable: true });
109
+ Object.defineProperty(Storage, "prototype", { value: prototype });
110
+ const create = (map) => {
111
+ const target = Object.create(prototype);
112
+ const named = (key) => typeof key === "string" && !(key in prototype);
113
+ const proxy = new Proxy(target, {
114
+ get(object, key) {
115
+ if (named(key) && map.has(key)) return map.get(key);
116
+ return Reflect.get(object, key, proxy);
117
+ },
118
+ set(object, key, value) {
119
+ if (!named(key)) return Reflect.set(object, key, value, proxy);
120
+ map.set(key, String(value));
121
+ return true;
122
+ },
123
+ has(object, key) {
124
+ return (named(key) && map.has(key)) || Reflect.has(object, key);
125
+ },
126
+ deleteProperty(object, key) {
127
+ if (named(key)) map.delete(key);
128
+ return Reflect.deleteProperty(object, key);
129
+ },
130
+ ownKeys(object) {
131
+ return [...map.keys(), ...Reflect.ownKeys(object)];
132
+ },
133
+ getOwnPropertyDescriptor(object, key) {
134
+ if (named(key) && map.has(key)) return { value: map.get(key), writable: true, enumerable: true, configurable: true };
135
+ return Reflect.getOwnPropertyDescriptor(object, key);
136
+ },
137
+ defineProperty(object, key, descriptor) {
138
+ if (!named(key) || !("value" in descriptor)) return Reflect.defineProperty(object, key, descriptor);
139
+ map.set(key, String(descriptor.value));
140
+ return true;
141
+ },
142
+ });
143
+ maps.set(proxy, map);
144
+ return proxy;
145
+ };
146
+ return { Storage, prototype, create };
147
+ }
148
+
149
+ /** ECMAScript's own global properties: not enumerable on a browser's window. */
150
+ const LANGUAGE_GLOBALS = new Set(["globalThis", "Infinity", "NaN", "undefined", "eval", "isFinite", "isNaN",
151
+ "parseFloat", "parseInt", "decodeURI", "decodeURIComponent", "encodeURI", "encodeURIComponent", "escape", "unescape"]);
152
+
153
+ /** Namespace objects (Web IDL §3.13) that are lowercase, so not caught by the capitalized rule. */
154
+ const NAMESPACE_GLOBALS = new Set(["console"]);
155
+
156
+ /** An interface constant such as Node.ELEMENT_NODE. */
157
+ const CONSTANT_NAME = /^[A-Z][A-Z0-9_]*$/;
158
+
159
+ /**
160
+ * The Web IDL shape of a page's global object, applied before page script:
161
+ *
162
+ * - every interface prototype names its interface for Object.prototype.toString
163
+ * (`[object Window]`, `[object HTMLDivElement]`),
164
+ * - language globals and interface objects (constructors and namespaces,
165
+ * which are capitalized) are not enumerable, while the window's own
166
+ * attributes and operations (document, setTimeout, onload...) are,
167
+ * - an interface prototype's constructor is not enumerable (§3.7.3), and
168
+ * - constants are read-only and non-configurable on both the interface
169
+ * object and its prototype (§3.7.6).
170
+ *
171
+ * @param {object} global the page global
172
+ * @param {object[]} intrinsicPrototypes prototypes that belong to the
173
+ * language (Object.prototype...), left without a tag
174
+ */
175
+ function applyInterfaceShape(Interface, prototype) {
176
+ const constructor = Object.getOwnPropertyDescriptor(prototype, "constructor");
177
+ if (constructor?.enumerable && constructor.configurable) {
178
+ Object.defineProperty(prototype, "constructor", { ...constructor, enumerable: false });
179
+ }
180
+ for (const object of [Interface, prototype]) {
181
+ for (const key of Object.getOwnPropertyNames(object)) {
182
+ if (!CONSTANT_NAME.test(key)) continue;
183
+ const descriptor = Object.getOwnPropertyDescriptor(object, key);
184
+ if (!descriptor.configurable || typeof descriptor.value !== "number") continue;
185
+ Object.defineProperty(object, key, { value: descriptor.value, enumerable: true, writable: false, configurable: false });
186
+ }
187
+ }
188
+ }
189
+
190
+ export function applyGlobalShape(global, intrinsicPrototypes = []) {
191
+ const skip = new Set(intrinsicPrototypes);
192
+ for (const key of Reflect.ownKeys(global)) {
193
+ if (typeof key !== "string") continue;
194
+ const descriptor = Object.getOwnPropertyDescriptor(global, key);
195
+ if (!descriptor) continue;
196
+ const value = descriptor.value;
197
+ const isInterface = /^[A-Z]/.test(key) && (typeof value === "function" || (value && typeof value === "object"));
198
+ if (typeof value === "function" && /^[A-Z]/.test(key) && value.prototype && typeof value.prototype === "object"
199
+ && !skip.has(value.prototype) && !Object.hasOwn(value.prototype, Symbol.toStringTag)
200
+ && Object.isExtensible(value.prototype)) {
201
+ Object.defineProperty(value.prototype, Symbol.toStringTag, { value: key, configurable: true });
202
+ }
203
+ if ((isInterface || LANGUAGE_GLOBALS.has(key) || NAMESPACE_GLOBALS.has(key)) && descriptor.enumerable && descriptor.configurable) {
204
+ Object.defineProperty(global, key, { ...descriptor, enumerable: false });
205
+ }
206
+ if (typeof value === "function" && /^[A-Z]/.test(key) && value.prototype && typeof value.prototype === "object"
207
+ && !skip.has(value.prototype)) {
208
+ applyInterfaceShape(value, value.prototype);
209
+ }
210
+ }
211
+ const windowPrototype = Object.getPrototypeOf(global);
212
+ if (windowPrototype && !Object.hasOwn(windowPrototype, Symbol.toStringTag) && Object.isExtensible(windowPrototype)) {
213
+ Object.defineProperty(windowPrototype, Symbol.toStringTag, { value: "Window", configurable: true });
214
+ }
215
+ }