{"_id":"@3sln/js-tools","_rev":"4-5cd9d5b0b6a96d8f3e7bacb28e3959f9","name":"@3sln/js-tools","dist-tags":{"latest":"0.2.0"},"versions":{"0.1.0":{"name":"@3sln/js-tools","version":"0.1.0","keywords":["build","esbuild","import-map","content-addressed","dev-server","hmr","esm"],"author":{"name":"Ray Stubbs"},"license":"MIT","_id":"@3sln/js-tools@0.1.0","maintainers":[{"name":"ray.3sln","email":"contact+npm@3sln.com"}],"homepage":"https://github.com/3sln/js-tools#readme","bugs":{"url":"https://github.com/3sln/js-tools/issues"},"bin":{"3sln-dev":"bin/dev.js","3sln-build":"bin/build.js"},"dist":{"shasum":"c13b5f6154f7c79cbca018f54a7a6ef762d3b65e","tarball":"https://registry.npmjs.org/@3sln/js-tools/-/js-tools-0.1.0.tgz","fileCount":17,"integrity":"sha512-Hbb/dubKqvXZAAD/hsUQZYy2ZS+vErQ2DF/T1yGKvR6adCBWjOxWOKZgqUq2Kt8OMgEdKqOdoLdlXuX3ic+SCA==","signatures":[{"sig":"MEYCIQC2bP9boAaVPtyuWbIrSeUJLkc7gFZXiBn7y7mpmGA5cwIhAKKX9m1gbL1sJ0yYeg8sXb0jTtN/tgxwkIkNuiD11UDC","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@3sln%2fjs-tools@0.1.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":54459},"type":"module","engines":{"node":">=20"},"exports":{".":"./index.js","./build":"./src/build.js","./config":"./src/config.js","./dev-server":"./src/dev/server.js","./package.json":"./package.json"},"gitHead":"6ce22f7fba61451c187b924181b50a51f4b44504","scripts":{"test":"bun test","format":"prettier -w \"src/**/*.js\" \"bin/**/*.js\" \"*.js\"","test:watch":"bun test --watch"},"_npmUser":{"name":"ray.3sln","email":"contact+npm@3sln.com"},"repository":{"url":"git+https://github.com/3sln/js-tools.git","type":"git"},"_npmVersion":"10.9.8","description":"Content-addressed builder and dev server for unbundled ES module applications.","directories":{},"_nodeVersion":"22.23.1","dependencies":{"esbuild":"^0.28.1","@web/dev-server":"^0.4.6","@web/dev-server-hmr":"^0.1.4"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"prettier":"^3.6.2"},"_npmOperationalInternal":{"tmp":"tmp/js-tools_0.1.0_1785856986122_0.7254969026262494","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"name":"@3sln/js-tools","version":"0.1.1","keywords":["build","esbuild","import-map","content-addressed","dev-server","hmr","esm"],"author":{"name":"Ray Stubbs"},"license":"MIT","_id":"@3sln/js-tools@0.1.1","maintainers":[{"name":"ray.3sln","email":"contact+npm@3sln.com"}],"homepage":"https://github.com/3sln/js-tools#readme","bugs":{"url":"https://github.com/3sln/js-tools/issues"},"bin":{"3sln-dev":"bin/dev.js","3sln-build":"bin/build.js"},"dist":{"shasum":"22318f8ba0849a9d6241554fe9a4a851e684f4e2","tarball":"https://registry.npmjs.org/@3sln/js-tools/-/js-tools-0.1.1.tgz","fileCount":18,"integrity":"sha512-52JrdtodhQQ8GoXkQvqhcwZ52TsKC10rSJdGC2BvsXHJWX+BNNjW2Ww3zJ9xQonxsYXsMAcaBDa8VRU4KZFRyg==","signatures":[{"sig":"MEUCIQDnLxLbYbjHb8i1m7na4u7+m0ZA4zyYYNCZ6VTCtsi45AIgYH14b26/KBJOTjr3wnzHnxi7/AqLjmZJe8EXu2hB7/c=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@3sln%2fjs-tools@0.1.1","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":62453},"type":"module","engines":{"node":">=20"},"exports":{".":"./index.js","./build":"./src/build.js","./config":"./src/config.js","./dev-server":"./src/dev/server.js","./package.json":"./package.json"},"gitHead":"e422d52ba2d5545e6b33b04ea945aa43f566a0de","scripts":{"test":"bun test","format":"prettier -w \"src/**/*.js\" \"bin/**/*.js\" \"*.js\"","test:watch":"bun test --watch"},"_npmUser":{"name":"ray.3sln","email":"contact+npm@3sln.com"},"repository":{"url":"git+https://github.com/3sln/js-tools.git","type":"git"},"_npmVersion":"10.9.8","description":"Content-addressed builder and dev server for unbundled ES module applications.","directories":{},"_nodeVersion":"22.23.1","dependencies":{"esbuild":"^0.28.1","@web/dev-server":"^0.4.6","@web/dev-server-hmr":"^0.1.4"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"prettier":"^3.6.2"},"_npmOperationalInternal":{"tmp":"tmp/js-tools_0.1.1_1785858094075_0.026038603597891008","host":"s3://npm-registry-packages-npm-production"}},"0.1.2":{"name":"@3sln/js-tools","version":"0.1.2","keywords":["build","esbuild","import-map","content-addressed","dev-server","hmr","esm"],"author":{"name":"Ray Stubbs"},"license":"MIT","_id":"@3sln/js-tools@0.1.2","maintainers":[{"name":"ray.3sln","email":"contact+npm@3sln.com"}],"homepage":"https://github.com/3sln/js-tools#readme","bugs":{"url":"https://github.com/3sln/js-tools/issues"},"bin":{"3sln-dev":"bin/dev.js","3sln-build":"bin/build.js"},"dist":{"shasum":"5d2755b31d4b0b654dddcf33f7f0df5dc84c6f68","tarball":"https://registry.npmjs.org/@3sln/js-tools/-/js-tools-0.1.2.tgz","fileCount":18,"integrity":"sha512-NTVo5aIjOLbh7CjC6wpYRF/PmcEmFT0QbU67KGYjUGT/5dqdS2MYEsR3452gz3qb4tp5TQSRYz1RGFKN0IndfA==","signatures":[{"sig":"MEQCIGDCPg8ql8boX3jtDg6Cui2NtRtb4M8fcmYtIS7otfJuAiA+DBAJwaBi0DsOH/8JjtJ56BpeZuUNGZMzw3BHqZRthg==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@3sln%2fjs-tools@0.1.2","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":63346},"type":"module","engines":{"node":">=20"},"exports":{".":"./index.js","./build":"./src/build.js","./config":"./src/config.js","./dev-server":"./src/dev/server.js","./package.json":"./package.json"},"gitHead":"f702eb844a8b241de90ad82bc95bd37edf7a3abf","scripts":{"test":"bun test","format":"prettier -w \"src/**/*.js\" \"bin/**/*.js\" \"*.js\"","test:watch":"bun test --watch"},"_npmUser":{"name":"ray.3sln","email":"contact+npm@3sln.com"},"repository":{"url":"git+https://github.com/3sln/js-tools.git","type":"git"},"_npmVersion":"10.9.8","description":"Content-addressed builder and dev server for unbundled ES module applications.","directories":{},"_nodeVersion":"22.23.2","dependencies":{"esbuild":"^0.28.1","@web/dev-server":"^0.4.6","@web/dev-server-hmr":"^0.1.4"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"prettier":"^3.6.2"},"_npmOperationalInternal":{"tmp":"tmp/js-tools_0.1.2_1786660550122_0.7846827625469641","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@3sln/js-tools","version":"0.2.0","description":"Content-addressed builder and dev server for unbundled ES module applications.","type":"module","author":{"name":"Ray Stubbs"},"repository":{"type":"git","url":"git+https://github.com/3sln/js-tools.git"},"license":"MIT","keywords":["build","esbuild","import-map","content-addressed","dev-server","hmr","esm"],"exports":{".":"./index.js","./build":"./src/build.js","./dev-server":"./src/dev/server.js","./config":"./src/config.js","./package.json":"./package.json"},"bin":{"3sln-build":"bin/build.js","3sln-dev":"bin/dev.js"},"publishConfig":{"access":"public"},"scripts":{"test":"bun test","test:watch":"bun test --watch","format":"prettier -w \"src/**/*.js\" \"bin/**/*.js\" \"*.js\""},"dependencies":{"@web/dev-server":"^0.4.6","@web/dev-server-hmr":"^0.1.4","esbuild":"^0.28.1"},"devDependencies":{"prettier":"^3.6.2"},"engines":{"node":">=20"},"_id":"@3sln/js-tools@0.2.0","gitHead":"4ff86dcbdb3516bdbbd0da9190c3e895fdd43ace","bugs":{"url":"https://github.com/3sln/js-tools/issues"},"homepage":"https://github.com/3sln/js-tools#readme","_nodeVersion":"22.23.1","_npmVersion":"10.9.8","dist":{"integrity":"sha512-dd1XzQaLfGTpnwnfDdObaYYmD5Tj49vQ4Bcg88zJMtkaYqn/zweW1b/Erjsd9leOgXmMx2E+MoBdHM1YQBlXSA==","shasum":"f21b6a304e66e336236826926b5ada6f7f2bd0d1","tarball":"https://registry.npmjs.org/@3sln/js-tools/-/js-tools-0.2.0.tgz","fileCount":19,"unpackedSize":69554,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@3sln%2fjs-tools@0.2.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQDNO0ThCXxkIUFcaAVggmgQsraz0nzAPZQ4NocYu12E0QIhAOs4GDtV1+YjsyYn1QfecqOAZ5jIkZcXHFKAs1wvuAiD"}]},"_npmUser":{"name":"ray.3sln","email":"contact+npm@3sln.com"},"directories":{},"maintainers":[{"name":"ray.3sln","email":"contact+npm@3sln.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/js-tools_0.2.0_1786663497004_0.7183089238843257"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-04T15:23:06.017Z","modified":"2026-08-13T23:24:57.456Z","0.1.0":"2026-08-04T15:23:06.287Z","0.1.1":"2026-08-04T15:41:34.245Z","0.1.2":"2026-08-13T22:35:50.249Z","0.2.0":"2026-08-13T23:24:57.147Z"},"bugs":{"url":"https://github.com/3sln/js-tools/issues"},"author":{"name":"Ray Stubbs"},"license":"MIT","homepage":"https://github.com/3sln/js-tools#readme","keywords":["build","esbuild","import-map","content-addressed","dev-server","hmr","esm"],"repository":{"type":"git","url":"git+https://github.com/3sln/js-tools.git"},"description":"Content-addressed builder and dev server for unbundled ES module applications.","maintainers":[{"name":"ray.3sln","email":"contact+npm@3sln.com"}],"readme":"# @3sln/js-tools\n\nA builder and a development server for applications that ship **unbundled ES\nmodules**, with nothing cache-busted and nothing rewritten.\n\n```sh\nnpm install --save-dev @3sln/js-tools\n```\n\nTwo kinds of code have two different shapes, so they get two treatments:\n\n|  | Project modules | Dependencies |\n| --- | --- | --- |\n| Shipped as | one file per module, as authored (or minified) | bundled, split by entry point |\n| Named | `main.<hash>.js` | `dodo-<HASH>.js` |\n| Changed by | an edit to that one file | a version bump |\n\nProject modules change one at a time, and a bundle means every edit invalidates\nevery file. Dependencies change rarely and must not be duplicated — bundling\n`@3sln/dodo` and `@3sln/dodo/reactive` as two separate bundles would give each\nits own copy of dodo's internals, so a cell created through one would not be\nrecognised by the other. Splitting is what keeps a package a singleton; it is\nnot a size optimisation.\n\nEverything a browser keeps long-term is content-addressed, so **a changed file\nis a changed URL**. Nothing is ever overwritten, so nothing needs invalidating —\nand a browser holding a poisoned copy of an old URL simply never asks for it\nagain.\n\n## No source is rewritten\n\nImport statements stay exactly as authored. An **import map** does the\nredirection:\n\n```json\n{\n  \"imports\": {\n    \"@3sln/dodo\":              \"/assets/vendor/3sln_dodo-QK3PZ7.js\",\n    \"/assets/shared/stack.js\": \"/assets/shared/stack.4f2c1ab9de.js\"\n  }\n}\n```\n\nA browser resolves a relative specifier to a URL *before* it consults the map,\nso the second key intercepts `../shared/stack.js` from a sibling module exactly\nas the first intercepts a bare specifier. That is why the source tree is\n*mirrored* under the asset root rather than hashed in place: one prefix covers\nevery neighbour import in the graph, and one `_headers` rule can mark the whole\ntree immutable.\n\n## The wire-up script\n\nA page needs three things before it can run: the import map, the entry\nstylesheet and the entry module. All three are content-addressed, so all three\nchange on most builds — which is a lot of coupling to hand to a hand-written\n`index.html`, or to a worker rendering HTML per request.\n\nSo the build emits one **synchronous classic script** that does all three, and\nthe page carries a single tag:\n\n```html\n<script src=\"/@wireup/app.js\"></script>\n```\n\nThe build rewrites that to the hashed URL. The dev server answers the same URL\nwith development paths in it. The page is byte-identical either way, and\nnothing downstream of it needs to know what a hash is.\n\n> It must come **before any module script** in the document: an import map has\n> to be installed before the first module load is triggered. Since the wire-up\n> appends the entry module itself, everything after it is in order by\n> construction.\n\nTwo details it takes care of that are easy to get wrong by hand:\n\n- A module script *inserted into the DOM* is not deferred — unlike one written\n  in the markup, it runs the moment it has loaded, which can be before the body\n  exists. The wire-up starts the fetch immediately with `modulepreload` (which\n  follows the import map, so it warms the whole unbundled graph rather than just\n  the entry) and appends the script at `DOMContentLoaded`.\n- An import map is inline script content as far as CSP is concerned, even\n  created through the DOM. The wire-up copies its own `nonce` onto the map, so a\n  page under a nonce policy works with no extra configuration — and it is a\n  no-op for a policy that does not use nonces.\n\n## Getting started\n\n```js\n// jstools.config.js\nimport { defineConfig } from '@3sln/js-tools';\n\nexport default defineConfig({\n  src: 'src',\n  include: ['client', 'shared'],   // src/worker is server-side; never shipped\n  out: 'dist/client',\n  assetRoot: 'assets',\n  entries: {\n    app: { module: 'client/main.js', css: 'client/app.css' },\n  },\n  html: ['index.html'],\n  manifest: 'dist/client-manifest.js',\n});\n```\n\n```json\n{\n  \"scripts\": {\n    \"build\": \"3sln-build\",\n    \"dev\": \"3sln-dev --open\"\n  }\n}\n```\n\nA project that needs to emit more than the client — a static site, a service\nworker with the build id stamped into it, a `_headers` file listing its own\nstable names — calls the builder from a script instead:\n\n```js\nimport { build, headersFile } from '@3sln/js-tools';\nimport config from './jstools.config.js';\n\nconst result = await build(config);\n// result.buildId, .entries, .imports, .modules, .copied, .html\n```\n\n### Development\n\n```js\n// web-dev-server.config.js\nimport { devServer } from '@3sln/js-tools/dev-server';\nimport config from './jstools.config.js';\n\nexport default devServer(config, {\n  appIndex: 'index.html',\n  middleware: [/* anything the project mounts itself */],\n});\n```\n\nOne config drives both halves. A dev server that disagrees with the builder\nabout which files are project modules or where an entry point lives is the whole\nclass of bug that only shows up after a deploy.\n\nWhat the dev server does differently:\n\n- **Project modules are served as they are**, which is what makes hot module\n  replacement possible at all — the file the browser is running is the file on\n  disk, so there is something to replace. HMR is `@web/dev-server-hmr`; a module\n  that does not opt in through `import.meta.hot` falls back to a page reload.\n- **ES module dependencies are served straight out of `node_modules`**, so a\n  stack trace names the real file and a linked package is edited and reloaded\n  like any other source.\n- **Everything else is converted by esbuild and cached** under\n  `node_modules/.cache/3sln-js-tools/`, served from `/@vendor/`. They are\n  converted *together*, with splitting, and every directly-served ESM specifier\n  is marked external — so a converted package reaches its ESM dependencies\n  through the import map rather than inlining a second copy of them. The cache\n  is rebuilt when one of those files changes on disk.\n- **Bare specifiers still resolve through the import map**, not through\n  `nodeResolve`. Development is not the one place where something other than the\n  map resolves the graph.\n\n## Config\n\n| Key | Default | |\n| --- | --- | --- |\n| `root` | `process.cwd()` | everything else is relative to it |\n| `src` | `'src'` | the project module root |\n| `include` | all of `src` | subdirectories to ship; a `worker/` or `server/` tree belongs outside it |\n| `exclude` | `[]` | paths within `include` to skip (`sw.js`) |\n| `extensions` | `['.js']` | what gets fingerprinted |\n| `entries` | — | `{name: {module, css}}`, relative to `src` |\n| `workers` | `{}` | `{name: spec}` — a path under `src` or a specifier node resolves; bundled whole |\n| `out` | `'dist'` | |\n| `assetRoot` | `'assets'` | the immutable, content-addressed prefix |\n| `vendorDir` | `'<assetRoot>/vendor'` | |\n| `packageJson` | `'package.json'` | where dependencies are read and resolved from |\n| `dependencies` | its `dependencies` | override to ship a subset |\n| `minify` | `false` | project modules; imports survive either way |\n| `minifyVendor` / `minifyCss` | `true` | |\n| `wireupPath` | `'/@wireup/[name].js'` | the URL a page carries, and the dev server answers |\n| `copy` | `[]` | `{from, to}` copied verbatim |\n| `html` | `[]` | pages whose wire-up tag is rewritten |\n| `manifest` | `'dist/client-manifest.js'` | `null` to skip |\n| `check` | `true` | walk the shipped graph and fail on an unmapped specifier |\n| `allowUnresolved` | `[]` | specifiers provided some other way |\n\n## The graph is checked before the build reports success\n\nEvery bare specifier reachable from an entry has to be in the import map.\nesbuild does the walking, so dynamic imports and re-exports are covered without\na regex guessing at JavaScript, and a miss names the file that imported it:\n\n```\nbuild failed: 1 specifier(s) in the shipped graph are not in the import map:\n  jszip — imported by src/client/console/bl/lpfImport.js\n```\n\nThis exists because the failure it catches is silent. jszip states its browser\nentry as a *map of redirects* rather than a filename; a resolver that hands the\nmap over as a path resolves nothing, and the package disappears from the import\nmap with the build still green. What you get is a blank page at runtime, in\nwhichever code path lazily imported it.\n\n## The manifest\n\nA JS module the server side can import, so an application that renders its own\nHTML never hard-codes a hashed URL:\n\n```js\nexport const BUILD_ID = 'a91c4f0e22';\nexport const IMPORT_MAP = { imports: { /* … */ } };\nexport const ENTRIES = {\n  app: {\n    module: '/assets/client/main.4f2c1ab9de.js',\n    css:    '/assets/app.9b1e77c204.css',\n    wireup: '/assets/wireup-app.31c0af8e12.js',\n  },\n};\n```\n\n`BUILD_ID` is derived from the emitted filenames, which already carry content\nhashes — so a build that changes nothing produces the same id, and a redeploy\ndoes not retire every client's service-worker cache.\n\n## `_headers`\n\n```js\nimport { headersFile, securityHeadersFrom } from '@3sln/js-tools';\n\nwriteFileSync('dist/_headers', headersFile({\n  assetRoot: 'assets',\n  security: securityHeadersFrom(readFileSync('site/_headers', 'utf8')),\n  revalidate: ['index.html', 'sw.js', 'manifest.json'],\n}));\n```\n\n`immutable` is claimed only where it is true. Claiming it on a stable-named\nentry point is what turns a deploy into a blank page: the browser will not\nre-check it, so it goes on importing modules from a build that no longer\nexists — and because that is a link-time failure, the whole graph dies before a\nline runs.\n\nThe other trap is that Cloudflare **appends** matching rules rather than\nreplacing them, and the strictest value wins. A `Cache-Control` on a catch-all\narrives alongside the one on the asset root as\n`no-cache, public, max-age=…, immutable`, and `no-cache` wins — silently\nthrowing away the caching the content addressing exists to enable. So exactly\none rule may set `Cache-Control` for any given path: the catch-all carries\nsecurity headers only, and everything with a stable name is listed.\n\n## Deep imports are unsupported\n\nA wildcard subpath (`\"./src/*\"`) cannot become a build entry point without\nenumerating what it might match, and giving a package's internals their own\nentry points would hand out a second copy of the package — the one thing this\narrangement exists to prevent. The dev server, where nothing is bundled and a\nsecond copy is not a risk, maps a wildcard to a directory prefix.\n\n## Releasing\n\nBumping `version` in `package.json` on `main` opens a draft release. Publishing\nthat draft creates the tag and publishes to npm, after re-running the tests and\nchecking the tag matches `package.json` — a version bump alone tags nothing.\n","readmeFilename":"README.md"}