@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.
- package/.github/workflows/ci.yml +5 -14
- package/.github/workflows/publish.yml +16 -27
- package/AGENTS.md +65 -0
- package/API.md +3 -3
- package/CHANGELOG.md +108 -0
- package/{readme.md → README.md} +73 -60
- package/index.mjs +11 -1
- package/package.json +7 -1
- package/test/exports.mjs +64 -0
- package/test/http-expression.mjs +60 -0
- package/test/index.mjs +1 -0
- package/utility/http-expression.mjs +25 -3
package/.github/workflows/ci.yml
CHANGED
|
@@ -7,17 +7,8 @@ on:
|
|
|
7
7
|
|
|
8
8
|
jobs:
|
|
9
9
|
test:
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 #
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
package/{readme.md → README.md}
RENAMED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# Letterpress
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/@johnhenry/letterpress)
|
|
4
|
+
[](https://github.com/johnhenry/letterpress/actions/workflows/ci.yml)
|
|
5
|
+
[](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
|
|
69
|
+
<title>Welcome to Letterpress</title>
|
|
55
70
|
</head>
|
|
56
71
|
<body>
|
|
57
|
-
<h1>Welcome to
|
|
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
|
-
##
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
227
|
+
See [CHANGELOG.md](./CHANGELOG.md) for the full history of changes.
|
|
221
228
|
|
|
222
|
-
|
|
229
|
+
## Family
|
|
223
230
|
|
|
224
|
-
|
|
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
|
-
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
package/test/exports.mjs
ADDED
|
@@ -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
|
+
});
|
package/test/http-expression.mjs
CHANGED
|
@@ -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
|
@@ -88,19 +88,38 @@ export const HTTPExpression = (strings, ...expressions) => {
|
|
|
88
88
|
};
|
|
89
89
|
|
|
90
90
|
const parseHeaderMatcher = (headerString) => {
|
|
91
|
-
|
|
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
|
|
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;
|