@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.
- 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 +136 -0
- package/{readme.md → README.md} +73 -60
- package/fs.d.ts +34 -0
- package/index.mjs +11 -1
- package/package.json +16 -2
- package/test/exports.mjs +69 -0
- package/test/http-expression.mjs +60 -0
- package/test/index.mjs +2 -0
- package/test/types/fixture.ts +109 -0
- package/test/types.test.mjs +51 -0
- package/tsconfig.json +12 -4
- package/types.d.ts +85 -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,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
|
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/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.
|
|
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"
|
package/test/exports.mjs
ADDED
|
@@ -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
|
+
});
|
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
|
@@ -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
|
-
".
|
|
10
|
-
"
|
|
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
|
-
|
|
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;
|