@njinlabs/njin 0.1.2 → 0.2.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.
@@ -1,137 +1,141 @@
1
- import { getConfig } from "../core/config";
2
- import { makeModule } from "../core/module";
3
- import Elysia from "elysia";
4
- import elysia from "./elysia";
5
- import view from "./view";
6
-
7
- export function isAllowed(imageUrl: string, requestHost: string, allowedHosts: string[] = getConfig().img.hosts): boolean {
8
- if (imageUrl.startsWith("/")) return true;
9
-
10
- let parsed: URL;
11
- try {
12
- parsed = new URL(imageUrl);
13
- } catch {
14
- return false;
15
- }
16
-
17
- const imageHostname = parsed.hostname;
18
- // Strip port — host header may include it (e.g. "localhost:3000")
19
- const requestHostname = requestHost.split(":")[0];
20
-
21
- // Same host as the incoming request — works across dev, staging, and production
22
- if (imageHostname === requestHostname) return true;
23
-
24
- // localhost variants — covers Vite dev server running on a different port (5173)
25
- if (imageHostname === "localhost" || imageHostname === "127.0.0.1") return true;
26
-
27
- // Truly external hosts: require explicit whitelist
28
- return allowedHosts.includes(imageHostname);
29
- }
30
-
31
- export function extractFilename(url: string): string {
32
- try {
33
- const pathname = url.startsWith("/") ? url : new URL(url).pathname;
34
- const last = pathname.split("/").filter(Boolean).pop() ?? "image";
35
- const name = last.replace(/\.[^.]+$/, "") || "image";
36
- return `${name}.webp`;
37
- } catch {
38
- return "image.webp";
39
- }
40
- }
41
-
42
- const img = makeModule(() => {
43
- const fn = () => {};
44
-
45
- fn.init = async () => {
46
- view().global("imgOptimize", (url: string, options?: { w?: number; h?: number; q?: number }) => {
47
- const params = new URLSearchParams({ url });
48
- if (options?.w != null) params.set("w", String(options.w));
49
- if (options?.h != null) params.set("h", String(options.h));
50
- if (options?.q != null) params.set("q", String(options.q));
51
- return `/img?${params.toString()}`;
52
- });
53
-
54
- const controller = new Elysia();
55
-
56
- controller.get("/img", async ({ query, request }) => {
57
- const { url, w, h, q = "80" } = query;
58
-
59
- if (!url) {
60
- return new Response("Missing url parameter", { status: 400 });
61
- }
62
-
63
- const width = w ? parseInt(w) : undefined;
64
- const height = h ? parseInt(h) : undefined;
65
- const quality = Math.min(100, Math.max(1, parseInt(q)));
66
-
67
- if (isNaN(quality)) {
68
- return new Response("Invalid q parameter", { status: 400 });
69
- }
70
-
71
- const requestHost = request.headers.get("host") ?? "";
72
-
73
- if (!isAllowed(url, requestHost)) {
74
- return new Response("Host not allowed", { status: 403 });
75
- }
76
-
77
- // ETag is deterministic from input params — allows 304 without fetching or processing
78
- const etag = `"${Bun.hash(`${url}|${w ?? ""}|${h ?? ""}|${quality}`).toString(16)}"`;
79
- const cacheControl = "public, max-age=31536000, immutable";
80
-
81
- if (request.headers.get("if-none-match") === etag) {
82
- return new Response(null, {
83
- status: 304,
84
- headers: { ETag: etag, "Cache-Control": cacheControl },
85
- });
86
- }
87
-
88
- let imageData: ArrayBuffer;
89
-
90
- if (url.startsWith("/")) {
91
- const resp = await fetch(`http://localhost:${getConfig().port}${url}`);
92
- if (!resp.ok) return new Response("Image not found", { status: 404 });
93
- imageData = await resp.arrayBuffer();
94
- } else {
95
- const resp = await fetch(url);
96
- if (!resp.ok) return new Response("Failed to fetch image", { status: 502 });
97
- imageData = await resp.arrayBuffer();
98
- }
99
-
100
- const pipeline = new Bun.Image(imageData);
101
-
102
- if (width) {
103
- // Bun.Image.resize() takes width first and derives height from aspect ratio
104
- // when height is omitted — there's no height-only equivalent, so width-only
105
- // and width+height both go through this branch unchanged.
106
- pipeline.resize(width, height, { fit: "inside", withoutEnlargement: true });
107
- } else if (height) {
108
- // Height-only request — derive a proportional width from the source dimensions
109
- // since resize() requires a width argument.
110
- const meta = await pipeline.metadata();
111
- const derivedWidth = Math.round((meta.width / meta.height) * height);
112
- pipeline.resize(derivedWidth, height, { fit: "inside", withoutEnlargement: true });
113
- }
114
-
115
- const output = await pipeline.webp({ quality }).buffer();
116
- const filename = extractFilename(url);
117
-
118
- return new Response(new Uint8Array(output), {
119
- headers: {
120
- "Content-Type": "image/webp",
121
- "Content-Length": String(output.byteLength),
122
- "Content-Disposition": `inline; filename="${filename}"`,
123
- "Cache-Control": cacheControl,
124
- ETag: etag,
125
- Vary: "Accept",
126
- },
127
- });
128
- });
129
-
130
- elysia().use(controller);
131
- return {};
132
- };
133
-
134
- return fn;
135
- });
136
-
137
- export default img;
1
+ import { getConfig } from "../core/config";
2
+ import { makeModule } from "../core/module";
3
+ import Elysia from "elysia";
4
+ import elysia from "./elysia";
5
+ import view from "./view";
6
+
7
+ export function isAllowed(imageUrl: string, allowedHosts: string[] = getConfig().img.hosts): boolean {
8
+ if (imageUrl.startsWith("/")) return true;
9
+
10
+ let parsed: URL;
11
+ try {
12
+ parsed = new URL(imageUrl);
13
+ } catch {
14
+ return false;
15
+ }
16
+
17
+ const imageHostname = parsed.hostname;
18
+
19
+ // localhost variants are only trusted outside production — covers the Vite dev
20
+ // server running on a different port (5173). Trusting the client-supplied `Host`
21
+ // header, or allowing loopback unconditionally, would turn this into an SSRF
22
+ // primitive (an attacker controls both the `url` query param and their own
23
+ // request's `Host` header), so neither is honored here.
24
+ if (process.env.NODE_ENV !== "production" && (imageHostname === "localhost" || imageHostname === "127.0.0.1")) return true;
25
+
26
+ // Every other host: require explicit whitelist
27
+ return allowedHosts.includes(imageHostname);
28
+ }
29
+
30
+ export function extractFilename(url: string): string {
31
+ try {
32
+ const pathname = url.startsWith("/") ? url : new URL(url).pathname;
33
+ const last = pathname.split("/").filter(Boolean).pop() ?? "image";
34
+ const name = last.replace(/\.[^.]+$/, "") || "image";
35
+ return `${name}.webp`;
36
+ } catch {
37
+ return "image.webp";
38
+ }
39
+ }
40
+
41
+ const img = makeModule(() => {
42
+ const fn = () => {};
43
+
44
+ fn.init = async () => {
45
+ view().global("imgOptimize", (url: string, options?: { w?: number; h?: number; q?: number }) => {
46
+ const params = new URLSearchParams({ url });
47
+ if (options?.w != null) params.set("w", String(options.w));
48
+ if (options?.h != null) params.set("h", String(options.h));
49
+ if (options?.q != null) params.set("q", String(options.q));
50
+ return `/img?${params.toString()}`;
51
+ });
52
+
53
+ const controller = new Elysia();
54
+
55
+ controller.get("/img", async ({ query, request }) => {
56
+ const { url, w, h, q = "80" } = query;
57
+
58
+ if (!url) {
59
+ return new Response("Missing url parameter", { status: 400 });
60
+ }
61
+
62
+ const width = w ? parseInt(w) : undefined;
63
+ const height = h ? parseInt(h) : undefined;
64
+ const quality = Math.min(100, Math.max(1, parseInt(q)));
65
+
66
+ if (isNaN(quality)) {
67
+ return new Response("Invalid q parameter", { status: 400 });
68
+ }
69
+
70
+ if (!isAllowed(url)) {
71
+ return new Response("Host not allowed", { status: 403 });
72
+ }
73
+
74
+ // ETag is deterministic from input params — allows 304 without fetching or processing
75
+ const etag = `"${Bun.hash(`${url}|${w ?? ""}|${h ?? ""}|${quality}`).toString(16)}"`;
76
+ const cacheControl = "public, max-age=31536000, immutable";
77
+
78
+ if (request.headers.get("if-none-match") === etag) {
79
+ return new Response(null, {
80
+ status: 304,
81
+ headers: { ETag: etag, "Cache-Control": cacheControl },
82
+ });
83
+ }
84
+
85
+ let imageData: ArrayBuffer;
86
+
87
+ if (url.startsWith("/")) {
88
+ const resp = await fetch(`http://localhost:${getConfig().port}${url}`);
89
+ if (!resp.ok) return new Response("Image not found", { status: 404 });
90
+ imageData = await resp.arrayBuffer();
91
+ } else {
92
+ // `redirect: "error"` — a validated host redirecting to an unvalidated
93
+ // (e.g. internal) one would otherwise silently defeat the isAllowed() check above.
94
+ let resp: Response;
95
+ try {
96
+ resp = await fetch(url, { redirect: "error" });
97
+ } catch {
98
+ return new Response("Failed to fetch image", { status: 502 });
99
+ }
100
+ if (!resp.ok) return new Response("Failed to fetch image", { status: 502 });
101
+ imageData = await resp.arrayBuffer();
102
+ }
103
+
104
+ const pipeline = new Bun.Image(imageData);
105
+
106
+ if (width) {
107
+ // Bun.Image.resize() takes width first and derives height from aspect ratio
108
+ // when height is omitted — there's no height-only equivalent, so width-only
109
+ // and width+height both go through this branch unchanged.
110
+ pipeline.resize(width, height, { fit: "inside", withoutEnlargement: true });
111
+ } else if (height) {
112
+ // Height-only request — derive a proportional width from the source dimensions
113
+ // since resize() requires a width argument.
114
+ const meta = await pipeline.metadata();
115
+ const derivedWidth = Math.round((meta.width / meta.height) * height);
116
+ pipeline.resize(derivedWidth, height, { fit: "inside", withoutEnlargement: true });
117
+ }
118
+
119
+ const output = await pipeline.webp({ quality }).buffer();
120
+ const filename = extractFilename(url);
121
+
122
+ return new Response(new Uint8Array(output), {
123
+ headers: {
124
+ "Content-Type": "image/webp",
125
+ "Content-Length": String(output.byteLength),
126
+ "Content-Disposition": `inline; filename="${filename}"`,
127
+ "Cache-Control": cacheControl,
128
+ ETag: etag,
129
+ Vary: "Accept",
130
+ },
131
+ });
132
+ });
133
+
134
+ elysia().use(controller);
135
+ return {};
136
+ };
137
+
138
+ return fn;
139
+ });
140
+
141
+ export default img;
@@ -0,0 +1,17 @@
1
+ import { getConfig } from "../core/config";
2
+ import { makeModule } from "../core/module";
3
+
4
+ const plugin = makeModule(() => {
5
+ const fn = () => {};
6
+
7
+ fn.init = async () => {
8
+ // Sequential, awaited, and never caught — a plugin whose init() throws must fail
9
+ // boot loudly, same contract as hook/event side-effect imports in api.ts.
10
+ for (const init of getConfig().pluginInits) await init();
11
+ return {};
12
+ };
13
+
14
+ return fn;
15
+ });
16
+
17
+ export default plugin;
@@ -1,7 +1,12 @@
1
1
  import { getConfig } from "../core/config";
