bun_bun_bundle 0.12.1 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c600790c95f2bacc7697a8d50cc9e74512a272709a8331efa6d6467656f6946
4
- data.tar.gz: 434ce76cca79dc85965958b693a03e4f71761ec1c477c36430edff6e5346f188
3
+ metadata.gz: 344a23b7763c7396493c708342f37c5caf1df751cac1fbbaa77b4d6e73c31aea
4
+ data.tar.gz: c82e810ba3503fc7f1808074ceb61cc8cb8095a6617d267692b61e9fb864780c
5
5
  SHA512:
6
- metadata.gz: 879819d9d12864a9091fdbcf8c86a88aa09582c3d2b9388ea0e2b1040ba8fc1922b833e272e669fced5cc7c1720cbd38b88a7c663796a7d1fe20e2bb3143afe0
7
- data.tar.gz: c70195c48956b37ce6c8af700f05015f065b90089aa21501ee0365f8a40d7677a9a97b0195605e1b86bc681528824ed79ddeaaaa9eff1f10fc91dbeab2b074b1
6
+ metadata.gz: 14da826847e471f03ad80b4763c34e10fb9cef6b91a41bd0525bcc62eb17039bd8043bf3d9c6ed0232aa2842f2daad1f05adde5121c5b45fe6925645a7bc713d
7
+ data.tar.gz: 269aafe80a301d4c662e7417413a9e98d3fa5225320df0f641526ae978c1e363defb7019f0f2c67d398d48a8d85b4d4dbddfa3da0e7ee4dc356da0a23ac1bbf2
data/README.md CHANGED
@@ -183,6 +183,9 @@ bun_bun_bundle build --fingerprint
183
183
  # Strip sourcemaps from a prod build
184
184
  bun_bun_bundle build --prod --sourcemap=none
185
185
 
186
+ # Production build with Subresource Integrity digests
187
+ bun_bun_bundle build --prod --sri=sha384
188
+
186
189
  # Development with verbose WebSocket logging
187
190
  bun_bun_bundle dev --debug
