vue_live 0.1.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.
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require_relative '../vue_live'
5
+
6
+ module VueLive
7
+ # The `vue-live` executable.
8
+ class CLI
9
+ TEMPLATE_DIR = File.expand_path('templates', __dir__)
10
+
11
+ USAGE = <<~TEXT.freeze
12
+ vue-live #{VueLive::VERSION}
13
+
14
+ Usage: vue-live <command> [options]
15
+
16
+ Commands:
17
+ init Create config/vue_live.yml and app/vue/ with an example component
18
+ --force overwrite existing files
19
+ --node also install @vue/compiler-sfc + vue (npm/yarn)
20
+ compile Write static .vue.js modules + manifest.json (--out DIR, default public/vue)
21
+ clobber Remove precompiled output and the compile cache
22
+ check Verify configuration, components and the Node toolchain
23
+ node-setup Install @vue/compiler-sfc and vue (--with pkg[,pkg] e.g. --with sass,esbuild);
24
+ adds the sucrase TypeScript transpiler when Node.js < 22.13
25
+ info Show versions and resolved settings
26
+ version Show the vue_live version
27
+
28
+ The environment comes from VUE_LIVE_ENV, RAILS_ENV, RACK_ENV or APP_ENV (default: development).
29
+ In a Rails project prefer `bin/rails generate vue_live:install` and the `vue_live:*` rake tasks.
30
+ TEXT
31
+
32
+ def self.start(argv = ARGV, out: $stdout, err: $stderr)
33
+ new(argv, out, err).run
34
+ end
35
+
36
+ def initialize(argv, out = $stdout, err = $stderr)
37
+ @command, *@args = argv
38
+ @out = out
39
+ @err = err
40
+ end
41
+
42
+ def run
43
+ VueLive.load_config_file if %w[compile clobber check info].include?(@command)
44
+ case @command
45
+ when 'init' then init
46
+ when 'compile' then compile
47
+ when 'clobber' then clobber
48
+ when 'check' then check
49
+ when 'node-setup' then node_setup
50
+ when 'info' then info
51
+ when 'version', '--version', '-v' then @out.puts VueLive::VERSION
52
+ when nil, 'help', '--help', '-h' then @out.puts USAGE
53
+ else
54
+ @err.puts "Unknown command: #{@command}\n\n#{USAGE}"
55
+ return 1
56
+ end
57
+ 0
58
+ rescue VueLive::Error => e
59
+ @err.puts e.message
60
+ 1
61
+ end
62
+
63
+ private
64
+
65
+ def root
66
+ VueLive.config.root
67
+ end
68
+
69
+ def init
70
+ force = @args.include?('--force')
71
+ if VueLive.rails_project?(root)
72
+ @out.puts 'Rails project detected: creating the same files `bin/rails generate vue_live:install` would.'
73
+ end
74
+ write('config/vue_live.yml', File.read(File.join(TEMPLATE_DIR, 'vue_live.yml')), force)
75
+ FileUtils.mkdir_p(File.join(root, VueLive.config.source_path))
76
+ write(File.join(VueLive.config.source_path, 'HelloVueLive.vue'), File.read(File.join(TEMPLATE_DIR, 'HelloVueLive.vue')),
77
+ force)
78
+ NodeTools.setup(root) if @args.include?('--node')
79
+ @out.puts ''
80
+ @out.puts 'Done. Mount the middleware (`use VueLive::Middleware`, or `register VueLive::Sinatra`) and render:'
81
+ @out.puts ''
82
+ @out.puts ' <%= vue_live_import_map_tag %>'
83
+ @out.puts ' <%= vue_live_mount_tag "HelloVueLive.vue", "#app", element: true %>'
84
+ end
85
+
86
+ def compile
87
+ out_dir = option_value('--out') || VueLive.config.precompile_dir
88
+ manifest = Precompiler.new.run(output: File.expand_path(out_dir, root), out: @out)
89
+ @out.puts " #{manifest.size} file(s) written to #{out_dir}"
90
+ end
91
+
92
+ def clobber
93
+ Precompiler.new.clobber
94
+ VueLive.store.clear
95
+ @out.puts " removed #{VueLive.config.precompile_dir} and #{VueLive.config.cache_dir}"
96
+ end
97
+
98
+ def check
99
+ problems = NodeTools.problems
100
+ components = VueLive.store.resolver.components
101
+ @out.puts " components: #{VueLive.config.source_dir} (#{components.size} .vue file(s))"
102
+ components.each do |c|
103
+ compiled = VueLive.store.fetch(c)
104
+ @out.puts " ok #{c} (#{compiled.backend})"
105
+ rescue VueLive::Error => e
106
+ problems << e.message
107
+ @out.puts " FAIL #{c}"
108
+ end
109
+ if problems.empty?
110
+ @out.puts 'Everything looks good.'
111
+ else
112
+ problems.each { |p| @out.puts " problem: #{p}" }
113
+ raise Error, "#{problems.size} problem(s) found"
114
+ end
115
+ end
116
+
117
+ def node_setup
118
+ extra = option_value('--with').to_s.split(',').map(&:strip).reject(&:empty?)
119
+ NodeTools.setup(root, packages: NodeTools::PACKAGES + extra)
120
+ @out.puts 'Node backend ready: set `compiler: node` (or leave `auto`) in config/vue_live.yml.'
121
+ end
122
+
123
+ def info
124
+ cfg = VueLive.config
125
+ {
126
+ 'vue_live' => VueLive::VERSION,
127
+ 'ruby' => RUBY_VERSION,
128
+ 'root' => cfg.root,
129
+ 'env' => cfg.env,
130
+ 'rails project' => VueLive.rails_project?(cfg.root),
131
+ 'components' => cfg.source_dir,
132
+ 'prefix' => cfg.normalized_prefix,
133
+ 'compiler' => cfg.compiler,
134
+ 'cache' => cfg.cache,
135
+ 'vue url' => cfg.resolved_vue_url,
136
+ 'node' => NodeTools.node_version || 'not found',
137
+ '@vue/compiler-sfc' => NodeTools.compiler_sfc_version || 'not installed',
138
+ 'package manager' => NodeTools.package_manager,
139
+ 'webpacker_cli' => NodeTools.webpacker_cli? ? 'available' : 'not installed'
140
+ }.each { |k, v| @out.puts "#{k}: #{v}" }
141
+ end
142
+
143
+ def write(rel, content, force)
144
+ path = File.join(root, rel)
145
+ if File.exist?(path) && !force
146
+ @out.puts " exist #{rel}"
147
+ return
148
+ end
149
+ FileUtils.mkdir_p(File.dirname(path))
150
+ File.write(path, content)
151
+ @out.puts " create #{rel}"
152
+ end
153
+
154
+ def option_value(flag)
155
+ idx = @args.index(flag)
156
+ return @args[idx + 1] if idx && @args[idx + 1]
157
+
158
+ @args.find { |a| a.start_with?("#{flag}=") }&.split('=', 2)&.last
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,294 @@
1
+ #!/usr/bin/env node
2
+ // Compiles .vue files with @vue/compiler-sfc. Driven by lib/vue_live/compiler/node.rb.
3
+ //
4
+ // One-shot: node compile.js <project-root> < request JSON
5
+ // Worker: node compile.js <project-root> --server (one request JSON per stdin line,
6
+ // one response JSON per stdout line)
7
+ //
8
+ // Request: { id?, source, filename, absolutePath?, scopeId, isProd, sourceMap? }
9
+ // Response: { id?, code, css, map?, scoped, dependencies, errors, tips }
10
+ //
11
+ // Style injection and the `.vue` -> `.vue.js` import rewrite happen on the Ruby side so both
12
+ // backends share them.
13
+ 'use strict'
14
+
15
+ // node:module#stripTypeScriptTypes prints an ExperimentalWarning; keep stdout/stderr clean for Ruby.
16
+ process.removeAllListeners('warning')
17
+ process.on('warning', () => {})
18
+
19
+ const path = require('path')
20
+ const args = process.argv.slice(2)
21
+ const serverMode = args.includes('--server')
22
+ const root = args.find(a => !a.startsWith('--')) || process.cwd()
23
+
24
+ function resolveFrom(name, from) {
25
+ return require(require.resolve(name, { paths: [from, root, process.cwd(), __dirname] }))
26
+ }
27
+
28
+ let sfc
29
+ let sfcDir
30
+ try {
31
+ const entry = require.resolve('@vue/compiler-sfc', { paths: [root, process.cwd(), __dirname] })
32
+ sfc = require(entry)
33
+ sfcDir = path.dirname(entry)
34
+ } catch (e) {
35
+ const err = { errors: ['@vue/compiler-sfc is not installed in ' + root + ' (run `vue-live node-setup`)'] }
36
+ process.stdout.write(JSON.stringify(err) + '\n')
37
+ process.exit(1)
38
+ }
39
+
40
+ // source-map-js is a dependency of @vue/compiler-sfc; use it to merge the script and template maps.
41
+ let sourceMapJs = null
42
+ try { sourceMapJs = resolveFrom('source-map-js', sfcDir) } catch (e) { /* source maps unavailable */ }
43
+
44
+ if (serverMode) {
45
+ serve()
46
+ } else {
47
+ let input = ''
48
+ process.stdin.setEncoding('utf8')
49
+ process.stdin.on('data', chunk => { input += chunk })
50
+ process.stdin.on('end', () => {
51
+ handle(JSON.parse(input)).then(
52
+ result => { process.stdout.write(JSON.stringify(result) + '\n'); process.exit(0) },
53
+ err => { process.stdout.write(JSON.stringify({ errors: [String(err && err.stack || err)] }) + '\n'); process.exit(1) }
54
+ )
55
+ })
56
+ }
57
+
58
+ // Long-lived worker: requests are processed strictly in order so Ruby can pair responses by id.
59
+ function serve() {
60
+ const readline = require('readline')
61
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity })
62
+ let queue = Promise.resolve()
63
+ rl.on('line', line => {
64
+ if (!line.trim()) return
65
+ queue = queue.then(async () => {
66
+ let request = {}
67
+ let response
68
+ try {
69
+ request = JSON.parse(line)
70
+ response = await handle(request)
71
+ } catch (err) {
72
+ response = { errors: [String(err && err.stack || err)] }
73
+ }
74
+ response.id = request.id
75
+ process.stdout.write(JSON.stringify(response) + '\n')
76
+ })
77
+ })
78
+ rl.on('close', () => process.exit(0))
79
+ process.stdin.on('end', () => process.exit(0))
80
+ }
81
+
82
+ async function handle(opts) {
83
+ try {
84
+ return await compile(opts)
85
+ } catch (err) {
86
+ return { errors: [formatError(err)] }
87
+ }
88
+ }
89
+
90
+ async function compile(opts) {
91
+ const { source, filename, scopeId, isProd } = opts
92
+ const wantMap = !!opts.sourceMap && !!sourceMapJs
93
+ const absolutePath = opts.absolutePath || path.join(root, filename)
94
+ const id = scopeId.replace(/^data-v-/, '')
95
+ const tips = []
96
+ const dependencies = []
97
+
98
+ const { descriptor, errors: parseErrors } = sfc.parse(source, { filename: absolutePath, sourceMap: wantMap })
99
+ if (parseErrors.length) return { errors: parseErrors.map(formatError) }
100
+
101
+ const hasScoped = descriptor.styles.some(s => s.scoped)
102
+ const hasSetup = !!descriptor.scriptSetup
103
+ const inlineTemplate = isProd && hasSetup
104
+ const parts = [] // { code, map? } concatenated with "\n"
105
+ let bindings
106
+
107
+ const templateOptions = {
108
+ filename: absolutePath,
109
+ id,
110
+ scoped: hasScoped,
111
+ slotted: descriptor.slotted,
112
+ isProd,
113
+ preprocessLang: descriptor.template && descriptor.template.lang,
114
+ compilerOptions: { mode: 'module' }
115
+ }
116
+
117
+ if (descriptor.script || descriptor.scriptSetup) {
118
+ const script = sfc.compileScript(descriptor, {
119
+ id,
120
+ isProd,
121
+ inlineTemplate,
122
+ genDefaultAs: '__sfc__',
123
+ templateOptions,
124
+ sourceMap: wantMap
125
+ })
126
+ let code = script.content
127
+ let map = script.map
128
+ bindings = script.bindings
129
+ // Older compiler-sfc versions ignore genDefaultAs; normalise either way.
130
+ if (/^\s*export\s+default\b/m.test(code) && !/\bconst __sfc__\b/.test(code)) {
131
+ code = sfc.rewriteDefault(code, '__sfc__')
132
+ }
133
+ if (script.warnings) script.warnings.forEach(w => tips.push(String(w)))
134
+ const lang = (descriptor.scriptSetup && descriptor.scriptSetup.lang) || (descriptor.script && descriptor.script.lang)
135
+ if (lang && /^tsx?$/.test(lang)) {
136
+ const stripped = stripTypes(code, absolutePath, lang)
137
+ code = stripped.code
138
+ if (!stripped.preservesLines) map = null
139
+ }
140
+ parts.push({ code, map })
141
+ } else {
142
+ parts.push({ code: 'const __sfc__ = {}' })
143
+ }
144
+
145
+ if (descriptor.template && !inlineTemplate) {
146
+ const tpl = sfc.compileTemplate(Object.assign({}, templateOptions, {
147
+ source: descriptor.template.content,
148
+ inMap: wantMap ? descriptor.template.map : undefined,
149
+ compilerOptions: { mode: 'module', bindingMetadata: bindings, sourceMap: wantMap }
150
+ }))
151
+ if (tpl.errors && tpl.errors.length) return { errors: tpl.errors.map(formatError) }
152
+ if (tpl.tips) tpl.tips.forEach(t => tips.push(String(t)))
153
+ const code = tpl.code.replace(/\nexport\s+(function|const)\s+render\b/, '\n$1 render') + '\n__sfc__.render = render'
154
+ parts.push({ code, map: tpl.map })
155
+ }
156
+
157
+ const cssParts = []
158
+ const cssModules = {}
159
+ for (const style of descriptor.styles) {
160
+ const result = await sfc.compileStyleAsync({
161
+ source: style.content,
162
+ filename: absolutePath,
163
+ id: scopeId,
164
+ scoped: !!style.scoped,
165
+ modules: !!style.module,
166
+ preprocessLang: style.lang,
167
+ isProd
168
+ })
169
+ if (result.errors && result.errors.length) return { errors: result.errors.map(formatError) }
170
+ cssParts.push(result.code)
171
+ if (style.module) {
172
+ const name = typeof style.module === 'string' ? style.module : '$style'
173
+ cssModules[name] = result.modules || {}
174
+ }
175
+ if (result.dependencies) for (const d of result.dependencies) dependencies.push(d)
176
+ }
177
+
178
+ let tail = ''
179
+ if (hasScoped) tail += `\n__sfc__.__scopeId = ${JSON.stringify(scopeId)}`
180
+ if (Object.keys(cssModules).length) tail += `\n__sfc__.__cssModules = ${JSON.stringify(cssModules)}`
181
+ if (!isProd) tail += `\n__sfc__.__file = ${JSON.stringify(filename)}`
182
+ tail += '\nexport default __sfc__\n'
183
+ parts.push({ code: tail.replace(/^\n/, '') })
184
+
185
+ const code = parts.map(p => p.code).join('\n')
186
+ const map = wantMap ? mergeMaps(parts, filename, source) : undefined
187
+ return { code, css: cssParts.join('\n'), map, scoped: hasScoped, dependencies, errors: [], tips }
188
+ }
189
+
190
+ // Concatenate the per-block source maps, shifting generated lines by each block's offset. All
191
+ // blocks map back to the single .vue file.
192
+ function mergeMaps(parts, filename, source) {
193
+ const generator = new sourceMapJs.SourceMapGenerator({ file: filename + '.js' })
194
+ let lineOffset = 0
195
+ for (const part of parts) {
196
+ if (part.map) {
197
+ try {
198
+ const consumer = new sourceMapJs.SourceMapConsumer(typeof part.map === 'string' ? JSON.parse(part.map) : part.map)
199
+ consumer.eachMapping(m => {
200
+ if (m.originalLine == null) return
201
+ generator.addMapping({
202
+ generated: { line: m.generatedLine + lineOffset, column: m.generatedColumn },
203
+ original: { line: m.originalLine, column: m.originalColumn || 0 },
204
+ source: filename,
205
+ name: m.name || undefined
206
+ })
207
+ })
208
+ } catch (e) { /* a block without a usable map is simply unmapped */ }
209
+ }
210
+ lineOffset += part.code.split('\n').length
211
+ }
212
+ generator.setSourceContent(filename, source)
213
+ return generator.toJSON()
214
+ }
215
+
216
+ // @vue/compiler-sfc leaves TypeScript syntax in place (bundlers strip it later), so do it here with
217
+ // whichever transpiler the project has. Returns { code, preservesLines }; a transpiler that keeps
218
+ // line numbers lets the script source map stay valid.
219
+ function stripTypes(code, filename, lang) {
220
+ const resolve = name => require(require.resolve(name, { paths: [root, process.cwd()] }))
221
+ const attempts = {
222
+ // Node.js >= 22.13 ships a type stripper (swc via amaro): no install needed. 'strip' mode
223
+ // replaces types with whitespace and keeps every line in place.
224
+ node: () => {
225
+ const strip = require('node:module').stripTypeScriptTypes
226
+ if (typeof strip !== 'function') throw notFound('node:module#stripTypeScriptTypes (Node.js >= 22.13)')
227
+ try { return { code: strip(code, { mode: 'strip' }), preservesLines: true } } catch (e) {
228
+ return { code: strip(code, { mode: 'transform' }), preservesLines: false }
229
+ }
230
+ },
231
+ sucrase: () => ({ code: resolve('sucrase').transform(code, { transforms: lang === 'tsx' ? ['typescript', 'jsx'] : ['typescript'], filePath: filename }).code, preservesLines: true }),
232
+ esbuild: () => ({ code: resolve('esbuild').transformSync(code, { loader: lang, format: 'esm', target: 'es2020', sourcefile: filename }).code, preservesLines: false }),
233
+ typescript: () => {
234
+ const ts = resolve('typescript')
235
+ // TypeScript 7 (the native port) exposes no transpile API from JavaScript.
236
+ if (typeof ts.transpileModule !== 'function') throw notFound('typescript#transpileModule (TypeScript < 7)')
237
+ const jsx = lang === 'tsx' ? ts.JsxEmit.Preserve : undefined
238
+ return {
239
+ code: ts.transpileModule(code, {
240
+ fileName: filename,
241
+ compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020, jsx, verbatimModuleSyntax: true }
242
+ }).outputText,
243
+ preservesLines: false
244
+ }
245
+ },
246
+ babel: () => {
247
+ const babel = resolve('@babel/core')
248
+ let plugin
249
+ try { plugin = require.resolve('@babel/plugin-transform-typescript', { paths: [root, process.cwd()] }) } catch (e) {
250
+ plugin = require.resolve('@babel/preset-typescript', { paths: [root, process.cwd()] })
251
+ }
252
+ const isPreset = /preset-typescript/.test(plugin)
253
+ return {
254
+ code: babel.transformSync(code, {
255
+ filename, babelrc: false, configFile: false, sourceType: 'module', retainLines: true,
256
+ presets: isPreset ? [[plugin, { onlyRemoveTypeImports: true }]] : [],
257
+ plugins: isPreset ? [] : [[plugin, { onlyRemoveTypeImports: true, isTSX: lang === 'tsx' }]]
258
+ }).code,
259
+ preservesLines: true
260
+ }
261
+ }
262
+ }
263
+
264
+ // VUE_LIVE_TS_TRANSPILER=node|sucrase|esbuild|typescript|babel pins one; otherwise try in order.
265
+ const forced = process.env.VUE_LIVE_TS_TRANSPILER
266
+ const order = forced ? [forced] : Object.keys(attempts)
267
+ const failures = []
268
+ for (const name of order) {
269
+ const attempt = attempts[name]
270
+ if (!attempt) throw new Error('unknown VUE_LIVE_TS_TRANSPILER ' + JSON.stringify(forced) + '; expected one of ' + Object.keys(attempts).join(', '))
271
+ try { return attempt() } catch (e) { failures.push(e) }
272
+ }
273
+ const real = failures.find(e => e && e.code !== 'MODULE_NOT_FOUND')
274
+ if (real) throw real
275
+ throw new Error(
276
+ '<script lang="ts"> needs a TypeScript transpiler and none is available (Node.js ' + process.version +
277
+ ' has no built-in one; that needs >= 22.13). Install one in the project: `vue-live node-setup --with sucrase` ' +
278
+ '(or esbuild, @babel/core + @babel/plugin-transform-typescript, typescript < 7); for this gem\'s own tests run `rake test:setup`.'
279
+ )
280
+ }
281
+
282
+ function notFound(what) {
283
+ const e = new Error(what + ' is not available')
284
+ e.code = 'MODULE_NOT_FOUND'
285
+ return e
286
+ }
287
+
288
+ function formatError(e) {
289
+ if (!e) return 'unknown error'
290
+ if (typeof e === 'string') return e
291
+ let msg = e.message || String(e)
292
+ if (e.loc && e.loc.start) msg += ` (line ${e.loc.start.line}, column ${e.loc.start.column})`
293
+ return msg
294
+ }
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'io/wait'
6
+
7
+ module VueLive
8
+ module Compiler
9
+ # Backend that drives @vue/compiler-sfc through Node.js (lib/vue_live/compiler/node/compile.js).
10
+ #
11
+ # Templates are compiled to render functions, so the runtime-only Vue build is enough;
12
+ # <script setup>, TypeScript, Pug, Sass/Less/Stylus and CSS modules all work when the matching
13
+ # npm packages are installed in the project.
14
+ #
15
+ # Requests go to a long-lived worker process (one per configuration), so after the first
16
+ # compile each one costs a few milliseconds instead of a Node start-up. Set
17
+ # config.node_worker = false to spawn one process per compile instead.
18
+ class Node < Base
19
+ SCRIPT = File.expand_path('node/compile.js', __dir__)
20
+ REQUEST_TIMEOUT = 60 # seconds; a hung worker is killed and restarted
21
+
22
+ @availability = {}
23
+ @workers = {}
24
+ @mutex = Mutex.new
25
+
26
+ class << self
27
+ # true when `node` runs and @vue/compiler-sfc resolves from the project root.
28
+ def available?(config = VueLive.config)
29
+ key = [config.node_bin, config.root]
30
+ @mutex.synchronize do
31
+ return @availability[key] if @availability.key?(key)
32
+
33
+ @availability[key] = probe(config)
34
+ end
35
+ end
36
+
37
+ def reset!
38
+ @mutex.synchronize do
39
+ @availability.clear
40
+ @workers.each_value(&:stop)
41
+ @workers.clear
42
+ end
43
+ end
44
+
45
+ # The shared worker for +config+ (started on first use).
46
+ def worker_for(config)
47
+ key = [config.node_bin, config.root.to_s]
48
+ @mutex.synchronize { @workers[key] ||= Worker.new(config) }
49
+ end
50
+
51
+ private
52
+
53
+ def probe(config)
54
+ out, status = Open3.capture2e(
55
+ config.node_bin, '-e',
56
+ "require.resolve('@vue/compiler-sfc', { paths: [process.argv[1]] })",
57
+ config.root.to_s
58
+ )
59
+ status.success? || (config.logger.debug("[vue_live] Node backend unavailable: #{out.strip}") && false)
60
+ rescue Errno::ENOENT, Errno::EACCES
61
+ false
62
+ end
63
+ end
64
+
65
+ def compile(descriptor, relative_path:, absolute_path: nil)
66
+ scope_id = Compiler.scope_id(relative_path)
67
+ payload = {
68
+ source: descriptor.source,
69
+ filename: relative_path,
70
+ absolutePath: absolute_path,
71
+ scopeId: scope_id,
72
+ isProd: config.production?,
73
+ sourceMap: config.source_maps?
74
+ }
75
+
76
+ data = if config.node_worker
77
+ self.class.worker_for(config).request(payload)
78
+ else
79
+ one_shot(payload)
80
+ end
81
+ check_errors!(data, relative_path)
82
+
83
+ Result.new(code: data['code'].to_s, css: data['css'].to_s, source_map: data['map'],
84
+ scope_id: data['scoped'] ? scope_id : nil, backend: :node,
85
+ dependencies: Array(data['dependencies']))
86
+ rescue Errno::ENOENT
87
+ raise CompileError.new("Node.js executable not found: #{config.node_bin}", file: relative_path)
88
+ end
89
+
90
+ private
91
+
92
+ def one_shot(payload)
93
+ out, err, status = Open3.capture3(config.node_bin, SCRIPT, config.root.to_s, stdin_data: JSON.generate(payload))
94
+ data = JSON.parse(out)
95
+ if !status.success? && Array(data['errors']).empty?
96
+ data['errors'] =
97
+ [err.strip.empty? ? 'node exited with an error' : err.strip]
98
+ end
99
+ data
100
+ rescue JSON::ParserError
101
+ { 'errors' => ["unexpected output from compile.js: #{(err.to_s + out.to_s).strip}"] }
102
+ end
103
+
104
+ def check_errors!(data, relative_path)
105
+ errors = Array(data['errors'])
106
+ raise CompileError.new(errors.join("\n"), file: relative_path) unless errors.empty?
107
+
108
+ Array(data['tips']).each { |tip| config.logger.warn("[vue_live] #{relative_path}: #{tip}") }
109
+ end
110
+
111
+ # A `node compile.js --server` process speaking newline-delimited JSON over stdin/stdout.
112
+ # Requests are serialised with a mutex; the worker is restarted transparently when it dies.
113
+ class Worker
114
+ def initialize(config)
115
+ @config = config
116
+ @mutex = Mutex.new
117
+ @sequence = 0
118
+ @pid = nil
119
+ at_exit { stop }
120
+ end
121
+
122
+ def request(payload)
123
+ @mutex.synchronize do
124
+ attempts = 0
125
+ loop do
126
+ attempts += 1
127
+ start unless alive?
128
+ begin
129
+ return exchange(payload)
130
+ rescue WorkerDied => e
131
+ stop
132
+ raise CompileError.new("Node worker failed: #{e.message}", file: payload[:filename]) if attempts >= 2
133
+
134
+ @config.logger.warn("[vue_live] Node worker died (#{e.message}); restarting")
135
+ end
136
+ end
137
+ end
138
+ end
139
+
140
+ def alive?
141
+ @pid && @wait_thread&.alive?
142
+ end
143
+
144
+ def stop
145
+ return unless @pid
146
+
147
+ begin
148
+ @stdin&.close
149
+ rescue IOError
150
+ nil
151
+ end
152
+ begin
153
+ Process.kill('TERM', @pid)
154
+ rescue Errno::ESRCH, Errno::EPERM
155
+ nil
156
+ end
157
+ @wait_thread&.join(2)
158
+ @stdout&.close unless @stdout&.closed?
159
+ @stderr&.close unless @stderr&.closed?
160
+ ensure
161
+ @pid = nil
162
+ end
163
+
164
+ private
165
+
166
+ class WorkerDied < StandardError; end
167
+
168
+ def start
169
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@config.node_bin, SCRIPT, @config.root.to_s, '--server')
170
+ @pid = @wait_thread.pid
171
+ @stdin.sync = true
172
+ @config.logger.debug("[vue_live] started Node worker pid #{@pid}")
173
+ end
174
+
175
+ def exchange(payload)
176
+ @sequence += 1
177
+ id = @sequence
178
+ begin
179
+ @stdin.puts(JSON.generate(payload.merge(id: id)))
180
+ rescue Errno::EPIPE, IOError => e
181
+ raise WorkerDied, e.message
182
+ end
183
+
184
+ line = read_line_with_timeout
185
+ data = JSON.parse(line)
186
+ raise WorkerDied, "response id mismatch (#{data['id']} != #{id})" if data.key?('id') && data['id'] != id
187
+
188
+ data
189
+ rescue JSON::ParserError => e
190
+ raise WorkerDied, "bad response: #{e.message}"
191
+ end
192
+
193
+ def read_line_with_timeout
194
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + REQUEST_TIMEOUT
195
+ loop do
196
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
197
+ raise WorkerDied, 'timed out waiting for compile.js' if remaining <= 0
198
+
199
+ next unless @stdout.wait_readable(remaining)
200
+
201
+ line = @stdout.gets
202
+ raise WorkerDied, "exited: #{drain_stderr}" if line.nil?
203
+
204
+ return line
205
+ end
206
+ end
207
+
208
+ def drain_stderr
209
+ @stderr.read_nonblock(8192).to_s.strip
210
+ rescue IO::WaitReadable, IOError, SystemCallError
211
+ ''
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end