# Hyvä Minification and Merging

HTML, inline CSS, and inline JavaScript response minification for Hyvä themes via a Node.js daemon.

Minifies full-page HTML responses before they leave PHP (and before Varnish caches them), using
[@swc/html](https://swc.rs/) — a native Rust-based minifier that handles HTML, JavaScript, and
CSS in a single pass with Alpine.js-safe settings.

## Strict CSP Compatibility

This module is compatible with both `hyva-themes/magento2-default-theme` and `hyva-themes/magento2-default-theme-csp` (strict Content-Security-Policy).
SHA-256 hashes for inline scripts are automatically computed from the minified content before
CSP headers are written, so browsers receive matching hashes for the minified scripts.

This works transparently for all page types, including pages with Varnish ESI blocks and
blocks served from the block_html cache.

## Requirements

- Magento 2.4.x with Hyvä Theme ^1.3.11
- Node.js >= 20
- PHP 8.1+

## Installation

```bash
composer require hyva-themes/magento2-minification
bin/magento module:enable Hyva_Minification
bin/magento setup:upgrade
```

## Starting the Daemon

```bash
cd vendor/hyva-themes/magento2-minification/node
npm install
npm run minifier-daemon
```

The daemon listens on `127.0.0.1:3100` by default.

> **Security:** The daemon has no authentication. Keep it bound to `127.0.0.1` (the default) and
> do **not** set `MINIFIER_HOST` to `0.0.0.0` or a public interface. In containerized environments,
> ensure the daemon port is not exposed outside the pod or host network.

### Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `MINIFIER_PORT` | `3100` | TCP port |
| `MINIFIER_HOST` | `127.0.0.1` | Bind address |
| `MINIFIER_SOCKET` | *(empty)* | Unix socket path (overrides TCP when set) |
| `MALLOC_ARENA_MAX` | `2` (set by Magento-managed start) | Caps glibc malloc arenas — see below |

### Memory: `MALLOC_ARENA_MAX`

When the daemon is started by Magento (cron, on-demand, or admin button), it is launched
with `MALLOC_ARENA_MAX=2` in its environment. This caps glibc's per-thread allocator pools
at 2 instead of the default `8 × CPU cores`.

The HTML/JS minifier produces many small short-lived allocations (AST nodes, attribute
strings, mangler symbol tables). glibc rarely returns freed memory to the OS — it keeps
it in per-arena free-lists. On multi-core hosts this can push the daemon's RSS into the
tens of GB even though the actual working set is small. Capping arenas bounds the
fragmentation overhead, typically reducing long-term RSS by an order of magnitude.

Trade-off: threads now share arenas, so malloc/free can contend on an arena lock. The
daemon is single-threaded JS with a small libuv worker pool, so contention is negligible
in practice.

The variable is harmless on platforms that don't use glibc (musl/Alpine, macOS, BSD) —
those allocators ignore it. If you run the daemon under a system service manager
(systemd, supervisor, etc.), add `MALLOC_ARENA_MAX=2` to the unit's environment as shown
in the systemd example above.

### Unix Socket Example

```bash
MINIFIER_SOCKET=/tmp/hyva-minifier.sock npm run minifier-daemon
```

### Running in Production

Use a process manager to keep the daemon alive. Example systemd unit:

```ini
[Unit]
Description=Hyvä Minifier Daemon
After=network.target

[Service]
Type=simple
Environment=MALLOC_ARENA_MAX=2
WorkingDirectory=/var/www/html/vendor/hyva-themes/magento2-minification/node
ExecStart=/usr/bin/node daemon.mjs
Restart=always
User=www-data

[Install]
WantedBy=multi-user.target
```

### Daemon Management

On environments where a system service is not available (e.g. Adobe Commerce Cloud), the daemon
can be managed automatically. Navigate to **Stores > Configuration > Hyvä Themes > System >
Response Minification > Daemon Management** and select one of:

| Mode | Description |
|------|-------------|
| **Off** (default) | The daemon must be started manually or via a process manager (systemd, supervisor, etc.) |
| **Cron** | A watchdog cron job runs every minute — if the daemon is not responding, it starts a new instance |
| **Start on-demand** | The daemon is started automatically on the first request that needs it. A file lock prevents concurrent requests from spawning multiple processes, and a failure counter stops retrying after 30 consecutive failures (auto-resets after 1 hour or when the daemon becomes healthy) |

Or using the CLI:

```bash
bin/magento config:set hyva_minification/general/daemon_management cron
bin/magento config:set hyva_minification/general/daemon_management on_demand
bin/magento config:set hyva_minification/general/daemon_management off
```

> **Legacy env.php setting:** The `hyva_minification/cron_daemon_manager_enabled` setting in
> `app/etc/env.php` is still supported. When present and truthy, it takes precedence over the
> admin panel setting and locks the dropdown to **Cron**. Remove the env.php setting to manage
> this option in the admin panel instead.

When minification is enabled, the admin panel shows a daemon status panel with:

- Whether the daemon is running
- Current daemon management mode
- PID and last start time (when available)
- Daemon log file size
- A **Start**, **Stop**, or **Restart** button depending on the current state

To restart the daemon without server access (e.g. after a deploy), click the restart button in the
admin panel, or create a restart signal file manually:

```bash
touch var/hyva_minifier_shutdown.flag
```

The daemon checks for this file every 5 seconds. When found, it shuts down cleanly. If daemon
management is set to **Cron** or **Start on-demand**, a new instance is started automatically.

> **File permissions (Cron mode):** The cron daemon manager requires that both the web server user
> and the cron process can read and write the same files in `var/` (PID file, log, restart flag).
> This is the case on Adobe Commerce Cloud out of the box. For on-premise deployments, ensure both
> processes run as the same OS user, or belong to a shared group with a consistent umask so that
> files created by one process remain writable by the other.
>
> See the Adobe Commerce documentation for details:
> - [File ownership and permissions (overview)](https://experienceleague.adobe.com/en/docs/commerce-operations/installation-guide/prerequisites/file-system/overview)
> - [Configure file ownership and permissions](https://experienceleague.adobe.com/en/docs/commerce-operations/installation-guide/prerequisites/file-system/configure-permissions)

## Configuration

### Daemon Connection (`env.php`)

The daemon address is configured in `app/etc/env.php` (not in the admin panel) so that only
users with server access can change where the PHP process connects.

The defaults work out of the box for a standard single-host setup (`tcp://127.0.0.1:3100`).
Override them when the daemon runs on a different host or uses a Unix socket.

Using `[n98-magerun](https://github.com/netz98/n98-magerun2)`:

```bash
# TCP (default) — set host and port
n98-magerun config:env:set hyva_minification/transport tcp
n98-magerun config:env:set hyva_minification/tcp_host 127.0.0.1
n98-magerun config:env:set hyva_minification/tcp_port 3100

# Unix socket — set transport and socket path
n98-magerun config:env:set hyva_minification/transport unix_socket
n98-magerun config:env:set hyva_minification/socket_path /tmp/hyva-minifier.sock
```

This produces the following structure in `app/etc/env.php`:

```php
'hyva_minification' => [
    'transport' => 'tcp',
    'tcp_host' => '127.0.0.1',
    'tcp_port' => '3100',
    // 'socket_path' => '/tmp/hyva-minifier.sock',
],
```

| Key | Default | Description |
|-----|---------|-------------|
| `transport` | `tcp` | `tcp` or `unix_socket` |
| `tcp_host` | `127.0.0.1` | Daemon TCP host |
| `tcp_port` | `3100` | Daemon TCP port |
| `socket_path` | `/tmp/hyva-minifier.sock` | Unix socket path (used when transport is `unix_socket`) |
| `cron_daemon_manager_enabled` | `false` | **Legacy.** When `true`, locks daemon management to Cron (see above) |

### Admin Panel

Navigate to **Stores > Configuration > Hyvä Themes > System > Response Minification** to configure:

- **Enable** — master toggle (store-scoped)
- **Daemon Management** — Off, Cron, or Start on-demand (global scope; see above)
- **Merge Inline JavaScript** — merge all inline scripts into a single `<script>` tag (see below)
- **Connection Timeout** — how long to wait for a connection before falling back (default: 100ms)
- **Read Timeout** — how long to wait for the minified response (default: 2500ms)
- **Shell Fallback (Developer Mode)** — fall back to shelling out to `node` when the daemon is unreachable (see below)

### Merge Inline JavaScript

When enabled, all inline `<script>` tags are merged into a single `<script>` tag placed just
before `</body>`. This reduces the number of SHA-256 hashes in the `Content-Security-Policy`
header from one per inline script to just one for the merged result.

This is especially useful with `hyva-themes/magento2-default-theme-csp` (strict CSP), where a
typical page may contain 20+ inline scripts, each requiring its own hash in the CSP header.
Merging them into one script dramatically reduces header size, which benefits mobile
performance and avoids header-size limits in some proxies and firewalls.

Key details:

- **Cacheable pages only** — scripts are only merged on pages that are cacheable in the
  full page cache (FPC). Uncacheable pages (e.g. checkout, customer account) are left as-is,
  because they don't shas to authorize inline scripts but only a single nonce.
- **Works independently of minification** — the merge can be enabled without the Node.js daemon;
  it runs entirely in PHP
- **Only merges JavaScript** — scripts with `type="text/javascript"` or no `type` attribute are
  merged. Non-JS types (`module`, `application/json`, `speculationrules`, etc.) are left untouched
- **Varnish ESI compatible** — scripts inside ESI blocks are captured and included in the merge
- **Disabled by default** — enable it in the admin panel or via CLI:
  ```bash
  bin/magento config:set hyva_minification/general/merge_inline_js 1
  ```

## Behaviour

- Only minifies `text/html` frontend responses on Hyvä-themed storefronts
- Admin panel and Luman theme responses are never minified
- If the daemon is unreachable or returns an error, the original unminified response is served
  and a warning is logged
- If the minified output is not smaller than the original, the original is served
- Varnish caches the minified response on cache miss; subsequent cache hits serve it directly

## Developer Mode Features

In developer mode:

- An `X-Hyva-Minified: 1` response header is added when minification was applied
- Append `?hyva_minification=false` to any URL to bypass minification for that request

### Shell Fallback

In developer mode, you can enable a shell fallback that minifies responses by shelling out to
`node` directly, without requiring the daemon to be running. This is slower than the daemon
(each request spawns a new Node process) but convenient for quick testing of whether a module
works with minification or merging.

Enable it in the admin panel under **Stores > Configuration > Hyvä Themes > System >
Response Minification > Shell Fallback (Developer Mode)**, or via CLI:

```bash
bin/magento config:set hyva_minification/general/dev_shell_fallback_enabled 1
```

The fallback only activates when the daemon is unreachable **and** Magento is in developer mode.
If the daemon is running, it is always preferred.

#### Custom Node Binary Path

If `node` is not on the default `PATH` (e.g. nvm-managed Node, or a non-standard container
layout), configure the path in `app/etc/env.php`.

The recommended location is the shared Hyvä key, so all Hyvä packages can use it:

```php
'hyva' => [
    'node_binary' => '/usr/local/bin/node',
],
```

For backward compatibility, the minification-specific key is also supported and takes
precedence:

```php
'hyva_minification' => [
    'node_binary' => '/usr/local/bin/node',
],
```

Lookup order: `hyva_minification/node_binary` → `hyva/node_binary` → `node` (from `$PATH`).

## Benchmark: HTML Size Reduction

Measured on Magento 2.4.8-p3 with Hyvä Theme and sample data:

| Page | Minified | Unminified | Savings | % |
|---|---|---|---|---|
| Homepage | 334,419 | 538,823 | 204,404 | 37.9% |
| PLP (Category) | 410,999 | 758,874 | 347,875 | 45.8% |
| PDP (Configurable) | 302,750 | 519,881 | 217,131 | 41.8% |
| Login | 158,395 | 264,022 | 105,627 | 40.0% |
| Registration | 187,997 | 322,330 | 134,333 | 41.7% |
| Customer Dashboard | 198,347 | 349,158 | 150,811 | 43.2% |
| Order History | 194,409 | 342,948 | 148,539 | 43.3% |
| Wishlist | 175,188 | 294,121 | 118,933 | 40.4% |
| Cart | 260,330 | 465,930 | 205,600 | 44.1% |
| Checkout | 294,501 | 590,686 | 296,185 | 50.1% |

|  | Min | Max | Average | Median |
|---|---|---|---|---|
| **Savings** | 37.9% | 50.1% | 42.8% | 42.5% |

## Benchmark: Daemon Throughput

Measured with 20 concurrent requests, ~500 KB HTML payload, 60-second run:

| Metric | Value |
|---|---|
| Requests/sec | 167.28 |
| Avg latency | 120 ms |
| P50 latency | 120 ms |
| P95 latency | 124 ms |
| P99 latency | 128 ms |
| Errors | 0 |
| Input size | 505 KB |
| Output size | 383 KB |
| Size savings | 24.1% |

The daemon is single-threaded (Node.js event loop). Minification is CPU-bound, so requests
are serialized under concurrency. The native Rust-based `@swc/html` minifier processes each
request in ~70–120 ms. For production workloads behind Varnish, the daemon only processes
cache misses, so throughput is rarely a bottleneck.

## Verifying It Works

```bash
# Health check
curl http://127.0.0.1:3100/health

# Test minification directly
curl -s -X POST -H "Content-Type: text/html" \
  -d '<html><body>  <p>hello</p>  </body></html>' \
  http://127.0.0.1:3100/
```

## License

This package is licensed under the **Open Software License (OSL 3.0)**.

* **Copyright:** Copyright © 2020-present Hyvä Themes. All rights reserved.
* **License Text (OSL 3.0):** The full text of the OSL 3.0 license can be found in the `LICENSE.txt` file within this package, and is also available online at [http://opensource.org/licenses/osl-3.0.php](http://opensource.org/licenses/osl-3.0.php).
