buninu-browser 0.2.0 → 0.2.1

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.
@@ -15,6 +15,7 @@ import { fetchResolved, HostResolver } from "./resolver.js";
15
15
 
16
16
  const MAX_REDIRECTS = 20;
17
17
  const MAX_BODY_BYTES = 64 * 1024 * 1024;
18
+ const MAX_CACHED_SUBRESOURCES = 256;
18
19
  const SIMPLE_METHODS = new Set(["GET", "HEAD", "POST"]);
19
20
  const FORBIDDEN_METHODS = new Set(["CONNECT", "TRACE", "TRACK"]);
20
21
  // Fetch §2.2.2 CORS-safelisted request headers (value checks simplified).
@@ -29,12 +30,23 @@ const FORBIDDEN_REQUEST_HEADERS = new Set([
29
30
  const SAFELISTED_RESPONSE_HEADERS = new Set([
30
31
  "cache-control", "content-language", "content-length", "content-type", "expires", "last-modified", "pragma",
31
32
  ]);
33
+ // Requests in flight to one host at a time, as browsers allow over HTTP/1.1.
34
+ const MAX_REQUESTS_PER_HOST = 6;
35
+ // Which waiting request to a busy host goes next (lower first): documents,
36
+ // then what blocks rendering or that a script waits for, then the rest.
37
+ const PRIORITY = { document: 0, stylesheet: 1, fetch: 1, script: 2, metadata: 2, image: 3, video: 3, audio: 3 };
32
38
 
33
39
  export class NetworkService {
34
40
  #fetch;
35
41
  #resolver;
36
42
  #cookies;
37
43
  #userAgent;
44
+ // Browser-process HTTP cache. Entries are partitioned by the request
45
+ // credentials that affect a subresource response; concurrent identical
46
+ // requests share one transfer.
47
+ #subresourceCache = new Map();
48
+ #subresourceInflight = new Map();
49
+ #hosts = new HostThrottle(MAX_REQUESTS_PER_HOST);
38
50
 
39
51
  /**
40
52
  * `resolver` looks host names up itself (see resolver.js); it is on for the
@@ -55,21 +67,63 @@ export class NetworkService {
55
67
  }
56
68
 
57
69
  set userAgent(value) {
58
- this.#userAgent = typeof value === "string" && value ? value : null;
70
+ const next = typeof value === "string" && value ? value : null;
71
+ if (next !== this.#userAgent) this.#subresourceCache.clear();
72
+ this.#userAgent = next;
59
73
  }
60
74
 
61
75
  /** A top-level document load. */
62
76
  async navigate(url, { onResponse } = {}) {
63
77
  return this.#send({ url, method: "GET", headers: [], body: null }, {
64
78
  credentials: "include", site: null, topLevelNavigation: true,
65
- }, { onResponse });
79
+ }, { onResponse, priority: PRIORITY.document });
66
80
  }
67
81
 
68
- /** A subresource the renderer needs to render (no-cors, credentials included). */
69
- async subresource(url, documentUrl) {
70
- return this.#send({ url, method: "GET", headers: [], body: null }, {
71
- credentials: "include", site: siteOf(documentUrl),
72
- });
82
+ /**
83
+ * A subresource the renderer needs to render (no-cors, credentials included).
84
+ * `kind` ("stylesheet", "script", "image"...) decides its turn at a busy host.
85
+ */
86
+ async subresource(url, documentUrl, kind = "metadata") {
87
+ const site = siteOf(documentUrl);
88
+ const cookie = this.#cookies.header(url, { site });
89
+ const key = `${url}\n${site ?? ""}\n${cookie}\n${this.#userAgent ?? ""}`;
90
+ const cached = this.#subresourceCache.get(key);
91
+ if (cached && cached.expires > Date.now()) return cached.result;
92
+ const pending = this.#subresourceInflight.get(key);
93
+ if (pending) return pending;
94
+ const request = (async () => {
95
+ const headers = cached?.etag ? [["if-none-match", cached.etag]] : [];
96
+ let result = await this.#send({ url, method: "GET", headers, body: null }, {
97
+ credentials: "include", site,
98
+ }, { priority: PRIORITY[kind] ?? PRIORITY.metadata });
99
+ if (result.status === 304 && cached) {
100
+ const rawHeaders = [...new Map([...cached.result.rawHeaders, ...result.rawHeaders])];
101
+ result = {
102
+ ...cached.result,
103
+ rawHeaders,
104
+ headers: [...new Map([...cached.result.headers, ...result.headers])],
105
+ };
106
+ const policy = cachePolicy(rawHeaders);
107
+ cached.expires = policy.expires;
108
+ return result;
109
+ }
110
+ const policy = cachePolicy(result.rawHeaders);
111
+ if (result.ok && policy.cacheable) {
112
+ if (!this.#subresourceCache.has(key) && this.#subresourceCache.size >= MAX_CACHED_SUBRESOURCES) {
113
+ this.#subresourceCache.delete(this.#subresourceCache.keys().next().value);
114
+ }
115
+ this.#subresourceCache.set(key, { result, expires: policy.expires, etag: policy.etag });
116
+ } else {
117
+ this.#subresourceCache.delete(key);
118
+ }
119
+ return result;
120
+ })();
121
+ this.#subresourceInflight.set(key, request);
122
+ try {
123
+ return await request;
124
+ } finally {
125
+ if (this.#subresourceInflight.get(key) === request) this.#subresourceInflight.delete(key);
126
+ }
73
127
  }
