@njinlabs/njin 0.1.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/LICENSE +21 -0
- package/README.md +223 -0
- package/package.json +59 -0
- package/src/cli/build.ts +162 -0
- package/src/cli/dev.ts +11 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/start.ts +9 -0
- package/src/config/module.ts +34 -0
- package/src/core/adapters/bun_filesystem.ts +37 -0
- package/src/core/adapters/s3.ts +60 -0
- package/src/core/banner.ts +54 -0
- package/src/core/boot.ts +10 -0
- package/src/core/config.ts +89 -0
- package/src/core/http_error.ts +21 -0
- package/src/core/model/data_type/array.ts +22 -0
- package/src/core/model/data_type/boolean.ts +15 -0
- package/src/core/model/data_type/date.ts +28 -0
- package/src/core/model/data_type/email.ts +21 -0
- package/src/core/model/data_type/file.ts +15 -0
- package/src/core/model/data_type/index.ts +13 -0
- package/src/core/model/data_type/multi_file.ts +18 -0
- package/src/core/model/data_type/numeric.ts +15 -0
- package/src/core/model/data_type/object.ts +15 -0
- package/src/core/model/data_type/relation.ts +73 -0
- package/src/core/model/data_type/relation_many.ts +55 -0
- package/src/core/model/data_type/richtext.ts +20 -0
- package/src/core/model/data_type/select.ts +15 -0
- package/src/core/model/data_type/text.ts +20 -0
- package/src/core/model/index.ts +244 -0
- package/src/core/module/index.ts +12 -0
- package/src/core/path_guard.ts +9 -0
- package/src/models/file.ts +17 -0
- package/src/models/user.ts +18 -0
- package/src/modules/admin.ts +43 -0
- package/src/modules/analytics.ts +212 -0
- package/src/modules/api.ts +160 -0
- package/src/modules/auth.ts +153 -0
- package/src/modules/elysia.ts +85 -0
- package/src/modules/file.ts +131 -0
- package/src/modules/img.ts +137 -0
- package/src/modules/logger.ts +18 -0
- package/src/modules/setup.ts +89 -0
- package/src/modules/surreal.ts +58 -0
- package/src/modules/users.ts +94 -0
- package/src/modules/view.ts +247 -0
- package/src/spin.ts +3 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { makeModule } from "../core/module";
|
|
2
|
+
import Elysia, { status } from "elysia";
|
|
3
|
+
import moment from "moment";
|
|
4
|
+
import { RecordId, Table, Uuid } from "surrealdb";
|
|
5
|
+
import z from "zod";
|
|
6
|
+
import elysia from "./elysia";
|
|
7
|
+
import surreal from "./surreal";
|
|
8
|
+
|
|
9
|
+
const tokenTable = new Table("token");
|
|
10
|
+
|
|
11
|
+
const setup = makeModule(() => {
|
|
12
|
+
const fn = () => {};
|
|
13
|
+
|
|
14
|
+
fn.init = async () => {
|
|
15
|
+
const { default: user } = await import("../models/user");
|
|
16
|
+
|
|
17
|
+
type Token = {
|
|
18
|
+
hash: string;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
updatedAt: string;
|
|
21
|
+
user: RecordId;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const hasUsers = async () => {
|
|
25
|
+
const [[row]] = await surreal().query<[{ count: number }[]]>(
|
|
26
|
+
`SELECT count() as count FROM ${user.prefix} GROUP ALL`,
|
|
27
|
+
);
|
|
28
|
+
return (row?.count ?? 0) > 0;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const controller = new Elysia({ prefix: "/api/setup" })
|
|
32
|
+
|
|
33
|
+
// Status — admin panel calls this on startup
|
|
34
|
+
.get("/status", async () => {
|
|
35
|
+
return { needsSetup: !(await hasUsers()) };
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// Create first admin — locked once any user exists
|
|
39
|
+
.post(
|
|
40
|
+
"/",
|
|
41
|
+
async ({ body }) => {
|
|
42
|
+
if (await hasUsers()) {
|
|
43
|
+
return status(403, { message: "Setup already completed" });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const created = await user.create({
|
|
47
|
+
...body,
|
|
48
|
+
password: Bun.password.hashSync(body.password),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const plainToken = Uuid.v7().toString();
|
|
52
|
+
const [token] = await surreal()
|
|
53
|
+
.create<Token>(tokenTable)
|
|
54
|
+
.content({
|
|
55
|
+
hash: new Bun.CryptoHasher("sha256").update(plainToken).digest("utf8"),
|
|
56
|
+
user: created.id,
|
|
57
|
+
createdAt: moment().toISOString(),
|
|
58
|
+
updatedAt: moment().toISOString(),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await surreal().relate(token!.id, new Table("user_token"), created.id);
|
|
62
|
+
|
|
63
|
+
const { password: _, ...safeUser } = created as typeof created & { password: string };
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
data: {
|
|
67
|
+
token: `${tokenTable.toString()}:${token!.id.id}:${plainToken}`,
|
|
68
|
+
user: safeUser,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
body: z.object({
|
|
74
|
+
name: z.string().min(1),
|
|
75
|
+
email: z.email(),
|
|
76
|
+
password: z.string().min(8),
|
|
77
|
+
}),
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
elysia().use(controller);
|
|
82
|
+
|
|
83
|
+
return {};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return fn;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export default setup;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { getConfig } from "../core/config";
|
|
2
|
+
import { makeModule } from "../core/module";
|
|
3
|
+
import { createNodeEngines } from "@surrealdb/node";
|
|
4
|
+
import { Surreal } from "surrealdb";
|
|
5
|
+
|
|
6
|
+
// SurrealDB's `GROUP ALL` aggregate (used for count queries) throws NotFoundError
|
|
7
|
+
// on a table that has never had a row created, unlike plain SELECT. Defining every
|
|
8
|
+
// table up front avoids that first-run failure (e.g. /api/setup/status before any user exists).
|
|
9
|
+
const ensureTables = async (db: Surreal) => {
|
|
10
|
+
const { default: userModel } = await import("../models/user");
|
|
11
|
+
const { default: fileModel } = await import("../models/file");
|
|
12
|
+
|
|
13
|
+
const prefixes = new Set<string>([userModel.prefix, fileModel.prefix]);
|
|
14
|
+
|
|
15
|
+
for (const factory of getConfig().models) {
|
|
16
|
+
const { default: model } = await factory();
|
|
17
|
+
prefixes.add(model.prefix);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
for (const prefix of prefixes) {
|
|
21
|
+
await db.query(`DEFINE TABLE IF NOT EXISTS ${prefix} SCHEMALESS;`);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const surreal = makeModule(() => {
|
|
26
|
+
let db: Surreal;
|
|
27
|
+
|
|
28
|
+
const fn = () => {
|
|
29
|
+
return db;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
fn.init = () => {
|
|
33
|
+
db = new Surreal({
|
|
34
|
+
engines: createNodeEngines(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
spin: async () => {
|
|
39
|
+
const { db: dbConfig } = getConfig();
|
|
40
|
+
await db.connect(dbConfig.path);
|
|
41
|
+
await db.use({ namespace: dbConfig.namespace, database: dbConfig.database });
|
|
42
|
+
await ensureTables(db);
|
|
43
|
+
|
|
44
|
+
const shutdown = async () => {
|
|
45
|
+
await db.close();
|
|
46
|
+
process.exit(0);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
process.once("SIGINT", shutdown);
|
|
50
|
+
process.once("SIGTERM", shutdown);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return fn;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export default surreal;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { makeModule } from "../core/module";
|
|
2
|
+
import Elysia, { status } from "elysia";
|
|
3
|
+
import z from "zod";
|
|
4
|
+
import auth from "./auth";
|
|
5
|
+
import elysia from "./elysia";
|
|
6
|
+
|
|
7
|
+
const stripPassword = <T extends { password: unknown }>({ password: _, ...rest }: T) => rest;
|
|
8
|
+
|
|
9
|
+
const users = makeModule(() => {
|
|
10
|
+
const fn = () => {};
|
|
11
|
+
|
|
12
|
+
fn.init = async () => {
|
|
13
|
+
const { default: user } = await import("../models/user");
|
|
14
|
+
const authPlugin = (await auth()).plugin;
|
|
15
|
+
|
|
16
|
+
const controller = new Elysia({ prefix: "/api/user" })
|
|
17
|
+
.use(authPlugin)
|
|
18
|
+
.get(
|
|
19
|
+
"/",
|
|
20
|
+
async ({ query: { search, page, limit, sort, order, filters } }) => {
|
|
21
|
+
const result = await user.read({ search, page, limit, sort, order, filters });
|
|
22
|
+
|
|
23
|
+
return { data: result.data.map(stripPassword), meta: result.meta };
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
auth: true,
|
|
27
|
+
query: z.object({
|
|
28
|
+
search: z.coerce.string().optional(),
|
|
29
|
+
page: z.coerce.number().int().positive().default(1),
|
|
30
|
+
limit: z.coerce.number().int().positive().max(100).default(20),
|
|
31
|
+
sort: z.coerce.string().optional(),
|
|
32
|
+
order: z.enum(["asc", "desc"]).default("asc"),
|
|
33
|
+
filters: z
|
|
34
|
+
.record(z.string(), z.union([z.coerce.string(), z.record(z.string(), z.coerce.string())]))
|
|
35
|
+
.optional(),
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
.get(
|
|
40
|
+
"/:id",
|
|
41
|
+
async ({ params }) => {
|
|
42
|
+
const data = await user.show(params.id);
|
|
43
|
+
|
|
44
|
+
return { data: data ? stripPassword(data) : null };
|
|
45
|
+
},
|
|
46
|
+
{ auth: true, params: z.object({ id: z.coerce.string() }) },
|
|
47
|
+
)
|
|
48
|
+
.post(
|
|
49
|
+
"/",
|
|
50
|
+
async ({ body }) => {
|
|
51
|
+
const created = await user.create(body);
|
|
52
|
+
|
|
53
|
+
return { data: stripPassword(created) };
|
|
54
|
+
},
|
|
55
|
+
{ auth: true, body: user.validation },
|
|
56
|
+
)
|
|
57
|
+
.put(
|
|
58
|
+
"/:id",
|
|
59
|
+
async ({ params, body }) => {
|
|
60
|
+
const updated = await user.update(params.id, body);
|
|
61
|
+
|
|
62
|
+
return { data: stripPassword(updated) };
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
auth: true,
|
|
66
|
+
params: z.object({ id: z.coerce.string() }),
|
|
67
|
+
body: user.validation.partial(),
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
.delete(
|
|
71
|
+
"/:id",
|
|
72
|
+
async ({ params, user: actingUser }) => {
|
|
73
|
+
// The only recovery path for a locked-out instance is a manual DB edit —
|
|
74
|
+
// setup is permanently disabled once any user exists, so block self-delete.
|
|
75
|
+
if (params.id === actingUser.id.id) {
|
|
76
|
+
return status(400, { message: "You cannot delete your own account" });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const deleted = await user.destroy(params.id);
|
|
80
|
+
|
|
81
|
+
return { data: stripPassword(deleted) };
|
|
82
|
+
},
|
|
83
|
+
{ auth: true, params: z.object({ id: z.coerce.string() }) },
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
elysia().use(controller);
|
|
87
|
+
|
|
88
|
+
return {};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return fn;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export default users;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { getConfig } from "../core/config";
|
|
2
|
+
import { HttpError } from "../core/http_error";
|
|
3
|
+
import { makeModule } from "../core/module";
|
|
4
|
+
import { Edge } from "edge.js";
|
|
5
|
+
import Elysia from "elysia";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import elysia from "./elysia";
|
|
8
|
+
|
|
9
|
+
const isDev = process.env.NODE_ENV !== "production";
|
|
10
|
+
const publicDir = join(process.cwd(), "public");
|
|
11
|
+
|
|
12
|
+
type ViteManifestChunk = {
|
|
13
|
+
file: string;
|
|
14
|
+
css?: string[];
|
|
15
|
+
isEntry?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type ViteGlobal = {
|
|
19
|
+
asset: (entry: string) => string;
|
|
20
|
+
static: (path: string) => string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const buildViteGlobal = async (): Promise<ViteGlobal> => {
|
|
24
|
+
if (isDev) {
|
|
25
|
+
const { createServer } = await import("vite");
|
|
26
|
+
const server = await createServer();
|
|
27
|
+
await server.listen();
|
|
28
|
+
server.printUrls();
|
|
29
|
+
|
|
30
|
+
const url = server.resolvedUrls?.local[0]?.replace(/\/$/, "") ?? "http://localhost:5173";
|
|
31
|
+
|
|
32
|
+
process.once("SIGINT", () => server.close());
|
|
33
|
+
process.once("SIGTERM", () => server.close());
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
asset: (entry) => {
|
|
37
|
+
if (entry.endsWith(".css")) {
|
|
38
|
+
return `<link rel="stylesheet" href="${url}/${entry}">`;
|
|
39
|
+
}
|
|
40
|
+
return (
|
|
41
|
+
`<script type="module" src="${url}/@vite/client"></script>\n` +
|
|
42
|
+
`<script type="module" src="${url}/${entry}"></script>`
|
|
43
|
+
);
|
|
44
|
+
},
|
|
45
|
+
static: (path) => `${url}/${path.replace(/^\//, "")}`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const manifestFile = Bun.file(join(publicDir, "manifest.json"));
|
|
50
|
+
|
|
51
|
+
if (!(await manifestFile.exists())) {
|
|
52
|
+
return {
|
|
53
|
+
asset: () => "<!-- vite manifest not found, run: bun run build -->",
|
|
54
|
+
static: (path) => `/${path.replace(/^\//, "")}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const manifest = (await manifestFile.json()) as Record<string, ViteManifestChunk>;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
asset: (entry) => {
|
|
62
|
+
const chunk = manifest[entry];
|
|
63
|
+
if (!chunk) return `<!-- vite entry "${entry}" not found in manifest -->`;
|
|
64
|
+
if (entry.endsWith(".css")) {
|
|
65
|
+
return `<link rel="stylesheet" href="/${chunk.file}">`;
|
|
66
|
+
}
|
|
67
|
+
const styles = chunk.css?.map((f) => `<link rel="stylesheet" href="/${f}">`).join("\n") ?? "";
|
|
68
|
+
const script = `<script type="module" src="/${chunk.file}"></script>`;
|
|
69
|
+
return [styles, script].filter(Boolean).join("\n");
|
|
70
|
+
},
|
|
71
|
+
static: (path) => `/${path.replace(/^\//, "")}`,
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const view = makeModule(() => {
|
|
76
|
+
const edge = new Edge();
|
|
77
|
+
|
|
78
|
+
const fn = () => edge;
|
|
79
|
+
|
|
80
|
+
fn.init = async () => {
|
|
81
|
+
// process.cwd()-based, not import.meta.dir — once view.ts lives inside node_modules/njin,
|
|
82
|
+
// import.meta.dir would point at the package's own location, not the consuming project's views.
|
|
83
|
+
const viewsDir = join(process.cwd(), "src/views");
|
|
84
|
+
const pagesDir = join(viewsDir, "pages");
|
|
85
|
+
|
|
86
|
+
edge.mount(viewsDir);
|
|
87
|
+
|
|
88
|
+
edge.global("vite", await buildViteGlobal());
|
|
89
|
+
// Fallback abort for non-page contexts (components, error templates).
|
|
90
|
+
// Pages get a per-request override that saves the error before EdgeJS wraps it.
|
|
91
|
+
edge.global("abort", (statusCode: number, message?: string) => {
|
|
92
|
+
throw new HttpError(statusCode, message);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
for (const modelPromise of getConfig().models) {
|
|
96
|
+
const { default: model } = await modelPromise();
|
|
97
|
+
edge.global(model.prefix, model);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const controller = new Elysia();
|
|
101
|
+
|
|
102
|
+
if (!isDev) {
|
|
103
|
+
controller.get("/assets/*", async ({ params }) => {
|
|
104
|
+
const file = Bun.file(join(publicDir, "assets", params["*"]));
|
|
105
|
+
if (!(await file.exists())) return new Response("Not Found", { status: 404 });
|
|
106
|
+
return file;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const files: string[] = [];
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const glob = new Bun.Glob("**/*.edge");
|
|
114
|
+
for await (const file of glob.scan({ cwd: pagesDir })) {
|
|
115
|
+
files.push(file);
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
elysia().use(controller);
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const file of files) {
|
|
123
|
+
const route = fileToRoute(file);
|
|
124
|
+
const template = `pages/${file.replace(/\\/g, "/").replace(/\.edge$/, "")}`;
|
|
125
|
+
|
|
126
|
+
controller.get(route, async ({ params, query, path, request, server }) => {
|
|
127
|
+
// Object property — avoids TypeScript's overly-aggressive narrowing of
|
|
128
|
+
// closure-assigned variables to `never`.
|
|
129
|
+
const ctx: { abortErr: HttpError | null } = { abortErr: null };
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const html = await edge.render(template, {
|
|
133
|
+
params,
|
|
134
|
+
query,
|
|
135
|
+
request: { path, url: request.url },
|
|
136
|
+
abort: (statusCode: number, message?: string) => {
|
|
137
|
+
ctx.abortErr = new HttpError(statusCode, message);
|
|
138
|
+
throw ctx.abortErr;
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Fire-and-forget — never awaited, so it can't add latency to the response.
|
|
143
|
+
import("./analytics").then(({ default: analytics, resolveClientIp }) =>
|
|
144
|
+
analytics().track({
|
|
145
|
+
path,
|
|
146
|
+
referrer: request.headers.get("referer"),
|
|
147
|
+
userAgent: request.headers.get("user-agent"),
|
|
148
|
+
ip: resolveClientIp(request, server),
|
|
149
|
+
requestUrl: request.url,
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if (ctx.abortErr) {
|
|
156
|
+
return new Response(await renderHttpError(edge, viewsDir, ctx.abortErr), {
|
|
157
|
+
status: ctx.abortErr.statusCode,
|
|
158
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (!isDev) throw err;
|
|
162
|
+
return new Response(renderErrorPage(err as Error, template, path), {
|
|
163
|
+
status: 500,
|
|
164
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Catch-all — must be registered last so specific routes take priority
|
|
171
|
+
controller.get("/*", async ({ path }) => {
|
|
172
|
+
if (!isDev) {
|
|
173
|
+
const staticFile = Bun.file(join(publicDir, path));
|
|
174
|
+
if (await staticFile.exists()) return staticFile;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return new Response(await renderHttpError(edge, viewsDir, new HttpError(404)), {
|
|
178
|
+
status: 404,
|
|
179
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
elysia().use(controller);
|
|
184
|
+
|
|
185
|
+
return {};
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
return fn;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
async function renderHttpError(edge: Edge, viewsDir: string, error: HttpError): Promise<string> {
|
|
192
|
+
const templateFile = Bun.file(join(viewsDir, `errors/${error.statusCode}.edge`));
|
|
193
|
+
if (await templateFile.exists()) {
|
|
194
|
+
try {
|
|
195
|
+
return await edge.render(`errors/${error.statusCode}`, {
|
|
196
|
+
statusCode: error.statusCode,
|
|
197
|
+
message: error.message,
|
|
198
|
+
});
|
|
199
|
+
} catch (e) {
|
|
200
|
+
console.error(`[view] failed to render errors/${error.statusCode}.edge:`, e);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const code = error.statusCode;
|
|
204
|
+
return `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${code} — ${error.message}</title>
|
|
205
|
+
<style>*{margin:0;padding:0;box-sizing:border-box}body{background:#0f172a;color:#e2e8f0;font-family:system-ui,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
|
206
|
+
.c{text-align:center}.n{font-size:6rem;font-weight:700;color:#1e293b;line-height:1}.m{color:#64748b;margin-top:.5rem}a{color:#818cf8;text-decoration:none;margin-top:1.5rem;display:inline-block}</style>
|
|
207
|
+
</head><body><div class="c"><div class="n">${code}</div><p class="m">${error.message}</p><a href="/">← Back to home</a></div></body></html>`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function renderErrorPage(error: Error, template: string, path: string): string {
|
|
211
|
+
const stack = (error.stack ?? "").replace(/</g, "<").replace(/>/g, ">");
|
|
212
|
+
const message = error.message.replace(/</g, "<").replace(/>/g, ">");
|
|
213
|
+
|
|
214
|
+
return `<!DOCTYPE html>
|
|
215
|
+
<html lang="en">
|
|
216
|
+
<head>
|
|
217
|
+
<meta charset="UTF-8" />
|
|
218
|
+
<title>Error — ${path}</title>
|
|
219
|
+
<style>
|
|
220
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
221
|
+
body { background: #0f172a; color: #e2e8f0; font-family: ui-monospace, monospace; padding: 2rem; min-height: 100vh; }
|
|
222
|
+
.badge { display: inline-block; background: #ef4444; color: #fff; font-size: .7rem; font-weight: 700; padding: .2rem .5rem; border-radius: .25rem; letter-spacing: .05em; margin-bottom: 1.5rem; }
|
|
223
|
+
h1 { font-size: 1.25rem; color: #f87171; margin-bottom: .5rem; word-break: break-word; }
|
|
224
|
+
.meta { font-size: .8rem; color: #64748b; margin-bottom: 2rem; }
|
|
225
|
+
.meta span { color: #94a3b8; }
|
|
226
|
+
pre { background: #1e293b; border: 1px solid #334155; border-radius: .5rem; padding: 1.5rem; font-size: .8rem; line-height: 1.7; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
|
|
227
|
+
.label { font-size: .7rem; color: #64748b; text-transform: uppercase; letter-spacing: .1em; margin-bottom: .5rem; }
|
|
228
|
+
</style>
|
|
229
|
+
</head>
|
|
230
|
+
<body>
|
|
231
|
+
<div class="badge">500 — DEV MODE</div>
|
|
232
|
+
<h1>${message}</h1>
|
|
233
|
+
<p class="meta">Template: <span>${template}</span> · Route: <span>${path}</span></p>
|
|
234
|
+
<p class="label">Stack trace</p>
|
|
235
|
+
<pre>${stack}</pre>
|
|
236
|
+
</body>
|
|
237
|
+
</html>`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function fileToRoute(file: string): string {
|
|
241
|
+
let route = file.replace(/\\/g, "/").replace(/\.edge$/, "");
|
|
242
|
+
route = route.replace(/\[([^\]]+)\]/g, ":$1");
|
|
243
|
+
route = route.replace(/\/index$|^index$/, "");
|
|
244
|
+
return "/" + route;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export default view;
|
package/src/spin.ts
ADDED