Module: Dommy::Js::Quickjs::SourceGuard

Defined in:
lib/dommy/js/quickjs/source_guard.rb

Overview

Source-level workaround for a QuickJS bytecode-generation bug: a ‘for…of` whose ITERABLE expression contains a `yield` fails to COMPILE with an internal “stack underflow” error (the for-of iterator-close finally region miscomputes the operand stack across the generator suspend). V8 / real browsers compile it fine, so SPA bundles ship it — e.g. note.com’s modern build has ‘for (var f of (yield O(), _)) v(f)`, and the whole code-split chunk fails to load, so the app never mounts.

The fix hoists the iterable into a temp ‘var` so the `yield` leaves the for-of operand position — `for (x of (yield a, b)) …` becomes `var t = (yield a, b); for (x of t) …` — semantically identical, and it compiles. Applied ONLY as a retry after a genuine “stack underflow” compile failure (see Backend), so working scripts are never rewritten and an imperfect transform cannot regress anything (the source already failed).

Defined Under Namespace

Classes: Rewriter

Constant Summary collapse

ERROR_MARKER =

The marker QuickJS raises for this codegen bug.

"stack underflow"
IDENT =
/[A-Za-z0-9_$]/.freeze

Class Method Summary collapse

Class Method Details

.atom_end(src, i, prev_sig) ⇒ Object

If ‘src` begins a string, template, regex literal, or comment, return the index just after it; otherwise nil. `prev_sig` (previous significant code char) disambiguates a regex `/` from division.



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/dommy/js/quickjs/source_guard.rb', line 62

def atom_end(src, i, prev_sig)
  case src[i]
  when "/"
    if src[i + 1] == "/"
      src.index("\n", i) || src.length
    elsif src[i + 1] == "*"
      (idx = src.index("*/", i + 2)) ? idx + 2 : src.length
    elsif regex_allowed?(prev_sig)
      scan_regex(src, i)
    end
  when "'", '"'
    scan_quote(src, i, src[i])
  when "`"
    scan_template(src, i)
  end
end

.contains_yield?(expr) ⇒ Boolean

Returns:

  • (Boolean)


202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
# File 'lib/dommy/js/quickjs/source_guard.rb', line 202

def contains_yield?(expr)
  i = 0
  n = expr.length
  prev = nil
  while i < n
    stop = atom_end(expr, i, prev)
    if stop
      prev = expr[stop - 1]
      i = stop
      next
    end
    if expr[i] == "y" && expr[i, 5] == "yield" &&
       !ident_char?(i.zero? ? nil : expr[i - 1]) && !ident_char?(expr[i + 5])
      return true
    end
    prev = expr[i] unless expr[i] =~ /\s/
    i += 1
  end
  false
end

.fix_for_of_yield(source) ⇒ Object

Rewrite every ‘for…of` whose iterable contains a `yield`, hoisting the iterable into a preceding `var`. Returns the source unchanged when there is nothing to do.



33
34
35
36
37
38
39
40
41
42
# File 'lib/dommy/js/quickjs/source_guard.rb', line 33

def fix_for_of_yield(source)
  return source unless source.include?("yield")

  # Scan at the BYTE level (binary encoding): on a multibyte (UTF-8) source
  # — note's bundle has Japanese strings — Ruby's character indexing is
  # O(index), making a char-by-char pass O(n^2). All tokens we look for are
  # ASCII, and UTF-8 continuation bytes (>= 0x80) never collide with them,
  # so byte scanning is both safe and O(1) per position.
  Rewriter.new(source.b).run.force_encoding(source.encoding)
end

.ident_char?(ch) ⇒ Boolean

Returns:

  • (Boolean)


46
47
48
# File 'lib/dommy/js/quickjs/source_guard.rb', line 46

def ident_char?(ch)
  !ch.nil? && IDENT.match?(ch)
end

.match_bracket(src, open, limit: nil) ⇒ Object

Index of the close bracket matching the (/[/{ at ‘open`, skipping nested brackets, strings, templates, regexes and comments. `limit` bounds the scan (a for-head is short; bounding keeps the whole pass linear and avoids a runaway scan when a mis-lexed token unbalances brackets).



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/dommy/js/quickjs/source_guard.rb', line 143