74
128
 
75
129
  /**
@@ -131,19 +185,19 @@ export class NetworkService {
131
185
 
132
186
  if (sameOrigin) {
133
187
  return this.#send({ url: url.href, method, headers, body: request.body ?? null }, context, {
134
- redirect: request.redirect ?? "follow", corsOrigin: null,
188
+ redirect: request.redirect ?? "follow", corsOrigin: null, priority: PRIORITY.fetch,
135
189
  });
136
190
  }
137
191
  if (mode === "no-cors") {
138
192
  const response = await this.#send({ url: url.href, method, headers: headers.filter(isSafelistedHeader), body: request.body ?? null },
139
- context, { redirect: "follow", corsOrigin: null });
193
+ context, { redirect: "follow", corsOrigin: null, priority: PRIORITY.fetch });
140
194
  return { ...response, type: "opaque", status: 0, statusText: "", url: "", headers: [], body: new Uint8Array(0), redirected: false };
141
195
  }
142
196
  // CORS: preflight unless the request is "simple" (Fetch §4.8).
143
197
  const needsPreflight = !SIMPLE_METHODS.has(method) || headers.some((header) => !isSafelistedHeader(header));
144
198
  if (needsPreflight) await this.#preflight(url, method, headers, origin, context);
145
199
  const response = await this.#send({ url: url.href, method, headers, body: request.body ?? null }, context, {
146
- redirect: request.redirect ?? "follow", corsOrigin: origin,
200
+ redirect: request.redirect ?? "follow", corsOrigin: origin, priority: PRIORITY.fetch,
147
201
  });
148
202
  const allowed = corsAllowed(response.rawHeaders, origin, context.credentials === "include");
149
203
  if (!allowed) throw new TypeError(`CORS: ${url.href} did not allow origin ${origin}`);
@@ -159,17 +213,23 @@ export class NetworkService {
159
213
 
160
214
  async #preflight(url, method, headers, origin, context) {
161
215
  const requestHeaders = headers.filter((entry) => !isSafelistedHeader(entry)).map(([name]) => name).sort();
162
- const response = await this.#request(url.href, {
163
- method: "OPTIONS",
164
- redirect: "manual",
165
- headers: {
166
- origin,
167
- "access-control-request-method": method,
168
- ...(requestHeaders.length ? { "access-control-request-headers": requestHeaders.join(",") } : {}),
169
- ...(this.#userAgent ? { "user-agent": this.#userAgent } : {}),
170
- },
171
- });
172
- await response.arrayBuffer().catch(() => {});
216
+ const release = await this.#hosts.acquire(url.href, PRIORITY.fetch);
217
+ let response;
218
+ try {
219
+ response = await this.#request(url.href, {
220
+ method: "OPTIONS",
221
+ redirect: "manual",
222
+ headers: {
223
+ origin,
224
+ "access-control-request-method": method,
225
+ ...(requestHeaders.length ? { "access-control-request-headers": requestHeaders.join(",") } : {}),
226
+ ...(this.#userAgent ? { "user-agent": this.#userAgent } : {}),
227
+ },
228
+ });
229
+ await response.arrayBuffer().catch(() => {});
230
+ } finally {
231
+ release();
232
+ }
173
233
  const raw = [...response.headers];
174
234
  if (response.status < 200 || response.status > 299 || !corsAllowed(raw, origin, context.credentials === "include")) {
175
235
  throw new TypeError(`CORS preflight for ${url.href} was rejected`);
@@ -191,8 +251,11 @@ export class NetworkService {
191
251
  return this.#resolver ? fetchResolved(this.#fetch, this.#resolver, url, init) : this.#fetch(url, init);
192
252
  }
193
253
 
194
- /** Sends a request, following redirects by hand so cookies apply to every hop. */
195
- async #send(request, context, { redirect = "follow", corsOrigin = null, onResponse = null } = {}) {
254
+ /**
255
+ * Sends a request, following redirects by hand so cookies apply to every hop.
256
+ * Each hop holds one of its host's request slots until its body is read.
257
+ */
258
+ async #send(request, context, { redirect = "follow", corsOrigin = null, onResponse = null, priority = PRIORITY.metadata } = {}) {
196
259
  let { url, method, body } = request;
