vroqjs.com

03-app-structure/10-hashed-assets-file-map-import-map.md

Hashed assets, file maps, import maps, and index.html

Hashed assets, file maps, import maps, and index.html

VroqJS apps can be served without a bundler, but production-like builds still need predictable browser caching.

The recommended pattern is:

  • local app assets use content-hashed filenames
  • index.html stays small and short-lived
  • a generated file map records logical file names to physical asset files
  • a generated import map tells the browser how to resolve JavaScript modules
  • VroqJS remains an external dependency loaded from the selected VroqJS source

This page uses Tryditor as a concrete example app, but the same structure applies to other VroqJS apps.

Hashed asset filenames

Use content-hashed filenames for local browser assets so browsers, proxies, and CDNs do not accidentally serve old JavaScript or CSS after a new deployment.

The convention is:

<name>.<hash>.<ext>

For JavaScript files:

app.<hash>.js

For CSS files:

styles.<hash>.css

Example:

assets/app.fd542af7ad30.js
assets/styles.701d9b614221.css

The name part is the stable logical name of the asset. The hash part is generated from the file contents. The ext part is the file type.

When the source code changes, the generated file content changes, the hash changes, and the browser sees a completely new URL. Old cached versions are no longer reused.

Why use name.hash.js

The goal is reliable cache invalidation.

A normal file such as:

app.js

is risky in production-like builds because the browser may keep using an older cached copy after the server has been updated.

A timestamp path such as:

/ignore--1781450000/App.js

also works, but it creates artificial URLs based on time rather than content. This can force unnecessary reloads even when the file content did not actually change.

Content-based filenames behave better:

assets/app.fd542af7ad30.js

Rules:

1. If the file content changes, the hash changes. 2. If the file content does not change, the filename remains stable. 3. Browser caching becomes safe and predictable. 4. index.html becomes the only short-lived entry point that points to the current asset versions. 5. Old asset files can remain on disk temporarily without breaking users who still have an older index.html.

Logical names versus physical filenames

Separate logical asset names from generated physical filenames.

Logical names are stable:

{
  "app.js": "...",
  "styles.css": "..."
}

Physical filenames are generated:

{
  "app.js": "assets/app.fd542af7ad30.js",
  "styles.css": "assets/styles.701d9b614221.css"
}

The app source can still think in terms of stable names such as app.js, but the browser loads the hashed file.

This mapping is called the file map.

File map

The file map is generated during the build or publish step.

Example:

{
  "app.js": "assets/app.fd542af7ad30.js",
  "styles.css": "assets/styles.701d9b614221.css"
}

The file map answers this question:

> Given a logical file name, what is the current hashed output file?

For example:

app.js -> assets/app.fd542af7ad30.js
styles.css -> assets/styles.701d9b614221.css

Embed the file map in index.html:

<script type="application/json" id="file-map">{
  "app.js": "assets/app.fd542af7ad30.js",
  "styles.css": "assets/styles.701d9b614221.css"
}</script>

This makes the build output self-describing. The browser, tests, debugging tools, or future runtime logic can inspect which generated files belong to the current HTML document.

The file map is not used as the browser's native module resolver. That job belongs to the import map.

Import map

The import map tells the browser how to resolve JavaScript module specifiers.

Instead of writing:

import "./assets/app.fd542af7ad30.js";

index.html can load:

import "tryditor/app";

And the import map defines what tryditor/app means:

<script type="importmap">{
  "imports": {
    "tryditor/app": "./assets/app.fd542af7ad30.js"
  }
}</script>

This gives the app a clean logical module name while still loading the hashed physical file.

The import map can also define VroqJS modules:

{
  "imports": {
    "vroqjs/runtime": "https://dev.vroqjs.com/vroqjs/v02/runtime.js",
    "vroqjs/ui": "https://dev.vroqjs.com/vroqjs/v02/ui.js",
    "vroqjs/components": "https://dev.vroqjs.com/vroqjs/v02/components.js",
    "vroqjs/components/Button": "https://dev.vroqjs.com/vroqjs/v02/components/Button.js"
  }
}

Application code can then import VroqJS through stable logical specifiers:

const { mount } = await import("vroqjs/runtime");

The browser resolves that to:

https://dev.vroqjs.com/vroqjs/v02/runtime.js

This keeps the app from copying VroqJS into the local app build. VroqJS remains an external dependency loaded from the selected VroqJS source.

File map versus import map

The file map is informational and build-oriented.

The import map is executable and browser-oriented.

The file map says:

