@johnhenry/letterpress 0.0.0 → 0.0.2

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,136 @@
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.2] - 2026-09-26
10
+
11
+ ### Fixed
12
+
13
+ - 🐛 **Types**: `package.json` declared a legacy top-level `"types":
14
+ "types.d.ts"`, but the `exports` map had no `types` condition. With an
15
+ `exports` map present, TypeScript (Bundler/Node16/NodeNext resolution)
16
+ ignores the legacy top-level `types` field entirely, so
17
+ `` import { createRouter } from '@johnhenry/letterpress' `` was untyped
18
+ (`TS7016`). Even fixing that alone wasn't enough: `types.d.ts` declared
19
+ only type *aliases* (`Router`, `CreateRouter`, `CreateRoute`,
20
+ `RouterInit`, …) with no *value* declarations for the actual named
21
+ exports, so the import would still fail with `TS2305`. Added `types`
22
+ conditions to `exports["."]` (→ `types.d.ts`) and `exports["./fs"]`
23
+ (→ a new `fs.d.ts`, since `createFSRouter` isn't on the main barrel —
24
+ see the `#6`/`#7` fix above), and added value declarations for every
25
+ actual named export: `createRouter`, `createRoute`, `createRequest`,
26
+ `createResponse`, `deconstruct`, `cook`, `HTTPExpression` (from
27
+ `index.mjs`, including its `utility/index.mjs` re-exports), and
28
+ `createFSRouter` (from the `./fs` subpath). Added a `test/types/fixture.ts`
29
+ fixture that imports `@johnhenry/letterpress` and
30
+ `@johnhenry/letterpress/fs` by their published package names and
31
+ references every one of those exports, type-checked via `tsc --noEmit`
32
+ in `test/types.test.mjs` (now part of `npm test`), plus a standalone
33
+ `npm run typecheck` script, so a regression here fails the test suite
34
+ instead of only being caught by a consumer eyeballing the `.d.ts` files
35
+ ([#9](https://github.com/johnhenry/letterpress/issues/9))
36
+
37
+ ## [0.0.1] - 2026-09-26
38
+
39
+ ### Fixed
40
+
41
+ - 🐛 **Security/reliability**: The colon-form header matcher documented in
42
+ the README (`` router.endpoint`GET /protected [Authorization: Bearer *]` ``)
43
+ parsed the `:` and everything up to the wildcard as part of the header
44
+ *name* instead of stopping the name at `:`, so `headers.get()` threw
45
+ `TypeError: invalid header name`. Because `createRouter`'s dispatch loop
46
+ calls matchers inside a top-level try/catch, this 500'd **every** request
47
+ routed through a router with such a route registered — not just requests
48
+ matching that path — for any consumer who followed the README's own
49
+ documented syntax. `parseHeaderMatcher`'s header-name capture now stops at
50
+ `:` (in addition to the existing operator characters), and the value
51
+ after the colon is treated as a wildcard-match pattern (`*` matches any
52
+ run of characters), so `[Authorization: Bearer *]` matches any bearer
53
+ token instead of crashing. The operator-form equivalent,
54
+ `[Authorization^=Bearer]`, is unaffected and continues to work
55
+ ([#4](https://github.com/johnhenry/letterpress/issues/4))
56
+ - 🐛 **Bundling**: The main barrel (`index.mjs`) re-exported `createFSRouter`
57
+ from `fs-router.mjs`, which imports `node:fs`, `node:path`, and
58
+ `theres-waldo` (itself using `node:url`'s `fileURLToPath`) at module top
59
+ level. Those imports run at *import* time, so simply importing anything
60
+ from `@johnhenry/letterpress` — even `HTTPExpression` or `createRoute`,
61
+ which have nothing to do with the filesystem router — pulled in
62
+ Node-only code and broke browser bundlers (e.g. Vite), even when the
63
+ Node builtins were shimmed, because the fs-router module body still ran.
64
+ `createFSRouter` is no longer re-exported from the main barrel; it's now
65
+ reachable via a dedicated `@johnhenry/letterpress/fs` subpath (added to a
66
+ new `package.json` `"exports"` map, alongside a `"./*"` wildcard so
67
+ existing deep-path imports like
68
+ `@johnhenry/letterpress/utility/http-expression.mjs` keep working
69
+ unchanged). Everything else in the barrel (`createRouter`, `createRoute`,
70
+ `createRequest`, `createResponse`, `HTTPExpression`) was already pure and
71
+ is now verified to bundle cleanly for the browser
72
+ ([#6](https://github.com/johnhenry/letterpress/issues/6))
73
+
74
+ ### Changed (breaking)
75
+
76
+ - **`createFSRouter` is no longer exported from the main `@johnhenry/letterpress`
77
+ barrel.** Import it from `@johnhenry/letterpress/fs` instead. This is the
78
+ only export affected; everything else's import path is unchanged.
79
+
80
+ ## [0.0.0] - npm scope migration - 2026-09-19
81
+
82
+ ### Changed (breaking)
83
+
84
+ - **Renamed the package from `leroute` to `@johnhenry/letterpress`**, and
85
+ restarted the version at `0.0.0` (previously published as `leroute`, last
86
+ unscoped version `0.0.1`).
87
+ - **Renamed the exported identifiers to match**: `createLeRoute` →
88
+ `createRoute`, `createLeRouter` → `createRouter`, `LeRoute` → `Route`,
89
+ `LeRouteInit` → `RouteInit`, `LeRouteMiddleware` → `RouteMiddleware`,
90
+ `LeRouter` → `Router`, `LeRouterExtension` → `RouterExtension`,
91
+ `LeRouterInit` → `RouterInit`, `LeRouterMiddleware` → `RouterMiddleware`.
92
+ `create-leroute.mjs`/`create-lerouter.mjs` were also renamed to
93
+ `create-route.mjs`/`create-router.mjs` (and their test files
94
+ correspondingly), matching the new export names.
95
+ - **Raised `engines.node` to `>=26.0.0`**, matching the rest of the
96
+ `@johnhenry/*` family's floor.
97
+
98
+ ### Fixed
99
+
100
+ - 🐛 `test/index.mjs` imported `lerouter.mjs` twice and never imported `leroute.mjs`, silently dropping the `createRoute` test suite from `npm test`
101
+ - 🐛 `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
102
+ - 🐛 `package.json` declared `"license": "ISC"` while the `LICENSE` file and README badge are MIT; changed to `"license": "MIT"` to match
103
+ - 📝 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
104
+ - 📝 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) }`
105
+ - 🔒 **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
106
+ - 🐛 `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
107
+ - 🐛 `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"
108
+ - 🐛 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)
109
+ - 🐛 `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
110
+ - 🐛 `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)
111
+
112
+ ### Added
113
+
114
+ - `"types"` field in `package.json` pointing to the existing `types.d.ts`
115
+ - A real `description` and `keywords` in `package.json` (previously empty)
116
+
117
+ ### Removed
118
+
119
+ - Deleted `utility/cd.mjs`, an unused debug scratch file with no imports and no tests
120
+
121
+ ## 0.0.0 (as `Route`/`loute`, pre-rename) - 2024-08-26
122
+
123
+ > Disambiguation: this is the *original* initial release, under an earlier
124
+ > name and a separate version-number sequence (`phrouter` 1.0.0 -> `loute`
125
+ > 0.0.0 -> `leroute` 0.0.0 -> `leroute` 0.0.1), predating the npm scope
126
+ > migration entry above, which is also numbered `0.0.0` per family
127
+ > convention (the version resets on scope import). These are two different
128
+ > releases that happen to share a version number.
129
+
130
+ ### Added
131
+
132
+ - 🎉 Initial release of Route
133
+ - 🛠 Core routing functionality
134
+ - 🛠 HTTP request and response handling
135
+ - 🛠 TypeScript definitions
136
+ - 🛠 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/fs.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ // Type declarations for the @johnhenry/letterpress/fs subpath.
2
+ //
3
+ // createFSRouter is intentionally NOT re-exported from the main barrel
4
+ // (see index.mjs's comment: it imports node:fs/node:path/theres-waldo at
5
+ // module top level, which breaks browser bundlers for every consumer).
6
+ // It's only reachable via this dedicated Node-only subpath, so its types
7
+ // live in their own declaration file rather than in types.d.ts -- see
8
+ // https://github.com/johnhenry/letterpress/issues/9.
9
+
10
+ // Context passed to the fs-router's defaultHandler: path params collected
11
+ // while walking the filesystem for a `#param`-style directory segment.
12
+ export type FSRouterContext = {
13
+ params: Record<string, string>;
14
+ };
15
+
16
+ // Handler invoked when no matching route file/directory is found.
17
+ export type FSRouterDefaultHandler = (
18
+ request: Request,
19
+ context: FSRouterContext
20
+ ) => Response | Promise<Response>;
21
+
22
+ // The router function returned by createFSRouter(). Resolves to `null`
23
+ // when no matching path segment is found while walking baseDir.
24
+ export type FSRouter = (request: Request) => Promise<Response | null>;
25
+
26
+ // Function to create a filesystem-based router: maps URL paths to
27
+ // `[method].mjs` files (or `index.html`) under a directory structure that
28
+ // mirrors the URL path, with `#name` directories acting as path params.
29
+ export type CreateFSRouter = (
30
+ baseDir: string,
31
+ defaultHandler?: FSRouterDefaultHandler
32
+ ) => FSRouter;
33
+
34
+ export declare const createFSRouter: CreateFSRouter;
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,21 @@
1
1
  {
2
2
  "name": "@johnhenry/letterpress",
3
- "version": "0.0.0",
3
+ "version": "0.0.2",
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
+ ".": {
9
+ "types": "./types.d.ts",
10
+ "default": "./index.mjs"
11
+ },
12
+ "./fs": {
13
+ "types": "./fs.d.ts",
14
+ "default": "./fs-router.mjs"
15
+ },
16
+ "./package.json": "./package.json",
17
+ "./*": "./*"
18
+ },
7
19
  "publishConfig": {
8
20
  "access": "public",
9
21
  "provenance": true
@@ -17,6 +29,7 @@
17
29
  },
18
30
  "scripts": {
19
31
  "test": "node --test test/index.mjs",
32
+ "typecheck": "tsc --noEmit -p tsconfig.json",
20
33
  "demo:server": "node demo/server.mjs",
21
34
  "demo:client": "node demo/client.mjs"
22
35
  },
@@ -39,7 +52,8 @@
39
52
  "node": ">=26.0.0"
40
53
  },
41
54
  "devDependencies": {
42
- "@johnhenry/leserve": "^0.0.0"
55
+ "@johnhenry/leserve": "^0.0.0",
56
+ "typescript": "^7.0.0"
43
57
  },
44
58
  "dependencies": {
45
59
  "theres-waldo": "^0.0.0"
@@ -0,0 +1,69 @@
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
+ // Both conditional blocks carry a `types` condition (see issue #9) in
53
+ // addition to the `default` runtime target, so these are objects rather
54
+ // than bare strings now.
55
+ assert.strictEqual(exportsMap["./fs"].default, "./fs-router.mjs");
56
+ assert.strictEqual(exportsMap["./fs"].types, "./fs.d.ts");
57
+ assert.strictEqual(exportsMap["."].default, "./index.mjs");
58
+ assert.strictEqual(exportsMap["."].types, "./types.d.ts");
59
+ });
60
+
61
+ test("@johnhenry/letterpress/fs resolves createFSRouter via self-reference", async () => {
62
+ const fsSubpath = await import("@johnhenry/letterpress/fs");
63
+ assert.strictEqual(typeof fsSubpath.createFSRouter, "function");
64
+ });
65
+
66
+ test("@johnhenry/letterpress (self-reference) does not carry createFSRouter", async () => {
67
+ const barrel = await import("@johnhenry/letterpress");
68
+ assert.strictEqual("createFSRouter" in barrel, false);
69
+ });
@@ -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,5 @@ 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";
8
+ import "./types.test.mjs";
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Type-only regression fixture for letterpress #9.
3
+ *
4
+ * Not executed -- test/types.test.mjs spawns `tsc --noEmit` over this file
5
+ * (via the root tsconfig.json). It exists to catch two regressions
6
+ * automatically instead of relying on someone eyeballing types.d.ts/fs.d.ts:
7
+ *
8
+ * 1. Importing "@johnhenry/letterpress" (and its "/fs" subpath) by their
9
+ * published package names -- as any real consumer does -- must resolve
10
+ * to the shipped declarations. Before #9, package.json's `exports["."]`
11
+ * (and `exports["./fs"]`) had no `types` condition, so under
12
+ * Bundler/Node16/NodeNext module resolution TypeScript reported TS7016
13
+ * ("could not find a declaration file for module") even though a
14
+ * top-level `types` field existed -- that legacy field is not
15
+ * consulted once `exports` is present under this resolution mode.
16
+ * 2. Even with the `types` condition wired up, types.d.ts declared only
17
+ * type *aliases* (Router, CreateRouter, CreateRoute, RouterInit, ...)
18
+ * with no *value* declarations for the actual exported functions, so
19
+ * `import { createRouter } from '@johnhenry/letterpress'` type-checked
20
+ * the module specifier but `createRouter` itself was still `any`/
21
+ * missing (TS2305 "has no exported member").
22
+ */
23
+ import {
24
+ createRouter,
25
+ createRoute,
26
+ createRequest,
27
+ createResponse,
28
+ deconstruct,
29
+ cook,
30
+ HTTPExpression,
31
+ } from "@johnhenry/letterpress";
32
+ import { createFSRouter } from "@johnhenry/letterpress/fs";
33
+
34
+ // createRouter: a Router is a Route (request, context?) => Response, plus
35
+ // an `.endpoint` tagged-template extension that registers routes.
36
+ const router = createRouter({ baseUrl: "http://localhost" });
37
+ const extended = router.endpoint`GET /ping`;
38
+ void extended;
39
+
40
+ async function checkRouter() {
41
+ const response = await router(new Request("http://localhost/ping"));
42
+ return response instanceof Response;
43
+ }
44
+ void checkRouter;
45
+
46
+ // createRoute: (init?) => tagged-template => Route
47
+ const routeTag = createRoute({ status: 200 });
48
+ const route = routeTag`Hello, ${"world"}!`;
49
+ async function checkRoute() {
50
+ const response = await route(new Request("http://localhost/"));
51
+ return response instanceof Response;
52
+ }
53
+ void checkRoute;
54
+
55
+ // createRequest: (init?) => tagged-template => Promise<Request>
56
+ async function checkRequest() {
57
+ const request = await createRequest({ baseUrl: "http://localhost" })`
58
+ GET /
59
+ `;
60
+ return request instanceof Request;
61
+ }
62
+ void checkRequest;
63
+
64
+ // createResponse: itself a tagged-template function => Response
65
+ async function checkResponse() {
66
+ const response = await createResponse`
67
+ HTTP/1.1 200 OK
68
+
69
+ ok`;
70
+ return response instanceof Response;
71
+ }
72
+ void checkResponse;
73
+
74
+ // deconstruct: tagged-template => { strings, substitutions, raw }
75
+ const deconstructed = deconstruct`GET /${"foo"}`;
76
+ const deconstructedStrings: string[] = deconstructed.strings;
77
+ const deconstructedSubstitutions: any[] = deconstructed.substitutions;
78
+ void deconstructedStrings;
79
+ void deconstructedSubstitutions;
80
+
81
+ // cook: tagged-template => string
82
+ const cooked: string = cook`GET /${"foo"}`;
83
+ void cooked;
84
+
85
+ // HTTPExpression: tagged-template => matcher with .test()/.exec()
86
+ const matcher = HTTPExpression`GET /users/:id`;
87
+ declare const incomingRequest: Request;
88
+ const isMatch: boolean = matcher.test(incomingRequest);
89
+ const execResult = matcher.exec(incomingRequest);
90
+ if (execResult) {
91
+ const method: string = execResult.method;
92
+ const headers: Headers = execResult.headers;
93
+ void method;
94
+ void headers;
95
+ }
96
+ void isMatch;
97
+
98
+ // createFSRouter: only reachable via the "/fs" subpath (not the main
99
+ // barrel), per index.mjs's browser-bundling comment.
100
+ const fsRouter = createFSRouter("/tmp/routes", (_request, context) => {
101
+ const params: Record<string, string> = context.params;
102
+ void params;
103
+ return new Response("not found", { status: 404 });
104
+ });
105
+ async function checkFsRouter() {
106
+ const response = await fsRouter(new Request("http://localhost/x"));
107
+ return response === null || response instanceof Response;
108
+ }
109
+ void checkFsRouter;
@@ -0,0 +1,51 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { fileURLToPath } from "node:url";
6
+ import path from "node:path";
7
+
8
+ // Regression test for https://github.com/johnhenry/letterpress/issues/9:
9
+ //
10
+ // package.json's `exports` map had no `types` condition, so under
11
+ // Bundler/Node16/NodeNext module resolution TypeScript ignored the legacy
12
+ // top-level `types` field entirely -- `import { createRouter } from
13
+ // '@johnhenry/letterpress'` type-checked as `any`/TS7016. Even with that
14
+ // fixed, types.d.ts declared only type aliases (Router, CreateRouter, ...)
15
+ // with no value declarations for the actual exports, so the import itself
16
+ // would still fail with TS2305.
17
+ //
18
+ // This spawns a real `tsc --noEmit` against test/types/fixture.ts, which
19
+ // imports "@johnhenry/letterpress" and "@johnhenry/letterpress/fs" by
20
+ // their published package names (self-reference, exactly as an external
21
+ // consumer would) and references every named export. node:test can't
22
+ // type-check anything itself, so this is the only thing in the suite that
23
+ // actually exercises types.d.ts/fs.d.ts/package.json#exports the way a
24
+ // real TypeScript consumer does.
25
+
26
+ const execFileAsync = promisify(execFile);
27
+ const rootDir = path.resolve(fileURLToPath(import.meta.url), "../..");
28
+ const tscBin = path.join(rootDir, "node_modules", ".bin", "tsc");
29
+ const tsconfigPath = path.join(rootDir, "tsconfig.json");
30
+
31
+ test("published .d.ts files type-check for a consumer importing by package name", async () => {
32
+ try {
33
+ const { stdout } = await execFileAsync(
34
+ tscBin,
35
+ ["--noEmit", "-p", tsconfigPath],
36
+ { cwd: rootDir }
37
+ );
38
+ assert.strictEqual(
39
+ stdout.trim(),
40
+ "",
41
+ `expected no tsc output, got:\n${stdout}`
42
+ );
43
+ } catch (error) {
44
+ assert.fail(
45
+ "tsc --noEmit failed against test/types/fixture.ts -- this means " +
46
+ "the shipped types.d.ts/fs.d.ts (or package.json's `exports` " +
47
+ "map) are broken for a real TypeScript consumer:\n" +
48
+ `${error.stdout ?? ""}${error.stderr ?? error.message}`
49
+ );
50
+ }
51
+ });
package/tsconfig.json CHANGED
@@ -2,11 +2,19 @@
2
2
  "compilerOptions": {
3
3
  "target": "ESNext",
4
4
  "lib": [
5
- "ESNext"
6
- ]
5
+ "ESNext",
6
+ "DOM"
7
+ ],
8
+ "module": "NodeNext",
9
+ "moduleResolution": "NodeNext",
10
+ "strict": true,
11
+ "noEmit": true,
12
+ "skipLibCheck": true,
13
+ "types": []
7
14
  },
8
15
  "include": [
9
- ".cushy/*.d.ts",
10
- "**/*.ts"
16
+ "types.d.ts",
17
+ "fs.d.ts",
18
+ "test/types/**/*.ts"
11
19
  ]
12
20
  }
package/types.d.ts CHANGED
@@ -83,3 +83,88 @@ export type HeaderMatchOptions = {
83
83
 
84
84
  export type InlineParam = (options: InlineParamOptions) => string;
85
85
  export type HeaderMatch = (options: HeaderMatchOptions) => string;
86
+
87
+ // Configuration options for createRequest
88
+ export type CreateRequestInit = {
89
+ baseUrl?: string;
90
+ setRetroactiveHeaders?: boolean;
91
+ };
92
+
93
+ // Tagged-template function returned by createRequest(), builds a Request
94
+ // from an HTTP-request-shaped template literal (request line, headers,
95
+ // blank line, body).
96
+ export type CreateRequestTag = (
97
+ template: TemplateStringsArray,
98
+ ...substitutions: any[]
99
+ ) => Promise<Request>;
100
+
101
+ // Function to create a createRequest tagged-template function
102
+ export type CreateRequest = (init?: CreateRequestInit) => CreateRequestTag;
103
+
104
+ // createResponse is itself a tagged-template function (not a factory that
105
+ // returns one): it builds a Response directly from an
106
+ // HTTP-response-shaped template literal.
107
+ export type CreateResponse = (
108
+ template: TemplateStringsArray,
109
+ ...substitutions: any[]
110
+ ) => Response | Promise<Response>;
111
+
112
+ // Result of deconstruct(): the raw pieces of a tagged template call.
113
+ export type DeconstructResult = {
114
+ strings: string[];
115
+ substitutions: any[];
116
+ raw: readonly string[];
117
+ };
118
+
119
+ // deconstruct: pulls a tagged template call apart into its strings/
120
+ // substitutions/raw pieces without any further processing.
121
+ export type Deconstruct = (
122
+ template: TemplateStringsArray,
123
+ ...substitutions: any[]
124
+ ) => DeconstructResult;
125
+
126
+ // cook: re-assembles a tagged template call's strings/substitutions back
127
+ // into a single string (the "cooked" value), per
128
+ // https://2ality.com/2016/11/computing-tag-functions.html
129
+ export type Cook = (
130
+ template: TemplateStringsArray,
131
+ ...substitutions: any[]
132
+ ) => string;
133
+
134
+ // Result of matching an HTTPExpression against a Request: the named path
135
+ // parameters plus the matched method and the request's headers.
136
+ export type HTTPExpressionMatch = {
137
+ [param: string]: string;
138
+ } & {
139
+ method: string;
140
+ headers: Headers;
141
+ };
142
+
143
+ // The tagged-template matcher object returned by HTTPExpression().
144
+ export type HTTPExpressionMatcher = {
145
+ test(request: Request): boolean;
146
+ exec(request: Request): HTTPExpressionMatch | null;
147
+ };
148
+
149
+ // HTTPExpression: a tagged-template function that builds a request
150
+ // matcher (method + path pattern + optional header matchers) usable via
151
+ // `.test(request)` / `.exec(request)`.
152
+ export type HTTPExpressionFn = (
153
+ template: TemplateStringsArray,
154
+ ...substitutions: any[]
155
+ ) => HTTPExpressionMatcher;
156
+
157
+ // Value declarations for the actual named exports of index.mjs (and the
158
+ // utility/index.mjs re-exports it pulls in). An `exports` map with no
159
+ // `types` condition makes TypeScript ignore the legacy top-level `types`
160
+ // field entirely under Bundler/Node16/NodeNext resolution, and even once
161
+ // that's fixed (see package.json's `exports["."].types`), aliases alone
162
+ // (above) don't type the values a consumer actually imports -- see
163
+ // https://github.com/johnhenry/letterpress/issues/9.
164
+ export declare const createRouter: CreateRouter;
165
+ export declare const createRoute: CreateRoute;
166
+ export declare const createRequest: CreateRequest;
167
+ export declare const createResponse: CreateResponse;
168
+ export declare const deconstruct: Deconstruct;
169
+ export declare const cook: Cook;
170
+ export declare const HTTPExpression: HTTPExpressionFn;
@@ -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;