197
260
  const baseHeaders = request.headers;
198
261
  let redirected = false;
@@ -206,28 +269,33 @@ export class NetworkService {
206
269
  });
207
270
  if (cookie) headers.set("cookie", cookie);
208
271
  }
209
- const response = await this.#request(url, { method, headers, body, redirect: "manual" });
210
- if (context.credentials === "include") this.#cookies.store(url, response.headers.getSetCookie?.() ?? []);
211
- const location = response.headers.get("location");
212
- if ([301, 302, 303, 307, 308].includes(response.status) && location) {
213
- await response.arrayBuffer().catch(() => {});
214
- if (redirect === "error") throw new TypeError("Redirect was not allowed");
215
- if (redirect === "manual") return this.#result(response, url, redirected, "opaqueredirect");
216
- const next = new URL(location, url);
217
- if (!["http:", "https:"].includes(next.protocol)) throw new TypeError(`Redirect to ${next.protocol} is not allowed`);
218
- if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === "POST")) {
219
- method = method === "HEAD" ? "HEAD" : "GET";
220
- body = null;
272
+ const release = await this.#hosts.acquire(url, priority);
273
+ try {
274
+ const response = await this.#request(url, { method, headers, body, redirect: "manual" });
275
+ if (context.credentials === "include") this.#cookies.store(url, response.headers.getSetCookie?.() ?? []);
276
+ const location = response.headers.get("location");
277
+ if ([301, 302, 303, 307, 308].includes(response.status) && location) {
278
+ await response.arrayBuffer().catch(() => {});
279
+ if (redirect === "error") throw new TypeError("Redirect was not allowed");
280
+ if (redirect === "manual") return await this.#result(response, url, redirected, "opaqueredirect");
281
+ const next = new URL(location, url);
282
+ if (!["http:", "https:"].includes(next.protocol)) throw new TypeError(`Redirect to ${next.protocol} is not allowed`);
283
+ if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === "POST")) {
284
+ method = method === "HEAD" ? "HEAD" : "GET";
285
+ body = null;
286
+ }
287
+ url = next.href;
288
+ redirected = true;
289
+ continue;
221
290
  }
222
- url = next.href;
223
- redirected = true;
224
- continue;
225
- }
226
- if (onResponse) {
227
- const handled = await onResponse(response, url);
228
- if (handled) return handled;
291
+ if (onResponse) {
292
+ const handled = await onResponse(response, url);
293
+ if (handled) return handled;
294
+ }
295
+ return await this.#result(response, url, redirected, "basic");
296
+ } finally {
297
+ release();
229
298
  }
230
- return this.#result(response, url, redirected, "basic");
231
299
  }
232
300
  throw new TypeError("Too many redirects");
233
301
  }
@@ -262,6 +330,20 @@ function header(rawHeaders, name) {
262
330
  return rawHeaders.find(([key]) => key === name)?.[1] ?? null;
263
331
  }
264
332
 
333
+ /** Freshness and validator data used by the in-process HTTP cache. */
334
+ function cachePolicy(rawHeaders, now = Date.now()) {
335
+ const control = (header(rawHeaders, "cache-control") ?? "").toLowerCase();
336
+ if (/(?:^|,)\s*no-store(?:\s|,|$)/.test(control)) return { cacheable: false, expires: 0, etag: null };
337
+ const age = control.match(/(?:^|,)\s*max-age\s*=\s*(\d+)/)?.[1];
338
+ const expiresHeader = header(rawHeaders, "expires");
339
+ const expires = age !== undefined
340
+ ? now + Number(age) * 1000
341
+ : expiresHeader && Number.isFinite(Date.parse(expiresHeader)) ? Date.parse(expiresHeader)
342
+ : /(?:^|,)\s*immutable(?:\s|,|$)/.test(control) ? now + 365 * 24 * 60 * 60 * 1000 : 0;
343
+ const etag = header(rawHeaders, "etag");
344
+ return { cacheable: expires > now || etag !== null, expires, etag };
345
+ }
346
+
265
347
  /** Fetch §4.9 CORS check. */
