@johnhenry/letterpress 0.0.0

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.
@@ -0,0 +1,23 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ strategy:
11
+ fail-fast: false
12
+ matrix:
13
+ # engines.node >=26.0.0 -- test the floor version directly rather
14
+ # than a matrix of now-unsupported older majors.
15
+ node-version: [26]
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: actions/setup-node@v4
20
+ with:
21
+ node-version: ${{ matrix.node-version }}
22
+ - run: npm ci
23
+ - run: npm test
@@ -0,0 +1,38 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch: {}
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ id-token: write # npm provenance
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - uses: actions/setup-node@v4
18
+ with:
19
+ node-version: '26'
20
+ registry-url: 'https://registry.npmjs.org'
21
+
22
+ - run: npm ci
23
+
24
+ - run: npm test
25
+
26
+ - name: Publish
27
+ # Idempotent: `npm view` exits non-zero for a version that isn't on
28
+ # the registry yet, which is the signal to publish.
29
+ run: |
30
+ PKG_NAME=$(node -p "require('./package.json').name")
31
+ PKG_VERSION=$(node -p "require('./package.json').version")
32
+ if npm view "$PKG_NAME@$PKG_VERSION" version >/dev/null 2>&1; then
33
+ echo "::notice title=Already published::$PKG_NAME@$PKG_VERSION is already on npm; nothing to do."
34
+ exit 0
35
+ fi
36
+ npm publish --access public
37
+ env:
38
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/API.md ADDED
@@ -0,0 +1,174 @@
1
+ # Route API Documentation
2
+
3
+ ## Table of Contents
4
+
5
+ 1. [createRouter](#createRouter)
6
+ 2. [createRoute](#createRoute)
7
+ 3. [Utility Functions](#utility-functions)
8
+
9
+ ## createRouter
10
+
11
+ Creates a new router instance.
12
+
13
+ ```typescript
14
+ function createRouter(init?: RouterInit): Router;
15
+ ```
16
+
17
+ ### Parameters
18
+
19
+ - `init` (optional): Configuration options for the router
20
+
21
+ ```typescript
22
+ type RouterInit = {
23
+ baseUrl?: string;
24
+ defaultHandler?: Route;
25
+ errorHandler?: (
26
+ error: Error,
27
+ request: Request
28
+ ) => Response | Promise<Response>;
29
+ cache?: CacheOptions;
30
+ };
31
+ ```
32
+
33
+ ### Returns
34
+
35
+ Returns a `Router` instance, which is a function that can be used as a request handler and also has an `endpoint` method for defining routes.
36
+
37
+ ### Example
38
+
39
+ ```javascript
40
+ import { createRouter } from "@johnhenry/letterpress";
41
+
42
+ const router = createRouter({
43
+ baseUrl: "https://api.example.com",
44
+ errorHandler: (error, request) => {
45
+ console.error("Error:", error);
46
+ return new Response("An error occurred", { status: 500 });
47
+ },
48
+ });
49
+
50
+ router.endpoint`GET /users/:id`(async (request, { params }) => {
51
+ // Handle the request
52
+ });
53
+ ```
54
+
55
+ ## createRoute
56
+
57
+ Creates a new route handler.
58
+
59
+ ```typescript
60
+ function createRoute(
61
+ init?: RouteInit
62
+ ): (template: TemplateStringsArray, ...substitutions: any[]) => Route;
63
+ ```
64
+
65
+ ### Parameters
66
+
67
+ - `init` (optional): Configuration options for the route
68
+
69
+ ```typescript
70
+ type RouteInit = {
71
+ headers?: HeadersInit | Headers;
72
+ status?: number;
73
+ statusText?: string;
74
+ streaming?: boolean;
75
+ };
76
+ ```
77
+
78
+ ### Returns
79
+
80
+ Returns a function that takes a template literal and returns a `Route` (a request handler function).
81
+
82
+ ### Example
83
+
84
+ ```javascript
85
+ import { createRoute } from "@johnhenry/letterpress";
86
+
87
+ const userRoute = createRoute({ streaming: true })`
88
+ HTTP/1.1 200 OK
89
+ Content-Type: application/json
90
+
91
+ {
92
+ "id": ${(_, { params }) => params.id},
93
+ "name": "John Doe",
94
+ "email": "john@example.com"
95
+ }
96
+ `;
97
+ ```
98
+
99
+ ## Utility Functions
100
+
101
+ Route provides several utility functions to help with request and response handling:
102
+
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
+
105
+ ### createRequest
106
+
107
+ Creates a new `Request` object from a template literal.
108
+
109
+ ```javascript
110
+ import { createRequest } from "@johnhenry/letterpress";
111
+
112
+ const request = await createRequest()`
113
+ GET /api/users HTTP/1.1
114
+ Accept: application/json
115
+ `;
116
+ ```
117
+
118
+ ### createResponse
119
+
120
+ Creates a new `Response` object from a template literal.
121
+
122
+ ```javascript
123
+ import { createResponse } from "@johnhenry/letterpress";
124
+
125
+ const response = await createResponse`
126
+ HTTP/1.1 200 OK
127
+ Content-Type: application/json
128
+
129
+ {"message": "Hello, World!"}
130
+ `;
131
+ ```
132
+
133
+ ### HTTPExpression
134
+
135
+ A tagged-template function for matching HTTP requests against a method/path/header pattern. It returns an object with `test(request)` and `exec(request)` methods — not `.method`/`.path`/`.version` properties.
136
+
137
+ ```javascript
138
+ import { HTTPExpression } from "@johnhenry/letterpress";
139
+
140
+ const expr = HTTPExpression`GET /users/:id`;
141
+
142
+ expr.test(new Request("https://example.com/users/123")); // true
143
+
144
+ expr.exec(new Request("https://example.com/users/123"));
145
+ // { id: '123', method: 'GET', headers: Headers {} }
146
+ ```
147
+
148
+ ### deconstruct
149
+
150
+ A low-level tagged-template helper that breaks a template literal down into its raw pieces, without evaluating substitutions into a final string. Returns `{ strings, substitutions, raw }`.
151
+
152
+ ```javascript
153
+ import { deconstruct } from "@johnhenry/letterpress";
154
+
155
+ const { strings, substitutions, raw } = deconstruct`
156
+ POST /api/users HTTP/1.1
157
+ Content-Type: application/json
158
+
159
+ {"name": "${"John Doe"}"}
160
+ `;
161
+ ```
162
+
163
+ ### cook
164
+
165
+ A low-level tagged-template helper that concatenates a template literal's strings and substitutions back into a single string (the inverse of `deconstruct`).
166
+
167
+ ```javascript
168
+ import { cook } from "@johnhenry/letterpress";
169
+
170
+ const message = cook`GET /api/users/${123} HTTP/1.1`;
171
+ // "GET /api/users/123 HTTP/1.1"
172
+ ```
173
+
174
+ This API documentation provides an overview of the main functions and utilities provided by the Route library. For more detailed information on specific use cases and advanced features, please refer to the README.md and the source code.
@@ -0,0 +1,14 @@
1
+ const echoRoute = async (request) => {
2
+ const { method, url, headers } = request;
3
+ const body = await request.text();
4
+ console.log(`${method} ${url}`);
5
+ console.log([...headers]);
6
+ console.log(body);
7
+ return new Response(body, {
8
+ status: 200,
9
+ headers: {
10
+ "content-type": headers.get("content-type") || "text/plain",
11
+ },
12
+ });
13
+ };
14
+ export default echoRoute;
@@ -0,0 +1,20 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <title>My First Web Page</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta charset="utf-8" />
7
+ </head>
8
+ <body>
9
+ <h1>${(request)=>request.method}</h1>
10
+ <h2>${(request)=>request.url}</h2>
11
+ at ${(request)=>new Date()}
12
+ <h3>Users:</h3>
13
+ <ul>
14
+ <li><a href="./user/123">123</a></li>
15
+ <li><a href="./user/abc">abc</a></li>
16
+ <li><a href="./user/bill the user">bill the user</a></li>
17
+ </ul>
18
+ <h3><a href="./products">products</a></h3>
19
+ </body>
20
+ </html>
@@ -0,0 +1,6 @@
1
+ export default async function (request) {
2
+ return new Response("List of products", {
3
+ status: 200,
4
+ headers: { "Content-Type": "text/plain" },
5
+ });
6
+ }
@@ -0,0 +1,6 @@
1
+ export default async function (request) {
2
+ return new Response("Product created", {
3
+ status: 201,
4
+ headers: { "Content-Type": "text/plain" },
5
+ });
6
+ }
@@ -0,0 +1,9 @@
1
+ export default async function (_, { params }) {
2
+ return new Response(
3
+ `User details for IDs: ${decodeURIComponent(params.id)}`,
4
+ {
5
+ status: 200,
6
+ headers: { "Content-Type": "text/plain" },
7
+ }
8
+ );
9
+ }
@@ -0,0 +1,6 @@
1
+ export default async function (request) {
2
+ return new Response("List of users", {
3
+ status: 200,
4
+ headers: { "Content-Type": "text/plain" },
5
+ });
6
+ }
package/app/server.mjs ADDED
@@ -0,0 +1,7 @@
1
+ import { createFSRouter } from "../fs-router.mjs";
2
+ import path from "path";
3
+ import serve from "leserve";
4
+ import theresWaldo from "theres-waldo";
5
+ const { dir } = theresWaldo(import.meta.url);
6
+ const router = createFSRouter(path.join(dir, "routes"));
7
+ serve({ port: 8000 }, router);
@@ -0,0 +1,108 @@
1
+ export const createRequest = (options = {}) => {
2
+ return async function (strings, ...values) {
3
+ let fullString = "";
4
+ let bodyValue = null;
5
+ for (let i = 0; i < strings.length; i++) {
6
+ fullString += strings[i];
7
+ if (i < values.length) {
8
+ if (values[i] instanceof Headers) {
9
+ fullString += [...values[i]]
10
+ .map(([key, value]) => `${key}: ${value}`)
11
+ .join("\n");
12
+ } else if (
13
+ values[i] instanceof URLSearchParams ||
14
+ values[i] instanceof FormData ||
15
+ values[i] instanceof Blob ||
16
+ values[i] instanceof Uint8Array ||
17
+ values[i] instanceof ReadableStream
18
+ ) {
19
+ bodyValue = values[i];
20
+ fullString += "[BODY_PLACEHOLDER]";
21
+ } else {
22
+ fullString += values[i];
23
+ }
24
+ }
25
+ }
26
+
27
+ const [requestLine, ...rest] = fullString.trim().split("\n");
28
+ const [method, url, httpVersion] = requestLine.split(" ");
29
+
30
+ let baseUrl = options.baseUrl || "http://localhost";
31
+ let fullUrl;
32
+ if (url.startsWith("http://") || url.startsWith("https://")) {
33
+ fullUrl = new URL(url);
34
+ } else {
35
+ fullUrl = new URL(url.startsWith("/") ? url : `/${url}`, baseUrl);
36
+ }
37
+
38
+ const headers = new Headers();
39
+ let body = "";
40
+ let isBody = false;
41
+
42
+ for (const line of rest) {
43
+ if (line.trim() === "") {
44
+ isBody = true;
45
+ continue;
46
+ }
47
+ if (!isBody) {
48
+ const [key, value] = line.split(": ");
49
+ headers.append(key.trim(), value.trim());
50
+ } else {
51
+ body += line + "\n";
52
+ }
53
+ }
54
+
55
+ if (!headers.has("Host")) {
56
+ headers.set("Host", fullUrl.host);
57
+ }
58
+
59
+ const requestInit = {
60
+ method,
61
+ headers,
62
+ };
63
+
64
+ if (bodyValue !== null) {
65
+ requestInit.body = bodyValue;
66
+ if (bodyValue instanceof ReadableStream) {
67
+ headers.set("Content-Type", "application/octet-stream");
68
+ requestInit.duplex = "half";
69
+ } else if (bodyValue instanceof Blob) {
70
+ headers.set(
71
+ "Content-Type",
72
+ bodyValue.type || "application/octet-stream"
73
+ );
74
+ } else if (bodyValue instanceof FormData) {
75
+ // FormData sets its own Content-Type
76
+ } else if (bodyValue instanceof URLSearchParams) {
77
+ headers.set("Content-Type", "application/x-www-form-urlencoded");
78
+ } else if (
79
+ bodyValue instanceof ArrayBuffer ||
80
+ bodyValue instanceof Uint8Array
81
+ ) {
82
+ headers.set("Content-Type", "application/octet-stream");
83
+ }
84
+ } else if (body.trim()) {
85
+ requestInit.body = body.trim();
86
+ try {
87
+ JSON.parse(body);
88
+ headers.set("Content-Type", "application/json");
89
+ } catch {
90
+ headers.set("Content-Type", "text/plain");
91
+ }
92
+ }
93
+
94
+ if (options.setRetroactiveHeaders !== false && requestInit.body) {
95
+ if (!headers.has("Content-Length")) {
96
+ const bodySize =
97
+ requestInit.body instanceof Blob
98
+ ? requestInit.body.size
99
+ : new Blob([requestInit.body]).size;
100
+ headers.set("Content-Length", bodySize.toString());
101
+ }
102
+ }
103
+
104
+ return new Request(fullUrl, requestInit);
105
+ };
106
+ };
107
+
108
+ export default createRequest;
@@ -0,0 +1,6 @@
1
+ import { createRoute } from "./create-route.mjs";
2
+
3
+ export const createResponse = (strings, ...substitutions) =>
4
+ createRoute()(strings, ...substitutions)();
5
+
6
+ export default createResponse;
@@ -0,0 +1,185 @@
1
+ const DEFAULT_REQUEST = () => new Request("http://.");
2
+
3
+ export const createRoute = (initOrMiddleware) => {
4
+ const getInit =
5
+ typeof initOrMiddleware === "function"
6
+ ? initOrMiddleware
7
+ : () => initOrMiddleware || {};
8
+
9
+ return (strings, ...substitutions) => {
10
+ return async (request = DEFAULT_REQUEST(), additionalContext = {}) => {
11
+ const init = getInit(request);
12
+ const headers = new Headers(init.headers);
13
+ let status = init.status || 200;
14
+ let statusText = init.statusText || "OK";
15
+ const streaming = init.streaming || false;
16
+
17
+ const context = {
18
+ setHeader: (name, value) => headers.set(name, value),
19
+ setStatus: (newStatus, newStatusText) => {
20
+ status = newStatus;
21
+ statusText = newStatusText;
22
+ },
23
+ setStatusText: (newStatusText) => {
24
+ statusText = newStatusText;
25
+ },
26
+ response: {
27
+ headers: headers,
28
+ },
29
+ ...additionalContext,
30
+ };
31
+
32
+ const processInput = async () => {
33
+ let mode = "INITIAL";
34
+ let buffer = "";
35
+ let bodyContent = "";
36
+ let headerName = "";
37
+ let i = 0;
38
+ let j = 0;
39
+
40
+ const processChunk = (chunk) => {
41
+ buffer += chunk;
42
+ const lines = buffer.split("\n");
43
+
44
+ while (lines.length > 1) {
45
+ const line = lines.shift().trim();
46
+
47
+ switch (mode) {
48
+ case "INITIAL":
49
+ if (line.startsWith("HTTP/")) {
50
+ const [httpVersion, statusCode, ...statusTextParts] =
51
+ line.split(" ");
52
+ status = parseInt(statusCode, 10);
53
+ statusText = statusTextParts.join(" ");
54
+ mode = "HEADER";
55
+ } else if (line !== "") {
56
+ mode = "BODY";
57
+ bodyContent += line + "\n";
58
+ }
59
+ break;
60
+
61
+ case "HEADER":
62
+ if (line === "") {
63
+ mode = "BODY";
64
+ } else {
65
+ const colonIndex = line.indexOf(":");
66
+ if (colonIndex > 0) {
67
+ const key = line.slice(0, colonIndex).trim();
68
+ const value = line.slice(colonIndex + 1).trim();
69
+ headers.set(key, value);
70
+ }
71
+ }
72
+ break;
73
+
74
+ case "BODY":
75
+ bodyContent += line + "\n";
76
+ break;
77
+ }
78
+ }
79
+
80
+ buffer = lines[0] || "";
81
+ };
82
+
83
+ while (i < strings.length) {
84
+ processChunk(strings[i]);
85
+
86
+ if (j < substitutions.length) {
87
+ const sub = substitutions[j];
88
+ if (typeof sub === "function") {
89
+ const result = await sub(request, context);
90
+ if (
91
+ result instanceof ReadableStream ||
92
+ result instanceof Blob ||
93
+ result instanceof ArrayBuffer ||
94
+ result instanceof Uint8Array
95
+ ) {
96
+ // A substitution function can return a stream/binary value
97
+ // just like a directly-substituted one; it must get the
98
+ // same raw-passthrough treatment rather than being coerced
99
+ // via .toString() (which would produce "[object ...]").
100
+ if (!headers.has("Content-Type")) {
101
+ headers.set(
102
+ "Content-Type",
103
+ result.type ?? "application/octet-stream" // Blob may have a type
104
+ );
105
+ }
106
+ return result;
107
+ } else if (result !== undefined && result !== null) {
108
+ processChunk(result.toString());
109
+ }
110
+ } else if (
111
+ sub instanceof ReadableStream ||
112
+ sub instanceof Blob ||
113
+ sub instanceof ArrayBuffer ||
114
+ sub instanceof Uint8Array
115
+ ) {
116
+ if (!headers.has("Content-Type")) {
117
+ headers.set(
118
+ "Content-Type",
119
+ sub.type ?? "application/octet-stream" // Blob may have a type
120
+ );
121
+ }
122
+ return sub;
123
+ } else if (sub instanceof Headers) {
124
+ for (const [key, value] of sub) {
125
+ headers.set(key, value);
126
+ }
127
+ } else if (sub !== undefined && sub !== null) {
128
+ processChunk(sub.toString());
129
+ }
130
+ j++;
131
+ }
132
+
133
+ i++;
134
+ }
135
+
136
+ // Process any remaining buffer content
137
+ if (buffer) {
138
+ processChunk("\n");
139
+ }
140
+
141
+ return bodyContent.trim();
142
+ };
143
+
144
+ let responseBody = await processInput();
145
+
146
+ if (!headers.has("Content-Type")) {
147
+ headers.set("Content-Type", "text/html");
148
+ }
149
+
150
+ if (typeof responseBody === "string") {
151
+ try {
152
+ JSON.parse(responseBody);
153
+ if (!headers.has("Content-Type")) {
154
+ headers.set("Content-Type", "application/json");
155
+ }
156
+ } catch {
157
+ // Not JSON, keep existing Content-Type
158
+ }
159
+
160
+ if (!headers.has("Content-Length")) {
161
+ headers.set(
162
+ "Content-Length",
163
+ new Blob([responseBody]).size.toString()
164
+ );
165
+ }
166
+ }
167
+
168
+ if (streaming && typeof responseBody === "string") {
169
+ responseBody = new ReadableStream({
170
+ start(controller) {
171
+ controller.enqueue(new TextEncoder().encode(responseBody));
172
+ controller.close();
173
+ },
174
+ });
175
+ }
176
+ return new Response(
177
+ status === 204 && !responseBody ? null : responseBody, // If status is 204, body must be null
178
+ { headers, status, statusText }
179
+ );
180
+ // return new Response(responseBody, { headers, status, statusText });
181
+ };
182
+ };
183
+ };
184
+
185
+ export default createRoute;
@@ -0,0 +1,61 @@
1
+ import { createRoute } from "./create-route.mjs";
2
+ import { HTTPExpression } from "./utility/http-expression.mjs";
3
+ /** @type {CreateRouter} */
4
+ export const createRouter = (initial = {}) => {
5
+ const routes = [];
6
+ const {
7
+ defaultHandler = (request) =>
8
+ new Response("404 Not Found", { status: 404 }),
9
+ errorHandler = (error, request) =>
10
+ new Response("500 Internal Server Error", { status: 500 }),
11
+ ...init
12
+ } = initial;
13
+
14
+ const router = async (request) => {
15
+ try {
16
+ for (const [matcher, handler] of routes) {
17
+ const match = matcher(request);
18
+ if (match) {
19
+ // Awaiting here (rather than returning the handler's promise
20
+ // directly) is required so that a handler that throws inside an
21
+ // async function (i.e. rejects its returned promise) is still
22
+ // caught below and routed to errorHandler.
23
+ return await handler(request, { ...init, ...match });
24
+ }
25
+ }
26
+ return await defaultHandler(request);
27
+ } catch (error) {
28
+ return errorHandler(error, request);
29
+ }
30
+ };
31
+
32
+ router.endpoint = (strings, ...substitutions) => {
33
+ const matcher = (request) => {
34
+ const match = HTTPExpression(strings, ...substitutions).exec(request);
35
+ if (!match) {
36
+ return null;
37
+ }
38
+ const { method, headers, ...params } = match;
39
+ return { params, method, headers };
40
+ };
41
+
42
+ return (values, ...substitutions) => {
43
+ const handler =
44
+ typeof values === "function"
45
+ ? values
46
+ : // `context` here is the per-request object the router dispatch
47
+ // builds as `{ ...init, ...match }`, i.e. it includes `headers`
48
+ // set to the *request's* Headers (see matcher above) so that
49
+ // substitution functions can read `context.headers`. It must
50
+ // NOT be used as the RouteInit passed to createRoute, or
51
+ // the request's headers (Cookie, Authorization, etc.) would be
52
+ // used to seed - and thus leak into - the response headers.
53
+ // createRoute is given only the router-level `init` instead.
54
+ (request, context) =>
55
+ createRoute(init)(values, ...substitutions)(request, context);
56
+ routes.push([matcher, handler]);
57
+ return router;
58
+ };
59
+ };
60
+ return router;
61
+ };