@johnhenry/letterpress 0.0.1 → 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/CHANGELOG.md +28 -0
- package/fs.d.ts +34 -0
- package/package.json +12 -4
- package/test/exports.mjs +7 -2
- package/test/index.mjs +1 -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/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
|
|
6
6
|
|
|
7
7
|
This project will adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once it reaches 1.0.0.
|
|
8
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
|
+
|
|
9
37
|
## [0.0.1] - 2026-09-26
|
|
10
38
|
|
|
11
39
|
### Fixed
|
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/package.json
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
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
7
|
"exports": {
|
|
8
|
-
".":
|
|
9
|
-
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./types.d.ts",
|
|
10
|
+
"default": "./index.mjs"
|
|
11
|
+
},
|
|
12
|
+
"./fs": {
|
|
13
|
+
"types": "./fs.d.ts",
|
|
14
|
+
"default": "./fs-router.mjs"
|
|
15
|
+
},
|
|
10
16
|
"./package.json": "./package.json",
|
|
11
17
|
"./*": "./*"
|
|
12
18
|
},
|
|
@@ -23,6 +29,7 @@
|
|
|
23
29
|
},
|
|
24
30
|
"scripts": {
|
|
25
31
|
"test": "node --test test/index.mjs",
|
|
32
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
26
33
|
"demo:server": "node demo/server.mjs",
|
|
27
34
|
"demo:client": "node demo/client.mjs"
|
|
28
35
|
},
|
|
@@ -45,7 +52,8 @@
|
|
|
45
52
|
"node": ">=26.0.0"
|
|
46
53
|
},
|
|
47
54
|
"devDependencies": {
|
|
48
|
-
"@johnhenry/leserve": "^0.0.0"
|
|
55
|
+
"@johnhenry/leserve": "^0.0.0",
|
|
56
|
+
"typescript": "^7.0.0"
|
|
49
57
|
},
|
|
50
58
|
"dependencies": {
|
|
51
59
|
"theres-waldo": "^0.0.0"
|
package/test/exports.mjs
CHANGED
|
@@ -49,8 +49,13 @@ test("package.json exposes a ./fs subpath export for createFSRouter", async () =
|
|
|
49
49
|
const pkg = await import("../package.json", { with: { type: "json" } });
|
|
50
50
|
const exportsMap = pkg.default.exports;
|
|
51
51
|
assert.ok(exportsMap, "package.json should declare an exports map");
|
|
52
|
-
|
|
53
|
-
|
|
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");
|
|
54
59
|
});
|
|
55
60
|
|
|
56
61
|
test("@johnhenry/letterpress/fs resolves createFSRouter via self-reference", async () => {
|
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;
|