dommy-js-quickjs 0.9.0 → 0.10.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +7 -0
- data/lib/dommy/js/quickjs/backend.rb +100 -93
- data/lib/dommy/js/quickjs/browser_environment.rb +93 -0
- data/lib/dommy/js/quickjs/capybara.rb +101 -19
- data/lib/dommy/js/quickjs/config.rb +62 -0
- data/lib/dommy/js/quickjs/error_translator.rb +137 -0
- data/lib/dommy/js/quickjs/event_loop.rb +149 -0
- data/lib/dommy/js/quickjs/js/browser_globals.js +36 -0
- data/lib/dommy/js/quickjs/js/error_rebuild.js +22 -0
- data/lib/dommy/js/quickjs/js/intl_polyfill.js +56 -0
- data/lib/dommy/js/quickjs/js/timer_globals.js +46 -0
- data/lib/dommy/js/quickjs/js/wasm_stub.js +26 -0
- data/lib/dommy/js/quickjs/js/window_builtins.js +14 -0
- data/lib/dommy/js/quickjs/runtime.rb +243 -442
- data/lib/dommy/js/quickjs/script_cache.rb +4 -1
- data/lib/dommy/js/quickjs/version.rb +1 -1
- data/lib/dommy/js/quickjs/wasm_bridge.rb +14 -8
- data/lib/dommy/js/quickjs.rb +5 -1
- metadata +22 -13
- data/.github/workflows/test.yml +0 -28
- data/Rakefile +0 -117
- data/docs/bridge-redesign.md +0 -559
- data/docs/wpt-conformance.md +0 -752
- data/lib/dommy/js/quickjs/source_guard.rb +0 -304
- data/script/build_jsx_transform.sh +0 -27
- data/script/build_stimulus_tests.sh +0 -52
|
@@ -1,304 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Dommy
|
|
4
|
-
module Js
|
|
5
|
-
module Quickjs
|
|
6
|
-
# Source-level workaround for a QuickJS bytecode-generation bug: a `for...of`
|
|
7
|
-
# whose ITERABLE expression contains a `yield` fails to COMPILE with an
|
|
8
|
-
# internal "stack underflow" error (the for-of iterator-close finally region
|
|
9
|
-
# miscomputes the operand stack across the generator suspend). V8 / real
|
|
10
|
-
# browsers compile it fine, so SPA bundles ship it — e.g. note.com's modern
|
|
11
|
-
# build has `for (var f of (yield O(), _)) v(f)`, and the whole code-split
|
|
12
|
-
# chunk fails to load, so the app never mounts.
|
|
13
|
-
#
|
|
14
|
-
# The fix hoists the iterable into a temp `var` so the `yield` leaves the
|
|
15
|
-
# for-of operand position — `for (x of (yield a, b)) …` becomes
|
|
16
|
-
# `var t = (yield a, b); for (x of t) …` — semantically identical, and it
|
|
17
|
-
# compiles. Applied ONLY as a retry after a genuine "stack underflow" compile
|
|
18
|
-
# failure (see Backend), so working scripts are never rewritten and an
|
|
19
|
-
# imperfect transform cannot regress anything (the source already failed).
|
|
20
|
-
module SourceGuard
|
|
21
|
-
module_function
|
|
22
|
-
|
|
23
|
-
# The marker QuickJS raises for this codegen bug.
|
|
24
|
-
ERROR_MARKER = "stack underflow"
|
|
25
|
-
|
|
26
|
-
def relevant_error?(error)
|
|
27
|
-
error.respond_to?(:message) && error.message.to_s.include?(ERROR_MARKER)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# Rewrite every `for...of` whose iterable contains a `yield`, hoisting the
|
|
31
|
-
# iterable into a preceding `var`. Returns the source unchanged when there
|
|
32
|
-
# is nothing to do.
|
|
33
|
-
def fix_for_of_yield(source)
|
|
34
|
-
return source unless source.include?("yield")
|
|
35
|
-
|
|
36
|
-
# Scan at the BYTE level (binary encoding): on a multibyte (UTF-8) source
|
|
37
|
-
# — note's bundle has Japanese strings — Ruby's character indexing is
|
|
38
|
-
# O(index), making a char-by-char pass O(n^2). All tokens we look for are
|
|
39
|
-
# ASCII, and UTF-8 continuation bytes (>= 0x80) never collide with them,
|
|
40
|
-
# so byte scanning is both safe and O(1) per position.
|
|
41
|
-
Rewriter.new(source.b).run.force_encoding(source.encoding)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
IDENT = /[A-Za-z0-9_$]/.freeze
|
|
45
|
-
|
|
46
|
-
def ident_char?(ch)
|
|
47
|
-
!ch.nil? && IDENT.match?(ch)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# A `/` begins a regex (not division) unless the previous significant code
|
|
51
|
-
# char can end an expression (ident / `)` / `]` / a literal).
|
|
52
|
-
def regex_allowed?(prev_sig)
|
|
53
|
-
return true if prev_sig.nil?
|
|
54
|
-
return false if ident_char?(prev_sig)
|
|
55
|
-
|
|
56
|
-
!")]'\"`".include?(prev_sig)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# If `src[i]` begins a string, template, regex literal, or comment, return
|
|
60
|
-
# the index just after it; otherwise nil. `prev_sig` (previous significant
|
|
61
|
-
# code char) disambiguates a regex `/` from division.
|
|
62
|
-
def atom_end(src, i, prev_sig)
|
|
63
|
-
case src[i]
|
|
64
|
-
when "/"
|
|
65
|
-
if src[i + 1] == "/"
|
|
66
|
-
src.index("\n", i) || src.length
|
|
67
|
-
elsif src[i + 1] == "*"
|
|
68
|
-
(idx = src.index("*/", i + 2)) ? idx + 2 : src.length
|
|
69
|
-
elsif regex_allowed?(prev_sig)
|
|
70
|
-
scan_regex(src, i)
|
|
71
|
-
end
|
|
72
|
-
when "'", '"'
|
|
73
|
-
scan_quote(src, i, src[i])
|
|
74
|
-
when "`"
|
|
75
|
-
scan_template(src, i)
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def scan_quote(src, i, quote)
|
|
80
|
-
j = i + 1
|
|
81
|
-
n = src.length
|
|
82
|
-
while j < n
|
|
83
|
-
c = src[j]
|
|
84
|
-
return j + 1 if c == quote
|
|
85
|
-
|
|
86
|
-
j += c == "\\" ? 2 : 1
|
|
87
|
-
end
|
|
88
|
-
n
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def scan_template(src, i)
|
|
92
|
-
j = i + 1
|
|
93
|
-
n = src.length
|
|
94
|
-
while j < n
|
|
95
|
-
c = src[j]
|
|
96
|
-
return j + 1 if c == "`"
|
|
97
|
-
if c == "\\"
|
|
98
|
-
j += 2
|
|
99
|
-
next
|
|
100
|
-
end
|
|
101
|
-
if c == "$" && src[j + 1] == "{"
|
|
102
|
-
close = match_bracket(src, j + 1)
|
|
103
|
-
return n unless close
|
|
104
|
-
|
|
105
|
-
j = close + 1
|
|
106
|
-
next
|
|
107
|
-
end
|
|
108
|
-
j += 1
|
|
109
|
-
end
|
|
110
|
-
n
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
# End of a regex literal at `i` (past its flags), or nil if it isn't one
|
|
114
|
-
# (an unescaped newline before the closing `/` → it was division).
|
|
115
|
-
def scan_regex(src, i)
|
|
116
|
-
j = i + 1
|
|
117
|
-
n = src.length
|
|
118
|
-
in_class = false
|
|
119
|
-
while j < n
|
|
120
|
-
c = src[j]
|
|
121
|
-
case c
|
|
122
|
-
when "\\" then j += 2; next
|
|
123
|
-
when "[" then in_class = true
|
|
124
|
-
when "]" then in_class = false
|
|
125
|
-
when "/"
|
|
126
|
-
unless in_class
|
|
127
|
-
j += 1
|
|
128
|
-
j += 1 while j < n && src[j] =~ /[a-z]/i
|
|
129
|
-
return j
|
|
130
|
-
end
|
|
131
|
-
when "\n"
|
|
132
|
-
return nil
|
|
133
|
-
end
|
|
134
|
-
j += 1
|
|
135
|
-
end
|
|
136
|
-
nil
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
# Index of the close bracket matching the (/[/{ at `open`, skipping nested
|
|
140
|
-
# brackets, strings, templates, regexes and comments. `limit` bounds the
|
|
141
|
-
# scan (a for-head is short; bounding keeps the whole pass linear and
|
|
142
|
-
# avoids a runaway scan when a mis-lexed token unbalances brackets).
|
|
143
|
-
def match_bracket(src, open, limit: nil)
|
|
144
|
-
pairs = {"(" => ")", "[" => "]", "{" => "}"}
|
|
145
|
-
want = [pairs[src[open]]]
|
|
146
|
-
i = open + 1
|
|
147
|
-
n = src.length
|
|
148
|
-
n = [n, open + limit].min if limit
|
|
149
|
-
prev = src[open]
|
|
150
|
-
while i < n
|
|
151
|
-
stop = atom_end(src, i, prev)
|
|
152
|
-
if stop
|
|
153
|
-
prev = src[stop - 1]
|
|
154
|
-
i = stop
|
|
155
|
-
next
|
|
156
|
-
end
|
|
157
|
-
c = src[i]
|
|
158
|
-
if pairs.key?(c)
|
|
159
|
-
want << pairs[c]
|
|
160
|
-
elsif ")]}".include?(c)
|
|
161
|
-
return i if want.size == 1 && want.last == c
|
|
162
|
-
|
|
163
|
-
want.pop if want.last == c
|
|
164
|
-
end
|
|
165
|
-
prev = c unless c =~ /\s/
|
|
166
|
-
i += 1
|
|
167
|
-
end
|
|
168
|
-
nil
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
# Position of the for-of `of` keyword at the top level of a for-head, or nil
|
|
172
|
-
# for a for-in / C-style for (a top-level `;` rules out for-of).
|
|
173
|
-
def top_level_of(head)
|
|
174
|
-
depth = 0
|
|
175
|
-
i = 0
|
|
176
|
-
n = head.length
|
|
177
|
-
prev = nil
|
|
178
|
-
while i < n
|
|
179
|
-
stop = atom_end(head, i, prev)
|
|
180
|
-
if stop
|
|
181
|
-
prev = head[stop - 1]
|
|
182
|
-
i = stop
|
|
183
|
-
next
|
|
184
|
-
end
|
|
185
|
-
c = head[i]
|
|
186
|
-
case c
|
|
187
|
-
when "(", "[", "{" then depth += 1
|
|
188
|
-
when ")", "]", "}" then depth -= 1
|
|
189
|
-
when ";" then return nil if depth.zero?
|
|
190
|
-
when "o"
|
|
191
|
-
if depth.zero? && head[i, 2] == "of" &&
|
|
192
|
-
!ident_char?(i.zero? ? nil : head[i - 1]) && !ident_char?(head[i + 2])
|
|
193
|
-
return i
|
|
194
|
-
end
|
|
195
|
-
end
|
|
196
|
-
prev = c unless c =~ /\s/
|
|
197
|
-
i += 1
|
|
198
|
-
end
|
|
199
|
-
nil
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def contains_yield?(expr)
|
|
203
|
-
i = 0
|
|
204
|
-
n = expr.length
|
|
205
|
-
prev = nil
|
|
206
|
-
while i < n
|
|
207
|
-
stop = atom_end(expr, i, prev)
|
|
208
|
-
if stop
|
|
209
|
-
prev = expr[stop - 1]
|
|
210
|
-
i = stop
|
|
211
|
-
next
|
|
212
|
-
end
|
|
213
|
-
if expr[i] == "y" && expr[i, 5] == "yield" &&
|
|
214
|
-
!ident_char?(i.zero? ? nil : expr[i - 1]) && !ident_char?(expr[i + 5])
|
|
215
|
-
return true
|
|
216
|
-
end
|
|
217
|
-
prev = expr[i] unless expr[i] =~ /\s/
|
|
218
|
-
i += 1
|
|
219
|
-
end
|
|
220
|
-
false
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
# Walks source once, copying literals/comments verbatim and rewriting a
|
|
224
|
-
# for-of-with-yield-in-iterable found at a statement boundary.
|
|
225
|
-
class Rewriter
|
|
226
|
-
# Upper bound on a `for (...)` head length we will consider (well above
|
|
227
|
-
# any real for-of header, including a large iterable expression).
|
|
228
|
-
HEAD_SCAN_LIMIT = 64 * 1024
|
|
229
|
-
|
|
230
|
-
def initialize(src)
|
|
231
|
-
@src = src
|
|
232
|
-
@n = src.length
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
def run
|
|
236
|
-
out = +""
|
|
237
|
-
i = 0
|
|
238
|
-
prev = nil # previous significant code char
|
|
239
|
-
counter = 0
|
|
240
|
-
while i < @n
|
|
241
|
-
stop = SourceGuard.atom_end(@src, i, prev)
|
|
242
|
-
if stop
|
|
243
|
-
out << @src[i...stop]
|
|
244
|
-
prev = @src[stop - 1]
|
|
245
|
-
i = stop
|
|
246
|
-
next
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
ch = @src[i]
|
|
250
|
-
if ch == "f" && @src[i, 3] == "for" &&
|
|
251
|
-
!SourceGuard.ident_char?(i.zero? ? nil : @src[i - 1]) &&
|
|
252
|
-
!SourceGuard.ident_char?(@src[i + 3]) &&
|
|
253
|
-
boundary?(prev)
|
|
254
|
-
rewrite = transform_for(i, counter)
|
|
255
|
-
if rewrite
|
|
256
|
-
out << rewrite[:text]
|
|
257
|
-
i = rewrite[:next]
|
|
258
|
-
counter += 1
|
|
259
|
-
prev = ")"
|
|
260
|
-
next
|
|
261
|
-
end
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
out << ch
|
|
265
|
-
prev = ch unless ch =~ /\s/
|
|
266
|
-
i += 1
|
|
267
|
-
end
|
|
268
|
-
out
|
|
269
|
-
end
|
|
270
|
-
|
|
271
|
-
private
|
|
272
|
-
|
|
273
|
-
# A `var t = …;` may be inserted before `for` only at a statement
|
|
274
|
-
# boundary; otherwise (e.g. `if (c) for (…)`) it would change meaning.
|
|
275
|
-
def boundary?(prev_sig)
|
|
276
|
-
prev_sig.nil? || ";{}".include?(prev_sig)
|
|
277
|
-
end
|
|
278
|
-
|
|
279
|
-
def transform_for(for_idx, counter)
|
|
280
|
-
open = for_idx + 3
|
|
281
|
-
open += 1 while open < @n && @src[open] =~ /\s/
|
|
282
|
-
return nil unless @src[open] == "("
|
|
283
|
-
|
|
284
|
-
# A for-head is short; cap the scan so an unbalanced (mis-lexed) head
|
|
285
|
-
# can't turn the per-`for` work into an O(n) scan.
|
|
286
|
-
close = SourceGuard.match_bracket(@src, open, limit: HEAD_SCAN_LIMIT)
|
|
287
|
-
return nil unless close
|
|
288
|
-
|
|
289
|
-
head = @src[(open + 1)...close]
|
|
290
|
-
of_at = SourceGuard.top_level_of(head)
|
|
291
|
-
return nil unless of_at
|
|
292
|
-
|
|
293
|
-
decl = head[0...of_at]
|
|
294
|
-
iterable = head[(of_at + 2)..].to_s
|
|
295
|
-
return nil unless SourceGuard.contains_yield?(iterable)
|
|
296
|
-
|
|
297
|
-
tmp = "__dommyForOf#{counter}"
|
|
298
|
-
{text: "var #{tmp}=(#{iterable});for(#{decl}of #{tmp})", next: close + 1}
|
|
299
|
-
end
|
|
300
|
-
end
|
|
301
|
-
end
|
|
302
|
-
end
|
|
303
|
-
end
|
|
304
|
-
end
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Regenerate test/fixtures/jsx-transform.umd.js — a minimal JSX→JS transpiler
|
|
3
|
-
# (sucrase) bundled into a single IIFE that exposes `globalThis.transformJSX`.
|
|
4
|
-
#
|
|
5
|
-
# Sucrase is a tiny, JSX/TS-focused alternative to Babel: the bundle is ~200 KB
|
|
6
|
-
# vs @babel/standalone's ~3 MB, since it carries only the JSX transform. At run
|
|
7
|
-
# time the bundle is loaded into the QuickJS VM (pure Ruby + RubyGems — no Node,
|
|
8
|
-
# no native binary); Node/esbuild are only needed here to (re)build it.
|
|
9
|
-
#
|
|
10
|
-
# script/build_jsx_transform.sh
|
|
11
|
-
set -euo pipefail
|
|
12
|
-
|
|
13
|
-
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
14
|
-
OUT="$ROOT/test/fixtures/jsx-transform.umd.js"
|
|
15
|
-
TMP="$(mktemp -d)"
|
|
16
|
-
trap 'rm -rf "$TMP"' EXIT
|
|
17
|
-
|
|
18
|
-
cd "$TMP"
|
|
19
|
-
npm init -y >/dev/null 2>&1
|
|
20
|
-
npm install sucrase >/dev/null 2>&1
|
|
21
|
-
# `production: true` emits classic React.createElement calls (no dev __source/
|
|
22
|
-
# __self props), which the vendored React UMD build consumes.
|
|
23
|
-
echo "import { transform } from 'sucrase';
|
|
24
|
-
globalThis.transformJSX = (code) => transform(code, { transforms: ['jsx'], production: true }).code;" > entry.js
|
|
25
|
-
|
|
26
|
-
npx --yes esbuild@0.24.0 entry.js --bundle --format=iife --minify --outfile="$OUT"
|
|
27
|
-
echo "Wrote $OUT ($(wc -c < "$OUT") bytes)"
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Regenerate test/fixtures/stimulus-tests.umd.js — the @hotwired/stimulus QUnit
|
|
3
|
-
# suite bundled into a single IIFE script runnable in the QuickJS VM.
|
|
4
|
-
#
|
|
5
|
-
# Requirements: git, node + npx (esbuild is fetched on demand).
|
|
6
|
-
#
|
|
7
|
-
# script/build_stimulus_tests.sh [stimulus-git-ref]
|
|
8
|
-
#
|
|
9
|
-
# Stimulus runs its tests under Karma+webpack; we replicate the inputs:
|
|
10
|
-
# 1. Clone the source (its tests live in src/tests, not the npm package).
|
|
11
|
-
# 2. Replace the webpack-only `require.context` entry with an explicit entry
|
|
12
|
-
# that imports every *_tests.ts module and calls defineModule().
|
|
13
|
-
# 3. Patch TestCase.testPropertyNames to use getOwnPropertyNames instead of
|
|
14
|
-
# Object.keys — native ES2017 class methods are non-enumerable, whereas
|
|
15
|
-
# Stimulus's own es5 (tsc) build emits enumerable prototype assignments.
|
|
16
|
-
# The two are equivalent (own, string-keyed); this just lets the esbuild
|
|
17
|
-
# ES2017 bundle discover the `test ...` methods.
|
|
18
|
-
# 4. esbuild-bundle to an IIFE that references a global QUnit (the shim in
|
|
19
|
-
# test/support/qunit_shim.js provides it at run time).
|
|
20
|
-
set -euo pipefail
|
|
21
|
-
|
|
22
|
-
REF="${1:-v3.2.2}"
|
|
23
|
-
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
24
|
-
OUT="$ROOT/test/fixtures/stimulus-tests.umd.js"
|
|
25
|
-
TMP="$(mktemp -d)"
|
|
26
|
-
trap 'rm -rf "$TMP"' EXIT
|
|
27
|
-
|
|
28
|
-
echo "Cloning hotwired/stimulus @ $REF ..."
|
|
29
|
-
git clone --depth 1 --branch "$REF" -q https://github.com/hotwired/stimulus.git "$TMP/stimulus"
|
|
30
|
-
cd "$TMP/stimulus"
|
|
31
|
-
|
|
32
|
-
# (3) enumerability patch
|
|
33
|
-
ruby -i -pe 'sub(/Object\.keys\(this\.prototype\)/, "Object.getOwnPropertyNames(this.prototype)")' \
|
|
34
|
-
src/tests/cases/test_case.ts
|
|
35
|
-
|
|
36
|
-
# (2) explicit entry replacing require.context
|
|
37
|
-
ruby -e '
|
|
38
|
-
mods = Dir.chdir("src/tests") { Dir["modules/**/*_tests.ts"].sort }
|
|
39
|
-
imports = mods.each_with_index.map { |m, i| %(import M#{i} from "./#{m.sub(/\.ts$/, "")}") }
|
|
40
|
-
File.write("src/tests/conformance.entry.ts",
|
|
41
|
-
imports.join("\n") + "\n" +
|
|
42
|
-
"const MODULES = [#{mods.size.times.map { |i| "M#{i}" }.join(", ")}]\n" +
|
|
43
|
-
"MODULES.forEach((c) => c.defineModule())\n")
|
|
44
|
-
puts "entry: #{mods.size} modules"
|
|
45
|
-
'
|
|
46
|
-
|
|
47
|
-
# (4) bundle
|
|
48
|
-
npx --yes esbuild@0.24.0 src/tests/conformance.entry.ts \
|
|
49
|
-
--bundle --format=iife --target=es2017 --tsconfig=tsconfig.json \
|
|
50
|
-
--outfile="$OUT"
|
|
51
|
-
|
|
52
|
-
echo "Wrote $OUT"
|