@johnhenry/letterpress 0.0.1 → 0.0.3

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 CHANGED
@@ -6,6 +6,60 @@ 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.3] - 2026-09-26
10
+
11
+ ### Fixed
12
+
13
+ - 🐛 **Types**: `RouterExtension.endpoint` (added by the `#9` fix) was
14
+ declared to return `Router` directly, i.e. callable only through
15
+ `Route`'s `(request: Request) => Response` signature. But at runtime
16
+ (and per the README's own documented examples) `router.endpoint` is
17
+ curried: the first tagged-template call defines the match pattern
18
+ (method/path/headers), and the *second* call — on the value the first
19
+ call returns — is either another tagged template supplying a raw HTTP
20
+ response literal (e.g.
21
+ `` router.endpoint`GET /x`` `HTTP/1.1 200 OK\n\n...`` ``) or a plain
22
+ handler function (`(request) => Response`), both registering the route
23
+ and returning the same `Router`. The old declaration didn't model this
24
+ curried, dual-mode second call at all, forcing consumers to fall back to
25
+ `@ts-expect-error`/`any`. Added `RouterEndpointResponder`, a callable
26
+ type with two overload signatures (tagged-template and single-handler),
27
+ and changed `RouterExtension.endpoint`'s return type from `Router` to
28
+ `RouterEndpointResponder`. Extended `test/types/fixture.ts` with both
29
+ curried forms (verified to fail to type-check against the pre-fix
30
+ declaration, and to pass cleanly against the fix) so a regression here
31
+ is caught by `npm test`/`npm run typecheck` instead of relying on
32
+ someone eyeballing `types.d.ts`
33
+ ([#11](https://github.com/johnhenry/letterpress/issues/11))
34
+
35
+ ## [0.0.2] - 2026-09-26
36
+
37
+ ### Fixed
38
+
39
+ - 🐛 **Types**: `package.json` declared a legacy top-level `"types":
40
+ "types.d.ts"`, but the `exports` map had no `types` condition. With an
41
+ `exports` map present, TypeScript (Bundler/Node16/NodeNext resolution)
42
+ ignores the legacy top-level `types` field entirely, so
43
+ `` import { createRouter } from '@johnhenry/letterpress' `` was untyped
44
+ (`TS7016`). Even fixing that alone wasn't enough: `types.d.ts` declared
45
+ only type *aliases* (`Router`, `CreateRouter`, `CreateRoute`,
46
+ `RouterInit`, …) with no *value* declarations for the actual named
47
+ exports, so the import would still fail with `TS2305`. Added `types`
48
+ conditions to `exports["."]` (→ `types.d.ts`) and `exports["./fs"]`
49
+ (→ a new `fs.d.ts`, since `createFSRouter` isn't on the main barrel —
50
+ see the `#6`/`#7` fix above), and added value declarations for every
51
+ actual named export: `createRouter`, `createRoute`, `createRequest`,
52
+ `createResponse`, `deconstruct`, `cook`, `HTTPExpression` (from
53
+ `index.mjs`, including its `utility/index.mjs` re-exports), and
54
+ `createFSRouter` (from the `./fs` subpath). Added a `test/types/fixture.ts`
55
+ fixture that imports `@johnhenry/letterpress` and
56
+ `@johnhenry/letterpress/fs` by their published package names and
57
+ references every one of those exports, type-checked via `tsc --noEmit`
58
+ in `test/types.test.mjs` (now part of `npm test`), plus a standalone
59
+ `npm run typecheck` script, so a regression here fails the test suite
60
+ instead of only being caught by a consumer eyeballing the `.d.ts` files
61
+ ([#9](https://github.com/johnhenry/letterpress/issues/9))
62
+
9
63
  ## [0.0.1] - 2026-09-26
10
64
 
11
65
  ### 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.1",
3
+ "version": "0.0.3",
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
- ".": "./index.mjs",
9
- "./fs": "./fs-router.mjs",
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
- assert.strictEqual(exportsMap["./fs"], "./fs-router.mjs");
53
- assert.strictEqual(exportsMap["."], "./index.mjs");
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
@@ -5,3 +5,4 @@ import "./req-res.mjs";
5
5
  import "./fs-router.mjs";
6
6
  import "./dynamic-run.mjs";
7
7
  import "./exports.mjs";
8
+ import "./types.test.mjs";
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Type-only regression fixture for letterpress #9 and #11.
3
+ *
4
+ * Not executed -- test/types.test.mjs spawns `tsc --noEmit` over this file
5
+ * (via the root tsconfig.json). It exists to catch 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
+ * 3. `RouterExtension.endpoint`'s declared type didn't model that it's
23
+ * curried at runtime (#11): the first tagged-template call returns a
24
+ * responder that must be called a *second* time, either as another
25
+ * tagged template (a raw HTTP response literal) or with a single
26
+ * handler function, both returning `Router`. Before the fix, the first
27
+ * call's return type was `Router` itself (callable only via `Route`'s
28
+ * `(request: Request) => Response` signature), so both curried forms
29
+ * below failed to type-check.
30
+ */
31
+ import {
32
+ createRouter,
33
+ createRoute,
34
+ createRequest,
35
+ createResponse,
36
+ deconstruct,
37
+ cook,
38
+ HTTPExpression,
39
+ } from "@johnhenry/letterpress";
40
+ import { createFSRouter } from "@johnhenry/letterpress/fs";
41
+
42
+ // createRouter: a Router is a Route (request, context?) => Response, plus
43
+ // an `.endpoint` tagged-template extension that registers routes.
44
+ const router = createRouter({ baseUrl: "http://localhost" });
45
+ const extended = router.endpoint`GET /ping`;
46
+ void extended;
47
+
48
+ // `.endpoint` is curried (letterpress #11): the first tagged-template call
49
+ // (above) defines the match pattern and returns a responder that must be
50
+ // called a *second* time, either as another tagged template (a raw HTTP
51
+ // response literal) or with a single handler function -- both forms
52
+ // register the route and return the same Router. Neither form below should
53
+ // require @ts-expect-error/@ts-ignore.
54
+ const routerAfterTemplateResponse = router.endpoint`GET /protected [Authorization: Bearer *]`
55
+ `HTTP/1.1 200 OK
56
+ Content-Type: text/plain
57
+
58
+ This is a protected resource
59
+ `;
60
+ const routerAfterHandlerResponse = router.endpoint`GET /api/data`(
61
+ async (request: Request) => {
62
+ void request;
63
+ return new Response("ok");
64
+ }
65
+ );
66
+ void routerAfterTemplateResponse;
67
+ void routerAfterHandlerResponse;
68
+
69
+ async function checkRouter() {
70
+ const response = await router(new Request("http://localhost/ping"));
71
+ return response instanceof Response;
72
+ }
73
+ void checkRouter;
74
+
75
+ // createRoute: (init?) => tagged-template => Route
76
+ const routeTag = createRoute({ status: 200 });
77
+ const route = routeTag`Hello, ${"world"}!`;
78
+ async function checkRoute() {
79
+ const response = await route(new Request("http://localhost/"));
80
+ return response instanceof Response;
81
+ }
82
+ void checkRoute;
83
+
84
+ // createRequest: (init?) => tagged-template => Promise<Request>
85
+ async function checkRequest() {
86
+ const request = await createRequest({ baseUrl: "http://localhost" })`
87
+ GET /
88
+ `;
89
+ return request instanceof Request;
90
+ }
91
+ void checkRequest;
92
+
93
+ // createResponse: itself a tagged-template function => Response
94
+ async function checkResponse() {
95
+ const response = await createResponse`
96
+ HTTP/1.1 200 OK
97
+
98
+ ok`;
99
+ return response instanceof Response;
100
+ }
101
+ void checkResponse;
102
+
103
+ // deconstruct: tagged-template => { strings, substitutions, raw }
104
+ const deconstructed = deconstruct`GET /${"foo"}`;
105
+ const deconstructedStrings: string[] = deconstructed.strings;
106
+ const deconstructedSubstitutions: any[] = deconstructed.substitutions;
107
+ void deconstructedStrings;
108
+ void deconstructedSubstitutions;
109
+
110
+ // cook: tagged-template => string
111
+ const cooked: string = cook`GET /${"foo"}`;
112
+ void cooked;
113
+
114
+ // HTTPExpression: tagged-template => matcher with .test()/.exec()
115
+ const matcher = HTTPExpression`GET /users/:id`;
116
+ declare const incomingRequest: Request;
117
+ const isMatch: boolean = matcher.test(incomingRequest);
118
+ const execResult = matcher.exec(incomingRequest);
119
+ if (execResult) {
120
+ const method: string = execResult.method;
121
+ const headers: Headers = execResult.headers;
122
+ void method;
123
+ void headers;
124
+ }
125
+ void isMatch;
126
+
127
+ // createFSRouter: only reachable via the "/fs" subpath (not the main
128
+ // barrel), per index.mjs's browser-bundling comment.
129
+ const fsRouter = createFSRouter("/tmp/routes", (_request, context) => {
130
+ const params: Record<string, string> = context.params;
131
+ void params;
132
+ return new Response("not found", { status: 404 });
133
+ });
134
+ async function checkFsRouter() {
135
+ const response = await fsRouter(new Request("http://localhost/x"));
136
+ return response === null || response instanceof Response;
137
+ }
138
+ void checkFsRouter;
@@ -0,0 +1,51 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { fileURLToPath } from "node:url";
6
+ import path from "node:path";
7
+
8
+ // Regression test for https://github.com/johnhenry/letterpress/issues/9:
9
+ //
10
+ // package.json's `exports` map had no `types` condition, so under
11
+ // Bundler/Node16/NodeNext module resolution TypeScript ignored the legacy
12
+ // top-level `types` field entirely -- `import { createRouter } from
13
+ // '@johnhenry/letterpress'` type-checked as `any`/TS7016. Even with that
14
+ // fixed, types.d.ts declared only type aliases (Router, CreateRouter, ...)
15
+ // with no value declarations for the actual exports, so the import itself
16
+ // would still fail with TS2305.
17
+ //
18
+ // This spawns a real `tsc --noEmit` against test/types/fixture.ts, which
19
+ // imports "@johnhenry/letterpress" and "@johnhenry/letterpress/fs" by
20
+ // their published package names (self-reference, exactly as an external
21
+ // consumer would) and references every named export. node:test can't
22
+ // type-check anything itself, so this is the only thing in the suite that
23
+ // actually exercises types.d.ts/fs.d.ts/package.json#exports the way a
24
+ // real TypeScript consumer does.
25
+
26
+ const execFileAsync = promisify(execFile);
27
+ const rootDir = path.resolve(fileURLToPath(import.meta.url), "../..");
28
+ const tscBin = path.join(rootDir, "node_modules", ".bin", "tsc");
29
+ const tsconfigPath = path.join(rootDir, "tsconfig.json");
30
+
31
+ test("published .d.ts files type-check for a consumer importing by package name", async () => {
32
+ try {
33
+ const { stdout } = await execFileAsync(
34
+ tscBin,
35
+ ["--noEmit", "-p", tsconfigPath],
36
+ { cwd: rootDir }
37
+ );
38
+ assert.strictEqual(
39
+ stdout.trim(),
40
+ "",
41
+ `expected no tsc output, got:\n${stdout}`
42
+ );
43
+ } catch (error) {
44
+ assert.fail(
45
+ "tsc --noEmit failed against test/types/fixture.ts -- this means " +
46
+ "the shipped types.d.ts/fs.d.ts (or package.json's `exports` " +
47
+ "map) are broken for a real TypeScript consumer:\n" +
48
+ `${error.stdout ?? ""}${error.stderr ?? error.message}`
49
+ );
50
+ }
51
+ });
package/tsconfig.json CHANGED
@@ -2,11 +2,19 @@
2
2
  "compilerOptions": {
3
3
  "target": "ESNext",
4
4
  "lib": [
5
- "ESNext"
6
- ]
5
+ "ESNext",
6
+ "DOM"
7
+ ],
8
+ "module": "NodeNext",
9
+ "moduleResolution": "NodeNext",
10
+ "strict": true,
11
+ "noEmit": true,
12
+ "skipLibCheck": true,
13
+ "types": []
7
14
  },
8
15
  "include": [
9
- ".cushy/*.d.ts",
10
- "**/*.ts"
16
+ "types.d.ts",
17
+ "fs.d.ts",
18
+ "test/types/**/*.ts"
11
19
  ]
12
20
  }
package/types.d.ts CHANGED
@@ -4,12 +4,27 @@ export type Route = (
4
4
  context?: Record<string, any>
5
5
  ) => Response | Promise<Response>;
6
6
 
7
+ // The value returned by the *first* `router.endpoint` tagged-template call
8
+ // (the method/path/header match pattern, e.g.
9
+ // `` router.endpoint`GET /protected [Authorization: Bearer *]` ``). That
10
+ // return value is itself curried: it must be called a *second* time,
11
+ // either as another tagged template supplying a raw HTTP response literal
12
+ // (e.g. `` `HTTP/1.1 200 OK\n\n...` ``, parsed the same way createResponse
13
+ // parses one) or with a single plain handler function
14
+ // (`(request: Request) => Response`), per the README's documented usage
15
+ // and create-router.mjs's actual implementation. Both forms register the
16
+ // route and return the same `Router` so calls can be chained.
17
+ export type RouterEndpointResponder = {
18
+ (template: TemplateStringsArray, ...substitutions: any[]): Router;
19
+ (handler: Route): Router;
20
+ };
21
+
7
22
  // Extension for the Router to add endpoints
8
23
  export type RouterExtension = {
9
24
  endpoint: (
10
25
  template: TemplateStringsArray,
11
26
  ...substitutions: any[]
12
- ) => Router;
27
+ ) => RouterEndpointResponder;
13
28
  };
14
29
 
15
30
  // Combines Route and RouterExtension
@@ -83,3 +98,88 @@ export type HeaderMatchOptions = {
83
98
 
84
99
  export type InlineParam = (options: InlineParamOptions) => string;
85
100
  export type HeaderMatch = (options: HeaderMatchOptions) => string;
101
+
102
+ // Configuration options for createRequest
103
+ export type CreateRequestInit = {
104
+ baseUrl?: string;
105
+ setRetroactiveHeaders?: boolean;
106
+ };
107
+
108
+ // Tagged-template function returned by createRequest(), builds a Request
109
+ // from an HTTP-request-shaped template literal (request line, headers,
110
+ // blank line, body).
111
+ export type CreateRequestTag = (
112
+ template: TemplateStringsArray,
113
+ ...substitutions: any[]
114
+ ) => Promise<Request>;
115
+
116
+ // Function to create a createRequest tagged-template function
117
+ export type CreateRequest = (init?: CreateRequestInit) => CreateRequestTag;
118
+
119
+ // createResponse is itself a tagged-template function (not a factory that
120
+ // returns one): it builds a Response directly from an
121
+ // HTTP-response-shaped template literal.
122
+ export type CreateResponse = (
123
+ template: TemplateStringsArray,
124
+ ...substitutions: any[]
125
+ ) => Response | Promise<Response>;
126
+
127
+ // Result of deconstruct(): the raw pieces of a tagged template call.
128
+ export type DeconstructResult = {
129
+ strings: string[];
130
+ substitutions: any[];
131
+ raw: readonly string[];
132
+ };
133
+
134
+ // deconstruct: pulls a tagged template call apart into its strings/
135
+ // substitutions/raw pieces without any further processing.
136
+ export type Deconstruct = (
137
+ template: TemplateStringsArray,
138
+ ...substitutions: any[]
139
+ ) => DeconstructResult;
140
+
141
+ // cook: re-assembles a tagged template call's strings/substitutions back
142
+ // into a single string (the "cooked" value), per
143
+ // https://2ality.com/2016/11/computing-tag-functions.html
144
+ export type Cook = (
145
+ template: TemplateStringsArray,
146
+ ...substitutions: any[]
147
+ ) => string;
148
+
149
+ // Result of matching an HTTPExpression against a Request: the named path
150
+ // parameters plus the matched method and the request's headers.
151
+ export type HTTPExpressionMatch = {
152
+ [param: string]: string;
153
+ } & {
154
+ method: string;
155
+ headers: Headers;
156
+ };
157
+
158
+ // The tagged-template matcher object returned by HTTPExpression().
159
+ export type HTTPExpressionMatcher = {
160
+ test(request: Request): boolean;
161
+ exec(request: Request): HTTPExpressionMatch | null;
162
+ };
163
+
164
+ // HTTPExpression: a tagged-template function that builds a request
165
+ // matcher (method + path pattern + optional header matchers) usable via
166
+ // `.test(request)` / `.exec(request)`.
167
+ export type HTTPExpressionFn = (
168
+ template: TemplateStringsArray,
169
+ ...substitutions: any[]
170
+ ) => HTTPExpressionMatcher;
171
+
172
+ // Value declarations for the actual named exports of index.mjs (and the
173
+ // utility/index.mjs re-exports it pulls in). An `exports` map with no
174
+ // `types` condition makes TypeScript ignore the legacy top-level `types`
175
+ // field entirely under Bundler/Node16/NodeNext resolution, and even once
176
+ // that's fixed (see package.json's `exports["."].types`), aliases alone
177
+ // (above) don't type the values a consumer actually imports -- see
178
+ // https://github.com/johnhenry/letterpress/issues/9.
179
+ export declare const createRouter: CreateRouter;
180
+ export declare const createRoute: CreateRoute;
181
+ export declare const createRequest: CreateRequest;
182
+ export declare const createResponse: CreateResponse;
183
+ export declare const deconstruct: Deconstruct;
184
+ export declare const cook: Cook;
185
+ export declare const HTTPExpression: HTTPExpressionFn;