@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 +2 -1
- package/src/cli/build-assets.ts +38 -0
- package/src/cli/build-worker.ts +96 -0
- package/src/cli/build.ts +2 -30
- package/src/cli/index.ts +5 -1
- package/src/core/adapters/bun_filesystem.ts +7 -3
- package/src/core/banner.ts +1 -1
- package/src/core/config.ts +8 -0
- package/src/core/worker.ts +147 -0
- package/src/modules/admin.ts +6 -3
- package/src/modules/elysia.ts +9 -1
- package/src/modules/file.ts +4 -2
- package/src/modules/surreal.ts +10 -0
- package/src/modules/view.ts +12 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@njinlabs/njin",
|
|
3
|
-
"version": "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
|
|
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
|
-
|
|
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
|
|
package/src/core/banner.ts
CHANGED
|
@@ -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(
|
|
33
|
+
const adminEntry = join(config.rootDir, "_admin", "index.html");
|
|
34
34
|
const hasAdmin = existsSync(adminEntry);
|
|
35
35
|
|
|
36
36
|
const rows: [string, string][] = [
|
package/src/core/config.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/modules/admin.ts
CHANGED
|
@@ -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
|
|
8
|
-
|
|
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);
|
package/src/modules/elysia.ts
CHANGED
|
@@ -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
|
-
|
|
81
|
+
if (process.env.NJIN_WORKER === "1") {
|
|
82
|
+
serveWorker(app);
|
|
83
|
+
} else {
|
|
84
|
+
app.listen(getConfig().port);
|
|
85
|
+
}
|
|
78
86
|
},
|
|
79
87
|
};
|
|
80
88
|
};
|
package/src/modules/file.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/src/modules/surreal.ts
CHANGED
|
@@ -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 {
|
package/src/modules/view.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
82
|
-
// import.meta.dir would point at the package's own location, not
|
|
83
|
-
|
|
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
|
|