188
191
  ```
@@ -196,8 +199,16 @@ bun_bun_bundle dev --debug
196
199
  to `inline` in `dev` and `linked` for builds, so production stack traces
197
200
  and browser devtools stay debuggable. Pass `--sourcemap=none` when you
198
201
  explicitly do not want maps shipped.
202
+ - `--sri[=ALGOS]`: compute [Subresource Integrity][sri] digests for each
203
+ asset. Pass a comma-separated list of `sha256`, `sha384`, or `sha512`
204
+ (bare `--sri` defaults to `sha384`). When digests are present, the
205
+ `bun_js_tag` and `bun_css_tag` helpers automatically render
206
+ `integrity="..." crossorigin="anonymous"` so browsers verify the response
207
+ before executing it
199
208
  - `--debug`: verbose WebSocket logging
200
209
 
210
+ [sri]: https://developer.mozilla.org/docs/Web/Security/Subresource_Integrity
211
+
201
212
  > [!NOTE]
202
213
  > When running from a Procfile (e.g. with Overmind or Foreman), use
203
214
  > `bundle exec bun_bun_bundle` to ensure the correct gem version is loaded.
data/exe/bun_bun_bundle CHANGED
@@ -27,6 +27,9 @@ else
27
27
  --minify Minify JS and CSS output
28
28
  --sourcemap[=KIND] Sourcemap kind: inline, linked, external, none
29
29
  (dev defaults to inline, builds default to linked)
30
+ --sri[=ALGOS] Compute Subresource Integrity digests for each
31
+ asset (comma-separated: sha256, sha384, sha512;
32
+ defaults to sha384)
30
33
  --debug Enable verbose WebSocket logging
31
34
 
32
35
  Examples:
@@ -34,6 +37,7 @@ else
34
37
  bun_bun_bundle build # plain build
35
38
  bun_bun_bundle build --prod # fingerprint + minify
36
39
  bun_bun_bundle build --fingerprint # fingerprint only
40
+ bun_bun_bundle build --prod --sri=sha384 # add integrity digests
37
41
  bun_bun_bundle build --prod --sourcemap=none # strip sourcemaps
38
42
 
39
43
  Configuration:
@@ -24,16 +24,17 @@ export default {
24
24
  fingerprint: false,
25
25
  minify: false,
26
26
  sourcemap: null,
27
+ sri: [],
27
28
  wsClients: new Set(),
28
29
  watchTimers: new Map(),
30
+ watchers: [],
29
31
  plugins: [],
30
32
 
33
+ SRI_ALGORITHMS: ['sha256', 'sha384', 'sha512'],
34
+
31
35
  flags(input) {
32
- const {debug, dev, prod, fingerprint, minify, sourcemap} = Array.isArray(
33
- input
34
- )
35
- ? this.parseArgv(input)
36
- : input
36
+ const {debug, dev, prod, fingerprint, minify, sourcemap, sri} =
37
+ Array.isArray(input) ? this.parseArgv(input) : input
37
38
  if (debug != null) this.debug = debug
38
39
  if (dev != null) this.dev = dev
39
40
  if (prod != null) this.prod = prod
@@ -42,31 +43,74 @@ export default {
42
43
  if (minify != null) this.minify = minify
43
44
  else if (prod === true) this.minify = true
44
45
  if (sourcemap != null) this.sourcemap = sourcemap
46
+ if (sri != null) this.sri = sri
45
47
  },
46
48
 
47
49
  SOURCEMAP_KINDS: ['inline', 'linked', 'external', 'none'],
50
+ BOOLEAN_FLAGS: ['debug', 'dev', 'prod', 'fingerprint', 'minify'],
48
51
 
49
52
  parseArgv(argv) {
53
+ return {
54
+ ...this.parseBooleanFlags(argv),
55
+ ...this.parseSourcemapFlag(argv),
56
+ ...this.parseSriFlag(argv)
57
+ }
58
+ },
59
+
60
+ parseBooleanFlags(argv) {
50
61
  const opts = {}
51
- if (argv.includes('--debug')) opts.debug = true
52
- if (argv.includes('--dev')) opts.dev = true
53
- if (argv.includes('--prod')) opts.prod = true
54
- if (argv.includes('--fingerprint')) opts.fingerprint = true
55
- if (argv.includes('--minify')) opts.minify = true
56
- const sm = argv.find(
57
- a => a === '--sourcemap' || a.startsWith('--sourcemap=')
58
- )
59
- if (sm) {
60
- const value = sm.includes('=') ? sm.split('=')[1] : 'linked'
61
- if (this.SOURCEMAP_KINDS.includes(value)) opts.sourcemap = value
62
- else
63
- console.warn(
64
- ` ▸ Ignoring --sourcemap=${value} (valid: ${this.SOURCEMAP_KINDS.join(', ')})`
65
- )
62
+ for (const name of this.BOOLEAN_FLAGS) {
63
+ if (argv.includes(`--${name}`)) opts[name] = true
66
64
  }
67
65
  return opts
68
66
  },
69
67
 
68
+ findValueFlag(argv, name, defaultValue) {
69
+ const flag = argv.find(a => a === `--${name}` || a.startsWith(`--${name}=`))
70
+ if (!flag) return null
71
+ return flag.includes('=') ? flag.split('=')[1] : defaultValue
72
+ },
73
+
74
+ parseSourcemapFlag(argv) {
75
+ const value = this.findValueFlag(argv, 'sourcemap', 'linked')
76
+ if (value === null) return {}
77
+ if (this.SOURCEMAP_KINDS.includes(value)) return {sourcemap: value}
78
+ console.warn(
79
+ ` ▸ Ignoring --sourcemap=${value} (valid: ${this.SOURCEMAP_KINDS.join(', ')})`
80
+ )
81
+ return {}
82
+ },
83
+
84
+ parseSriFlag(argv) {
85
+ const value = this.findValueFlag(argv, 'sri', 'sha384')
86
+ if (value === null) return {}
87
+ const algos = value.split(',').map(a => a.trim()).filter(Boolean)
88
+ const valid = algos.filter(a => this.SRI_ALGORITHMS.includes(a))
89
+ const invalid = algos.filter(a => !this.SRI_ALGORITHMS.includes(a))
90
+ if (invalid.length) {
91
+ console.warn(
92
+ ` ▸ Ignoring --sri=${invalid.join(',')} (valid: ${this.SRI_ALGORITHMS.join(', ')})`
93
+ )
94
+ }
95
+ return valid.length ? {sri: valid} : {}
96
+ },
97
+
98
+ computeSri(content) {
99
+ if (!this.sri.length) return null
100
+ return this.sri.map(algo => {
101
+ const hasher = new Bun.CryptoHasher(algo)
102
+ hasher.update(content)
103
+ return `${algo}-${hasher.digest('base64')}`
104
+ })
105
+ },
106
+
107
+ manifestEntry(url, content) {
108
+ const entry = {url}
109
+ const sri = this.computeSri(content)
110
+ if (sri) entry.sri = sri
111
+ return entry
112
+ },
113
+
70
114
  deepMerge(target, source) {
71
115
  const result = {...target}
72
116
  for (const k of Object.keys(source))
@@ -186,7 +230,10 @@ export default {
186
230
  }
187
231
 
188
232
  await Bun.write(join(outDir, fileName), content)
189
- this.manifest[`${type}/${entryName}${ext}`] = `${type}/${fileName}`
233
+ this.manifest[`${type}/${entryName}${ext}`] = this.manifestEntry(
234
+ `${type}/${fileName}`,
235
+ content
236
+ )
190
237
  }
191
238
  },
192
239
 
@@ -215,20 +262,20 @@ export default {
215
262
  for await (const file of glob.scan({cwd: fullDir, onlyFiles: true})) {
216
263
  const srcPath = join(fullDir, file)
217
264
  const content = await Bun.file(srcPath).arrayBuffer()
265
+ const bytes = new Uint8Array(content)
218
266
 
219
267
  const ext = extname(file)
220
268
  const name = file.slice(0, -ext.length) || file
221
- const fileName = this.fingerprintName(
222
- name,
223
- ext,
224
- new Uint8Array(content)
225
- )
269
+ const fileName = this.fingerprintName(name, ext, bytes)
226
270
  const destPath = join(destDir, fileName)
227
271
 
228
272
  mkdirSync(dirname(destPath), {recursive: true})
229
273
  await Bun.write(destPath, content)
230
274
 
231
- this.manifest[`${assetType}/${file}`] = `${assetType}/${fileName}`
275
+ this.manifest[`${assetType}/${file}`] = this.manifestEntry(
276
+ `${assetType}/${fileName}`,
277
+ bytes
278
+ )
232
279
  }
233
280
  }
234
281
  },
@@ -260,7 +307,10 @@ export default {
260
307
 
261
308
  prettyManifest() {
262
309
  const lines = Object.entries(this.manifest)
263
- .map(([key, value]) => ` ${key} → ${value}`)
310
+ .map(([key, value]) => {
311
+ const url = value && typeof value === 'object' ? value.url : value
312
+ return ` ${key} → ${url}`
313
+ })
264
314
  .join('\n')
265
315
  return `\n${lines}\n\n`
266
316
  },
@@ -338,12 +388,27 @@ export default {
338
388
  console.warn(` ▸ Watch directory ${dir} does not exist, skipping...`)
339
389
  continue
340
390
  }
341
- watch(fullDir, {recursive: true}, handler)
391
+ this.watchers.push(watch(fullDir, {recursive: true}, handler))
342
392
  }
343
393
 
344
394
  console.log('Beginning to watch your project')
345
395
  },
346
396
 
397
+ shutdown() {
398
+ for (const w of this.watchers) {
399
+ try {
400
+ w.close()
401
+ } catch {}
402
+ }
403
+ this.watchers = []
404
+ for (const client of this.wsClients) {
405
+ try {
406
+ client.close()
407
+ } catch {}
408
+ }
409
+ this.wsClients.clear()
410
+ },
411
+
347
412
  async serve() {
348
413
  await this.build()
349
414
  await this.watch()
@@ -353,7 +418,7 @@ export default {
353
418
  const debug = this.debug
354
419
  const wsClients = this.wsClients
355
420
 
356
- Bun.serve({
421
+ const server = Bun.serve({
357
422
  hostname,
358
423
  port,
359
424
  fetch(req, server) {
@@ -376,6 +441,15 @@ export default {
376
441
 
377
442
  const protocol = secure ? 'wss' : 'ws'
378
443
  console.log(`\n\n 🔌 Live reload at ${protocol}://${host}:${port}\n\n`)