266
348
  function corsAllowed(rawHeaders, origin, withCredentials) {
267
349
  const allowOrigin = header(rawHeaders, "access-control-allow-origin");
@@ -270,3 +352,54 @@ function corsAllowed(rawHeaders, origin, withCredentials) {
270
352
  if (allowOrigin !== origin) return false;
271
353
  return !withCredentials || header(rawHeaders, "access-control-allow-credentials") === "true";
272
354
  }
355
+
356
+ /**
357
+ * Limits the requests in flight to each host (scheme, host and port). A burst
358
+ * of requests that each open a connection slows every transfer, and servers
359
+ * reset some; with a few connections kept alive and reused, a page's
360
+ * resources arrive sooner. Waiting requests go by priority, then in order.
361
+ */
362
+ class HostThrottle {
363
+ #limit;
364
+ #hosts = new Map();
365
+
366
+ constructor(limit) {
367
+ this.#limit = limit;
368
+ }
369
+
370
+ /** Resolves with a function that gives the slot back (once) when the request is done. */
371
+ acquire(url, priority) {
372
+ let key;
373
+ try {
374
+ const target = new URL(url);
375
+ key = `${target.protocol}//${target.host}`;
376
+ } catch {
377
+ key = String(url);
378
+ }
379
+ let host = this.#hosts.get(key);
380
+ if (!host) this.#hosts.set(key, host = { active: 0, waiting: [] });
381
+ const release = this.#releaser(key, host);
382
+ if (host.active < this.#limit) {
383
+ host.active++;
384
+ return Promise.resolve(release);
385
+ }
386
+ return new Promise((resolve) => {
387
+ const entry = { priority, start: () => resolve(release) };
388
+ const index = host.waiting.findIndex((other) => other.priority > priority);
389
+ if (index === -1) host.waiting.push(entry);
390
+ else host.waiting.splice(index, 0, entry);
391
+ });
392
+ }
393
+
394
+ #releaser(key, host) {
395
+ let released = false;
396
+ return () => {
397
+ if (released) return;
398
+ released = true;
399
+ const next = host.waiting.shift();
400
+ if (next) next.start(); // the slot passes on
401
+ else if (--host.active === 0) this.#hosts.delete(key);
402
+ };
403
+ }
404
+ }
405
+
@@ -24,7 +24,7 @@ const TRANSPARENT = "rgba(0, 0, 0, 0)";
24
24
  * @param tree the render tree it was laid out from; without it, visibility,
25
25
  * opacity and overflow clipping of ancestors are not applied.
26
26
  */
