@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.
- package/.github/workflows/ci.yml +23 -0
- package/.github/workflows/publish.yml +38 -0
- package/API.md +174 -0
- package/app/routes/echo/all.mjs +14 -0
- package/app/routes/index.html +20 -0
- package/app/routes/products/get.mjs +6 -0
- package/app/routes/products/post.mjs +6 -0
- package/app/routes/user/#id/get.mjs +9 -0
- package/app/routes/user/get.mjs +6 -0
- package/app/server.mjs +7 -0
- package/create-request.mjs +108 -0
- package/create-response.mjs +6 -0
- package/create-route.mjs +185 -0
- package/create-router.mjs +61 -0
- package/demo/client.mjs +173 -0
- package/demo/index.html +128 -0
- package/demo/registry.example.js +30 -0
- package/demo/server.mjs +53 -0
- package/demo/style.css +49 -0
- package/fs-router.mjs +161 -0
- package/index.mjs +8 -0
- package/license +21 -0
- package/logo.jpeg +0 -0
- package/package.json +47 -0
- package/readme.md +259 -0
- package/test/dynamic-run.mjs +68 -0
- package/test/fs-router.mjs +83 -0
- package/test/http-expression.mjs +602 -0
- package/test/index.mjs +6 -0
- package/test/req-res.mjs +289 -0
- package/test/route.mjs +116 -0
- package/test/router.mjs +213 -0
- package/tsconfig.json +12 -0
- package/types.d.ts +85 -0
- package/utility/cook.mjs +5 -0
- package/utility/deconstruct.mjs +5 -0
- package/utility/dynamic-run.mjs +30 -0
- package/utility/http-expression.mjs +158 -0
- package/utility/index.mjs +3 -0
|
@@ -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>
|
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;
|
package/create-route.mjs
ADDED
|
@@ -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
|
+
};
|