444
+
445
+ process.on('SIGINT', () => {
446
+ console.log('\n ▸ Shutting down...')
447
+ this.shutdown()
448
+ try {
449
+ server.stop(true)
450
+ } catch {}
451
+ process.exit(0)
452
+ })
379
453
  },
380
454
 
381
455
  async bake() {
@@ -15,6 +15,8 @@ module BunBunBundle
15
15
  module Helpers
16
16
  include SafeHtml
17
17
 
18
+ FINGERPRINTED_CSS_REGEX = /-[0-9a-f]{8}\.css$/
19
+
18
20
  # Returns the public path to an asset from the manifest.
19
21
  #
20
22
  # Prepends the configured public_path and asset_host:
@@ -23,8 +25,7 @@ module BunBunBundle
23
25
  # bun_asset("images/logo.png") # => "/assets/images/logo-abc123.png" (production)
24
26
  #
25
27
  def bun_asset(path)
26
- fingerprinted = BunBunBundle.manifest[path]
27
- "#{BunBunBundle.asset_host}#{BunBunBundle.config.public_path}/#{fingerprinted}"
28
+ bun_asset_url(bun_entry(path))
28
29
  end
29
30
 
30
31
  # Generates a <script> tag for a JS entry point.
@@ -33,8 +34,11 @@ module BunBunBundle
33
34
  # # => '<script src="/assets/js/app.js" type="text/javascript"></script>'
34
35
  #
35
36
  def bun_js_tag(source, **options)
36
- src = bun_asset(source)
37
- attrs = { type: 'text/javascript' }.merge(options).merge(src: src)
37
+ entry = bun_entry(source)
38
+ attrs = { type: 'text/javascript' }
39
+ .merge(bun_sri_attrs(entry))
40
+ .merge(options)
41
+ .merge(src: bun_asset_url(entry))
38
42
  bun_safe(%(<script #{bun_html_attrs(attrs)}></script>))
39
43
  end
40
44
 
@@ -44,14 +48,19 @@ module BunBunBundle
44
48
  # # => '<link href="/assets/css/app.css" type="text/css" rel="stylesheet">'
45
49
  #
46
50
  def bun_css_tag(source, **options)
51
+ entry = bun_entry(source)
47
52
  attrs = { type: 'text/css', rel: 'stylesheet' }
53
+ .merge(bun_sri_attrs(entry))
48
54
  .merge(options)
49
- .merge(href: bun_href_with_timestamp(bun_asset(source)))
55
+ .merge(href: bun_href_with_timestamp(bun_asset_url(entry)))
50
56
  bun_safe(%(<link #{bun_html_attrs(attrs)}>))
51
57
  end
52
58
 
53
59
  # Generates an <img> tag for an image asset.
54
60
  #
61
+ # Subresource Integrity is intentionally not rendered on images: browsers
62
+ # do not enforce SRI for <img> elements.
63
+ #
55
64
  # bun_img_tag("images/logo.png", alt: "Logo")
56
65
  # # => '<img src="/assets/images/logo.png" alt="Logo">'
57
66
  #
@@ -64,6 +73,20 @@ module BunBunBundle
64
73
 
65
74
  private
66
75
 
76
+ def bun_entry(source)
77
+ BunBunBundle.manifest[source]
78
+ end
79
+
80
+ def bun_asset_url(entry)
81
+ "#{BunBunBundle.asset_host}#{BunBunBundle.config.public_path}/#{entry.url}"
82
+ end
83
+
84
+ def bun_sri_attrs(entry)
85
+ return {} if entry.sri.empty?
86
+
87
+ { integrity: entry.sri.join(' '), crossorigin: 'anonymous' }
88
+ end
89
+
67
90
  def bun_html_attrs(hash)
68
91
  bun_flatten_attrs(hash).compact.map do |k, v|
69
92
  k = k.to_s.tr('_', '-')
@@ -89,7 +112,7 @@ module BunBunBundle
89
112
  def bun_href_with_timestamp(href)
90
113
  config = BunBunBundle.config
91
114
  return href unless href.start_with?(config.public_path)
92
- return href if href.match?(/-[0-9a-f]{8}\.css$/)
115
+ return href if href.match?(FINGERPRINTED_CSS_REGEX)
93
116
 
94
117
  file_path = href.sub(config.public_path, config.out_dir)
95
118
  mtime = File.exist?(file_path) ? File.mtime(file_path).to_i : Time.now.to_i
@@ -5,11 +5,30 @@ require 'json'
5
5
  module BunBunBundle
6
6
  class Manifest
7
7
  class MissingAssetError < StandardError; end
8
+ class MigrationError < StandardError; end
9
+ class InvalidEntryError < StandardError; end
10
+
11
+ Entry = Data.define(:url, :sri) do
12
+ def self.from(value)
13
+ value.is_a?(Hash) || raise(
14
+ MigrationError,
15
+ 'Manifest predates bun_bun_bundle 0.13. Run: bun_bun_bundle build',
16
+ )
17
+
18
+ if (url = value['url']).nil? || url.empty?
19
+ raise InvalidEntryError, "Manifest entry is missing 'url'"
20
+ end
21
+
22
+ new(url: url, sri: Array(value['sri']).freeze)
23
+ end
24
+ end
8
25
 
9
26
  attr_reader :entries
10
27
 
11
28
  def initialize(entries = {})
12
- @entries = entries.freeze
29
+ @entries = entries.transform_values do |value|
30
+ value.is_a?(Entry) ? value : Entry.from(value)
31
+ end.freeze
13
32
  end
14
33
 
15
34
  # Loads the manifest from a JSON file.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BunBunBundle
4
- VERSION = '0.12.1'
4
+ VERSION = '0.13.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bun_bun_bundle
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.1
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wout Fierens
@@ -73,7 +73,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
73
73
  - !ruby/object:Gem::Version
74
74
  version: '0'
75
75
  requirements: []
76
- rubygems_version: 4.0.6
76
+ rubygems_version: 4.0.11
77
77
  specification_version: 4
78
78
  summary: A self-contained asset bundler powered by Bun
79
79
  test_files: []