@tknf/matchbox 0.2.1
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/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/cgi.d.ts +106 -0
- package/dist/cgi.js +234 -0
- package/dist/html.d.ts +12 -0
- package/dist/html.js +62 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/plugin.d.ts +15 -0
- package/dist/plugin.js +56 -0
- package/dist/with-defaults.d.ts +8 -0
- package/dist/with-defaults.js +78 -0
- package/package.json +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 TKNF LLC
|
|
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/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Matchbox
|
|
2
|
+
|
|
3
|
+
A simple CGI-style web server framework built on top of Hono. Treats `.cgi.tsx` / `.cgi.jsx` files under `public/` as pages, and brings Apache-style conventions like `.htaccess` and `.htpasswd` into modern tooling.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- File-based routing (`.cgi.tsx` / `.cgi.jsx` map directly to endpoints)
|
|
8
|
+
- CGI-style context (`$_GET`, `$_POST`, `$_SESSION`, etc.)
|
|
9
|
+
- `.htaccess` rewrites/redirects and `.htpasswd` Basic Auth
|
|
10
|
+
- Session cookie configuration and custom middleware
|
|
11
|
+
- Vite + TypeScript + JSX support
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add matchbox hono
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
`vite.config.ts`:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import devServer from "@hono/vite-dev-server";
|
|
25
|
+
import { defineConfig } from "vite";
|
|
26
|
+
import { MatchboxPlugin } from "matchbox/plugin";
|
|
27
|
+
|
|
28
|
+
export default defineConfig({
|
|
29
|
+
plugins: [
|
|
30
|
+
MatchboxPlugin(),
|
|
31
|
+
devServer({
|
|
32
|
+
entry: "server.ts",
|
|
33
|
+
exclude: [/^\/public\/.+/, /^\/favicon\.ico$/],
|
|
34
|
+
}),
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`server.ts`:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { createCgi } from "matchbox";
|
|
43
|
+
|
|
44
|
+
export default createCgi();
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`public/index.cgi.tsx`:
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
import type { CgiContext } from "matchbox";
|
|
51
|
+
|
|
52
|
+
export default function ({ $_SERVER }: CgiContext) {
|
|
53
|
+
return (
|
|
54
|
+
<html>
|
|
55
|
+
<head>
|
|
56
|
+
<title>Matchbox</title>
|
|
57
|
+
</head>
|
|
58
|
+
<body>
|
|
59
|
+
<h1>Hello Matchbox</h1>
|
|
60
|
+
<p>Method: {$_SERVER.REQUEST_METHOD}</p>
|
|
61
|
+
</body>
|
|
62
|
+
</html>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Start the dev server:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pnpm dev
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## CGI Context
|
|
74
|
+
|
|
75
|
+
Page functions receive a `CgiContext`.
|
|
76
|
+
|
|
77
|
+
- `$_GET` / `$_POST` / `$_FILES` / `$_REQUEST`
|
|
78
|
+
- `$_SESSION` / `$_COOKIE` / `$_ENV` / `$_SERVER`
|
|
79
|
+
- `header(name, value)` / `status(code)` / `redirect(url, status?)`
|
|
80
|
+
- `cgiinfo()` / `get_modules()` / `get_version()`
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
### MatchboxPlugin
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
MatchboxPlugin({
|
|
88
|
+
publicDir: "public",
|
|
89
|
+
config: { siteName: "My Site" },
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
- `publicDir`: Root directory for page discovery (default: `public`)
|
|
94
|
+
- `config`: Configuration object injected into pages
|
|
95
|
+
|
|
96
|
+
### createCgi
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
createCgi({
|
|
100
|
+
sessionCookie: {
|
|
101
|
+
name: "_SESSION_ID",
|
|
102
|
+
sameSite: "Lax",
|
|
103
|
+
secure: true,
|
|
104
|
+
maxAge: 3600,
|
|
105
|
+
},
|
|
106
|
+
enforceTrailingSlash: true,
|
|
107
|
+
middleware: [
|
|
108
|
+
async (c, next) => {
|
|
109
|
+
c.header("X-App", "matchbox");
|
|
110
|
+
await next();
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
logger: (message, level) => {
|
|
114
|
+
console.log(`[${level ?? "info"}] ${message}`);
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Auth and Rewrites
|
|
120
|
+
|
|
121
|
+
Place these under `public/` to enable them.
|
|
122
|
+
|
|
123
|
+
- `.htpasswd`: Directory-level Basic Auth
|
|
124
|
+
- `.htaccess`: Simple `RewriteRule` / `Redirect` support
|
|
125
|
+
|
|
126
|
+
## References
|
|
127
|
+
|
|
128
|
+
- Examples: `examples/README.md`
|
|
129
|
+
- Security: `docs/security.md`
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT License. See `LICENSE` for details.
|
package/dist/cgi.d.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import * as hono_types from 'hono/types';
|
|
2
|
+
import { Context, Hono } from 'hono';
|
|
3
|
+
import { HtmlEscapedString } from 'hono/utils/html';
|
|
4
|
+
|
|
5
|
+
type ConfigObject = Record<string, any>;
|
|
6
|
+
/**
|
|
7
|
+
* --- Session Cookie Configuration ---
|
|
8
|
+
*/
|
|
9
|
+
interface SessionCookieOptions {
|
|
10
|
+
/** Session cookie name (default: "_SESSION_ID") */
|
|
11
|
+
name?: string;
|
|
12
|
+
/** Session cookie path (default: "/") */
|
|
13
|
+
path?: string;
|
|
14
|
+
/** Session cookie domain */
|
|
15
|
+
domain?: string;
|
|
16
|
+
/** Session cookie secure flag (default: false) */
|
|
17
|
+
secure?: boolean;
|
|
18
|
+
/** Session cookie SameSite attribute (default: "Lax") */
|
|
19
|
+
sameSite?: "Strict" | "Lax" | "None";
|
|
20
|
+
/** Session cookie max age in seconds */
|
|
21
|
+
maxAge?: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* --- Matchbox Options ---
|
|
25
|
+
*/
|
|
26
|
+
interface MatchboxOptions {
|
|
27
|
+
/** Session cookie configuration */
|
|
28
|
+
sessionCookie?: SessionCookieOptions;
|
|
29
|
+
/** Enforce trailing slash on URLs */
|
|
30
|
+
enforceTrailingSlash?: boolean;
|
|
31
|
+
/** Custom middleware functions */
|
|
32
|
+
middleware?: Array<(c: Context, next: () => Promise<void>) => Promise<Response | undefined>>;
|
|
33
|
+
/** Custom logging function */
|
|
34
|
+
logger?: (message: string, level?: "info" | "warn" | "error") => void;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* --- Module Information ---
|
|
38
|
+
*/
|
|
39
|
+
interface ModuleInfo {
|
|
40
|
+
/** URL path where the module is accessible */
|
|
41
|
+
urlPath: string;
|
|
42
|
+
/** Directory path for index modules, null otherwise */
|
|
43
|
+
dirPath: string | null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* --- Matchbox CGI Environment Types ---
|
|
47
|
+
*/
|
|
48
|
+
interface CgiContext<ConfigType = ConfigObject> {
|
|
49
|
+
$_GET: Record<string, string>;
|
|
50
|
+
$_POST: Record<string, string>;
|
|
51
|
+
$_FILES: Record<string, File | File[]>;
|
|
52
|
+
$_REQUEST: Record<string, any>;
|
|
53
|
+
$_SESSION: Record<string, any>;
|
|
54
|
+
$_COOKIE: Record<string, string>;
|
|
55
|
+
$_ENV: Record<string, string | undefined>;
|
|
56
|
+
$_SERVER: {
|
|
57
|
+
REQUEST_METHOD: string;
|
|
58
|
+
REQUEST_URI: string;
|
|
59
|
+
REMOTE_ADDR: string;
|
|
60
|
+
USER_AGENT: string;
|
|
61
|
+
SCRIPT_NAME: string;
|
|
62
|
+
PATH_INFO: string;
|
|
63
|
+
QUERY_STRING: string;
|
|
64
|
+
[key: string]: any;
|
|
65
|
+
};
|
|
66
|
+
config: ConfigType;
|
|
67
|
+
c: Context;
|
|
68
|
+
header: (name: string, value: string) => void;
|
|
69
|
+
status: (code: number) => void;
|
|
70
|
+
redirect: (url: string, status?: number) => {
|
|
71
|
+
__type: "redirect";
|
|
72
|
+
url: string;
|
|
73
|
+
status: number;
|
|
74
|
+
};
|
|
75
|
+
cgiinfo: () => HtmlEscapedString | Promise<HtmlEscapedString>;
|
|
76
|
+
request_headers: () => Record<string, string>;
|
|
77
|
+
response_headers: () => Record<string, string>;
|
|
78
|
+
log: (message: string) => void;
|
|
79
|
+
get_version: () => string;
|
|
80
|
+
/** Get list of all loaded CGI modules */
|
|
81
|
+
get_modules: () => ModuleInfo[];
|
|
82
|
+
}
|
|
83
|
+
type Page = {
|
|
84
|
+
urlPath: string;
|
|
85
|
+
dirPath: string | null;
|
|
86
|
+
component: (context: CgiContext) => any | Promise<any>;
|
|
87
|
+
};
|
|
88
|
+
type RedirectRule = {
|
|
89
|
+
type: "redirect";
|
|
90
|
+
code: string;
|
|
91
|
+
source: string;
|
|
92
|
+
target: string;
|
|
93
|
+
};
|
|
94
|
+
type RewriteRule = {
|
|
95
|
+
type: "rewrite";
|
|
96
|
+
pattern: string;
|
|
97
|
+
target: string;
|
|
98
|
+
flags: string;
|
|
99
|
+
};
|
|
100
|
+
type RewriteMap = Record<string, Array<RedirectRule | RewriteRule>>;
|
|
101
|
+
/**
|
|
102
|
+
* --- Matchbox Runtime Engine ---
|
|
103
|
+
*/
|
|
104
|
+
declare const createCgiWithPages: (pages: Page[], siteConfig?: ConfigObject, authMap?: Record<string, string>, rewriteMap?: RewriteMap, options?: MatchboxOptions) => Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
|
|
105
|
+
|
|
106
|
+
export { type CgiContext, type ConfigObject, type MatchboxOptions, type ModuleInfo, type Page, type RewriteMap, type SessionCookieOptions, createCgiWithPages };
|
package/dist/cgi.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { basicAuth } from "hono/basic-auth";
|
|
3
|
+
import { getCookie, setCookie } from "hono/cookie";
|
|
4
|
+
import packageJson from "../package.json";
|
|
5
|
+
import { generateCgiError, generateCgiInfo } from "./html";
|
|
6
|
+
const isRedirectObject = (obj) => {
|
|
7
|
+
return obj && obj.__type === "redirect" && typeof obj.url === "string";
|
|
8
|
+
};
|
|
9
|
+
const createCgiWithPages = (pages, siteConfig = {}, authMap = {}, rewriteMap = {}, options = {}) => {
|
|
10
|
+
const app = new Hono();
|
|
11
|
+
const SESS_KEY = options.sessionCookie?.name || "_SESSION_ID";
|
|
12
|
+
if (options.middleware && options.middleware.length > 0) {
|
|
13
|
+
for (const mw of options.middleware) {
|
|
14
|
+
app.use("*", async (c, next) => {
|
|
15
|
+
const result = await mw(c, next);
|
|
16
|
+
if (result instanceof Response) {
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const protectedFiles = [".htaccess", ".htpasswd", ".htdigest", ".htgroup"];
|
|
23
|
+
app.use("*", async (c, next) => {
|
|
24
|
+
const path = c.req.path;
|
|
25
|
+
const lastSegment = path.slice(path.lastIndexOf("/") + 1);
|
|
26
|
+
if (protectedFiles.some((file) => lastSegment === file)) {
|
|
27
|
+
return c.text("Forbidden", 403);
|
|
28
|
+
}
|
|
29
|
+
await next();
|
|
30
|
+
});
|
|
31
|
+
if (options.enforceTrailingSlash) {
|
|
32
|
+
app.use("*", async (c, next) => {
|
|
33
|
+
const path = c.req.path;
|
|
34
|
+
if (!path.endsWith("/") && !path.includes(".")) {
|
|
35
|
+
return c.redirect(`${path}/`, 301);
|
|
36
|
+
}
|
|
37
|
+
await next();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
Object.entries(rewriteMap).forEach(([dir, rules]) => {
|
|
41
|
+
const basePath = dir === "/" ? "" : dir.replace(/\/$/, "");
|
|
42
|
+
app.use(`${basePath}/*`, async (c, next) => {
|
|
43
|
+
const relPath = c.req.path.replace(basePath, "") || "/";
|
|
44
|
+
for (const rule of rules) {
|
|
45
|
+
if (rule.type === "redirect") {
|
|
46
|
+
if (relPath === rule.source) {
|
|
47
|
+
return c.redirect(
|
|
48
|
+
rule.target,
|
|
49
|
+
Number.parseInt(rule.code, 10) || 302
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
} else if (rule.type === "rewrite") {
|
|
53
|
+
const regex = new RegExp(rule.pattern);
|
|
54
|
+
if (regex.test(relPath)) {
|
|
55
|
+
const target = rule.target.startsWith("/") ? rule.target : `${basePath}/${rule.target}`;
|
|
56
|
+
if (rule.flags.includes("R")) {
|
|
57
|
+
const code = rule.flags.match(/R=(\d+)/)?.[1] || "302";
|
|
58
|
+
return c.redirect(target, Number.parseInt(code, 10));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
await next();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
Object.entries(authMap).forEach(([dir, content]) => {
|
|
67
|
+
const credentials = content.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#")).map((line) => {
|
|
68
|
+
const [username, password] = line.split(":");
|
|
69
|
+
return { username, password };
|
|
70
|
+
});
|
|
71
|
+
if (credentials.length > 0) {
|
|
72
|
+
const authPath = dir === "/" ? "*" : `${dir.replace(/\/$/, "")}/*`;
|
|
73
|
+
app.use(authPath, async (c, next) => {
|
|
74
|
+
const handler = basicAuth({
|
|
75
|
+
verifyUser: (u, p) => credentials.some((cred) => cred.username === u && cred.password === p),
|
|
76
|
+
realm: "Restricted Area"
|
|
77
|
+
});
|
|
78
|
+
return handler(c, next);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
pages.forEach(({ urlPath, dirPath, component }) => {
|
|
83
|
+
const routes = [urlPath, `${urlPath}/*`];
|
|
84
|
+
if (dirPath) {
|
|
85
|
+
routes.push(dirPath);
|
|
86
|
+
if (dirPath !== "/") {
|
|
87
|
+
routes.push(`${dirPath}/*`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
routes.forEach((route) => {
|
|
91
|
+
app.all(route, async (c) => {
|
|
92
|
+
const $_GET = c.req.query();
|
|
93
|
+
const body = await c.req.parseBody({ all: true }).catch(() => ({}));
|
|
94
|
+
const $_POST = {};
|
|
95
|
+
const $_FILES = {};
|
|
96
|
+
for (const [key, value] of Object.entries(body)) {
|
|
97
|
+
if (value instanceof File || Array.isArray(value) && value[0] instanceof File) {
|
|
98
|
+
$_FILES[key] = value;
|
|
99
|
+
} else {
|
|
100
|
+
$_POST[key] = value;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const $_COOKIE = getCookie(c);
|
|
104
|
+
const $_REQUEST = {
|
|
105
|
+
...$_COOKIE,
|
|
106
|
+
...$_GET,
|
|
107
|
+
...$_POST
|
|
108
|
+
};
|
|
109
|
+
const $_ENV = typeof process !== "undefined" && process.env ? process.env : c.env || {};
|
|
110
|
+
let $_SESSION = {};
|
|
111
|
+
const sRaw = getCookie(c, SESS_KEY);
|
|
112
|
+
if (sRaw) {
|
|
113
|
+
try {
|
|
114
|
+
$_SESSION = JSON.parse(decodeURIComponent(sRaw));
|
|
115
|
+
} catch {
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
let responseStatus = 200;
|
|
119
|
+
const responseHeaders = {
|
|
120
|
+
"Content-Type": "text/html; charset=utf-8"
|
|
121
|
+
};
|
|
122
|
+
const $_SERVER = {
|
|
123
|
+
...$_ENV,
|
|
124
|
+
REQUEST_METHOD: c.req.method,
|
|
125
|
+
REQUEST_URI: c.req.url,
|
|
126
|
+
REMOTE_ADDR: c.req.header("x-forwarded-for") || "127.0.0.1",
|
|
127
|
+
USER_AGENT: c.req.header("user-agent") || "",
|
|
128
|
+
SCRIPT_NAME: urlPath,
|
|
129
|
+
PATH_INFO: c.req.path.replace(urlPath, "") || "/",
|
|
130
|
+
QUERY_STRING: new URL(c.req.url).search.slice(1)
|
|
131
|
+
};
|
|
132
|
+
const cgiinfo = generateCgiInfo({
|
|
133
|
+
$_SERVER,
|
|
134
|
+
$_REQUEST,
|
|
135
|
+
$_SESSION,
|
|
136
|
+
config: siteConfig
|
|
137
|
+
});
|
|
138
|
+
const context = {
|
|
139
|
+
$_GET,
|
|
140
|
+
$_POST,
|
|
141
|
+
$_FILES,
|
|
142
|
+
$_REQUEST,
|
|
143
|
+
$_COOKIE,
|
|
144
|
+
$_ENV,
|
|
145
|
+
$_SERVER,
|
|
146
|
+
$_SESSION,
|
|
147
|
+
config: siteConfig,
|
|
148
|
+
c,
|
|
149
|
+
header: (name, value) => {
|
|
150
|
+
responseHeaders[name.toLocaleLowerCase()] = value;
|
|
151
|
+
},
|
|
152
|
+
status: (code) => {
|
|
153
|
+
responseStatus = code;
|
|
154
|
+
},
|
|
155
|
+
redirect: (url, status = 302) => {
|
|
156
|
+
return { __type: "redirect", url, status };
|
|
157
|
+
},
|
|
158
|
+
cgiinfo,
|
|
159
|
+
request_headers: () => {
|
|
160
|
+
return Object.fromEntries(c.req.raw.headers.entries());
|
|
161
|
+
},
|
|
162
|
+
response_headers: () => {
|
|
163
|
+
return responseHeaders;
|
|
164
|
+
},
|
|
165
|
+
log: (message) => {
|
|
166
|
+
if (options.logger) {
|
|
167
|
+
options.logger(message, "info");
|
|
168
|
+
} else {
|
|
169
|
+
console.log(`[CGI LOG] ${message}`);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
get_version: () => {
|
|
173
|
+
return `MatchboxCGI/v${packageJson.version}`;
|
|
174
|
+
},
|
|
175
|
+
/**
|
|
176
|
+
* Returns information about all loaded CGI modules
|
|
177
|
+
* @returns Array of module information containing urlPath and dirPath
|
|
178
|
+
*/
|
|
179
|
+
get_modules: () => {
|
|
180
|
+
return pages.map((page) => ({
|
|
181
|
+
urlPath: page.urlPath,
|
|
182
|
+
dirPath: page.dirPath
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
try {
|
|
187
|
+
const result = await component(context);
|
|
188
|
+
const sessionValue = encodeURIComponent(JSON.stringify($_SESSION));
|
|
189
|
+
const sessionOptions = {
|
|
190
|
+
path: options.sessionCookie?.path || "/",
|
|
191
|
+
httpOnly: true,
|
|
192
|
+
sameSite: options.sessionCookie?.sameSite || "Lax"
|
|
193
|
+
};
|
|
194
|
+
if (options.sessionCookie?.secure !== void 0) {
|
|
195
|
+
sessionOptions.secure = options.sessionCookie.secure;
|
|
196
|
+
}
|
|
197
|
+
if (options.sessionCookie?.domain) {
|
|
198
|
+
sessionOptions.domain = options.sessionCookie.domain;
|
|
199
|
+
}
|
|
200
|
+
if (options.sessionCookie?.maxAge) {
|
|
201
|
+
sessionOptions.maxAge = options.sessionCookie.maxAge;
|
|
202
|
+
}
|
|
203
|
+
if (isRedirectObject(result)) {
|
|
204
|
+
setCookie(c, SESS_KEY, sessionValue, sessionOptions);
|
|
205
|
+
return c.redirect(result.url, result.status);
|
|
206
|
+
}
|
|
207
|
+
if (result instanceof Response) {
|
|
208
|
+
setCookie(c, SESS_KEY, sessionValue, sessionOptions);
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
setCookie(c, SESS_KEY, sessionValue, sessionOptions);
|
|
212
|
+
Object.entries(responseHeaders).forEach(([key, value]) => {
|
|
213
|
+
c.header(key, value);
|
|
214
|
+
});
|
|
215
|
+
const contentType = responseHeaders["content-type"];
|
|
216
|
+
if (contentType?.includes("application/json")) {
|
|
217
|
+
return c.json(
|
|
218
|
+
// biome-ignore lint/suspicious/noExplicitAny: to JSON response
|
|
219
|
+
result ?? { success: true },
|
|
220
|
+
responseStatus
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return c.html(result, responseStatus);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return c.html(generateCgiError({ error, $_SERVER }), 500);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
return app;
|
|
231
|
+
};
|
|
232
|
+
export {
|
|
233
|
+
createCgiWithPages
|
|
234
|
+
};
|
package/dist/html.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as hono_utils_html from 'hono/utils/html';
|
|
2
|
+
import { CgiContext } from './cgi.js';
|
|
3
|
+
import 'hono/types';
|
|
4
|
+
import 'hono';
|
|
5
|
+
|
|
6
|
+
declare const generateCgiInfo: ({ $_SERVER, $_SESSION, $_REQUEST, config, }: Pick<CgiContext, "$_SERVER" | "$_SESSION" | "$_REQUEST" | "config">) => () => hono_utils_html.HtmlEscapedString | Promise<hono_utils_html.HtmlEscapedString>;
|
|
7
|
+
declare const generateCgiError: ({ error, $_SERVER, }: {
|
|
8
|
+
error: any;
|
|
9
|
+
$_SERVER: CgiContext["$_SERVER"];
|
|
10
|
+
}) => hono_utils_html.HtmlEscapedString | Promise<hono_utils_html.HtmlEscapedString>;
|
|
11
|
+
|
|
12
|
+
export { generateCgiError, generateCgiInfo };
|
package/dist/html.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { html } from "hono/html";
|
|
2
|
+
const generateCgiInfo = ({
|
|
3
|
+
$_SERVER,
|
|
4
|
+
$_SESSION,
|
|
5
|
+
$_REQUEST,
|
|
6
|
+
config
|
|
7
|
+
}) => {
|
|
8
|
+
return () => {
|
|
9
|
+
const infoSection = ({ title, data }) => {
|
|
10
|
+
return html`
|
|
11
|
+
<div style="margin-bottom: 20px; width: 100%; max-width: 900px;">
|
|
12
|
+
<h2 style="background: #ccccff; color: #000; padding: 5px 10px; margin: 0; font-size: 1.2em; border: 1px solid #000; font-family: 'MS PGothic', sans-serif;">${title}</h2>
|
|
13
|
+
<table style="width: 100%; border-collapse: collapse; border: 1px solid #000; table-layout: fixed; font-family: 'MS PGothic', sans-serif;">
|
|
14
|
+
${Object.entries(data).map(
|
|
15
|
+
([key, val], i) => html`
|
|
16
|
+
<tr style="background: ${i % 2 === 0 ? "#f0f0ff" : "#ffffff"}">
|
|
17
|
+
<td style="padding: 3px 10px; border: 1px solid #000; font-weight: bold; width: 30%; word-break: break-all;">${key}</td>
|
|
18
|
+
<td style="padding: 3px 10px; border: 1px solid #000; width: 70%; word-break: break-all;">
|
|
19
|
+
${typeof val === "object" ? JSON.stringify(val, null, 2) : String(val)}
|
|
20
|
+
</td>
|
|
21
|
+
</tr>
|
|
22
|
+
`
|
|
23
|
+
)}
|
|
24
|
+
</table>
|
|
25
|
+
</div>
|
|
26
|
+
`;
|
|
27
|
+
};
|
|
28
|
+
return html`
|
|
29
|
+
<div style="background: #ffffff; color: #333333; font-family: 'MS PGothic', sans-serif; padding: 20px; display: flex; flex-direction: column; align-items: center;">
|
|
30
|
+
<h1 style="color: #000000; border-bottom: 3px double #000000; padding-bottom: 10px; margin-bottom: 30px;">Matchbox CGI Version Information</h1>
|
|
31
|
+
<div style="width: 100%; max-width: 900px; padding: 15px; background: #ffffcc; border: 1px dashed #000000; margin-bottom: 20px; text-align: center;">
|
|
32
|
+
<strong>Server Software:</strong> Matchbox Engine on ${typeof process !== "undefined" ? "Node.js/Bun" : "Edge"}
|
|
33
|
+
</div>
|
|
34
|
+
${infoSection({ title: "$_SERVER (Environment)", data: $_SERVER })}
|
|
35
|
+
${infoSection({ title: "$_SESSION", data: $_SESSION })}
|
|
36
|
+
${infoSection({ title: "$_REQUEST", data: $_REQUEST })}
|
|
37
|
+
${infoSection({ title: "Site Configuration", data: config })}
|
|
38
|
+
<div style="margin-top: 40px; font-size: 0.9em; font-style: italic; border-top: 1px solid #ccc; width: 100%; max-width: 900px; text-align: right; padding-top: 10px;">
|
|
39
|
+
Generated by Matchbox Framework - Ignition for Hono
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
`;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
const generateCgiError = ({
|
|
46
|
+
error,
|
|
47
|
+
$_SERVER
|
|
48
|
+
}) => {
|
|
49
|
+
return html`
|
|
50
|
+
<div style="padding:2rem; background:#fffafa; border:5px double #cc0000; font-family: 'MS PGothic', sans-serif;">
|
|
51
|
+
<h1 style="color:#cc0000; border-bottom: 2px solid #cc0000; padding-bottom: 5px;">Matchbox: Runtime Exception</h1>
|
|
52
|
+
<p><strong>Fatal Error:</strong> ${error.message}</p>
|
|
53
|
+
<pre style="background:#f0f0f0; padding:1rem; border:1px inset #ccc; overflow: auto;">${error.stack}</pre>
|
|
54
|
+
<hr style="border: 0; border-top: 1px solid #cc0000;" />
|
|
55
|
+
<div style="text-align: right; font-size: 0.8em;">Matchbox CGI Engine Server at ${$_SERVER.REMOTE_ADDR}</div>
|
|
56
|
+
</div>
|
|
57
|
+
`;
|
|
58
|
+
};
|
|
59
|
+
export {
|
|
60
|
+
generateCgiError,
|
|
61
|
+
generateCgiInfo
|
|
62
|
+
};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Plugin } from 'vite';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* --- Matchbox Plugin Options ---
|
|
5
|
+
*/
|
|
6
|
+
interface MatchboxPluginOptions {
|
|
7
|
+
config?: any;
|
|
8
|
+
publicDir?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* --- Vite Plugin: Matchbox Plugin ---
|
|
12
|
+
*/
|
|
13
|
+
declare const MatchboxPlugin: (options?: MatchboxPluginOptions) => Plugin;
|
|
14
|
+
|
|
15
|
+
export { MatchboxPlugin, type MatchboxPluginOptions };
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const MatchboxPlugin = (options = {}) => {
|
|
4
|
+
let viteConfig;
|
|
5
|
+
const siteConfig = options.config || {};
|
|
6
|
+
const publicDir = options.publicDir || "public";
|
|
7
|
+
return {
|
|
8
|
+
name: "vite-plugin-matchbox",
|
|
9
|
+
config() {
|
|
10
|
+
return {
|
|
11
|
+
optimizeDeps: {
|
|
12
|
+
exclude: ["matchbox"]
|
|
13
|
+
},
|
|
14
|
+
ssr: {
|
|
15
|
+
noExternal: ["matchbox"]
|
|
16
|
+
},
|
|
17
|
+
// .htpasswd, .htaccess, .htdigest, .htgroup files in /public should be treated as raw assets
|
|
18
|
+
assetsInclude: [
|
|
19
|
+
`${publicDir}/**/.htpasswd`,
|
|
20
|
+
`${publicDir}/**/.htaccess`,
|
|
21
|
+
`${publicDir}/**/.htdigest`,
|
|
22
|
+
`${publicDir}/**/.htgroup`
|
|
23
|
+
],
|
|
24
|
+
publicDir,
|
|
25
|
+
esbuild: {
|
|
26
|
+
jsxImportSource: "hono/jsx",
|
|
27
|
+
jsx: "automatic"
|
|
28
|
+
},
|
|
29
|
+
define: {
|
|
30
|
+
__MATCHBOX_CONFIG__: JSON.stringify(siteConfig)
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
configResolved(resolvedConfig) {
|
|
35
|
+
viteConfig = resolvedConfig;
|
|
36
|
+
},
|
|
37
|
+
closeBundle() {
|
|
38
|
+
const outDir = path.resolve(viteConfig.root, viteConfig.build.outDir);
|
|
39
|
+
const cleanDir = (dir) => {
|
|
40
|
+
if (!fs.existsSync(dir)) return;
|
|
41
|
+
fs.readdirSync(dir).forEach((file) => {
|
|
42
|
+
const fullPath = path.join(dir, file);
|
|
43
|
+
if (fs.statSync(fullPath).isDirectory()) {
|
|
44
|
+
cleanDir(fullPath);
|
|
45
|
+
} else if (file.includes(".cgi.tsx") || file.includes(".cgi.jsx") || file === ".htpasswd" || file === ".htaccess" || file === ".htdigest" || file === ".htgroup") {
|
|
46
|
+
fs.unlinkSync(fullPath);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
cleanDir(outDir);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
export {
|
|
55
|
+
MatchboxPlugin
|
|
56
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { MatchboxOptions } from './cgi.js';
|
|
2
|
+
import * as hono from 'hono';
|
|
3
|
+
import * as hono_types from 'hono/types';
|
|
4
|
+
import 'hono/utils/html';
|
|
5
|
+
|
|
6
|
+
declare const createCgi: (options?: MatchboxOptions) => hono.Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
|
|
7
|
+
|
|
8
|
+
export { createCgi };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createCgiWithPages } from "./cgi";
|
|
2
|
+
const loadPagesFromPublic = () => {
|
|
3
|
+
const modules = import.meta.glob("/public/**/*.cgi.{tsx,jsx}", {
|
|
4
|
+
eager: true
|
|
5
|
+
});
|
|
6
|
+
const htpasswds = import.meta.glob("/public/**/.htpasswd", {
|
|
7
|
+
eager: true,
|
|
8
|
+
query: "?raw",
|
|
9
|
+
import: "default"
|
|
10
|
+
});
|
|
11
|
+
const htaccessFiles = import.meta.glob("/public/**/.htaccess", {
|
|
12
|
+
eager: true,
|
|
13
|
+
query: "?raw",
|
|
14
|
+
import: "default"
|
|
15
|
+
});
|
|
16
|
+
void import.meta.glob("/public/**/.htdigest", {
|
|
17
|
+
eager: true,
|
|
18
|
+
query: "?raw",
|
|
19
|
+
import: "default"
|
|
20
|
+
});
|
|
21
|
+
void import.meta.glob("/public/**/.htgroup", {
|
|
22
|
+
eager: true,
|
|
23
|
+
query: "?raw",
|
|
24
|
+
import: "default"
|
|
25
|
+
});
|
|
26
|
+
const basePathRegex = /^\/public/;
|
|
27
|
+
const pages = Object.keys(modules).map((key) => {
|
|
28
|
+
const urlPath = key.replace(basePathRegex, "").replace(/.tsx$/, "").replace(/.jsx$/, "");
|
|
29
|
+
const isIndex = urlPath.endsWith("/index.cgi") || urlPath === "/index.cgi";
|
|
30
|
+
const dirPath = isIndex ? urlPath.replace(/\/index\.cgi$/, "/") : null;
|
|
31
|
+
return { urlPath, dirPath, component: modules[key].default };
|
|
32
|
+
});
|
|
33
|
+
const authMap = Object.keys(htpasswds).reduce(
|
|
34
|
+
(acc, key) => {
|
|
35
|
+
const dir = key.replace(basePathRegex, "").replace(/\.htpasswd$/, "") || "/";
|
|
36
|
+
acc[dir] = htpasswds[key];
|
|
37
|
+
return acc;
|
|
38
|
+
},
|
|
39
|
+
{}
|
|
40
|
+
);
|
|
41
|
+
const rewriteMap = Object.keys(htaccessFiles).reduce((acc, key) => {
|
|
42
|
+
const dir = key.replace(basePathRegex, "").replace(/\.htaccess$/, "") || "/";
|
|
43
|
+
const lines = htaccessFiles[key].split("\n");
|
|
44
|
+
const rules = lines.map((line) => {
|
|
45
|
+
const l = line.trim();
|
|
46
|
+
if (!l || l.startsWith("#")) return null;
|
|
47
|
+
const parts = l.split(/\s+/);
|
|
48
|
+
if (parts[0] === "RewriteRule") {
|
|
49
|
+
return {
|
|
50
|
+
type: "rewrite",
|
|
51
|
+
pattern: parts[1],
|
|
52
|
+
target: parts[2],
|
|
53
|
+
flags: parts[3] || ""
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (parts[0] === "Redirect") {
|
|
57
|
+
return {
|
|
58
|
+
type: "redirect",
|
|
59
|
+
code: parts[1],
|
|
60
|
+
source: parts[2],
|
|
61
|
+
target: parts[3]
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}).filter(Boolean);
|
|
66
|
+
acc[dir] = rules;
|
|
67
|
+
return acc;
|
|
68
|
+
}, {});
|
|
69
|
+
return { pages, authMap, rewriteMap };
|
|
70
|
+
};
|
|
71
|
+
const createCgi = (options) => {
|
|
72
|
+
const resolvedConfig = typeof __MATCHBOX_CONFIG__ === "undefined" ? {} : __MATCHBOX_CONFIG__;
|
|
73
|
+
const { pages, authMap, rewriteMap } = loadPagesFromPublic();
|
|
74
|
+
return createCgiWithPages(pages, resolvedConfig, authMap, rewriteMap, options);
|
|
75
|
+
};
|
|
76
|
+
export {
|
|
77
|
+
createCgi
|
|
78
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tknf/matchbox",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "A Simple Web Server Framework",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cgi",
|
|
7
|
+
"framework",
|
|
8
|
+
"hono",
|
|
9
|
+
"server",
|
|
10
|
+
"web"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "tknf <dev@tknf.net>",
|
|
14
|
+
"repository": {
|
|
15
|
+
"url": "https://github.com/tknf/matchbox.git"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"module": "dist/index.mjs",
|
|
22
|
+
"types": "dist/index.d.mts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"require": {
|
|
26
|
+
"types": "./dist/index.d.cts",
|
|
27
|
+
"import": "./dist/index.cjs"
|
|
28
|
+
},
|
|
29
|
+
"import": {
|
|
30
|
+
"types": "./dist/index.d.mts",
|
|
31
|
+
"import": "./dist/index.mjs"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"./plugin": {
|
|
35
|
+
"require": {
|
|
36
|
+
"types": "./dist/plugin.d.cts",
|
|
37
|
+
"import": "./dist/plugin.cjs"
|
|
38
|
+
},
|
|
39
|
+
"import": {
|
|
40
|
+
"types": "./dist/plugin.d.mts",
|
|
41
|
+
"import": "./dist/plugin.mjs"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public",
|
|
47
|
+
"registry": "https://registry.npmjs.org/"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"glob": "^13.0.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^25.0.3",
|
|
54
|
+
"@vitest/coverage-v8": "^4.0.16",
|
|
55
|
+
"hono": "^4.11.1",
|
|
56
|
+
"oxfmt": "^0.20.0",
|
|
57
|
+
"oxlint": "^1.35.0",
|
|
58
|
+
"tsup": "^8.5.1",
|
|
59
|
+
"typescript": "^5.9.3",
|
|
60
|
+
"vite": "^7.3.0",
|
|
61
|
+
"vitest": "^4.0.16"
|
|
62
|
+
},
|
|
63
|
+
"peerDependencies": {
|
|
64
|
+
"hono": "*"
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"test": "vitest run",
|
|
68
|
+
"test:coverage": "vitest run --coverage",
|
|
69
|
+
"lint:check": "oxlint",
|
|
70
|
+
"lint:fix": "oxlint --fix",
|
|
71
|
+
"format:check": "oxfmt --check",
|
|
72
|
+
"format:write": "oxfmt",
|
|
73
|
+
"watch": "tsup --watch",
|
|
74
|
+
"build": "tsup"
|
|
75
|
+
}
|
|
76
|
+
}
|