{
  "app.js": "assets/app.fd542af7ad30.js"
}

The import map says:

{
  "imports": {
    "tryditor/app": "./assets/app.fd542af7ad30.js"
  }
}

The file map maps logical filenames to generated filenames.

The import map maps JavaScript module specifiers to actual browser-loadable URLs.

Both are generated from the same build information, but they serve different purposes.

Recommended index.html structure

index.html should stay small. It should not contain application logic. Its job is to load the current generated assets and define the module resolution environment.

Example:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Tryditor</title>

  <!-- Remote VroqJS CSS -->
  <link rel="stylesheet" href="https://dev.vroqjs.com/vroqjs/v02/ui.css">

  <!-- Local hashed CSS -->
  <link rel="stylesheet" href="./assets/styles.701d9b614221.css">

  <!-- Generated file map -->
  <script type="application/json" id="file-map">{
    "app.js": "assets/app.fd542af7ad30.js",
    "styles.css": "assets/styles.701d9b614221.css"
  }</script>

  <!-- Generated import map -->
  <script type="importmap">{
    "imports": {
      "tryditor/app": "./assets/app.fd542af7ad30.js",
      "vroqjs/runtime": "https://dev.vroqjs.com/vroqjs/v02/runtime.js",
      "vroqjs/ui": "https://dev.vroqjs.com/vroqjs/v02/ui.js",
      "vroqjs/components": "https://dev.vroqjs.com/vroqjs/v02/components.js",
      "vroqjs/components/Button": "https://dev.vroqjs.com/vroqjs/v02/components/Button.js"
    }
  }</script>
</head>
<body>
  <main id="app" aria-live="polite"></main>

  <!-- Start the app through a logical import-map specifier -->
  <script type="module">
    import "tryditor/app";
  </script>
</body>
</html>

The most important detail is that the app is loaded through:

import "tryditor/app";

not through a hard-coded unhashed filename.

This makes index.html the only file that needs to know the current hash.

Build process

The build or publish process should do the following:

1. Read local source files, for example:

src/client/app.js
src/client/styles.css

2. Compute a content hash for each output asset. 3. Write files into build/public/assets/ using the naming convention:

assets/app.<hash>.js
assets/styles.<hash>.css

4. Generate the file map:

{
  "app.js": "assets/app.<hash>.js",
  "styles.css": "assets/styles.<hash>.css"
}

5. Generate the import map:

{
  "imports": {
    "tryditor/app": "./assets/app.<hash>.js",
    "vroqjs/runtime": "https://dev.vroqjs.com/vroqjs/v02/runtime.js"
  }
}

6. Generate build/public/index.html using the current generated filenames. 7. Optionally generate build/public/manifest.json so tests and tooling can inspect the complete build output.

Cache behavior

Serve index.html with a short cache lifetime or no-store during development:

Cache-Control: no-store

This ensures the browser asks the server for the latest HTML.

Serve hashed assets aggressively because their URLs change when the content changes:

Cache-Control: public, max-age=31536000, immutable

This is safe because:

app.fd542af7ad30.js

will never be reused for different content.

If the content changes, the filename becomes something else:

app.9aa813e21b44.js

Rules

Use these rules consistently:

1. Do not serve local application JavaScript as plain app.js in production-like builds. 2. Do not use /ignore--<time>/ for production-like local assets. 3. Use <name>.<hash>.js and <name>.<hash>.css. 4. Generate index.html; do not hand-edit hashed filenames. 5. Put local generated files under build/public/assets/. 6. Keep VroqJS remote and map it through the import map. 7. Keep index.html small and declarative. 8. Keep the file map and import map generated from the same build manifest. 9. Tests should verify that generated local JS files match the hashed naming pattern. 10. Tests should verify that no local asset URL contains /ignore--.

Example final output

A valid build should look like:

build/public/
  index.html
  manifest.json
  assets/
    app.fd542af7ad30.js
    styles.701d9b614221.css

And the generated index.html should include:

<script type="application/json" id="file-map">{
  "app.js": "assets/app.fd542af7ad30.js",
  "styles.css": "assets/styles.701d9b614221.css"
}</script>

plus:

<script type="importmap">{
  "imports": {
    "tryditor/app": "./assets/app.fd542af7ad30.js",
    "vroqjs/runtime": "https://dev.vroqjs.com/vroqjs/v02/runtime.js"
  }
}</script>

The browser then starts the app with:

<script type="module">
  import "tryditor/app";
</script>

This gives the app stable logical imports, safe browser caching, and predictable deployment behavior.