dommy-js-quickjs 0.9.0 → 0.11.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.
@@ -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"