1.4.1

Migrating from Webpack to ESM

Amber 2.0 replaces Webpack bundling with native browser ESM modules and import maps. This eliminates the need for Node.js, npm, and complex build configurations.

Why Migrate?

| Aspect | Webpack | Asset Pipeline | |--------|---------|----------------| | Build time | 10-60+ seconds | None | | Configuration | Complex webpack.config.js | Simple Crystal code | | Dependencies | npm, node_modules | CDN or local files | | Debugging | Source maps required | Native browser tools | | Hot reload | Requires HMR plugin | Built-in browser support |

Step-by-Step Migration

1. Add Asset Pipeline Shard

# shard.yml
dependencies:
  asset_pipeline:
    github: amberframework/asset_pipeline
    version: ~> 0.36.0
shards install

2. Create Asset Configuration

# config/initializers/assets.cr
require "asset_pipeline"

JS_SOURCE_PATH = Path["src/javascript"]
JS_OUTPUT_PATH = Path["public/javascript"]

FRONT_LOADER = AssetPipeline::FrontLoader.new(
  js_source_path: JS_SOURCE_PATH,
  js_output_path: JS_OUTPUT_PATH
) do |import_maps|
  import_map = AssetPipeline::ImportMap.new("application", Path["/javascript"])

  # Add your dependencies here (see Step 4)

  import_maps << import_map
end

3. Update Layout

Before (Webpack):

doctype html
html
  head
    title My App
    / Webpack bundle
    script src="/dist/bundle.js"
  body
    == content

After (Asset Pipeline):

<!doctype html>
<html>
  <head>
    <title>My App</title>
    <%= FRONT_LOADER.render_import_map_tag %>
  </head>
  <body>
    <%= content %>
    <%= FRONT_LOADER.render_stimulus_initialization_script %>
  </body>
</html>

4. Migrate Dependencies

Find your npm dependencies and replace with CDN imports:

package.json (before):

{
  "dependencies": {
    "@hotwired/stimulus": "^3.2.2",
    "jquery": "^3.7.1",
    "lodash": "^4.17.21",
    "chart.js": "^4.4.0"
  }
}

Asset Pipeline (after):

# config/initializers/assets.cr
import_map.add_import(
  "@hotwired/stimulus",
  "https://cdn.jsdelivr.net/npm/@hotwired/[email protected]/+esm",
  preload: true
)

import_map.add_import(
  "jquery",
  "https://cdn.jsdelivr.net/npm/[email protected]/+esm"
)

import_map.add_import(
  "lodash",
  "https://cdn.jsdelivr.net/npm/[email protected]/+esm"
)

import_map.add_import(
  "chart.js",
  "https://cdn.jsdelivr.net/npm/[email protected]/+esm"
)

5. Migrate JavaScript Files

Move and update your JavaScript:

Before (src/assets/javascripts/application.js):

// Webpack imports
import { Application } from "@hotwired/stimulus"
import HelloController from "./controllers/hello_controller"

const app = Application.start()
app.register("hello", HelloController)

After (src/javascript/controllers/hello_controller.js):

// Native ESM - same syntax, no bundler
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["output"]

  greet() {
    this.outputTarget.textContent = "Hello!"
  }
}

The Asset Pipeline handles Stimulus initialization automatically.

6. Register Controllers

# config/initializers/assets.cr
import_map = AssetPipeline::ImportMap.new("application", Path["/javascript"])

import_map.add_import(
  "@hotwired/stimulus",
  "https://cdn.jsdelivr.net/npm/@hotwired/[email protected]/+esm",
  preload: true
)

# Register your Stimulus controllers
import_map.add_import("HelloController", "controllers/hello_controller.js")
import_map.add_import("DropdownController", "controllers/dropdown_controller.js")
import_map.add_import("ModalController", "controllers/modal_controller.js")

# Or auto-discover controllers
Dir.glob("#{JS_SOURCE_PATH}/controllers/*_controller.js").each do |file|
  name = File.basename(file, ".js")
    .split("_")
    .map(&.capitalize)
    .join
    .gsub("Controller", "Controller")  # Ensure "Controller" suffix

  import_map.add_import(name, "controllers/#{File.basename(file)}")
end

7. Clean Up Webpack

# Remove Webpack files
rm webpack.config.js
rm -rf node_modules
rm package.json
rm package-lock.json
rm yarn.lock

# Remove Webpack from .gitignore entries
# Edit .gitignore to remove node_modules/, dist/, etc.

Common Migration Patterns

jQuery

Webpack:

import $ from "jquery"

