bun_bun_bundle 0.12.1 → 0.14.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: 39edf3123436dc20c093f42cf59baa94d7cfa12b80cb1024af6fac74d872a2e9
4
+ data.tar.gz: 170ca35cd0f7fa6e81ac9e156ea0e2a2830edef61b54393e2c1eb8b8dd6d36c4
5
5
  SHA512:
6
- metadata.gz: 879819d9d12864a9091fdbcf8c86a88aa09582c3d2b9388ea0e2b1040ba8fc1922b833e272e669fced5cc7c1720cbd38b88a7c663796a7d1fe20e2bb3143afe0
7
- data.tar.gz: c70195c48956b37ce6c8af700f05015f065b90089aa21501ee0365f8a40d7677a9a97b0195605e1b86bc681528824ed79ddeaaaa9eff1f10fc91dbeab2b074b1
6
+ metadata.gz: 2f25361191c1840f4642af90d0cac4e6836a069d6d7850fa4ebff0721b22c5044742776d8b6719efee4c604d09e6c39406f4a0cfeaf8733402680e42bea71b98
7
+ data.tar.gz: 852f04b4ff3ba7cd7110bb083e8effb1d9d0f7e38584e7686afa64ac42158dec178517bce103711f385e07166bebda6b3e71fa22fba368083f9251e84b313fa5
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.
@@ -233,6 +244,10 @@ Place a `config/bun.json` in your project root:
233
244
  > Creating a `bun.json` file is entirely optional. All values shown above are
234
245
  > defaults, you only need to specify what you want to override.
235
246
 
247
+ `watchDirs` entries may be glob patterns. For example, in a Hanami app with
248
+ multiple slices, `"slices/*/assets"` will watch every slice's assets directory
249
+ without having to list them explicitly.
250
+
236
251
  If you're developing inside a Docker container, set `listenHost` so the
237
252
  WebSocket server accepts connections from the host machine:
238
253
 
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:
@@ -1,4 +1,4 @@
1
- import {mkdirSync, readFileSync, existsSync, rmSync, watch} from 'fs'
1
+ import {mkdirSync, readFileSync, existsSync, rmSync, statSync, watch} from 'fs'
2
2
  import {join, dirname, basename, extname} from 'path'
3
3
  import {Glob} from 'bun'
4
4
  import {resolvePlugins} from './plugins/index.js'
@@ -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
  },
@@ -332,18 +382,38 @@ export default {
332
382
  })()
333
383
  }
334
384
 
335
- for (const dir of this.config.watchDirs) {
336
- const fullDir = join(this.root, dir)
337
- if (!existsSync(fullDir)) {
338
- console.warn(` ▸ Watch directory ${dir} does not exist, skipping...`)
339
- continue
385
+ for (const pattern of this.config.watchDirs) {
386
+ const dirs = pattern.includes('*')
387
+ ? await Array.fromAsync(new Glob(pattern).scan({cwd: this.root, onlyFiles: false}))
388
+ : [pattern]
389
+ for (const dir of dirs) {
390
+ const fullDir = join(this.root, dir)
391
+ if (!existsSync(fullDir) || !statSync(fullDir).isDirectory()) {
392
+ console.warn(` ▸ Watch directory ${dir} does not exist, skipping...`)
393
+ continue
394
+ }
395
+ this.watchers.push(watch(fullDir, {recursive: true}, handler))
340
396
  }
341
- watch(fullDir, {recursive: true}, handler)
342
397
  }
343
398
 
344
399
  console.log('Beginning to watch your project')
345
400
  },
346
401
 
402
+ shutdown() {
403
+ for (const w of this.watchers) {
404
+ try {
405
+ w.close()
406
+ } catch {}
407
+ }
408
+ this.watchers = []
409
+ for (const client of this.wsClients) {
410
+ try {
411
+ client.close()
412
+ } catch {}
413
+ }
414
+ this.wsClients.clear()
415
+ },
416
+
347
417
  async serve() {
348
418
  await this.build()
349
419
  await this.watch()
@@ -353,7 +423,7 @@ export default {
353
423
  const debug = this.debug
354
424
  const wsClients = this.wsClients
355
425
 
356
- Bun.serve({
426
+ const server = Bun.serve({
357
427
  hostname,
358
428
  port,
359
429
  fetch(req, server) {
@@ -376,6 +446,15 @@ export default {
376
446
 
377
447
  const protocol = secure ? 'wss' : 'ws'
378
448
  console.log(`\n\n 🔌 Live reload at ${protocol}://${host}:${port}\n\n`)
449
+
450
+ process.on('SIGINT', () => {
451
+ console.log('\n ▸ Shutting down...')
452
+ this.shutdown()
453
+ try {
454
+ server.stop(true)
455
+ } catch {}
456
+ process.exit(0)
457
+ })
379
458
  },
380
459
 
381
460
  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.14.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.14.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: []