@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
package/demo/client.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Import the necessary functions
|
|
2
|
+
import { createResponse, createRequest } from "../index.mjs";
|
|
3
|
+
// Utility function to create a simple readable stream
|
|
4
|
+
const createReadableStream = (data) => {
|
|
5
|
+
return new ReadableStream({
|
|
6
|
+
start(controller) {
|
|
7
|
+
controller.enqueue(new TextEncoder().encode(data));
|
|
8
|
+
controller.close();
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Demo 1: Simple GET request
|
|
14
|
+
async function demoSimpleGetRequest() {
|
|
15
|
+
console.log("Demo 1: Simple GET request");
|
|
16
|
+
const request = await createRequest()`
|
|
17
|
+
GET /api/users HTTP/1.1
|
|
18
|
+
Accept: application/json
|
|
19
|
+
`;
|
|
20
|
+
console.log("Method:", request.method);
|
|
21
|
+
console.log("URL:", request.url);
|
|
22
|
+
console.log("Headers:", Object.fromEntries(request.headers));
|
|
23
|
+
console.log("\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Demo 2: POST request with JSON body
|
|
27
|
+
async function demoPostRequestWithJson() {
|
|
28
|
+
console.log("Demo 2: POST request with JSON body");
|
|
29
|
+
const userData = { name: "John Doe", email: "john@example.com" };
|
|
30
|
+
const request = await createRequest({ baseUrl: "https://api.example.com" })`
|
|
31
|
+
POST /users HTTP/1.1
|
|
32
|
+
Content-Type: application/json
|
|
33
|
+
|
|
34
|
+
${JSON.stringify(userData)}
|
|
35
|
+
`;
|
|
36
|
+
console.log("Method:", request.method);
|
|
37
|
+
console.log("URL:", request.url);
|
|
38
|
+
console.log("Headers:", Object.fromEntries(request.headers));
|
|
39
|
+
console.log("Body:", await request.json());
|
|
40
|
+
console.log("\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Demo 3: PUT request with URLSearchParams
|
|
44
|
+
async function demoPutRequestWithUrlSearchParams() {
|
|
45
|
+
console.log("Demo 3: PUT request with URLSearchParams");
|
|
46
|
+
const params = new URLSearchParams({ key1: "value1", key2: "value2" });
|
|
47
|
+
const request = await createRequest()`
|
|
48
|
+
PUT https://api.example.com/update HTTP/1.1
|
|
49
|
+
User-Agent: TestAgent/1.0
|
|
50
|
+
|
|
51
|
+
${params}
|
|
52
|
+
`;
|
|
53
|
+
console.log("Method:", request.method);
|
|
54
|
+
console.log("URL:", request.url);
|
|
55
|
+
console.log("Headers:", Object.fromEntries(request.headers));
|
|
56
|
+
console.log("Body:", await request.text());
|
|
57
|
+
console.log("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Demo 4: POST request with FormData
|
|
61
|
+
async function demoPostRequestWithFormData() {
|
|
62
|
+
console.log("Demo 4: POST request with FormData");
|
|
63
|
+
const formData = new FormData();
|
|
64
|
+
formData.append("username", "testuser");
|
|
65
|
+
formData.append("password", "testpass");
|
|
66
|
+
const request = await createRequest({ baseUrl: "https://auth.example.com" })`
|
|
67
|
+
POST /login HTTP/1.1
|
|
68
|
+
User-Agent: TestAgent/1.0
|
|
69
|
+
|
|
70
|
+
${formData}
|
|
71
|
+
`;
|
|
72
|
+
console.log("Method:", request.method);
|
|
73
|
+
console.log("URL:", request.url);
|
|
74
|
+
console.log("Headers:", Object.fromEntries(request.headers));
|
|
75
|
+
console.log("Body is FormData:", request.body instanceof FormData);
|
|
76
|
+
console.log("\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Demo 5: Basic HTML response
|
|
80
|
+
async function demoBasicHtmlResponse() {
|
|
81
|
+
console.log("Demo 5: Basic HTML response");
|
|
82
|
+
const response = await createResponse`HTTP/1.1 200 OK
|
|
83
|
+
Content-Type: text/html
|
|
84
|
+
|
|
85
|
+
<html><body>Hello, World!</body></html>
|
|
86
|
+
`;
|
|
87
|
+
console.log("Status:", response.status);
|
|
88
|
+
console.log("Headers:", Object.fromEntries(response.headers));
|
|
89
|
+
console.log("Body:", await response.text());
|
|
90
|
+
console.log("\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Demo 6: JSON response
|
|
94
|
+
async function demoJsonResponse() {
|
|
95
|
+
console.log("Demo 6: JSON response");
|
|
96
|
+
const jsonBody = JSON.stringify({ message: "Success", code: 200 });
|
|
97
|
+
const response = await createResponse`HTTP/1.1 200 OK
|
|
98
|
+
Content-Type: application/json
|
|
99
|
+
|
|
100
|
+
${jsonBody}
|
|
101
|
+
`;
|
|
102
|
+
console.log("Status:", response.status);
|
|
103
|
+
console.log("Headers:", Object.fromEntries(response.headers));
|
|
104
|
+
console.log("Body:", await response.json());
|
|
105
|
+
console.log("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Demo 7: Response with custom status code
|
|
109
|
+
async function demoCustomStatusResponse() {
|
|
110
|
+
console.log("Demo 7: Response with custom status code");
|
|
111
|
+
const response = await createResponse`HTTP/1.1 404 Not Found
|
|
112
|
+
Content-Type: text/plain
|
|
113
|
+
|
|
114
|
+
Resource not found
|
|
115
|
+
`;
|
|
116
|
+
console.log("Status:", response.status);
|
|
117
|
+
console.log("Status Text:", response.statusText);
|
|
118
|
+
console.log("Headers:", Object.fromEntries(response.headers));
|
|
119
|
+
console.log("Body:", await response.text());
|
|
120
|
+
console.log("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Demo 8: Response with ReadableStream body
|
|
124
|
+
async function demoReadableStreamResponse() {
|
|
125
|
+
console.log("Demo 8: Response with ReadableStream body");
|
|
126
|
+
const streamData = "This is streaming response data";
|
|
127
|
+
const stream = createReadableStream(streamData);
|
|
128
|
+
const response = await createResponse`HTTP/1.1 200 OK
|
|
129
|
+
Content-Type: application/octet-stream
|
|
130
|
+
|
|
131
|
+
${stream}
|
|
132
|
+
`;
|
|
133
|
+
console.log("Status:", response.status);
|
|
134
|
+
console.log("Headers:", Object.fromEntries(response.headers));
|
|
135
|
+
console.log("Body:", await response.text());
|
|
136
|
+
console.log("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const demoResponse = async () => {
|
|
140
|
+
const response = await createResponse`HTTP/1.1 200 OK
|
|
141
|
+
Content-Type: text/html
|
|
142
|
+
|
|
143
|
+
<!DOCTYPE html>
|
|
144
|
+
<html>
|
|
145
|
+
<head>
|
|
146
|
+
<title>Letterpress Demo</title>
|
|
147
|
+
</head>
|
|
148
|
+
<body>
|
|
149
|
+
<h1>Welcome to Letterpress!</h1>
|
|
150
|
+
<p>The current time is: </p>
|
|
151
|
+
<p>Your user agent is: </p>
|
|
152
|
+
</body>
|
|
153
|
+
</html>`;
|
|
154
|
+
console.log("Status:", response.status);
|
|
155
|
+
console.log("Headers:", Object.fromEntries(response.headers));
|
|
156
|
+
console.log("Body:", await response.text());
|
|
157
|
+
console.log("\n");
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Run all demos
|
|
161
|
+
async function runAllDemos() {
|
|
162
|
+
await demoSimpleGetRequest();
|
|
163
|
+
await demoPostRequestWithJson();
|
|
164
|
+
await demoPutRequestWithUrlSearchParams();
|
|
165
|
+
await demoPostRequestWithFormData();
|
|
166
|
+
await demoBasicHtmlResponse();
|
|
167
|
+
await demoJsonResponse();
|
|
168
|
+
await demoCustomStatusResponse();
|
|
169
|
+
await demoReadableStreamResponse();
|
|
170
|
+
await demoResponse();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
runAllDemos().catch(console.error);
|
package/demo/index.html
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Home</title>
|
|
5
|
+
<link rel="stylesheet" type="text/css" href="./style.css" />
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<main>
|
|
9
|
+
<ui-card>
|
|
10
|
+
<template shadowrootmode="closed">
|
|
11
|
+
<style>
|
|
12
|
+
:host {
|
|
13
|
+
display: block;
|
|
14
|
+
contain: content;
|
|
15
|
+
box-sizing: border-box;
|
|
16
|
+
box-shadow: var(--shadow-raised);
|
|
17
|
+
border: var(--stroke-thicknessMinus1) var(--stroke-style)
|
|
18
|
+
var(--color-layerBorder);
|
|
19
|
+
border-radius: var(--border-radius);
|
|
20
|
+
background: var(--background, transparent);
|
|
21
|
+
color: var(--foreground, var(--color-onLayerBase));
|
|
22
|
+
}
|
|
23
|
+
</style>
|
|
24
|
+
<slot></slot>
|
|
25
|
+
</template>
|
|
26
|
+
ui-card
|
|
27
|
+
</ui-card>
|
|
28
|
+
<ui-card-1>ui-card-1</ui-card-1>
|
|
29
|
+
<ui-card-2>ui-card-2</ui-card-2>
|
|
30
|
+
<ui-card-3>ui-card-3</ui-card-3>
|
|
31
|
+
</main>
|
|
32
|
+
<script>
|
|
33
|
+
const render = (tagname = null, attributes = {}, ...children) => {
|
|
34
|
+
children = children.join("");
|
|
35
|
+
return tagname === null
|
|
36
|
+
? children
|
|
37
|
+
: `<${tagname} ${Object.entries(attributes)
|
|
38
|
+
.map(([key, value]) => `${key}="${value}"`)
|
|
39
|
+
.join(" ")}>
|
|
40
|
+
${children}
|
|
41
|
+
</${tagname}>`;
|
|
42
|
+
};
|
|
43
|
+
const renderFragment = (...elements) => elements.join("");
|
|
44
|
+
const Define = (
|
|
45
|
+
tagname = "ui-card",
|
|
46
|
+
body,
|
|
47
|
+
{ shadowRootMode = "open" } = {}
|
|
48
|
+
) => {
|
|
49
|
+
const range = document.createRange();
|
|
50
|
+
const fragment = range.createContextualFragment(body);
|
|
51
|
+
customElements.define(
|
|
52
|
+
tagname,
|
|
53
|
+
class extends HTMLElement {
|
|
54
|
+
constructor() {
|
|
55
|
+
super();
|
|
56
|
+
this.attachShadow({ mode: shadowRootMode });
|
|
57
|
+
this.shadowRoot.appendChild(fragment);
|
|
58
|
+
// this.shadowRoot.appendChild(fragment.content.cloneNode(true));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
const DefineTag = (tagname, options) => {
|
|
64
|
+
return (strings, ...substitutions) => {
|
|
65
|
+
const body = strings.reduce((acc, string, i) => {
|
|
66
|
+
return acc + string + (substitutions[i] || "");
|
|
67
|
+
}, "");
|
|
68
|
+
Define(tagname, body, options);
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
Define(
|
|
72
|
+
"ui-card-1",
|
|
73
|
+
`<style>
|
|
74
|
+
:host {
|
|
75
|
+
display: block;
|
|
76
|
+
contain: content;
|
|
77
|
+
box-sizing: border-box;
|
|
78
|
+
box-shadow: var(--shadow-raised);
|
|
79
|
+
border: var(--stroke-thicknessMinus1) var(--stroke-style)
|
|
80
|
+
var(--color-layerBorder);
|
|
81
|
+
border-radius: var(--border-radius);
|
|
82
|
+
background: var(--background, transparent);
|
|
83
|
+
color: var(--foreground, var(--color-onLayerBase));
|
|
84
|
+
}
|
|
85
|
+
</style>
|
|
86
|
+
<slot></slot>`
|
|
87
|
+
);
|
|
88
|
+
Define(
|
|
89
|
+
"ui-card-2",
|
|
90
|
+
render(
|
|
91
|
+
null,
|
|
92
|
+
null,
|
|
93
|
+
render(
|
|
94
|
+
"style",
|
|
95
|
+
{},
|
|
96
|
+
`
|
|
97
|
+
:host {
|
|
98
|
+
display: block;
|
|
99
|
+
contain: content;
|
|
100
|
+
box-sizing: border-box;
|
|
101
|
+
box-shadow: var(--shadow-raised);
|
|
102
|
+
border: var(--stroke-thicknessMinus1) var(--stroke-style)
|
|
103
|
+
var(--color-layerBorder);
|
|
104
|
+
border-radius: var(--border-radius);
|
|
105
|
+
background: var(--background, transparent);
|
|
106
|
+
color: var(--foreground, var(--color-onLayerBase));
|
|
107
|
+
}`
|
|
108
|
+
),
|
|
109
|
+
render("slot", {})
|
|
110
|
+
)
|
|
111
|
+
);
|
|
112
|
+
DefineTag("ui-card-3")`<style>
|
|
113
|
+
:host {
|
|
114
|
+
display: block;
|
|
115
|
+
contain: content;
|
|
116
|
+
box-sizing: border-box;
|
|
117
|
+
box-shadow: var(--shadow-raised);
|
|
118
|
+
border: var(--stroke-thicknessMinus1) var(--stroke-style)
|
|
119
|
+
var(--color-layerBorder);
|
|
120
|
+
border-radius: var(--border-radius);
|
|
121
|
+
background: var(--background, transparent);
|
|
122
|
+
color: var(--foreground, var(--color-onLayerBase));
|
|
123
|
+
}
|
|
124
|
+
</style>
|
|
125
|
+
<slot></slot>`;
|
|
126
|
+
</script>
|
|
127
|
+
</body>
|
|
128
|
+
</html>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const HTML = ({ registry = {} } = {}) => {
|
|
2
|
+
return (strings, ...substitutions) => {};
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
const createRegistry = (tags = []) => ({
|
|
6
|
+
toString() {
|
|
7
|
+
return tags
|
|
8
|
+
.map(
|
|
9
|
+
([name, tag]) =>
|
|
10
|
+
`<script>customElements.define('${name}',${tag})</script>`
|
|
11
|
+
)
|
|
12
|
+
.join("");
|
|
13
|
+
},
|
|
14
|
+
instantiate() {
|
|
15
|
+
tags.forEach(([name, tag]) => {
|
|
16
|
+
customElements.define(name, tag);
|
|
17
|
+
});
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const registry = createRegistry([["ui-card", class extends HTMLElement {}]]);
|
|
22
|
+
|
|
23
|
+
HTML({ registry })`<html>
|
|
24
|
+
<head>
|
|
25
|
+
${registry.toString()}
|
|
26
|
+
</head>
|
|
27
|
+
<body>
|
|
28
|
+
<ui-card></ui-card>
|
|
29
|
+
</body>
|
|
30
|
+
</html>`;
|
package/demo/server.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import serve from "@johnhenry/leserve";
|
|
2
|
+
import { createRoute, createRouter } from "../index.mjs";
|
|
3
|
+
|
|
4
|
+
// Server with single html route
|
|
5
|
+
const htmlRoute = createRoute({ streaming: true })`<!DOCTYPE html>
|
|
6
|
+
<html>
|
|
7
|
+
<head>
|
|
8
|
+
<title>Letterpress Demo</title>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<h1>Welcome to Letterpress!</h1>
|
|
12
|
+
<p>The current time is: ${() => new Date().toISOString()}</p>
|
|
13
|
+
<p>Your user agent is: ${(request) => request.headers.get("User-Agent")}</p>
|
|
14
|
+
</body>
|
|
15
|
+
</html>`;
|
|
16
|
+
serve({ port: 8080 }, htmlRoute);
|
|
17
|
+
// Server with single non-html route
|
|
18
|
+
const customRoute = createRoute({ streaming: true })`HTTP/1.1 200 OK
|
|
19
|
+
Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
access time: ${() => new Date().toISOString()}
|
|
23
|
+
user agent: ${(request) => request.headers.get("User-Agent")}
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
# Custom Response
|
|
27
|
+
This custom response has it's content type set to 'text/markdown'.
|
|
28
|
+
`;
|
|
29
|
+
serve({ port: 8081 }, customRoute);
|
|
30
|
+
|
|
31
|
+
// Server with multiple routes using router
|
|
32
|
+
const router = createRouter();
|
|
33
|
+
router.endpoint`GET /`(htmlRoute);
|
|
34
|
+
router.endpoint`GET /about`(customRoute);
|
|
35
|
+
router.endpoint`GET /user/:id?``<!DOCTYPE html>
|
|
36
|
+
<html>
|
|
37
|
+
<body>
|
|
38
|
+
<h1>User Profile</h1>
|
|
39
|
+
<p>User ID: ${(_, { params }) => (params.id ? params.id : "???")}</p>
|
|
40
|
+
</body>
|
|
41
|
+
</html>`;
|
|
42
|
+
router.endpoint`GET /debug/:id?`(async (request, { params }) => {
|
|
43
|
+
const obj = {
|
|
44
|
+
method: request.method,
|
|
45
|
+
url: request.url,
|
|
46
|
+
headers: Object.fromEntries(request.headers.entries()),
|
|
47
|
+
params,
|
|
48
|
+
};
|
|
49
|
+
return new Response(JSON.stringify(obj, null, 2), {
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
serve({ port: 8082 }, router);
|
package/demo/style.css
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
:not(:defined) > template[shadowrootmode] ~ * {
|
|
2
|
+
display: none;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
:root {
|
|
6
|
+
--spacing-base: 0.75rem;
|
|
7
|
+
--spacing-baseMinus2: calc(0.5 * var(--spacing-base));
|
|
8
|
+
--gap: var(--spacing-base);
|
|
9
|
+
--gap-small: var(--spacing-baseMinus2);
|
|
10
|
+
|
|
11
|
+
--stroke-style: solid;
|
|
12
|
+
--stroke-thicknessBase: 0.15rem;
|
|
13
|
+
--stroke-thicknessMinus1: calc(0.5 * var(--stroke-thicknessBase));
|
|
14
|
+
|
|
15
|
+
--typography-sizeBase: 1rem;
|
|
16
|
+
--typography-lineHeightBase: 1.3rem;
|
|
17
|
+
--typography-sizePlus1: 1.2rem;
|
|
18
|
+
--typography-lineHeightPlus1: 1.6rem;
|
|
19
|
+
--typography-weightPlus1: 600;
|
|
20
|
+
|
|
21
|
+
--border-radius: 0.375rem;
|
|
22
|
+
|
|
23
|
+
--shadow-raised: 0 0.25rem 0.5rem var(--color-shadow);
|
|
24
|
+
|
|
25
|
+
--color-accent: #008ed8;
|
|
26
|
+
--color-onAccent: #f4f4f4;
|
|
27
|
+
--color-onLayerBase: #2c2c2c;
|
|
28
|
+
--color-shadow: #00000033;
|
|
29
|
+
--color-layerBorder: #d8d8d8;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
body {
|
|
33
|
+
color: var(--color-onLayerBase);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
h1 {
|
|
37
|
+
padding-inline-start: var(--gap);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.container {
|
|
41
|
+
display: flex;
|
|
42
|
+
gap: var(--gap);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@media (width <= 768px) {
|
|
46
|
+
.container {
|
|
47
|
+
flex-direction: column;
|
|
48
|
+
}
|
|
49
|
+
}
|
package/fs-router.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file fs-router.mjs
|
|
3
|
+
* @description Implements a filesystem-based router for handling HTTP requests.
|
|
4
|
+
* This router maps URL paths to directory structures and matches HTTP methods to
|
|
5
|
+
* corresponding JavaScript modules (.mjs files).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs/promises";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { pathToFileURL } from "url";
|
|
11
|
+
import dynamicRun from "./utility/dynamic-run.mjs";
|
|
12
|
+
const ALL = "all";
|
|
13
|
+
import theresWaldo from "theres-waldo";
|
|
14
|
+
const { dir } = theresWaldo(import.meta.url);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {import('./types').Route} Route
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Creates a filesystem-based router
|
|
22
|
+
* @param {string} baseDir - The base directory for routing
|
|
23
|
+
* @returns {Route} A Route function
|
|
24
|
+
*
|
|
25
|
+
* @description
|
|
26
|
+
* This function creates a router that maps URL paths to a directory structure.
|
|
27
|
+
* It looks for files named [http method].mjs in the corresponding directories
|
|
28
|
+
* and executes the default exported function from these files.
|
|
29
|
+
*
|
|
30
|
+
* Directory Structure:
|
|
31
|
+
* The router expects a directory structure that mirrors the URL path structure.
|
|
32
|
+
* For example, for a URL path /user/profile, the directory structure would be:
|
|
33
|
+
*
|
|
34
|
+
* /baseDir
|
|
35
|
+
* /user
|
|
36
|
+
* /profile
|
|
37
|
+
* get.mjs
|
|
38
|
+
* post.mjs
|
|
39
|
+
* ...
|
|
40
|
+
*
|
|
41
|
+
* Path Parameters:
|
|
42
|
+
* Path parameters are denoted by directories starting with '#'.
|
|
43
|
+
* For example, /user/#id would match a URL like /user/123, and the 'id' parameter
|
|
44
|
+
* would be available in the request object passed to the handler function.
|
|
45
|
+
*
|
|
46
|
+
* Handler Functions:
|
|
47
|
+
* Each [http method].mjs file should export a default function of type Route.
|
|
48
|
+
* This function will receive the request object, augmented with a 'params' property
|
|
49
|
+
* containing any path parameters.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* // Usage:
|
|
53
|
+
* import { createFSRouter } from './fs-router.mjs';
|
|
54
|
+
* import path from 'path';
|
|
55
|
+
*
|
|
56
|
+
* const baseDir = path.join(process.cwd(), 'routes');
|
|
57
|
+
* const router = createFSRouter(baseDir);
|
|
58
|
+
*
|
|
59
|
+
* // Then use this router with your server implementation
|
|
60
|
+
* server.on('request', async (req, res) => {
|
|
61
|
+
* const result = await router(req);
|
|
62
|
+
* if (result) {
|
|
63
|
+
* // Handle the result
|
|
64
|
+
* } else {
|
|
65
|
+
* // Handle 404 Not Found
|
|
66
|
+
* }
|
|
67
|
+
* });
|
|
68
|
+
*/
|
|
69
|
+
export function createFSRouter(
|
|
70
|
+
baseDir,
|
|
71
|
+
defaultHandler = () => new Response("not found", { status: 404 })
|
|
72
|
+
) {
|
|
73
|
+
return async function fsRouter(req) {
|
|
74
|
+
const { pathname } = new URL(req.url, `http://${req.headers.host}`);
|
|
75
|
+
const method = req.method.toLowerCase();
|
|
76
|
+
|
|
77
|
+
// Split the pathname into segments
|
|
78
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
79
|
+
|
|
80
|
+
// Try to find a matching file
|
|
81
|
+
let currentPath = baseDir;
|
|
82
|
+
const params = {};
|
|
83
|
+
|
|
84
|
+
for (const segment of segments) {
|
|
85
|
+
const dir = await fs.readdir(currentPath, { withFileTypes: true });
|
|
86
|
+
const match = dir.find(
|
|
87
|
+
(dirent) =>
|
|
88
|
+
dirent.isDirectory() &&
|
|
89
|
+
(dirent.name === segment || dirent.name.startsWith("#"))
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
if (!match) {
|
|
93
|
+
return null; // No matching path found
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (match.name.startsWith("#")) {
|
|
97
|
+
params[match.name.slice(1)] = segment;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
currentPath = path.join(currentPath, match.name);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Look for a matching method file
|
|
104
|
+
let methodFile = path.join(currentPath, `${method}.mjs`);
|
|
105
|
+
|
|
106
|
+
let html;
|
|
107
|
+
try {
|
|
108
|
+
// Check if the file exists before attempting to import
|
|
109
|
+
try {
|
|
110
|
+
await fs.access(methodFile);
|
|
111
|
+
} catch {
|
|
112
|
+
try {
|
|
113
|
+
methodFile = path.join(currentPath, `index.html`);
|
|
114
|
+
await fs.access(methodFile);
|
|
115
|
+
html = true;
|
|
116
|
+
} catch {
|
|
117
|
+
methodFile = path.join(currentPath, `${ALL}.mjs`);
|
|
118
|
+
await fs.access(methodFile);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Convert the file path to a file URL
|
|
123
|
+
const fileUrl = pathToFileURL(methodFile).href;
|
|
124
|
+
|
|
125
|
+
let module;
|
|
126
|
+
if (html) {
|
|
127
|
+
const file = await fs
|
|
128
|
+
.readFile(methodFile, { encoding: "utf-8" })
|
|
129
|
+
.catch((e) => {
|
|
130
|
+
console.error(e);
|
|
131
|
+
return "";
|
|
132
|
+
});
|
|
133
|
+
const data = `import {createRoute} from "${path.join(
|
|
134
|
+
dir,
|
|
135
|
+
"./index.mjs"
|
|
136
|
+
)}";
|
|
137
|
+
export default createRoute()\`${file}\``;
|
|
138
|
+
// Write the synthesized temp module next to the matched route's
|
|
139
|
+
// index.html (currentPath), not into letterpress's own package
|
|
140
|
+
// directory (dir). Writing into the package dir means every
|
|
141
|
+
// index.html request requires the installed package directory
|
|
142
|
+
// (e.g. node_modules/@johnhenry/letterpress) to be writable, which
|
|
143
|
+
// commonly isn't true in production (read-only installs/
|
|
144
|
+
// containers), and has nothing to do with the route being served.
|
|
145
|
+
module = await dynamicRun(data, currentPath);
|
|
146
|
+
} else {
|
|
147
|
+
module = await import(fileUrl);
|
|
148
|
+
}
|
|
149
|
+
const handler = module.default;
|
|
150
|
+
if (typeof handler !== "function") {
|
|
151
|
+
throw new Error(`${methodFile} does not export a default function`);
|
|
152
|
+
}
|
|
153
|
+
return handler(req, { params });
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (error.code === "ENOENT" || error.code === "ERR_MODULE_NOT_FOUND") {
|
|
156
|
+
return defaultHandler(req, { params });
|
|
157
|
+
}
|
|
158
|
+
throw error; // Re-throw other errors
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
package/index.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createRequest } from "./create-request.mjs";
|
|
2
|
+
export { createRouter } from "./create-router.mjs";
|
|
3
|
+
export { createRoute } from "./create-route.mjs";
|
|
4
|
+
export { createResponse } from "./create-response.mjs";
|
|
5
|
+
export { createFSRouter } from "./fs-router.mjs";
|
|
6
|
+
export * from "./utility/index.mjs";
|
|
7
|
+
export { deconstruct } from "./utility/deconstruct.mjs";
|
|
8
|
+
export { cook } from "./utility/cook.mjs";
|
package/license
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 John Henry
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/logo.jpeg
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@johnhenry/letterpress",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "A tagged-template-string HTTP routing library for defining and matching request handlers with plain JavaScript syntax.",
|
|
5
|
+
"main": "index.mjs",
|
|
6
|
+
"types": "types.d.ts",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public",
|
|
9
|
+
"provenance": true
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/johnhenry/letterpress.git"
|
|
14
|
+
},
|
|
15
|
+
"directories": {
|
|
16
|
+
"test": "test"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test test/index.mjs",
|
|
20
|
+
"demo:server": "node demo/server.mjs",
|
|
21
|
+
"demo:client": "node demo/client.mjs"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"router",
|
|
25
|
+
"routing",
|
|
26
|
+
"http",
|
|
27
|
+
"tagged-template",
|
|
28
|
+
"template-literal",
|
|
29
|
+
"request",
|
|
30
|
+
"response"
|
|
31
|
+
],
|
|
32
|
+
"author": "John Henry",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/johnhenry/letterpress/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://opensource.johnhenry.me/letterpress/",
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=26.0.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@johnhenry/leserve": "^0.0.0"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"theres-waldo": "^0.0.0"
|
|
46
|
+
}
|
|
47
|
+
}
|