$(document).ready(() => {
  $(".dropdown").dropdown()
})

ESM:

# config/initializers/assets.cr
import_map.add_import("jquery", "https://cdn.jsdelivr.net/npm/[email protected]/+esm")
// src/javascript/app.js
import $ from "jquery"

document.addEventListener("DOMContentLoaded", () => {
  $(".dropdown").dropdown()
})

React/Vue/Angular

For complex SPA frameworks, you have options:

Option 1: Use ESM builds from CDN

# React
import_map.add_import("react", "https://esm.sh/react@18")
import_map.add_import("react-dom", "https://esm.sh/react-dom@18")

# Vue
import_map.add_import("vue", "https://unpkg.com/vue@3/dist/vue.esm-browser.js")

Option 2: Keep Webpack for SPA, use ESM for Amber pages

You can run both systems:

# For Amber-rendered pages
FRONT_LOADER.render_import_map_tag

# For SPA routes, continue serving Webpack bundle
script src="/spa/dist/bundle.js"

Lodash

Webpack:

import _ from "lodash"

ESM (use lodash-es for tree-shaking):

import_map.add_import("lodash", "https://cdn.jsdelivr.net/npm/[email protected]/+esm")
// Import specific functions for smaller bundles
import { debounce, throttle } from "lodash"

TypeScript

TypeScript requires compilation. Options:

  1. Pre-compile TypeScript to JavaScript, serve ESM
  2. Use esbuild for fast TypeScript compilation
  3. Keep minimal Webpack for TypeScript only
# Option 2: esbuild
npm install -g esbuild

# Compile TypeScript to ESM
esbuild src/typescript/*.ts --outdir=public/javascript --format=esm

Directory Structure Migration

Before (Webpack):

my_app/
├── src/
│   └── assets/
│       └── javascripts/
│           ├── application.js
│           └── controllers/
├── node_modules/
├── webpack.config.js
├── package.json
└── public/
    └── dist/
        └── bundle.js

After (Asset Pipeline):

my_app/
├── src/
│   └── javascript/
│       ├── controllers/
│       │   ├── hello_controller.js
│       │   └── dropdown_controller.js
│       ├── services/
│       │   └── api_service.js
│       └── utils/
│           └── helpers.js
├── config/
│   └── initializers/
│       └── assets.cr
└── public/
    └── javascript/
        └── (served directly - no build step)

Handling CSS

Asset Pipeline focuses on JavaScript. For CSS, continue using your existing approach:

Option 1: Plain CSS

<link rel="stylesheet" href="/css/application.css">

Option 2: Sass compilation

# Compile Sass separately
sass src/stylesheets:public/css --watch

Option 3: Tailwind CSS

# Tailwind CLI (no npm required)
tailwindcss -i src/css/input.css -o public/css/output.css --watch

Testing the Migration

1. Check Browser Console

Open browser DevTools and verify:

  • No 404 errors for JavaScript files
  • Import map loads correctly
  • Stimulus controllers connect

2. Verify Functionality

Test interactive features:

  • Form submissions
  • Dropdowns/modals
  • Dynamic content loading
  • WebSocket connections

3. Performance Comparison

# Before (Webpack build time)
time npm run build

# After (no build!)
# Just save and refresh

Rollback Plan

If issues arise, you can run both systems temporarily:

# config/initializers/assets.cr
FRONT_LOADER = AssetPipeline::FrontLoader.new(...)

# In layout, conditionally use one or the other
if use_new_assets?
  FRONT_LOADER.render_import_map_tag
else
  # Fall back to Webpack bundle
  raw %(<script src="/dist/bundle.js"></script>)
end

Troubleshooting

"Module not found" errors

Ensure the CDN URL uses ESM format:

# Wrong - CommonJS format
import_map.add_import("lodash", "https://cdn.jsdelivr.net/npm/lodash/lodash.js")

# Correct - ESM format
import_map.add_import("lodash", "https://cdn.jsdelivr.net/npm/[email protected]/+esm")

Import map not loading

Import map must be in <head> before any module scripts:

<head>
  <%= FRONT_LOADER.render_import_map_tag %>
  <!-- other head content -->
</head>

Stimulus controllers not connecting

Verify controller naming:

# Import name must end with "Controller"
import_map.add_import("HelloController", "controllers/hello_controller.js")  # Correct
import_map.add_import("hello", "controllers/hello_controller.js")            # Wrong

CORS errors

For CDN resources, most major CDNs handle CORS. If self-hosting:

location /javascript/ {
  add_header Access-Control-Allow-Origin *;
}