@johnhenry/letterpress 0.0.0 → 0.0.1

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.
@@ -7,17 +7,8 @@ on:
7
7
 
8
8
  jobs:
9
9
  test:
10
- strategy:
11
- fail-fast: false
12
- matrix:
13
- # engines.node >=26.0.0 -- test the floor version directly rather
14
- # than a matrix of now-unsupported older majors.
15
- node-version: [26]
16
- runs-on: ubuntu-latest
17
- steps:
18
- - uses: actions/checkout@v4
19
- - uses: actions/setup-node@v4
20
- with:
21
- node-version: ${{ matrix.node-version }}
22
- - run: npm ci
23
- - run: npm test
10
+ uses: johnhenry/workflows/.github/workflows/ci.yml@v1
11
+ with:
12
+ node-versions: '["26"]'
13
+ os: '["ubuntu-latest"]'
14
+ gate-commands: npm test
@@ -3,36 +3,25 @@ name: Publish to npm
3
3
  on:
4
4
  release:
5
5
  types: [published]
6
+ push:
7
+ # Redundant with the release trigger above -- see fileable's
8
+ # publish.yml / johnhenry/workflows' README for the full incident
9
+ # writeup: creating a release very soon after a push/merge to the
10
+ # same commit can silently drop the release event for a job whose
11
+ # body is uses: <external-repo>/...@v1. This gives a second,
12
+ # idempotency-safe chance to fire.
13
+ tags:
14
+ - "v*"
6
15
  workflow_dispatch: {}
7
16
 
8
17
  jobs:
9
18
  publish:
10
- runs-on: ubuntu-latest
11
19
  permissions:
12
20
  contents: read
13
- id-token: write # npm provenance
14
- steps:
15
- - uses: actions/checkout@v4
16
-
17
- - uses: actions/setup-node@v4
18
- with:
19
- node-version: '26'
20
- registry-url: 'https://registry.npmjs.org'
21
-
22
- - run: npm ci
23
-
24
- - run: npm test
25
-
26
- - name: Publish
27
- # Idempotent: `npm view` exits non-zero for a version that isn't on
28
- # the registry yet, which is the signal to publish.
29
- run: |
30
- PKG_NAME=$(node -p "require('./package.json').name")
31
- PKG_VERSION=$(node -p "require('./package.json').version")
32
- if npm view "$PKG_NAME@$PKG_VERSION" version >/dev/null 2>&1; then
33
- echo "::notice title=Already published::$PKG_NAME@$PKG_VERSION is already on npm; nothing to do."
34
- exit 0
35
- fi
36
- npm publish --access public
37
- env:
38
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
21
+ id-token: write # required by the reusable workflow's provenance step --
22
+ # see johnhenry/workflows' README for why this must be declared here too.
23
+ uses: johnhenry/workflows/.github/workflows/npm-publish.yml@v1
24
+ with:
25
+ node-version: "26"
26
+ gate-commands: npm test
27
+ secrets: inherit
package/AGENTS.md ADDED
@@ -0,0 +1,65 @@
1
+ # Agent playbook
2
+
3
+ `@johnhenry/letterpress` -- a tagged-template-string HTTP routing library:
4
+ defining and matching request handlers with plain JavaScript syntax. Single
5
+ package, Node >= 26, `node:test` (`npm test` runs `test/index.mjs`), ships
6
+ source directly (`index.mjs` + friends); no build step.
7
+
8
+ `CLAUDE.md` in this directory is a symlink to this file.
9
+
10
+ ## The verification loop (before every push)
11
+
12
+ 1. `npm test` -- runs `node --test test/index.mjs`, which must itself
13
+ import every test module (see gotcha below -- this has silently dropped
14
+ whole suites before).
15
+ 2. A genuinely fresh clone:
16
+ `git clone . /tmp/letterpress-verifyN && cd $_ && npm ci && npm test`.
17
+ This is the only way to catch "works on my checked-out tree" bugs
18
+ (missing `files` entries, undeclared deps).
19
+ 3. Commit, push, close the issue with a comment naming the commit SHA.
20
+
21
+ CI (`.github/workflows/ci.yml`) runs the same `npm test` command; match it
22
+ locally.
23
+
24
+ ## Repo-specific gotchas
25
+
26
+ - **`test/index.mjs` is a hand-maintained list of test-file imports, not a
27
+ glob.** A past bug imported one file twice and never imported the other,
28
+ silently dropping an entire suite from `npm test` with a passing exit
29
+ code. When you add a new `test/*.mjs` file, add its import to
30
+ `test/index.mjs` and verify the new suite's assertions actually ran (not
31
+ just that `npm test` exited 0).
32
+ - **`createRoute` substitutions are not escaped.** See
33
+ `## Honest limitations` in the README before treating unescaped HTML
34
+ output as a bug to silently "fix" with auto-escaping -- that would be a
35
+ breaking behavior change, not a bugfix, and needs its own CHANGELOG entry
36
+ and version bump if done.
37
+ - **`createRouter`'s dispatch loop must `await` the handler.** A past bug
38
+ called `handler(request, ctx)` without awaiting it, so an async handler
39
+ that threw crashed as an unhandled rejection instead of being caught by
40
+ `errorHandler`/`defaultHandler`. Any new dispatch path must await.
41
+ - **`fs-router.mjs`'s synthesized `index.html` temp module is written next
42
+ to the matched route's own directory, not into `letterpress`'s installed
43
+ package directory.** A past bug wrote it into the package dir, which
44
+ broke the whole feature under any read-only install (containers,
45
+ CI-built images). Keep new temp-file writes scoped to the route
46
+ directory, and don't `unlink` a temp file when the write that created it
47
+ already failed (that masks the real error as a misleading `ENOENT`).
48
+
49
+ ## Definition of done
50
+
51
+ A change is done when all of the following hold, not just when tests pass:
52
+ - A regression test exists for any bug fixed, and is actually wired into
53
+ `test/index.mjs` (see gotcha above).
54
+ - Anything the feature does **not** do is stated in the README's
55
+ `## Honest limitations` section, not only in an issue comment.
56
+ - `CHANGELOG.md` has an entry.
57
+ - `api.md` is re-checked against the real exports if the change touches
58
+ public API shape.
59
+
60
+ ## Releases
61
+
62
+ Bump `version` in `package.json` in a PR, add the `CHANGELOG.md` entry,
63
+ merge, then `gh release create v<version>` -- the release event triggers
64
+ `.github/workflows/publish.yml`, which is idempotent (skips if the version
65
+ is already on npm).
package/API.md CHANGED
@@ -1,4 +1,4 @@
1
- # Route API Documentation
1
+ # Letterpress API Documentation
2
2
 
