@njinlabs/njin 0.5.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@njinlabs/njin",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A modern framework for building company profiles, landing pages, and content-driven websites.",
5
5
  "type": "module",
6
6
  "keywords": ["bun", "elysia", "surrealdb", "edgejs", "cms", "framework"],
@@ -29,6 +29,7 @@
29
29
  "./config": "./src/core/config.ts",
30
30
  "./boot": "./src/core/boot.ts",
31
31
  "./banner": "./src/core/banner.ts",
32
+ "./worker": "./src/core/worker.ts",
32
33
  "./adapters/*": "./src/core/adapters/*.ts"
33
34
  },
34
35
  "devDependencies": {
@@ -0,0 +1,38 @@
1
+ import { existsSync } from "node:fs";
2
+ import { cp, mkdir, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ // Shared staging steps between `njin build` (compiled binary) and `njin build:worker`
6
+ // (bundled JS entry) — both produce the same `out/public`, `out/src/views`, `out/_admin`
7
+ // layout; only the server artifact itself differs.
8
+ export const stageBuildAssets = async (root: string, outDir: string): Promise<void> => {
9
+ await rm(outDir, { recursive: true, force: true });
10
+ await mkdir(outDir, { recursive: true });
11
+
12
+ // Vite build — outDir is forced here regardless of what the project's own
13
+ // vite.config.ts says, so the build always lands in a predictable place.
14
+ const { build } = await import("vite");
15
+ await build({
16
+ configFile: join(root, "vite.config.ts"),
17
+ build: { outDir: join(outDir, "public"), emptyOutDir: true },
18
+ });
19
+ console.log("✓ Built client assets -> out/public");
20
+
21
+ // view.ts resolves views from process.cwd()/src/views at runtime, so the build
22
+ // output preserves that same relative layout instead of flattening it.
23
+ const viewsDir = join(root, "src/views");
24
+ if (existsSync(viewsDir)) {
25
+ await cp(viewsDir, join(outDir, "src/views"), { recursive: true });
26
+ console.log("✓ Copied views -> out/src/views");
27
+ } else {
28
+ console.log("• No src/views found — skipped");
29
+ }
30
+
31
+ const adminDir = join(root, "_admin");
32
+ if (existsSync(adminDir)) {
33
+ await cp(adminDir, join(outDir, "_admin"), { recursive: true });
34
+ console.log("✓ Copied admin panel -> out/_admin");
35
+ } else {
36
+ console.log("• No _admin found — skipped");
37
+ }
38
+ };
@@ -0,0 +1,96 @@
1
+ import { rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { stageBuildAssets } from "./build-assets";
4
+
5
+ const root = process.cwd();
6
+ const outDir = join(root, "out");
7
+
8
+ console.log("Building worker for production...\n");
9
+
10
+ await stageBuildAssets(root, outDir);
11
+
12
+ // Unlike `njin build`, this targets `bun out/worker.js` (or `new Worker("out/worker.js")`)
13
+ // running from inside the project tree (not a self-contained binary), so node_modules is
14
+ // always reachable via Bun's normal upward directory resolution — no need for build.ts's
15
+ // native-binding/geoip path patching for the two packages that do stay external below.
16
+ const entryPath = join(root, ".njin-worker-entry.ts");
17
+ const configPath = join(root, "config.ts").replace(/\\/g, "/");
18
+
19
+ try {
20
+ await Bun.write(
21
+ entryPath,
22
+ `import config from ${JSON.stringify(configPath)};
23
+ import { loadConfig } from "@njinlabs/njin/config";
24
+
25
+ // Both env vars must be set for real, and before the dynamic import() of boot below: static
26
+ // imports are hoisted and evaluated before this file's own body runs, so view.ts (reached
27
+ // transitively through that import) would otherwise still see NODE_ENV unset. Same reasoning
28
+ // as dev.ts/start.ts.
29
+ //
30
+ // NODE_ENV in particular is also baked in at build time via the "define" option below —
31
+ // @njinlabs/njin's own source (view.ts's dev/production check included) is bundled directly
32
+ // into worker.js (see the explicit "external" list below), not left as a separate runtime
33
+ // import, specifically because a Bun Worker thread does not reliably propagate an in-script
34
+ // process.env mutation across a module resolved from a genuinely external package — this
35
+ // runtime assignment is belt-and-suspenders for anything that still reads it at runtime
36
+ // (e.g. NJIN_WORKER, now read from the same bundled scope elysia.ts lives in).
37
+ process.env.NODE_ENV = "production";
38
+ process.env.NJIN_WORKER = "1";
39
+
40
+ // Defaults rootDir to wherever this bundle itself physically ends up at runtime — not
41
+ // process.cwd() (a supervisor process's Worker threads all share its cwd, so a runtime cwd
42
+ // fallback would resolve src/views, _admin, public, and upload dirs against the wrong
43
+ // directory), and not a literal build-time path baked in as a string constant either (that
44
+ // would keep pointing at this build's own source tree even after out/worker.js — and the
45
+ // public/src/views/_admin siblings staged alongside it — get copied somewhere else
46
+ // entirely, e.g. into a supervisor's per-client clients/<host>/ directory). import.meta.dir
47
+ // inside a Bun-bundled file resolves to that file's own real location at runtime, so it
48
+ // stays correct no matter where the bundle is moved.
49
+ await loadConfig({ ...config, rootDir: config.rootDir ?? import.meta.dir });
50
+
51
+ const { boot } = await import("@njinlabs/njin/boot");
52
+
53
+ // No printBanner() here — headless has no port/admin URL to show. serveWorker()'s own
54
+ // "ready" postMessage() (src/core/worker.ts) is the supervisor's readiness signal instead.
55
+ await boot();
56
+ `,
57
+ );
58
+
59
+ const result = await Bun.build({
60
+ entrypoints: [entryPath],
61
+ outdir: outDir,
62
+ naming: "worker.js",
63
+ target: "bun",
64
+ // @njinlabs/njin's own source bundles directly into worker.js (see the comment in the
65
+ // generated entry above for why) — only the two packages that genuinely can't survive
66
+ // bundling stay external, same reasoning build.ts already documents for its own build:
67
+ // vite is dev-only (dead code once NODE_ENV is baked in as "production" below, but its own
68
+ // internal lazy import("esbuild") isn't installed as a real dependency) and @surrealdb/node
69
+ // is a native binding whose loader resolves its .node file relative to its own real
70
+ // file location — bundled, that location would become worker.js's, breaking the lookup.
71
+ // geoip-lite is external for a related reason: it resolves its .dat files via
72
+ // path.resolve(__dirname, '../data/'), and Bun bakes __dirname into bundled output as the
73
+ // literal build-time path (see build.ts) — fine on the exact machine that ran this build,
74
+ // but not guaranteed if out/ is copied elsewhere before running. Staying external keeps
75
+ // its own real file location intact.
76
+ // Unlike build.ts's compiled-binary case, none of these three need a copy/patch step here:
77
+ // worker.js still runs from inside a real project tree, so external resolution just finds
78
+ // the real node_modules/{vite,@surrealdb/node,geoip-lite} (vite is dev only, never
79
+ // actually reached).
80
+ external: ["vite", "@surrealdb/node", "geoip-lite"],
81
+ define: {
82
+ "process.env.NODE_ENV": JSON.stringify("production"),
83
+ },
84
+ });
85
+
86
+ if (!result.success) {
87
+ console.error("\n✗ Build failed");
88
+ for (const message of result.logs) console.error(message);
89
+ process.exit(1);
90
+ }
91
+
92
+ console.log("\n✓ Bundled worker -> out/worker.js");
93
+ console.log('\nRun it as a Worker thread:\n new Worker("./out/worker.js")');
94
+ } finally {
95
+ await rm(entryPath, { force: true });
96
+ }
package/src/cli/build.ts CHANGED
@@ -1,43 +1,15 @@
1
1
  import type { BunPlugin } from "bun";
2
- import { existsSync } from "node:fs";
3
2
  import { cp, mkdir, rm } from "node:fs/promises";
4
3
  import { dirname, join } from "node:path";
5
4
  import { fileURLToPath } from "node:url";
5
+ import { stageBuildAssets } from "./build-assets";
6
6
 
7
7
  const root = process.cwd();
8
8
  const outDir = join(root, "out");
9
9
 
10
10
  console.log("Building for production...\n");
11
11
 
12
- await rm(outDir, { recursive: true, force: true });
13
- await mkdir(outDir, { recursive: true });
14
-
15
- // Vite build — outDir is forced here regardless of what the project's own
16
- // vite.config.ts says, so `njin build` always lands in a predictable place.
17
- const { build } = await import("vite");
18
- await build({
19
- configFile: join(root, "vite.config.ts"),
20
- build: { outDir: join(outDir, "public"), emptyOutDir: true },
21
- });
22
- console.log("✓ Built client assets -> out/public");
23
-
24
- // view.ts resolves views from process.cwd()/src/views at runtime, so the build
25
- // output preserves that same relative layout instead of flattening it.
26
- const viewsDir = join(root, "src/views");
27
- if (existsSync(viewsDir)) {
28
- await cp(viewsDir, join(outDir, "src/views"), { recursive: true });
29
- console.log("✓ Copied views -> out/src/views");
30
- } else {
31
- console.log("• No src/views found — skipped");
32
- }
33
-
34
- const adminDir = join(root, "_admin");
35
- if (existsSync(adminDir)) {
36
- await cp(adminDir, join(outDir, "_admin"), { recursive: true });
37
- console.log("✓ Copied admin panel -> out/_admin");
38
- } else {
39
- console.log("• No _admin found — skipped");
40
- }
12
+ await stageBuildAssets(root, outDir);
41
13
 
42
14
  // surreal.ts only imports @surrealdb/node dynamically, on the embedded-db.path branch — so a
43
15
  // project whose resolved db.path is remote (ws/wss/http/https) never needs its native binding
package/src/cli/index.ts CHANGED
@@ -14,15 +14,19 @@ switch (command) {
14
14
  case "build":
15
15
  await import("./build");
16
16
  break;
17
+ case "build:worker":
18
+ await import("./build-worker");
19
+ break;
17
20
  case "update":
18
21
  await import("./update");
19
22
  break;
20
23
  default:
21
- console.log(`Usage: njin <create|dev|build|start|update>
24
+ console.log(`Usage: njin <create|dev|build|build:worker|start|update>
22
25
 
23
26
  create <dir> Scaffold a new project (defaults to current directory)
24
27
  dev Run the dev server (Vite HMR + live reload)
25
28
  build Build for production -> ./out (public/, _admin/, views/, server)
29
+ build:worker Build a headless worker for production -> ./out (public/, _admin/, views/, worker.js)
26
30
  start Run from source in production mode (no compile)
27
31
  update Update @njinlabs/njin to the latest version and refresh _admin/`);
28
32
  process.exit(command ? 1 : 0);
@@ -1,7 +1,8 @@
1
1
  import type { FileAdapter } from "../../modules/file";
2
2
  import { init } from "@paralleldrive/cuid2";
3
- import { join } from "node:path";
3
+ import { join, resolve } from "node:path";
4
4
  import z from "zod";
5
+ import { getConfig } from "../config";
5
6
  import { sanitizeFileName } from "../path_guard";
6
7
 
7
8
  const createId = init({
@@ -21,7 +22,10 @@ const bunFilesystemAdapter = ({ dir = "./uploads" }: { dir?: string } = {}): Fil
21
22
 
22
23
  const name = `${fileName}_${createId()}.${exts.join(".")}`;
23
24
 
24
- await Bun.write(join(dir, name), await file.arrayBuffer());
25
+ // resolve(), not join() — dir is normally project-relative ("./uploads"), but an
26
+ // already-absolute dir (as tests pass directly) must win outright rather than get
27
+ // nested under rootDir.
28
+ await Bun.write(join(resolve(getConfig().rootDir, dir), name), await file.arrayBuffer());
25
29
 
26
30
  return {
27
31
  meta: null,
@@ -31,7 +35,7 @@ const bunFilesystemAdapter = ({ dir = "./uploads" }: { dir?: string } = {}): Fil
31
35
  url: `/uploads/${name}`,
32
36
  };
33
37
  },
34
- unlink: (file) => Bun.file(join(dir, file.name)).delete(),
38
+ unlink: (file) => Bun.file(join(resolve(getConfig().rootDir, dir), file.name)).delete(),
35
39
  };
36
40
  };
37
41
 
@@ -30,7 +30,7 @@ export type BootSummary = {
30
30
  export const printBanner = ({ mode }: BootSummary): void => {
31
31
  const config = getConfig();
32
32
  const port = config.port;
33
- const adminEntry = join(process.cwd(), "_admin", "index.html");
33
+ const adminEntry = join(config.rootDir, "_admin", "index.html");
34
34
  const hasAdmin = existsSync(adminEntry);
35
35
 
36
36
  const rows: [string, string][] = [
@@ -36,6 +36,12 @@ export type HelperFactory = () => Promise<{ default: Helper }>;
36
36
 
37
37
  export type NjinConfig = {
38
38
  port?: number;
39
+ // Base directory every project-relative path (src/views, _admin, public, upload dirs)
40
+ // resolves against. Defaults to process.cwd() — only needs overriding by a build step
41
+ // (see build-worker.ts) that bakes in an absolute path for a runtime whose cwd isn't
42
+ // reliably the project root, e.g. multiple `new Worker(...)` instances sharing one
43
+ // supervisor process's cwd.
44
+ rootDir?: string;
39
45
  db?: {
40
46
  path?: string;
41
47
  namespace?: string;
@@ -62,6 +68,7 @@ export type NjinConfig = {
62
68
 
63
69
  export type ResolvedConfig = {
64
70
  port: number;
71
+ rootDir: string;
65
72
  db: { path: string; namespace: string; database: string; auth?: { username: string; password: string } | string };
66
73
  img: { hosts: string[] };
67
74
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -126,6 +133,7 @@ export const loadConfig = async (preloaded?: NjinConfig): Promise<void> => {
126
133
  // prefix fails the same way it already does today — naturally, at runtime.
127
134
  resolved = {
128
135
  port: userConfig.port ?? 3000,
136
+ rootDir: userConfig.rootDir ?? process.cwd(),
129
137
  db: {
130
138
  path: userConfig.db?.path ?? "rocksdb://data",
131
139
  namespace: userConfig.db?.namespace ?? "general",
@@ -0,0 +1,147 @@
1
+ import type { AnyElysia } from "elysia";
2
+ import logger from "../modules/logger";
3
+
4
+ // This project's tsconfig only loads lib "DOM" (not "DOM.Iterable"/webworker), which ships no
5
+ // DedicatedWorkerGlobalScope type. Bun's own docs recommend this exact local override — reusing
6
+ // the DOM `Worker` interface's shape (postMessage/onmessage) as a stand-in for the worker-side
7
+ // global, since both expose the same members. Scoped to this module only (not global) because
8
+ // this file has imports/exports.
9
+ declare var self: Worker;
10
+
11
+ // Wire protocol between a supervisor thread (`new Worker("out/worker.js")`, spawned/terminated
12
+ // on its own schedule) and this headless njin instance running inside that worker. The
13
+ // supervisor owns request routing/load-balancing — this module only speaks the message shapes
14
+ // below over `self.postMessage`/`self.onmessage`.
15
+ export type WorkerRequestMessage = {
16
+ type: "request";
17
+ id: string;
18
+ method: string;
19
+ url: string;
20
+ headers: [string, string][];
21
+ body: ArrayBuffer | null;
22
+ };
23
+
24
+ export type WorkerShutdownMessage = {
25
+ type: "shutdown";
26
+ };
27
+
28
+ export type WorkerInboundMessage = WorkerRequestMessage | WorkerShutdownMessage;
29
+
30
+ export type WorkerResponseMessage = {
31
+ type: "response";
32
+ id: string;
33
+ status: number;
34
+ headers: [string, string][];
35
+ body: ArrayBuffer | null;
36
+ };
37
+
38
+ export type WorkerErrorMessage = {
39
+ type: "error";
40
+ id: string;
41
+ message: string;
42
+ };
43
+
44
+ export type WorkerReadyMessage = {
45
+ type: "ready";
46
+ };
47
+
48
+ export type WorkerOutboundMessage = WorkerResponseMessage | WorkerErrorMessage | WorkerReadyMessage;
49
+
50
+ // Bounds how long shutdown waits for in-flight requests to finish before exiting anyway —
51
+ // the supervisor can terminate a worker at any time, so this is a safety net, not the
52
+ // expected path.
53
+ const SHUTDOWN_DRAIN_TIMEOUT_MS = 10_000;
54
+ const SHUTDOWN_POLL_INTERVAL_MS = 50;
55
+
56
+ const isRequestMessage = (raw: unknown): raw is WorkerRequestMessage =>
57
+ typeof raw === "object" && raw !== null && (raw as { type?: unknown }).type === "request";
58
+
59
+ const isShutdownMessage = (raw: unknown): raw is WorkerShutdownMessage =>
60
+ typeof raw === "object" && raw !== null && (raw as { type?: unknown }).type === "shutdown";
61
+
62
+ // Pure request/response conversion, split out from serveWorker()'s self/postMessage wiring
63
+ // so it can be unit tested directly without spinning up a real Worker thread.
64
+ export const buildResponseMessage = async (app: AnyElysia, msg: WorkerRequestMessage): Promise<WorkerResponseMessage> => {
65
+ const request = new Request(msg.url, {
66
+ method: msg.method,
67
+ headers: msg.headers,
68
+ body: msg.body ?? undefined,
69
+ });
70
+
71
+ const response = await app.handle(request);
72
+ const body = await response.arrayBuffer();
73
+
74
+ // lib.dom (without lib.dom.iterable) doesn't type Headers as iterable, so build the
75
+ // array via forEach rather than a for...of/spread over response.headers directly.
76
+ const headers: [string, string][] = [];
77
+ response.headers.forEach((value, key) => headers.push([key, value]));
78
+
79
+ return {
80
+ type: "response",
81
+ id: msg.id,
82
+ status: response.status,
83
+ headers,
84
+ body: body.byteLength > 0 ? body : null,
85
+ };
86
+ };
87
+
88
+ // Worker-mode replacement for `app.listen()` — wired in by elysia.ts's spin() when
89
+ // NJIN_WORKER=1. Requests arrive as postMessage()s from the supervisor thread instead of
90
+ // real socket connections; app.handle() is the same Elysia entrypoint either way.
91
+ export const serveWorker = (app: AnyElysia): void => {
92
+ if (Bun.isMainThread) {
93
+ throw new Error('serveWorker() must run inside a Worker thread (new Worker("out/worker.js")), not the main thread.');
94
+ }
95
+
96
+ let inFlight = 0;
97
+ let shuttingDown = false;
98
+
99
+ const handleRequest = async (msg: WorkerRequestMessage): Promise<void> => {
100
+ if (shuttingDown) {
101
+ self.postMessage({ type: "error", id: msg.id, message: "worker is shutting down" } satisfies WorkerErrorMessage);
102
+ return;
103
+ }
104
+
105
+ inFlight++;
106
+ try {
107
+ self.postMessage(await buildResponseMessage(app, msg));
108
+ } catch (error) {
109
+ logger().error(error);
110
+ self.postMessage({
111
+ type: "error",
112
+ id: msg.id,
113
+ message: error instanceof Error ? error.message : "Unknown error",
114
+ } satisfies WorkerErrorMessage);
115
+ } finally {
116
+ inFlight--;
117
+ }
118
+ };
119
+
120
+ // No OS signal equivalent per-thread — the supervisor asks for a graceful shutdown by
121
+ // posting { type: "shutdown" } instead of sending SIGTERM/SIGINT to a whole process.
122
+ const shutdown = (): void => {
123
+ if (shuttingDown) return;
124
+ shuttingDown = true;
125
+
126
+ const start = Date.now();
127
+ const poll = setInterval(() => {
128
+ if (inFlight === 0 || Date.now() - start > SHUTDOWN_DRAIN_TIMEOUT_MS) {
129
+ clearInterval(poll);
130
+ // Scoped to this worker thread only — confirmed in Bun's own docs — the supervisor's
131
+ // main thread and any sibling workers keep running.
132
+ process.exit(0);
133
+ }
134
+ }, SHUTDOWN_POLL_INTERVAL_MS);
135
+ };
136
+
137
+ self.onmessage = (event: MessageEvent<unknown>) => {
138
+ const raw = event.data;
139
+ if (isRequestMessage(raw)) {
140
+ void handleRequest(raw);
141
+ } else if (isShutdownMessage(raw)) {
142
+ shutdown();
143
+ }
144
+ };
145
+
146
+ self.postMessage({ type: "ready" } satisfies WorkerReadyMessage);
147
+ };
@@ -1,11 +1,14 @@
1
+ import { getConfig } from "../core/config";
1
2
  import { makeModule } from "../core/module";
2
3
  import { resolveSafePath } from "../core/path_guard";
3
4
  import Elysia from "elysia";
4
5
  import { join } from "node:path";
5
6
  import elysia from "./elysia";
6
7
 
7
- const adminDir = join(process.cwd(), "_admin");
8
- const indexFile = () => Bun.file(join(adminDir, "index.html"));
8
+ // Lazy, not a top-level const — this file's static import runs before module.ts's own
9
+ // `await loadConfig()`, so getConfig() isn't populated yet at that point.
10
+ const adminDir = () => join(getConfig().rootDir, "_admin");
11
+ const indexFile = () => Bun.file(join(adminDir(), "index.html"));
9
12
 
10
13
  const admin = makeModule(() => {
11
14
  const fn = () => {};
@@ -22,7 +25,7 @@ const admin = makeModule(() => {
22
25
  return indexFile();
23
26
  }
24
27
 
25
- const requested = resolveSafePath(adminDir, decoded);
28
+ const requested = resolveSafePath(adminDir(), decoded);
26
29
  if (!requested) return indexFile();
27
30
 
28
31
  const file = Bun.file(requested);
@@ -2,6 +2,7 @@ import cors from "@elysia/cors";
2
2
  import { getConfig } from "../core/config";
3
3
  import { UniqueConstraintError } from "../core/model";
4
4
  import { makeModule } from "../core/module";
5
+ import { serveWorker } from "../core/worker";
5
6
  import Elysia, { status, ValidationError, type AnyElysia } from "elysia";
6
7
  import logger from "./logger";
7
8
 
@@ -73,8 +74,15 @@ const elysia = makeModule(() => {
73
74
  return {
74
75
  // No startup log here — the CLI prints one consolidated banner after every
75
76
  // module has finished booting (src/core/banner.ts), instead of one line per module.
77
+ // Worker mode (NJIN_WORKER=1, set by the generated `njin build:worker` entry) skips
78
+ // the real socket entirely — requests arrive via postMessage() from a supervisor-owned
79
+ // Worker thread instead (see src/core/worker.ts).
76
80
  spin: () => {
77
- app.listen(getConfig().port);
81
+ if (process.env.NJIN_WORKER === "1") {
82
+ serveWorker(app);
83
+ } else {
84
+ app.listen(getConfig().port);
85
+ }
78
86
  },
79
87
  };
80
88
  };
@@ -3,7 +3,7 @@ import type { makeModel } from "../core/model";
3
3
  import { makeModule } from "../core/module";
4
4
  import { resolveSafePath } from "../core/path_guard";
5
5
  import Elysia from "elysia";
6
- import { join } from "node:path";
6
+ import { join, resolve } from "node:path";
7
7
  import { RecordId } from "surrealdb";
8
8
  import z from "zod";
9
9
  import auth from "./auth";
@@ -95,7 +95,9 @@ const file = makeModule(() => {
95
95
  const adapterDir = getConfig().adapters.file.dir;
96
96
 
97
97
  if (adapterDir) {
98
- const uploadsDir = join(process.cwd(), adapterDir);
98
+ // resolve(), not join() — adapterDir is normally project-relative, but an
99
+ // already-absolute dir must win outright rather than get nested under rootDir.
100
+ const uploadsDir = resolve(getConfig().rootDir, adapterDir);
99
101
 
100
102
  const uploadsController = new Elysia().get("/uploads/*", async ({ params }) => {
101
103
  // Elysia leaves wildcard params percent-encoded — decode before touching the filesystem.
@@ -61,6 +61,16 @@ const surreal = makeModule(() => {
61
61
 
62
62
  db = new Surreal({ engines });
63
63
  await db.connect(dbConfig.path, { authentication: dbConfig.auth });
64
+
65
+ // Explicit DEFINE, not left to auto-create-on-USE — that only happens for a
66
+ // root-authenticated connection, and isn't guaranteed across SurrealDB versions/configs.
67
+ // Needed for multi-tenant setups where several njin instances share one remote SurrealDB,
68
+ // each isolated into its own namespace (embedded engines are unaffected either way — a
69
+ // private local file always has implicit owner access). DEFINE DATABASE must run inside
70
+ // the namespace it belongs to, hence the two-step USE.
71
+ await db.query(`DEFINE NAMESPACE IF NOT EXISTS \`${dbConfig.namespace}\`;`);
72
+ await db.use({ namespace: dbConfig.namespace });
73
+ await db.query(`DEFINE DATABASE IF NOT EXISTS \`${dbConfig.database}\`;`);
64
74
  await db.use({ namespace: dbConfig.namespace, database: dbConfig.database });
65
75
 
66
76
  return {
@@ -7,7 +7,11 @@ import { join } from "path";
7
7
  import elysia from "./elysia";
8
8
 
9
9
  const isDev = process.env.NODE_ENV !== "production";
10
- const publicDir = join(process.cwd(), "public");
10
+ // Lazy, not a top-level const — this module's static import runs (as a side effect of
11
+ // module.ts importing it) before module.ts's own `await loadConfig()` line, so getConfig()
12
+ // isn't populated yet at that point. Every call site below only runs from inside fn.init()
13
+ // or a request handler, both well after loadConfig() has resolved.
14
+ const publicDir = () => join(getConfig().rootDir, "public");
11
15
 
12
16
  type ViteManifestChunk = {
13
17
  file: string;
@@ -46,7 +50,7 @@ export const buildViteGlobal = async (): Promise<ViteGlobal> => {
46
50
  };
47
51
  }
48
52
 
49
- const manifestFile = Bun.file(join(publicDir, "manifest.json"));
53
+ const manifestFile = Bun.file(join(publicDir(), "manifest.json"));
50
54
 
51
55
  if (!(await manifestFile.exists())) {
52
56
  return {
@@ -78,9 +82,10 @@ const view = makeModule(() => {
78
82
  const fn = () => edge;
79
83
 
80
84
  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");
85
+ // getConfig().rootDir-based, not import.meta.dir — once view.ts lives inside
86
+ // node_modules/njin, import.meta.dir would point at the package's own location, not
87
+ // the consuming project's views.
88
+ const viewsDir = join(getConfig().rootDir, "src/views");
84
89
  const pagesDir = join(viewsDir, "pages");
85
90
 
86
91
  edge.mount(viewsDir);
@@ -111,7 +116,7 @@ const view = makeModule(() => {
111
116
 
112
117
  if (!isDev) {
113
118
  controller.get("/assets/*", async ({ params }) => {
114
- const file = Bun.file(join(publicDir, "assets", params["*"]));
119
+ const file = Bun.file(join(publicDir(), "assets", params["*"]));
115
120
  if (!(await file.exists())) return new Response("Not Found", { status: 404 });
116
121
  return file;
117
122
  });
@@ -180,7 +185,7 @@ const view = makeModule(() => {
180
185
  // Catch-all — must be registered last so specific routes take priority
181
186
  controller.get("/*", async ({ path }) => {
182
187
  if (!isDev) {
183
- const staticFile = Bun.file(join(publicDir, path));
188
+ const staticFile = Bun.file(join(publicDir(), path));
184
189
  if (await staticFile.exists()) return staticFile;
185
190
  }
186
191