def match_bracket(src, open, limit: nil)
  pairs = {"(" => ")", "[" => "]", "{" => "}"}
  want = [pairs[src[open]]]
  i = open + 1
  n = src.length
  n = [n, open + limit].min if limit
  prev = src[open]
  while i < n
    stop = atom_end(src, i, prev)
    if stop
      prev = src[stop - 1]
      i = stop
      next
    end
    c = src[i]
    if pairs.key?(c)
      want << pairs[c]
    elsif ")]}".include?(c)
      return i if want.size == 1 && want.last == c

      want.pop if want.last == c
    end
    prev = c unless c =~ /\s/
    i += 1
  end
  nil
end

.regex_allowed?(prev_sig) ⇒ Boolean

A ‘/` begins a regex (not division) unless the previous significant code char can end an expression (ident / `)` / `]` / a literal).

Returns:

  • (Boolean)


52
53
54
55
56
57
# File 'lib/dommy/js/quickjs/source_guard.rb', line 52

def regex_allowed?(prev_sig)
  return true if prev_sig.nil?
  return false if ident_char?(prev_sig)

  !")]'\"`".include?(prev_sig)
end

.relevant_error?(error) ⇒ Boolean

Returns:

  • (Boolean)


26
27
28
# File 'lib/dommy/js/quickjs/source_guard.rb', line 26

def relevant_error?(error)
  error.respond_to?(:message) && error.message.to_s.include?(ERROR_MARKER)
end

.scan_quote(src, i, quote) ⇒ Object



79
80
81
82
83
84
85
86
87
88
89
# File 'lib/dommy/js/quickjs/source_guard.rb', line 79

def scan_quote(src, i, quote)
  j = i + 1
  n = src.length
  while j < n
    c = src[j]
    return j + 1 if c == quote

    j += c == "\\" ? 2 : 1
  end
  n
end

.scan_regex(src, i) ⇒ Object

End of a regex literal at ‘i` (past its flags), or nil if it isn’t one (an unescaped newline before the closing ‘/` → it was division).



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/dommy/js/quickjs/source_guard.rb', line 115

def scan_regex(src, i)
  j = i + 1
  n = src.length
  in_class = false
  while j < n
    c = src[j]
    case c
    when "\\" then j += 2; next
    when "[" then in_class = true
    when "]" then in_class = false
    when "/"
      unless in_class
        j += 1
        j += 1 while j < n && src[j] =~ /[a-z]/i
        return j
      end
    when "\n"
      return nil
    end
    j += 1
  end
  nil
end

.scan_template(src, i) ⇒ Object



91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/dommy/js/quickjs/source_guard.rb', line 91

def scan_template(src, i)
  j = i + 1
  n = src.length
  while j < n
    c = src[j]
    return j + 1 if c == "`"
    if c == "\\"
      j += 2
      next
    end
    if c == "$" && src[j + 1] == "{"
      close = match_bracket(src, j + 1)
      return n unless close

      j = close + 1
      next
    end
    j += 1
  end
  n
end

.top_level_of(head) ⇒ Object

Position of the for-of ‘of` keyword at the top level of a for-head, or nil for a for-in / C-style for (a top-level `;` rules out for-of).



173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/dommy/js/quickjs/source_guard.rb', line 173

def top_level_of(head)
  depth = 0
  i = 0
  n = head.length
  prev = nil
  while i < n
    stop = atom_end(head, i, prev)
    if stop
      prev = head[stop - 1]
      i = stop
      next
    end
    c = head[i]
    case c
    when "(", "[", "{" then depth += 1
    when ")", "]", "}" then depth -= 1
    when ";" then return nil if depth.zero?
    when "o"
      if depth.zero? && head[i, 2] == "of" &&
         !ident_char?(i.zero? ? nil : head[i - 1]) && !ident_char?(head[i + 2])
        return i
      end
    end
    prev = c unless c =~ /\s/
    i += 1
  end
  nil
end