2
2
  import { makeModule } from "../core/module";
3
- import { createNodeEngines } from "@surrealdb/node";
4
- import { Surreal } from "surrealdb";
3
+ import { Surreal, createRemoteEngines } from "surrealdb";
4
+ import type { Engines } from "surrealdb";
5
+
6
+ const REMOTE_SCHEMES = ["ws://", "wss://", "http://", "https://"];
7
+ const EMBEDDED_SCHEMES = ["mem://", "rocksdb://", "surrealkv://"];
8
+
9
+ export const isRemotePath = (path: string) => REMOTE_SCHEMES.some((scheme) => path.startsWith(scheme));
5
10
 
6
11
  // SurrealDB's `GROUP ALL` aggregate (used for count queries) throws NotFoundError
7
12
  // on a table that has never had a row created, unlike plain SELECT. Defining every
@@ -10,7 +15,7 @@ const ensureTables = async (db: Surreal) => {
10
15
  const { default: userModel } = await import("../models/user");
11
16
  const { default: fileModel } = await import("../models/file");
12
17
 
13
- const prefixes = new Set<string>([userModel.prefix, fileModel.prefix]);
18
+ const prefixes = new Set<string>([userModel.prefix, fileModel.prefix, "vars"]);
14
19
 
15
20
  for (const factory of getConfig().models) {
16
21
  const { default: model } = await factory();
@@ -29,18 +34,38 @@ const surreal = makeModule(() => {
29
34
  return db;
30
35
  };
31
36
 
32
- fn.init = () => {
33
- db = new Surreal({
34
- engines: createNodeEngines(),
35
- });
37
+ // Connect during init(), not spin() — init() calls for every module run (in
38
+ // src/config/module.ts's array construction) before any module's spin() runs, and a
39
+ // plugin's own init() (src/modules/plugin.ts) may query the DB (e.g. reading its own
40
+ // vars group) as part of its own setup. Deferring the actual connect to spin() left a
41
+ // window where `surreal()` returned an instance that existed but had never connected,
42
+ // so any DB call made from a plugin's init() failed with ConnectionUnavailableError.
43
+ fn.init = async () => {
44
+ const { db: dbConfig } = getConfig();
36
45
 
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);
46
+ let engines: Engines;
47
+ if (isRemotePath(dbConfig.path)) {
48
+ engines = createRemoteEngines();
49
+ } else if (EMBEDDED_SCHEMES.some((scheme) => dbConfig.path.startsWith(scheme))) {
50
+ // Dynamically imported (not a static top-level import) so a compiled build whose
51
+ // config resolves to a remote db.path can exclude @surrealdb/node entirely — see
52
+ // src/cli/build.ts, which externalizes this package for remote-only builds since its
53
+ // native binding loader runs eagerly on import, not lazily on first use.
54
+ const { createNodeEngines } = await import("@surrealdb/node");
55
+ engines = createNodeEngines();
56
+ } else {
57
+ throw new Error(
58
+ `Unrecognized db.path scheme: "${dbConfig.path}" — expected one of ${EMBEDDED_SCHEMES.join(", ")} (embedded) or ${REMOTE_SCHEMES.join(", ")} (remote)`,
59
+ );
60
+ }
43
61
 
62
+ db = new Surreal({ engines });
63
+ await db.connect(dbConfig.path, { authentication: dbConfig.auth });
64
+ await db.use({ namespace: dbConfig.namespace, database: dbConfig.database });
65
+ await ensureTables(db);
66
+
67
+ return {
68
+ spin: () => {
44
69
  const shutdown = async () => {
45
70
  await db.close();
46
71
  process.exit(0);
@@ -0,0 +1,34 @@
1
+ import { getConfig } from "../core/config";
2
+ import { makeModule } from "../core/module";
3
+ import Elysia from "elysia";
4
+ import auth from "./auth";
5
+ import elysia from "./elysia";
6
+
7
+ const vars = makeModule(() => {
8
+ const fn = () => {};
9
+
10
+ fn.init = async () => {
11
+ const groups = getConfig().vars;
12
+ const authPlugin = await auth();
13
+
14
+ for (const promise of groups) {
15
+ const { default: group } = await promise();
16
+
17
+ const controller = new Elysia({ prefix: `/api/vars/${group.prefix}` })
18
+ .use(authPlugin.plugin)
19
+ .get("/", async () => ({ data: await group.get() }), { auth: true })
20
+ .put("/", async ({ body }) => ({ data: await group.update(body) }), {
21
+ auth: true,
22
+ body: group.validation.partial(),
23
+ });
24
+
25
+ elysia().use(controller);
26
+ }
27
+
28
+ return {};
29
+ };
30
+
31
+ return fn;
32
+ });
33
+
34
+ export default vars;
@@ -20,7 +20,7 @@ type ViteGlobal = {
20
20
  static: (path: string) => string;
21
21
  };
22
22
 
23
- const buildViteGlobal = async (): Promise<ViteGlobal> => {
23
+ export const buildViteGlobal = async (): Promise<ViteGlobal> => {
24
24
  if (isDev) {
25
25
  const { createServer } = await import("vite");
26
26
  const server = await createServer();
@@ -97,6 +97,11 @@ const view = makeModule(() => {
97
97
  edge.global(model.prefix, model);
98
98
  }
99
99
 
100
+ for (const varsPromise of getConfig().vars) {
101
+ const { default: group } = await varsPromise();
102
+ edge.global(group.prefix, group);
103
+ }
104
+
100
105
  const controller = new Elysia();
101
106
 
102
107
  if (!isDev) {
@@ -188,7 +193,7 @@ const view = makeModule(() => {
188
193
  return fn;
189
194
  });
190
195
 
191
- async function renderHttpError(edge: Edge, viewsDir: string, error: HttpError): Promise<string> {
196
+ export async function renderHttpError(edge: Edge, viewsDir: string, error: HttpError): Promise<string> {
192
197
  const templateFile = Bun.file(join(viewsDir, `errors/${error.statusCode}.edge`));
193
198
  if (await templateFile.exists()) {
194
199
  try {
@@ -207,7 +212,7 @@ async function renderHttpError(edge: Edge, viewsDir: string, error: HttpError):
207
212
  </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
213
  }
209
214
 
210
- function renderErrorPage(error: Error, template: string, path: string): string {
215
+ export function renderErrorPage(error: Error, template: string, path: string): string {
211
216
  const stack = (error.stack ?? "").replace(/</g, "&lt;").replace(/>/g, "&gt;");
212
217
  const message = error.message.replace(/</g, "&lt;").replace(/>/g, "&gt;");
213
218
 
@@ -237,7 +242,7 @@ function renderErrorPage(error: Error, template: string, path: string): string {
237
242
  </html>`;
238
243
  }
239
244
 
240
- function fileToRoute(file: string): string {
245
+ export function fileToRoute(file: string): string {
241
246
  let route = file.replace(/\\/g, "/").replace(/\.edge$/, "");
242
247
  route = route.replace(/\[([^\]]+)\]/g, ":$1");
243
248
  route = route.replace(/\/index$|^index$/, "");