27
- export function buildDisplayList(layout, tree = null) {
27
+ export function buildDisplayList(layout, tree = null, resources = null) {
28
28
  const items = [];
29
29
  const images = [];
30
30
  const effects = paintEffects(layout, tree);
@@ -156,15 +156,18 @@ export function buildDisplayList(layout, tree = null) {
156
156
  return;
157
157
  }
158
158
  if (run.type === "image") {
159
+ const currentResource = resources && tree
160
+ ? resources.get(tree.nodesById.get(run.nodeId)?.domNode) ?? run.resource
161
+ : run.resource;
159
162
  if (run.video || run.audio) {
160
163
  const rect = { x: run.x, y, width: run.width, height: run.height };
161
- images.push(run.resource);
164
+ images.push(currentResource);
162
165
  items.push({ op: run.video ? "drawVideo" : "drawAudio", nodeId: run.nodeId, image: images.length - 1, rect, bounds: rect });
163
166
  return;
164
167
  }
165
- if (!run.resource?.image) return;
168
+ if (!currentResource?.image) return;
166
169
  const rect = { x: run.x, y, width: run.width, height: run.height };
167
- images.push(run.resource);
170
+ images.push(currentResource);
168
171
  items.push({
169
172
  op: "drawImage", nodeId: run.nodeId, image: images.length - 1, rect, radii: cornerRadii(run.style, rect), bounds: rect,
170
173
  });
@@ -223,17 +226,27 @@ function paintEffects(layout, tree) {
223
226
  const boxes = new Map();
224
227
  for (const box of layout.boxes) if (!box.inline && !boxes.has(box.nodeId)) boxes.set(box.nodeId, box);
225
228
  const cache = new Map();
229
+ // Each element's own clips, worked out once for all the items below it.
230
+ const memo = (compute) => {
231
+ const results = new Map();
232
+ return (node) => {
233
+ if (results.has(node)) return results.get(node);
234
+ const result = compute(node);
235
+ results.set(node, result === null ? null : Object.freeze(result));
236
+ return results.get(node);
237
+ };
238
+ };
226
239
 
227
240
  // The clip an element applies to its descendants (and, for clip/clip-path, to itself).
228
- const overflowClip = (node) => {
241
+ const overflowClip = memo((node) => {
229
242
  const style = node.style;
230
243
  const box = boxes.get(node.id);
231
244
  if (!style || !box || node === tree.root) return null;
232
245
  if (style.overflowX !== "clip" && style.overflowY !== "clip") return null;
233
246
  const [top, right, bottom, left] = style.borderWidths ?? [0, 0, 0, 0];
234
247
  return { x: box.x + left, y: box.y + top, width: box.width - left - right, height: box.height - top - bottom };
235
- };
236
- const shapeClip = (node) => {
248
+ });
249
+ const shapeClip = memo((node) => {
237
250
  const style = node.style;
238
251
  const box = boxes.get(node.id);
239
252
  if (!style || !box) return null;
@@ -253,7 +266,7 @@ function paintEffects(layout, tree) {
253
266
  rect = intersectRects(rect, { x: x0, y: y0, width: x1 - x0, height: y1 - y0 });
254
267
  }
255
268
  return rect;
256
- };
269
+ });
257
270
 
258
271
  return (nodeId, kind) => {
259
272
  const key = `${kind}:${nodeId}`;
@@ -351,7 +364,9 @@ function inflate(rect, amount) {
351
364
  return { x: rect.x - amount, y: rect.y - amount, width: rect.width + amount * 2, height: rect.height + amount * 2 };
352
365
  }
353
366
 
367
+ /** Freezes an item and what it holds; frozen values (shared clips, style data) are already done. */
354
368
  function deepFreeze(value) {
369
+ if (Object.isFrozen(value)) return value;
355
370
  for (const child of Object.values(value)) {
356
371
  if (child && typeof child === "object") deepFreeze(child);
357
372
  }
@@ -30,6 +30,7 @@ const BLOCK_ELEMENTS = new Set([
30
30
  */
31
31
  export class RenderTreeBuilder {
32
32
  #ids = new WeakMap();
33
+ #previous = new WeakMap();
33
34
  #pseudoKeys = new WeakMap();
34
35
  #nextId = 1;
35
36
  #generation = 0;
@@ -71,12 +72,18 @@ export class RenderTreeBuilder {
71
72
  }
72
73
 
73
74
  #node(domNode, type, nodesById, properties, idKey = domNode) {
75
+ const previous = this.#previous.get(idKey);
76
+ if (previous && sameNodeProperties(previous, type, properties)) {
77
+ nodesById.set(previous.id, previous);
78
+ return previous;
79
+ }
74
80
  const node = Object.freeze({
75
81
  id: this.#id(idKey),
76
82
  type,
77
83
  domNode,
78
84
  ...properties,
79
85
  });
86
+ this.#previous.set(idKey, node);
80
87
  nodesById.set(node.id, node);
81
88
  return node;
82
89
  }
@@ -242,6 +249,44 @@ export class RenderTreeBuilder {
242
249
  }
243
250
  }
244
251
 
252
+ function sameNodeProperties(previous, type, properties) {
253
+ if (previous.type !== type || Object.keys(previous).length !== Object.keys(properties).length + 3) return false;
254
+ for (const [key, value] of Object.entries(properties)) {
255
+ const old = previous[key];
256
+ if (old === value) continue;
257
+ if (key !== "children" || old.length !== value.length) return false;
258
+ for (let index = 0; index < old.length; index++) if (old[index] !== value[index]) return false;
259
+ }
260
+ return true;
261
+ }
262
+
263
+ /**
264
+ * Whether two generations provide identical input to layout. Node IDs are
265
+ * stable for retained DOM nodes, while computed styles and decoded resources
266
+ * are immutable values shared between generations. Callers which separately
267
+ * track whether resource dimensions changed may ignore resource identity.
268
+ */
269
+ export function sameLayoutInput(left, right, options = {}) {
270
+ if (left === right) return true;
271
+ if (!left || !right || left.id !== right.id || left.type !== right.type
272
+ || (!options.ignoreStyles && left.style !== right.style) || left.tagName !== right.tagName
273
+ || left.text !== right.text || left.pseudo !== right.pseudo
274
+ || (!options.ignoreResources && left.resource !== right.resource)
275
+ || !sameSvg(left.svg, right.svg)
276
+ || left.widthAttribute !== right.widthAttribute || left.heightAttribute !== right.heightAttribute
277
+ || left.children.length !== right.children.length) return false;
278
+ for (let index = 0; index < left.children.length; index++) {
279
+ if (!sameLayoutInput(left.children[index], right.children[index], options)) return false;
280
+ }
281
+ return true;
282
+ }
283
+
284
+ function sameSvg(left, right) {
285
+ if (left === right) return true;
286
+ if (!left || !right) return false;
287
+ return JSON.stringify(left) === JSON.stringify(right);
288
+ }
289
+
245
290
  function boxType(style, tagName = null) {
246
291
  const display = style?.display;
247
292
  if (["flex", "inline-flex", "inline-block", "grid", "inline-grid"].includes(display)) return display;
@@ -189,8 +189,8 @@ function toNode(wrapper, value) {
189
189
  }
190
190
 
191
191
  /** The page changed `wrapper`'s node (state the style engine may read: attributes, checked, value). */
192
- function touch(wrapper) {
193
- realmOf(wrapper).hooks.touch(NodeBinding.unwrap(wrapper));
192
+ function touch(wrapper, stateFeatures = []) {
193
+ realmOf(wrapper).hooks.touch(NodeBinding.unwrap(wrapper), stateFeatures);
194
194
  }
195
195
 
196
196
  /**
@@ -842,7 +842,7 @@ class ElementBinding extends ParentBinding {
842
842
  set selectedIndex(value) {
843
843
  if (nodeOf(this).tagName !== "SELECT") return;
844
844
  nodeOf(this).selectedIndex = Number(value);
845
- touch(this);
845
+ touch(this, [":state"]);
846
846
  }
847
847
 
848
848
  // Overflow inside elements is not scrollable here, so the scroll size is the
@@ -926,7 +926,7 @@ for (const property of REFLECTED_PROPERTIES) {
926
926
  node[property] = typeof node[property] === "boolean" ? Boolean(value)
927
927
  : typeof node[property] === "number" ? Number(value)
928
928
  : String(value);
929
- touch(this);
929
+ touch(this, [":state"]);
930
930
  },
931
931
  });
932
932
  }
@@ -519,7 +519,7 @@ export class RendererHost {
519
519
  try {
520
520
  const response = target.protocol === "data:"
521
521
  ? await dataResponse(target)
522
- : await this.#network.subresource(target.href, this.#documentUrl);
522
+ : await this.#network.subresource(target.href, this.#documentUrl, kind);
523
523
  if (response.body.byteLength > MAX_RESOURCE_BYTES) return reply({ error: "resource too large" });
524
524
  reply({
525
525
  result: {
@@ -28,7 +28,7 @@ const hostStructuredClone = globalThis.structuredClone;
28
28
  const hostAtob = globalThis.atob;
29
29
  const hostBtoa = globalThis.btoa;
30
30
 
31
- const CLASSIC_SCRIPT_TYPES = new Set([
31
+ export const CLASSIC_SCRIPT_TYPES = new Set([
32
32
  "", "text/javascript", "application/javascript", "text/ecmascript", "application/ecmascript",
33
33
  "application/x-javascript", "text/jscript", "text/livescript",
34
34
  ]);
@@ -93,7 +93,7 @@ function cssEscape(value) {
93
93
  * @param {{
94
94
  * window: object, document: object, mode: "ses" | "host",
95
95
  * hooks: {
96
- * touch(): void, afterTask(): void,
96
+ * touch(node?: object, stateFeatures?: string[]): void, afterTask(): void,
97
97
  * boundsOf(element: object): { left: number, top: number, width: number, height: number } | null,
98
98
  * viewport(): { width: number, height: number, scrollX: number, scrollY: number, devicePixelRatio: number },
99
99
  * scrollTo(x: number, y: number): void,