3
3
  ## Table of Contents
4
4
 
@@ -98,7 +98,7 @@ Content-Type: application/json
98
98
 
99
99
  ## Utility Functions
100
100
 
101
- Route provides several utility functions to help with request and response handling:
101
+ Letterpress provides several utility functions to help with request and response handling:
102
102
 
103
103
  > Note: `@johnhenry/letterpress` does not export a `serve` function or a `tagRequest` function. To run a server, pair it with [leserve](https://www.npmjs.com/package/@johnhenry/leserve) (imported directly, e.g. `import serve from "@johnhenry/leserve"`) or any server of your choice. To build a `Request` from a template literal, use `createRequest` (see below).
104
104
 
@@ -171,4 +171,4 @@ const message = cook`GET /api/users/${123} HTTP/1.1`;
171
171
  // "GET /api/users/123 HTTP/1.1"
172
172
  ```
173
173
 
174
- This API documentation provides an overview of the main functions and utilities provided by the Route library. For more detailed information on specific use cases and advanced features, please refer to the README.md and the source code.
174
+ This API documentation provides an overview of the main functions and utilities provided by the Letterpress library. For more detailed information on specific use cases and advanced features, please refer to the README.md and the source code.
package/CHANGELOG.md ADDED
@@ -0,0 +1,108 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
6
+
7
+ This project will adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once it reaches 1.0.0.
8
+
9
+ ## [0.0.1] - 2026-09-26
10
+
11
+ ### Fixed
12
+
13
+ - 🐛 **Security/reliability**: The colon-form header matcher documented in
14
+ the README (`` router.endpoint`GET /protected [Authorization: Bearer *]` ``)
15
+ parsed the `:` and everything up to the wildcard as part of the header
16
+ *name* instead of stopping the name at `:`, so `headers.get()` threw
17
+ `TypeError: invalid header name`. Because `createRouter`'s dispatch loop
18
+ calls matchers inside a top-level try/catch, this 500'd **every** request
19
+ routed through a router with such a route registered — not just requests
20
+ matching that path — for any consumer who followed the README's own
21
+ documented syntax. `parseHeaderMatcher`'s header-name capture now stops at
22
+ `:` (in addition to the existing operator characters), and the value
23
+ after the colon is treated as a wildcard-match pattern (`*` matches any
24
+ run of characters), so `[Authorization: Bearer *]` matches any bearer
25
+ token instead of crashing. The operator-form equivalent,
26
+ `[Authorization^=Bearer]`, is unaffected and continues to work
27
+ ([#4](https://github.com/johnhenry/letterpress/issues/4))
28
+ - 🐛 **Bundling**: The main barrel (`index.mjs`) re-exported `createFSRouter`
29
+ from `fs-router.mjs`, which imports `node:fs`, `node:path`, and
30
+ `theres-waldo` (itself using `node:url`'s `fileURLToPath`) at module top
31
+ level. Those imports run at *import* time, so simply importing anything
32
+ from `@johnhenry/letterpress` — even `HTTPExpression` or `createRoute`,
33
+ which have nothing to do with the filesystem router — pulled in
34
+ Node-only code and broke browser bundlers (e.g. Vite), even when the
35
+ Node builtins were shimmed, because the fs-router module body still ran.
36
+ `createFSRouter` is no longer re-exported from the main barrel; it's now
37
+ reachable via a dedicated `@johnhenry/letterpress/fs` subpath (added to a
38
+ new `package.json` `"exports"` map, alongside a `"./*"` wildcard so
39
+ existing deep-path imports like
40
+ `@johnhenry/letterpress/utility/http-expression.mjs` keep working
41
+ unchanged). Everything else in the barrel (`createRouter`, `createRoute`,
42
+ `createRequest`, `createResponse`, `HTTPExpression`) was already pure and
43
+ is now verified to bundle cleanly for the browser
44
+ ([#6](https://github.com/johnhenry/letterpress/issues/6))
45
+
46
+ ### Changed (breaking)
47
+
48
+ - **`createFSRouter` is no longer exported from the main `@johnhenry/letterpress`
49
+ barrel.** Import it from `@johnhenry/letterpress/fs` instead. This is the
50
+ only export affected; everything else's import path is unchanged.
51
+
52
+ ## [0.0.0] - npm scope migration - 2026-09-19
53
+
54
+ ### Changed (breaking)
55
+
56
+ - **Renamed the package from `leroute` to `@johnhenry/letterpress`**, and
57
+ restarted the version at `0.0.0` (previously published as `leroute`, last
58
+ unscoped version `0.0.1`).
59
+ - **Renamed the exported identifiers to match**: `createLeRoute` →
60
+ `createRoute`, `createLeRouter` → `createRouter`, `LeRoute` → `Route`,
61
+ `LeRouteInit` → `RouteInit`, `LeRouteMiddleware` → `RouteMiddleware`,
62
+ `LeRouter` → `Router`, `LeRouterExtension` → `RouterExtension`,
63
+ `LeRouterInit` → `RouterInit`, `LeRouterMiddleware` → `RouterMiddleware`.
64
+ `create-leroute.mjs`/`create-lerouter.mjs` were also renamed to
65
+ `create-route.mjs`/`create-router.mjs` (and their test files
66
+ correspondingly), matching the new export names.
67
+ - **Raised `engines.node` to `>=26.0.0`**, matching the rest of the
68
+ `@johnhenry/*` family's floor.
69
+
70
+ ### Fixed
71
+
72
+ - 🐛 `test/index.mjs` imported `lerouter.mjs` twice and never imported `leroute.mjs`, silently dropping the `createRoute` test suite from `npm test`
73
+ - 🐛 `test/leroute.mjs` imported `createRoute` from `create-router.mjs` (which doesn't export it) instead of `create-route.mjs`, which would have thrown once the missing import above was fixed
74
+ - 🐛 `package.json` declared `"license": "ISC"` while the `LICENSE` file and README badge are MIT; changed to `"license": "MIT"` to match
75
+ - 📝 README and API docs referenced `serve` and `tagRequest` as exports of `leroute`; neither is exported — `serve` comes from the separate `leserve` package, and `tagRequest` doesn't exist. Docs corrected to list the real exports
76
+ - 📝 API docs showed `HTTPExpression(...)` as a constructor with `.method`/`.path`/`.version` properties; corrected to show it as a tagged-template function returning `{ test(request), exec(request) }`
77
+ - 🔒 **Security**: `router.endpoint` reused the per-request context object (which carries the incoming request's `Headers`) as the response-init object passed to `createRoute`, so every incoming request header — `Authorization`, `Cookie`, arbitrary custom headers — was echoed back on every response
78
+ - 🐛 `createRouter`'s dispatch loop called `handler(request, ctx)` without `await`, so an async handler that threw was never caught by the configured `errorHandler`/`defaultHandler` and instead crashed as an unhandled rejection
79
+ - 🐛 `createRoute` threw `Cannot read properties of null/undefined (reading 'toString')` on a `${null}`/`${undefined}` substitution instead of rendering nothing (ordinary template-literal semantics), even though a substitution *function* returning `undefined` was already treated as "no output"
80
+ - 🐛 A substitution *function* returning a `ReadableStream`/`Blob`/`ArrayBuffer`/`Uint8Array` fell through to `.toString()`, producing the literal string `"[object ReadableStream]"` instead of streaming the actual content (direct substitutions of these types already worked correctly)
81
+ - 🐛 `utility/dynamic-run.mjs` unconditionally attempted to `fs.unlink()` its temp file even when `fs.writeFile()` itself had failed, masking the real write error behind a misleading `ENOENT` — which `fs-router.mjs` then silently treated as "route not found" instead of surfacing a genuine permissions/disk-full failure. A cleanup (`unlink`) failure *after* a successful import also discarded the successful result
82
+ - 🐛 `fs-router.mjs`'s `index.html` handling wrote its synthesized temp module into leroute's own installed package directory instead of the matched route's directory — breaking the feature entirely in any deployment where the package install directory is read-only (containers, CI-built images)
83
+
84
+ ### Added
85
+
86
+ - `"types"` field in `package.json` pointing to the existing `types.d.ts`
87
+ - A real `description` and `keywords` in `package.json` (previously empty)
88
+
89
+ ### Removed
90
+
91
+ - Deleted `utility/cd.mjs`, an unused debug scratch file with no imports and no tests
92
+
93
+ ## 0.0.0 (as `Route`/`loute`, pre-rename) - 2024-08-26
94
+
95
+ > Disambiguation: this is the *original* initial release, under an earlier
96
+ > name and a separate version-number sequence (`phrouter` 1.0.0 -> `loute`
97
+ > 0.0.0 -> `leroute` 0.0.0 -> `leroute` 0.0.1), predating the npm scope
98
+ > migration entry above, which is also numbered `0.0.0` per family
99
+ > convention (the version resets on scope import). These are two different
100
+ > releases that happen to share a version number.
101
+
102
+ ### Added
103
+
104
+ - 🎉 Initial release of Route
105
+ - 🛠 Core routing functionality
106
+ - 🛠 HTTP request and response handling
107
+ - 🛠 TypeScript definitions
108
+ - 🛠 Support for route parameters and query strings
@@ -1,7 +1,10 @@
1
1
  # Letterpress
2
2
 
3
- [![npm version](https://badge.fury.io/js/%40johnhenry%2Fletterpress.svg)](https://www.npmjs.com/package/@johnhenry/letterpress)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
3
+ [![npm version](https://img.shields.io/npm/v/%40johnhenry%2Fletterpress.svg)](https://www.npmjs.com/package/@johnhenry/letterpress)
4
+ [![CI](https://github.com/johnhenry/letterpress/actions/workflows/ci.yml/badge.svg)](https://github.com/johnhenry/letterpress/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/%40johnhenry%2Fletterpress.svg)](license)
6
+
7
+ Full documentation: [opensource.johnhenry.me/letterpress](https://opensource.johnhenry.me/letterpress/)
5
8
 
6
9
  <img alt="Letterpress Logo" width="512" height="512" src="./logo.jpeg" style="width:512px;height:512px"/>
7
10
 
@@ -12,6 +15,18 @@ Letterpress is a flexible and powerful routing library for handling HTTP request
12
15
 
13
16
  Letterpress works great with [leserve](https://www.npmjs.com/package/@johnhenry/leserve), a library for serving endpoints.
14
17
 
18
+ ## Contents
19
+
20
+ - [Features](#-features)
21
+ - [Installation](#-installation)
22
+ - [Usage](#-usage)
23
+ - [API Reference](#-api-reference)
24
+ - [Honest limitations](#honest-limitations)
25
+ - [Changelog](#-changelog)
26
+ - [Family](#family)
27
+ - [Contributing](#-contributing)
28
+ - [License](#-license)
29
+
15
30
  ## 🚀 Features
16
31
 
17
32
  - Intuitive API using tagged template strings for route creation
@@ -51,10 +66,10 @@ router.endpoint`GET /``
51
66
  <head>
52
67
  <meta charset="UTF-8">
53
68
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
54
- <title>Welcome to Route</title>
69
+ <title>Welcome to Letterpress</title>
55
70
  </head>
56
71
  <body>
57
- <h1>Welcome to Route!</h1>
72
+ <h1>Welcome to Letterpress!</h1>
58
73
  <p>The current time is: ${() => new Date().toISOString()}</p>
59
74
  </body>
60
75
  </html>
@@ -164,74 +179,72 @@ Tagged-template functions for building `Request`/`Response` objects from raw HTT
164
179
 
165
180
  Builds a `Router` from a filesystem-based route directory.
166
181
 
182
+ ```javascript
183
+ import { createFSRouter } from "@johnhenry/letterpress/fs";
184
+ ```
185
+
186
+ > `createFSRouter` is imported from the `@johnhenry/letterpress/fs`
187
+ > subpath, not the main `@johnhenry/letterpress` barrel. It's the only
188
+ > piece of Letterpress that touches Node built-ins (`node:fs`, `node:path`)
189
+ > and the `theres-waldo` dependency, so it's kept out of the main entry
190
+ > point to keep that entry point bundler-friendly for browser use. Everything
191
+ > else (`createRouter`, `createRoute`, `createRequest`, `createResponse`,
192
+ > `HTTPExpression`) is pure and safe to bundle for the browser.
193
+
167
194
  ### `deconstruct`, `cook`
168
195
 
169
196
  Lower-level utility functions used to parse and process tagged HTTP template literals. See [api.md](./api.md) for details.
170
197
 
171
198
  > Note: `@johnhenry/letterpress` does not export a `serve` function. To actually run a server, pair it with [leserve](https://www.npmjs.com/package/@johnhenry/leserve) (or any server of your choice) as shown in the usage examples above.
172
199
 
173
- ## 📜 Changelog
174
-
175
- All notable changes to this project will be documented in this file.
176
-
177
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
200
+ ## Honest limitations
201
+
202
+ - **`createRoute` template substitutions are not HTML-escaped.** A
203
+ substitution value is inserted via `.toString()` and spliced directly
204
+ into the response body; there is no auto-escaping step, even though the
205
+ default `Content-Type` is `text/html`. Interpolating untrusted input
206
+ (request params, headers, query values) directly into an
207
+ `` router.endpoint`GET /...`` `` HTML template -- e.g.
208
+ `` `<p>${(_, {params}) => params.id}</p>` `` -- reflects that value
209
+ byte-for-byte into the response. Escape anything derived from the request
210
+ yourself (e.g. a small HTML-escaping helper) before interpolating it, the
211
+ same way you would with any other unescaped template-literal HTML
212
+ response.
213
+ - **`createFSRouter`'s `index.html` handling evaluates the file as live
214
+ JavaScript, not literal text.** To reuse `createRoute`'s own template
215
+ engine for static `index.html` files, `fs-router.mjs` reads the file's
216
+ raw content and splices it directly into a synthesized module string as
217
+ a template literal (`` export default createRoute()`${file}` ``), then
218
+ dynamically imports and runs that module. Any literal backtick or
219
+ `${...}` sequence inside that `index.html` file is interpreted as real
220
+ JavaScript in the synthesized module's scope, not rendered as text. This
221
+ is fine for trusted, hand-authored `index.html` files (the intended use),
222
+ but `index.html` files under a filesystem route tree should be treated as
223
+ code, not as passive markup, if their contents are ever user-editable.
178
224
 
179
- This project will adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once it reaches 1.0.0.
180
-
181
- ### [Unreleased]
182
-
183
- #### Changed (breaking)
184
-
185
- - **Renamed the package from `leroute` to `@johnhenry/letterpress`**, and
186
- restarted the version at `0.0.0` (previously published as `leroute`, last
187
- unscoped version `0.0.1`).
188
- - **Renamed the exported identifiers to match**: `createLeRoute` →
189
- `createRoute`, `createLeRouter` → `createRouter`, `LeRoute` → `Route`,
190
- `LeRouteInit` → `RouteInit`, `LeRouteMiddleware` → `RouteMiddleware`,
191
- `LeRouter` → `Router`, `LeRouterExtension` → `RouterExtension`,
192
- `LeRouterInit` → `RouterInit`, `LeRouterMiddleware` → `RouterMiddleware`.
193
- `create-leroute.mjs`/`create-lerouter.mjs` were also renamed to
194
- `create-route.mjs`/`create-router.mjs` (and their test files
195
- correspondingly), matching the new export names.
196
- - **Raised `engines.node` to `>=26.0.0`**, matching the rest of the
197
- `@johnhenry/*` family's floor.
198
-
199
- #### Fixed
200
-
201
- - 🐛 `test/index.mjs` imported `lerouter.mjs` twice and never imported `leroute.mjs`, silently dropping the `createRoute` test suite from `npm test`
202
- - 🐛 `test/leroute.mjs` imported `createRoute` from `create-router.mjs` (which doesn't export it) instead of `create-route.mjs`, which would have thrown once the missing import above was fixed
203
- - 🐛 `package.json` declared `"license": "ISC"` while the `LICENSE` file and README badge are MIT; changed to `"license": "MIT"` to match
204
- - 📝 README and API docs referenced `serve` and `tagRequest` as exports of `leroute`; neither is exported — `serve` comes from the separate `leserve` package, and `tagRequest` doesn't exist. Docs corrected to list the real exports
205
- - 📝 API docs showed `HTTPExpression(...)` as a constructor with `.method`/`.path`/`.version` properties; corrected to show it as a tagged-template function returning `{ test(request), exec(request) }`
206
- - 🔒 **Security**: `router.endpoint` reused the per-request context object (which carries the incoming request's `Headers`) as the response-init object passed to `createRoute`, so every incoming request header — `Authorization`, `Cookie`, arbitrary custom headers — was echoed back on every response
207
- - 🐛 `createRouter`'s dispatch loop called `handler(request, ctx)` without `await`, so an async handler that threw was never caught by the configured `errorHandler`/`defaultHandler` and instead crashed as an unhandled rejection
208
- - 🐛 `createRoute` threw `Cannot read properties of null/undefined (reading 'toString')` on a `${null}`/`${undefined}` substitution instead of rendering nothing (ordinary template-literal semantics), even though a substitution *function* returning `undefined` was already treated as "no output"
209
- - 🐛 A substitution *function* returning a `ReadableStream`/`Blob`/`ArrayBuffer`/`Uint8Array` fell through to `.toString()`, producing the literal string `"[object ReadableStream]"` instead of streaming the actual content (direct substitutions of these types already worked correctly)
210
- - 🐛 `utility/dynamic-run.mjs` unconditionally attempted to `fs.unlink()` its temp file even when `fs.writeFile()` itself had failed, masking the real write error behind a misleading `ENOENT` — which `fs-router.mjs` then silently treated as "route not found" instead of surfacing a genuine permissions/disk-full failure. A cleanup (`unlink`) failure *after* a successful import also discarded the successful result
211
- - 🐛 `fs-router.mjs`'s `index.html` handling wrote its synthesized temp module into leroute's own installed package directory instead of the matched route's directory — breaking the feature entirely in any deployment where the package install directory is read-only (containers, CI-built images)
212
-
213
- #### Added
214
-
215
- - `"types"` field in `package.json` pointing to the existing `types.d.ts`
216
- - A real `description` and `keywords` in `package.json` (previously empty)
217
-
218
- #### Removed
225
+ ## 📜 Changelog
219
226
 
220
- - Deleted `utility/cd.mjs`, an unused debug scratch file with no imports and no tests
227
+ See [CHANGELOG.md](./CHANGELOG.md) for the full history of changes.
221
228
 
222
- ### [0.0.0] - 2024-08-26
229
+ ## Family
223
230
 
224
- #### Added
231
+ Letterpress isn't a standalone server -- it deliberately doesn't export a
232
+ `serve` function (see the note at the end of the API Reference above), and
233
+ is designed to pair with a sibling package that does.
225
234
 
226
- - 🎉 Initial release of Route
227
- - 🛠 Core routing functionality
228
- - 🛠 HTTP request and response handling
229
- - 🛠 TypeScript definitions
230
- - 🛠 Support for route parameters and query strings
235
+ - **[`@johnhenry/leserve`](https://github.com/johnhenry/leserve)** -- a
236
+ library for serving endpoints. `createRouter()`'s output (a `Route`
237
+ function matching `(request, additionalContext) => Promise<Response>`) is
238
+ exactly the shape `leserve`'s `serve()` accepts as its handler, so pairing
239
+ the two needs no adapter -- `serve({ port }, router)`, as shown throughout
240
+ this README's usage examples. Any other server that can call a
241
+ `Route`-shaped function works too; `leserve` is the tested, documented
242
+ pairing, not a hard dependency (it's a `devDependency` here, used only in
243
+ the demo scripts).
231
244
 
232
245
  ## 🤝 Contributing
233
246
 
234
- We welcome contributions to Route! Here's how you can help:
247
+ We welcome contributions to Letterpress! Here's how you can help:
235
248
 
236
249
  1. Fork the repository
237
250
  2. Create a new branch: `git checkout -b feature/your-feature-name`
@@ -247,7 +260,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
247
260
 
248
261
  ## 🙏 Acknowledgements
249
262
 
250
- - Thanks to all contributors who have helped shape Route
263
+ - Thanks to all contributors who have helped shape Letterpress
251
264
  - Inspired by modern web development practices and the need for flexible routing solutions
252
265
 
253
266
  ## 📬 Contact
@@ -256,4 +269,4 @@ For questions, suggestions, or issues, please open an issue on the GitHub reposi
256
269
 
257
270
  ---
258
271
 
259
- Happy routing with Route! 🚀
272
+ Happy routing with Letterpress! 🚀
package/index.mjs CHANGED
@@ -2,7 +2,17 @@ export { createRequest } from "./create-request.mjs";
2
2
  export { createRouter } from "./create-router.mjs";
3
3
  export { createRoute } from "./create-route.mjs";
4
4
  export { createResponse } from "./create-response.mjs";
5
- export { createFSRouter } from "./fs-router.mjs";
6
5
  export * from "./utility/index.mjs";
7
6
  export { deconstruct } from "./utility/deconstruct.mjs";
8
7
  export { cook } from "./utility/cook.mjs";
8
+
9
+ // `createFSRouter` is intentionally NOT re-exported from this barrel.
10
+ // fs-router.mjs imports `node:fs`, `node:path`, and `theres-waldo` (which
11
+ // itself uses `fileURLToPath` from `node:url`) at module top level. Those
12
+ // side effects run at *import* time, not call time, so merely having this
13
+ // barrel import fs-router.mjs breaks browser bundlers (e.g. Vite) for every
14
+ // consumer, even ones who never call createFSRouter -- and shimming
15
+ // `node:fs`/`node:path` doesn't help, since the fs-router module body still
16
+ // executes on import. Import createFSRouter from the dedicated Node-only
17
+ // subpath instead:
18
+ // import { createFSRouter } from "@johnhenry/letterpress/fs";
package/package.json CHANGED
@@ -1,9 +1,15 @@
1
1
  {
2
2
  "name": "@johnhenry/letterpress",
3
- "version": "0.0.0",
3
+ "version": "0.0.1",
4
4
  "description": "A tagged-template-string HTTP routing library for defining and matching request handlers with plain JavaScript syntax.",
5
5
  "main": "index.mjs",
6
6
  "types": "types.d.ts",
7
+ "exports": {
8
+ ".": "./index.mjs",
9
+ "./fs": "./fs-router.mjs",
10
+ "./package.json": "./package.json",
11
+ "./*": "./*"
12
+ },
7
13
  "publishConfig": {
8
14
  "access": "public",
9
15
  "provenance": true
@@ -0,0 +1,64 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert";
3
+
4
+ // Regression tests for https://github.com/johnhenry/letterpress/issues/6:
5
+ // the main barrel used to re-export `createFSRouter` from `fs-router.mjs`,
6
+ // which imports `node:fs`, `node:path`, and `theres-waldo` (which itself
7
+ // uses `node:url`) at module top level. That broke browser bundlers (e.g.
8
+ // Vite) for every consumer of the barrel, even ones who never touch
9
+ // `createFSRouter`, because those Node-only imports run at *import* time.
10
+ //
11
+ // These tests lock in the shape the fix depends on: the pure/browser-safe
12
+ // exports stay on the main barrel, `createFSRouter` moves to a dedicated
13
+ // `./fs` subpath, and both resolve correctly via the package's own name
14
+ // (self-reference), matching how an external consumer would import them.
15
+ // The actual "does this bundle for the browser" claim is verified
16
+ // separately with a real esbuild --platform=browser build (see PR
17
+ // description) since node:test can't drive a bundler.
18
+
19
+ test("Main barrel does not export createFSRouter", async () => {
20
+ const barrel = await import("../index.mjs");
21
+ assert.strictEqual(
22
+ "createFSRouter" in barrel,
23
+ false,
24
+ "createFSRouter should not be re-exported from the main barrel " +
25
+ "(it pulls in node:fs/node:path/theres-waldo at import time)"
26
+ );
27
+ // The rest of the barrel should still be there.
28
+ for (const name of [
29
+ "createRouter",
30
+ "createRoute",
31
+ "createRequest",
32
+ "createResponse",
33
+ "HTTPExpression",
34
+ ]) {
35
+ assert.strictEqual(
36
+ typeof barrel[name],
37
+ "function",
38
+ `expected barrel to still export ${name}`
39
+ );
40
+ }
41
+ });
42
+
43
+ test("createFSRouter is exported from fs-router.mjs directly", async () => {
44
+ const fsRouterModule = await import("../fs-router.mjs");
45
+ assert.strictEqual(typeof fsRouterModule.createFSRouter, "function");
46
+ });
47
+
48
+ test("package.json exposes a ./fs subpath export for createFSRouter", async () => {
49
+ const pkg = await import("../package.json", { with: { type: "json" } });
50
+ const exportsMap = pkg.default.exports;
51
+ assert.ok(exportsMap, "package.json should declare an exports map");
52
+ assert.strictEqual(exportsMap["./fs"], "./fs-router.mjs");
53
+ assert.strictEqual(exportsMap["."], "./index.mjs");
54
+ });
55
+
56
+ test("@johnhenry/letterpress/fs resolves createFSRouter via self-reference", async () => {
57
+ const fsSubpath = await import("@johnhenry/letterpress/fs");
58
+ assert.strictEqual(typeof fsSubpath.createFSRouter, "function");
59
+ });
60
+
61
+ test("@johnhenry/letterpress (self-reference) does not carry createFSRouter", async () => {
62
+ const barrel = await import("@johnhenry/letterpress");
63
+ assert.strictEqual("createFSRouter" in barrel, false);
64
+ });
@@ -599,4 +599,64 @@ test("HTTPExpression - Additional Tests", async (t) => {
599
599
  });
600
600
  assert.strictEqual(expr.test(request), true);
601
601
  });
602
+
603
+ // Regression tests for https://github.com/johnhenry/letterpress/issues/4:
604
+ // `[Authorization: Bearer *]` (the README's colon-form matcher) used to
605
+ // fold ": Bearer" into the header *name*, so `headers.get()` threw
606
+ // `TypeError: invalid header name` -- and since the router calls matchers
607
+ // inside a top-level try/catch, that 500'd EVERY request routed through
608
+ // a matcher registered with this syntax, not just matching requests.
609
+ await t.test(
610
+ "Colon-form header matcher does not throw when the header is absent (issue #4 repro)",
611
+ () => {
612
+ const expr = HTTPExpression(["GET /admin [Authorization: Bearer *]"]);
613
+ const request = new Request("http://localhost/admin", {
614
+ method: "GET",
615
+ });
616
+ assert.doesNotThrow(() => expr.test(request));
617
+ // No Authorization header present, so the wildcard match fails.
618
+ assert.strictEqual(expr.test(request), false);
619
+ }
620
+ );
621
+
622
+ await t.test(
623
+ "Colon-form header matcher wildcard-matches any bearer token",
624
+ () => {
625
+ const expr = HTTPExpression(["GET /admin [Authorization: Bearer *]"]);
626
+ const request = new Request("http://localhost/admin", {
627
+ method: "GET",
628
+ headers: { Authorization: "Bearer eyJhbGciOiJIUzI1NiJ9.abc" },
629
+ });
630
+ assert.strictEqual(expr.test(request), true);
631
+ }
632
+ );
633
+
634
+ await t.test(
635
+ "Colon-form header matcher rejects a non-matching value",
636
+ () => {
637
+ const expr = HTTPExpression(["GET /admin [Authorization: Bearer *]"]);
638
+ const request = new Request("http://localhost/admin", {
639
+ method: "GET",
640
+ headers: { Authorization: "Basic dXNlcjpwYXNz" },
641
+ });
642
+ assert.strictEqual(expr.test(request), false);
643
+ }
644
+ );
645
+
646
+ await t.test(
647
+ "Starts-with operator form [Authorization^=Bearer] keeps working alongside the colon form",
648
+ () => {
649
+ const expr = HTTPExpression(["GET /admin [Authorization^=Bearer]"]);
650
+ const matching = new Request("http://localhost/admin", {
651
+ method: "GET",
652
+ headers: { Authorization: "Bearer token123" },
653
+ });
654
+ const nonMatching = new Request("http://localhost/admin", {
655
+ method: "GET",
656
+ headers: { Authorization: "Basic dXNlcjpwYXNz" },
657
+ });
658
+ assert.strictEqual(expr.test(matching), true);
659
+ assert.strictEqual(expr.test(nonMatching), false);
660
+ }
661
+ );
602
662
  });
package/test/index.mjs CHANGED
@@ -4,3 +4,4 @@ import "./route.mjs";
4
4
  import "./req-res.mjs";
5
5
  import "./fs-router.mjs";
6
6
  import "./dynamic-run.mjs";
7
+ import "./exports.mjs";
@@ -88,19 +88,38 @@ export const HTTPExpression = (strings, ...expressions) => {
88
88
  };
89
89
 
90
90
  const parseHeaderMatcher = (headerString) => {
91
- const match = headerString.match(/\[(!?)([^=~^$*<>]+)([=~^$*<>]+)?(.+)?\]/);
91
+ // Header name stops at ':' (colon form, e.g. `[Authorization: Bearer *]`)
92
+ // as well as at any operator character (`=~^$*<>`, e.g. `[Authorization^=Bearer]`).
93
+ // Without excluding ':' here, a colon-form matcher gets its ": Bearer"
94
+ // folded into the header *name*, which then throws when passed to
95
+ // `Headers.get()` (colons/whitespace are invalid header-name characters).
96
+ const match = headerString.match(
97
+ /\[(!?)([^=~^$*<>:]+)(?:(:)\s*|([=~^$*<>]+))?(.+)?\]/
98
+ );
92
99
  if (!match) {
93
100
  throw new Error(`Invalid header matcher: ${headerString}`);
94
101
  }
95
- const [, negation, name, operator, value] = match;
102
+ const [, negation, name, colon, operator, value] = match;
96
103
  return createHeaderMatcherFunction({
97
104
  name: name.trim(),
98
- operator: operator?.trim(),
105
+ // Colon form has no operator character of its own; treat the value
106
+ // that follows the ':' as a wildcard-match pattern (`*` matches any
107
+ // run of characters), so `[Authorization: Bearer *]` matches any
108
+ // bearer token instead of requiring an exact value.
109
+ operator: colon ? "wildcard" : operator?.trim(),
99
110
  value: value?.trim(),
100
111
  negate: !!negation,
101
112
  });
102
113
  };
103
114
 
115
+ const escapeRegExpLiteral = (str) =>
116
+ str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
117
+
118
+ const wildcardToRegExp = (pattern) =>
119
+ new RegExp(
120
+ "^" + pattern.split("*").map(escapeRegExpLiteral).join(".*") + "$"
121
+ );
122
+
104
123
  const createHeaderMatcherFunction = ({ name, operator, value, negate }) => {
105
124
  return (headers) => {
106
125
  const headerValue = headers.get(name);
@@ -135,6 +154,9 @@ const createHeaderMatcherFunction = ({ name, operator, value, negate }) => {
135
154
  case "*=":
136
155
  match = headerValue.includes(value || "");
137
156
  break;
157
+ case "wildcard":
158
+ match = wildcardToRegExp(value || "").test(headerValue);
159
+ break;
138
160
  case ">":
139
161
  match = parseFloat(headerValue) > parseFloat(value || "0");
140
162
  break;