@njinlabs/njin 0.1.2 → 0.3.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/README.md CHANGED
@@ -6,7 +6,7 @@ A modern framework for building company profiles, landing pages, and content-dri
6
6
 
7
7
  - **[Bun](https://bun.sh)** — runtime, package manager, bundler (njin is Bun-only — no Node/Deno support)
8
8
  - **[Elysia](https://elysiajs.com)** — HTTP framework with type-safe CRUD generation
9
- - **[SurrealDB](https://surrealdb.com)** — embedded multi-model database (no separate server)
9
+ - **[SurrealDB](https://surrealdb.com)** — multi-model database, embedded by default (no separate server) or pointed at a remote instance
10
10
  - **[EdgeJS](https://edgejs.dev)** — server-side template engine with file-based routing
11
11
  - **[Vite](https://vitejs.dev)** + **[Tailwind CSS v4](https://tailwindcss.com)** + **[Alpine.js](https://alpinejs.dev)** — frontend tooling
12
12
 
@@ -82,6 +82,170 @@ This automatically generates:
82
82
  | `DELETE` | `/api/post/:id` | Delete |
83
83
  | `GET` | `/api/schema` | Full schema for admin panel |
84
84
 
85
+ ## Vars — user-editable settings
86
+
87
+ `vars` groups are singleton settings objects (site name, SEO meta, feature toggles, ...) meant to be edited later through an admin panel — unlike a model, there's no list of records, just one object per group.
88
+
89
+ ```ts
90
+ // src/vars/general.ts
91
+ import { makeVars, text, boolean } from "@njinlabs/njin";
92
+ import z from "zod";
93
+
94
+ const general = makeVars("general", {
95
+ name: "General",
96
+ schema: z.object({
97
+ // Every field needs a .default(...) — there's no "create" step, so
98
+ // get() must always return a complete object even before anything
99
+ // has ever been saved.
100
+ siteName: text({ label: "Site Name" }, (z) => z.default("My Site")),
101
+ maintenanceMode: boolean({ label: "Maintenance Mode" }, (z) => z.default(false)),
102
+ }),
103
+ });
104
+
105
+ export default general;
106
+ ```
107
+
108
+ Register it in `config.ts`:
109
+
110
+ ```ts
111
+ // config.ts
112
+ export default defineConfig({
113
+ vars: [
114
+ () => import("./src/vars/general"),
115
+ ],
116
+ });
117
+ ```
118
+
119
+ This automatically generates:
120
+
121
+ | Method | Endpoint | Description |
122
+ |--------|----------|--------------|
123
+ | `GET` | `/api/vars/general` | Read current values (defaults-filled even before the first save) |
124
+ | `PUT` | `/api/vars/general` | Partial update — merges into the existing values |
125
+ | `GET` | `/api/schema` | Also lists every registered vars group's schema, under a `vars` key |
126
+
127
+ `vars` groups are also available in templates as global async functions, same as models — but only `get()`/`update()`, not the full model method set:
128
+
129
+ ```edge
130
+ @let(settings = await general.get())
131
+ <title>{{ settings.siteName }}</title>
132
+ ```
133
+
134
+ ## Helpers — custom template functions
135
+
136
+ `helpers` register a plain, stateless function as an Edge global — unlike `vars`/`models`, there's no DB record and no auto-generated REST endpoint, just a function callable from any `.edge` template.
137
+
138
+ ```ts
139
+ // src/helpers/format_date.ts
140
+ import { defineHelper } from "@njinlabs/njin";
141
+ import moment from "moment";
142
+
143
+ export default defineHelper("formatDate", (date: string, format = "DD MMM YYYY") =>
144
+ moment(date).format(format),
145
+ );
146
+ ```
147
+
148
+ Register it in `config.ts`:
149
+
150
+ ```ts
151
+ // config.ts
152
+ export default defineConfig({
153
+ helpers: [
154
+ () => import("./src/helpers/format_date"),
155
+ ],
156
+ });
157
+ ```
158
+
159
+ Use it directly in a template, no `await` needed since it's a plain synchronous function (an `async` `fn` works too, called with `await` like any other async global):
160
+
161
+ ```edge
162
+ <p>{{ formatDate(post.createdAt) }}</p>
163
+ ```
164
+
165
+ ## Events
166
+
167
+ A type-safe event bus for fan-out notifications (e.g. "an order was paid", "a user registered") — different from model hooks (`beforeCreate`/`afterCreate`/...): hooks are scoped to one model and can abort the operation by throwing, while events are fire-and-forget — a listener that throws is logged but never stops other listeners or the code that dispatched.
168
+
169
+ `makeEvent()` has no name — dispatch and listen are correlated by sharing the same instance (not a string key), so always define one event in one canonical file and import it wherever you need it:
170
+
171
+ ```ts
172
+ // src/events/order_paid.ts
173
+ import { makeEvent } from "@njinlabs/njin";
174
+
175
+ const orderPaid = makeEvent<{ orderId: string; total: number }>();
176
+
177
+ export default orderPaid;
178
+ ```
179
+
180
+ ```ts
181
+ // src/events/listeners/send_receipt.ts
182
+ import orderPaid from "../order_paid";
183
+
184
+ orderPaid.listen(async (payload) => {
185
+ // errors here are caught and logged, they never bubble back to whoever dispatched
186
+ console.log(`Sending receipt for order ${payload.orderId} ($${payload.total})`);
187
+ });
188
+ ```
189
+
190
+ Register the listener file in `config.ts` so it's imported (and its `.listen()` call runs) at boot:
191
+
192
+ ```ts
193
+ // config.ts
194
+ export default defineConfig({
195
+ events: [
196
+ () => import("./src/events/listeners/send_receipt"),
197
+ ],
198
+ });
199
+ ```
200
+
201
+ ## Plugins
202
+
203
+ A plugin bundles models/vars/hooks/events/routes into one reusable, installable unit — like a self-contained njin app that extends whatever project installs it. It's a factory function that takes options and returns its bundle, so it ships as a plain npm package:
204
+
205
+ ```ts
206
+ // my-plugin/index.ts
207
+ import { definePlugin } from "@njinlabs/njin";
208
+
209
+ export default (options: { apiKey: string }) =>
210
+ definePlugin({
211
+ models: [() => import("./models/order")],
212
+ routes: [() => import("./routes/webhook")],
213
+ init: async () => {
214
+ // runs once at boot, before any model/route/hook/event goes live —
215
+ // validate options, construct an SDK client, etc.
216
+ },
217
+ });
218
+ ```
219
+
220
+ Register it in `config.ts` — the factory is called directly (not wrapped in another thunk), same as `adapters.file`:
221
+
222
+ ```ts
223
+ // config.ts
224
+ import myPlugin from "my-plugin";
225
+
226
+ export default defineConfig({
227
+ plugins: [myPlugin({ apiKey: process.env.MY_PLUGIN_KEY! })],
228
+ });
229
+ ```
230
+
231
+ Everything a plugin contributes is merged in ahead of your own project's models/vars/hooks/events/routes — so your project's own registrations always run after, and can build on top of what the plugin sets up.
232
+
233
+ Dispatch from anywhere — for example, composing with a model's `afterCreate` hook:
234
+
235
+ ```ts
236
+ // src/models/order.ts
237
+ import { makeModel, afterCreate } from "@njinlabs/njin";
238
+ import orderPaid from "../events/order_paid";
239
+
240
+ const order = makeModel("order", { /* ... */ });
241
+
242
+ afterCreate(order, (record) => {
243
+ orderPaid.dispatch({ orderId: record.id.id, total: record.total });
244
+ });
245
+
246
+ export default order;
247
+ ```
248
+
85
249
  ## File-based routing
86
250
 
87
251
  Files in `src/views/pages/` map to routes automatically:
@@ -179,6 +343,12 @@ export default defineConfig({
179
343
  path: process.env.DB_PATH ?? "rocksdb://data",
180
344
  namespace: process.env.DB_NAMESPACE ?? "general",
181
345
  database: process.env.DB_DATABASE ?? "general",
346
+ // only needed for a remote db.path — username/password (root/system auth) or a token
347
+ auth: process.env.DB_TOKEN
348
+ ? process.env.DB_TOKEN
349
+ : process.env.DB_USERNAME && process.env.DB_PASSWORD
350
+ ? { username: process.env.DB_USERNAME, password: process.env.DB_PASSWORD }
351
+ : undefined,
182
352
  },
183
353
  img: {
184
354
  hosts: ["cdn.example.com"], // external hosts allowed for GET /img besides same-origin/localhost
@@ -189,9 +359,17 @@ export default defineConfig({
189
359
  models: [
190
360
  () => import("./src/models/post"),
191
361
  ],
362
+ vars: [
363
+ () => import("./src/vars/general"),
364
+ ],
365
+ events: [
366
+ () => import("./src/events/listeners/send_receipt"),
367
+ ],
192
368
  });
193
369
  ```
194
370
 
371
+ `db.path` accepts either an embedded scheme — `rocksdb://<dir>` (default), `mem://` (in-memory, wiped on restart), `surrealkv://<dir>` — or a remote one — `ws://`, `wss://`, `http://`, `https://` — pointed at a running `surreal start` instance or SurrealDB Cloud. `db.auth` is only needed for a remote instance that requires it, and accepts either `{ username, password }` (root/system auth) or a bearer token string. Switching between embedded and remote is just a config/env change for `njin dev`/`njin start`; a compiled `njin build` binary bakes in whichever mode was resolved at build time (see below).
372
+
195
373
  To store uploads in S3 (or an S3-compatible service like R2/Spaces/MinIO) instead:
196
374
 
197
375
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@njinlabs/njin",
3
- "version": "0.1.2",
3
+ "version": "0.3.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"],
@@ -18,7 +18,8 @@
18
18
  },
19
19
  "files": ["src", "LICENSE"],
20
20
  "scripts": {
21
- "test": "bun test"
21
+ "test": "bun test --isolate",
22
+ "test:coverage": "bun test --isolate --coverage"
22
23
  },
23
24
  "bin": {
24
25
  "njin": "./src/cli/index.ts"
package/src/cli/build.ts CHANGED
@@ -39,16 +39,36 @@ if (existsSync(adminDir)) {
39
39
  console.log("• No _admin found — skipped");
40
40
  }
41
41
 
42
- // surreal.ts statically imports @surrealdb/node unconditionally, and module.ts always wires
43
- // up the surreal module, so every compiled binary needs this native binding to boot — even
44
- // for projects using a remote SurrealDB instance. Its loader resolves the platform .node file
45
- // via `const require = createRequire(import.meta.url); require("./surrealdb-node.<platform>.node")`
46
- // — using a local `require` value rather than the literal CJS keyword means Bun's bundler never
47
- // statically recognizes that call as an import to resolve/embed, so it survives bundling
48
- // untouched and then fails at runtime in a compiled binary (no real filesystem path next to
49
- // `import.meta.url` to load it from). So instead of embedding it, the matching platform binary
50
- // is copied next to the executable as a plain file, and a build plugin patches the loader's
51
- // source to read from there (via `process.execPath`'s directory) at runtime.
42
+ // surreal.ts only imports @surrealdb/node dynamically, on the embedded-db.path branch — so a
43
+ // project whose resolved db.path is remote (ws/wss/http/https) never needs its native binding
44
+ // at all. We resolve the project's own config here (same file loadConfig() reads from
45
+ // process.cwd() at runtime) to decide, at build time, whether to bundle it.
46
+ const { loadConfig, getConfig } = await import("../core/config");
47
+ const { isRemotePath } = await import("../modules/surreal");
48
+
49
+ // If the project's config.ts can't be resolved here (missing, throws, or its db.path depends
50
+ // on an env var not set at build time), default to embedded — the binding just goes unused in
51
+ // a genuinely remote deployment, same as today, rather than risking a binary that can't do
52
+ // embedded mode if the env var resolves differently at runtime than it did at build time.
53
+ let isRemoteBuild = false;
54
+ try {
55
+ await loadConfig();
56
+ isRemoteBuild = isRemotePath(getConfig().db.path);
57
+ } catch {
58
+ isRemoteBuild = false;
59
+ }
60
+
61
+ // surreal.ts's dynamic import() of @surrealdb/node is still literal-specifier, so Bun's
62
+ // bundler would otherwise trace and embed it regardless of which branch runs at runtime. Its
63
+ // loader resolves the platform .node file via `const require = createRequire(import.meta.url);
64
+ // require("./surrealdb-node.<platform>.node")` — using a local `require` value rather than the
65
+ // literal CJS keyword means Bun's bundler never statically recognizes that call as an import to
66
+ // resolve/embed, so when the package IS bundled it survives untouched and then fails at runtime
67
+ // in a compiled binary (no real filesystem path next to `import.meta.url` to load it from). So
68
+ // for an embedded build, instead of embedding it, the matching platform binary is copied next
69
+ // to the executable as a plain file, and a build plugin patches the loader's source to read
70
+ // from there (via `process.execPath`'s directory) at runtime. For a remote build, the package
71
+ // is excluded from the bundle entirely (see `external` below) since that branch is unreachable.
52
72
  const NATIVE_FILE: Record<string, string> = {
53
73
  "win32-x64": "surrealdb-node.win32-x64-msvc.node",
54
74
  "win32-arm64": "surrealdb-node.win32-arm64-msvc.node",
@@ -58,34 +78,39 @@ const NATIVE_FILE: Record<string, string> = {
58
78
  "linux-x64": "surrealdb-node.linux-x64-gnu.node",
59
79
  "linux-arm64": "surrealdb-node.linux-arm64-gnu.node",
60
80
  };
61
- const nativeFile = NATIVE_FILE[`${process.platform}-${process.arch}`];
62
- if (!nativeFile) {
63
- console.error(`\n✗ Unsupported platform for @surrealdb/node: ${process.platform}-${process.arch}`);
64
- process.exit(1);
65
- }
66
81
 
67
- const surrealNodeDistDir = dirname(fileURLToPath(import.meta.resolve("@surrealdb/node")));
68
- const bindingsDir = join(outDir, "bindings");
69
- await mkdir(bindingsDir, { recursive: true });
70
- await cp(join(surrealNodeDistDir, nativeFile), join(bindingsDir, nativeFile));
71
- console.log(`✓ Copied native binding -> out/bindings/${nativeFile}`);
82
+ let surrealNativePlugin: BunPlugin | undefined;
83
+ if (isRemoteBuild) {
84
+ console.log("• Remote db.path detected — skipping @surrealdb/node native binding");
85
+ } else {
86
+ const nativeFile = NATIVE_FILE[`${process.platform}-${process.arch}`];
87
+ if (!nativeFile) {
88
+ console.error(`\n✗ Unsupported platform for @surrealdb/node: ${process.platform}-${process.arch}`);
89
+ process.exit(1);
90
+ }
72
91
 
73
- const surrealNativePlugin: BunPlugin = {
74
- name: "surrealdb-native-binding",
75
- setup(build) {
76
- build.onLoad({ filter: /surrealdb-node\.mjs$/ }, async (args) => {
77
- const original = await Bun.file(args.path).text();
78
- const marker = "const require = createRequire(import.meta.url);";
79
- if (!original.includes(marker)) {
80
- throw new Error("@surrealdb/node's loader shape changed — expected createRequire marker not found");
81
- }
82
- // Wrap `require` so the host platform's own .node lookup is redirected to the copy in
83
- // out/bindings/; every other platform's file and the optional sibling npm packages fall
84
- // through to the real require() and fail exactly as they would unpatched (still recorded
85
- // in the loader's own loadErrors, so a genuinely missing binding still throws clearly).
86
- const patched = original.replace(
87
- marker,
88
- `const __realRequire = createRequire(import.meta.url);
92
+ const surrealNodeDistDir = dirname(fileURLToPath(import.meta.resolve("@surrealdb/node")));
93
+ const bindingsDir = join(outDir, "bindings");
94
+ await mkdir(bindingsDir, { recursive: true });
95
+ await cp(join(surrealNodeDistDir, nativeFile), join(bindingsDir, nativeFile));
96
+ console.log(`✓ Copied native binding -> out/bindings/${nativeFile}`);
97
+
98
+ surrealNativePlugin = {
99
+ name: "surrealdb-native-binding",
100
+ setup(build) {
101
+ build.onLoad({ filter: /surrealdb-node\.mjs$/ }, async (args) => {
102
+ const original = await Bun.file(args.path).text();
103
+ const marker = "const require = createRequire(import.meta.url);";
104
+ if (!original.includes(marker)) {
105
+ throw new Error("@surrealdb/node's loader shape changed — expected createRequire marker not found");
106
+ }
107
+ // Wrap `require` so the host platform's own .node lookup is redirected to the copy in
108
+ // out/bindings/; every other platform's file and the optional sibling npm packages fall
109
+ // through to the real require() and fail exactly as they would unpatched (still recorded
110
+ // in the loader's own loadErrors, so a genuinely missing binding still throws clearly).
111
+ const patched = original.replace(
112
+ marker,
113
+ `const __realRequire = createRequire(import.meta.url);
89
114
  const require = (specifier) => {
90
115
  if (specifier === ${JSON.stringify(`./${nativeFile}`)}) {
91
116
  const path = __realRequire("node:path");
@@ -93,11 +118,12 @@ const require = (specifier) => {
93
118
  }
94
119
  return __realRequire(specifier);
95
120
  };`,
96
- );
97
- return { contents: patched, loader: "js" };
98
- });
99
- },
100
- };
121
+ );
122
+ return { contents: patched, loader: "js" };
123
+ });
124
+ },
125
+ };
126
+ }
101
127
 
102
128
  // Generated build entry — a static, literal import of the project's config.ts so
103
129
  // `bun build --compile` can trace and embed it (and the model files it dynamically
@@ -114,11 +140,16 @@ const require = (specifier) => {
114
140
  // binary still points at node_modules/geoip-lite/data on the machine that ran `njin build` —
115
141
  // which won't exist wherever the binary is actually deployed. Same fix shape as the SurrealDB
116
142
  // native binding above: ship the data dir next to the executable and patch the lookup.
143
+ // Only the country .dat files are shipped — analytics.ts only ever reads the `country` field,
144
+ // and geoip-lite falls back to country-only mode automatically when the (much larger, ~100MB)
145
+ // city .dat files are absent, keeping the buffers it preloads into memory at startup small.
117
146
  const geoipLibDir = dirname(fileURLToPath(import.meta.resolve("geoip-lite")));
118
147
  const geoipDataDir = join(geoipLibDir, "..", "data");
119
148
  const outGeoipDataDir = join(outDir, "geoip-data");
120
- await cp(geoipDataDir, outGeoipDataDir, { recursive: true });
121
- console.log("✓ Copied geoip-lite data -> out/geoip-data");
149
+ await mkdir(outGeoipDataDir, { recursive: true });
150
+ await cp(join(geoipDataDir, "geoip-country.dat"), join(outGeoipDataDir, "geoip-country.dat"));
151
+ await cp(join(geoipDataDir, "geoip-country6.dat"), join(outGeoipDataDir, "geoip-country6.dat"));
152
+ console.log("✓ Copied geoip-lite country data -> out/geoip-data");
122
153
 
123
154
  const geoipDataPlugin: BunPlugin = {
124
155
  name: "geoip-lite-data-dir",
@@ -166,9 +197,11 @@ printBanner({ mode: "production" });
166
197
  entrypoints: [entryPath],
167
198
  compile: { outfile: join(outDir, exeName) },
168
199
  // vite is dev-only (view.ts gates it behind `if (isDev)`, dead in production) — bundling
169
- // it would also drag in its own internal (not-installed) lazy `import("esbuild")`.
170
- external: ["vite"],
171
- plugins: [surrealNativePlugin, geoipDataPlugin],
200
+ // it would also drag in its own internal (not-installed) lazy `import("esbuild")`. Same
201
+ // reasoning for @surrealdb/node on a remote build — surreal.ts's dynamic import of it is
202
+ // unreachable when db.path is remote, so it's safe to leave unresolved.
203
+ external: isRemoteBuild ? ["vite", "@surrealdb/node"] : ["vite"],
204
+ plugins: [surrealNativePlugin, geoipDataPlugin].filter((p): p is BunPlugin => p !== undefined),
172
205
  // The CLI's `bun build --compile` implicitly inlines process.env.NODE_ENV as "production";
173
206
  // the Bun.build() JS API doesn't, so view.ts's top-level `isDev` check would otherwise read
174
207
  // undefined and take the dev branch (importing the externalized, not-on-disk `vite`).
@@ -185,8 +218,11 @@ printBanner({ mode: "production" });
185
218
 
186
219
  console.log(`\n✓ Compiled server -> out/${exeName}`);
187
220
  console.log(
188
- "\nNote: out/bindings/ and out/geoip-data/ must ship alongside the executable — they hold " +
189
- "the native SurrealDB binding and the geoip-lite database loaded at runtime.",
221
+ isRemoteBuild
222
+ ? "\nNote: out/geoip-data/ must ship alongside the executable — it holds the geoip-lite " +
223
+ "database loaded at runtime. No native SurrealDB binding was bundled (remote db.path)."
224
+ : "\nNote: out/bindings/ and out/geoip-data/ must ship alongside the executable — they hold " +
225
+ "the native SurrealDB binding and the geoip-lite database loaded at runtime.",
190
226
  );
191
227
  console.log(`\nRun it with:\n cd out && ./${exeName}`);
192
228
  } finally {
@@ -7,9 +7,11 @@ import elysia from "../modules/elysia";
7
7
  import file from "../modules/file";
8
8
  import img from "../modules/img";
9
9
  import logger from "../modules/logger";
10
+ import plugin from "../modules/plugin";
10
11
  import setup from "../modules/setup";
11
12
  import surreal from "../modules/surreal";
12
13
  import users from "../modules/users";
14
+ import vars from "../modules/vars";
13
15
  import view from "../modules/view";
14
16
 
15
17
  // Must resolve before anything below reads getConfig() — db path, port, file
@@ -18,12 +20,20 @@ await loadConfig();
18
20
 
19
21
  const modules = [
20
22
  logger.init(),
21
- surreal.init(),
23
+ // Awaited — surreal's init() now performs the actual DB connect (see surreal.ts),
24
+ // so plugin.init() below (which may query the DB, e.g. reading its own vars group)
25
+ // never runs against an unconnected instance.
26
+ await surreal.init(),
22
27
  elysia.init(),
28
+ // Early — after elysia/surreal singletons exist (and surreal is connected), but well
29
+ // before api.init() (which drains hooks/events and mounts models/routes) — so a
30
+ // plugin's own setup always finishes before its own contributed routes/hooks/events go live.
31
+ await plugin.init(),
23
32
  await file.init(),
24
33
  await auth.init(),
25
34
  await setup.init(),
26
35
  await api.init(),
36
+ await vars.init(),
27
37
  await img.init(),
28
38
  await analytics.init(),
29
39
  await users.init(),
@@ -2,6 +2,7 @@ import type { FileAdapter } from "../../modules/file";
2
2
  import { init } from "@paralleldrive/cuid2";
3
3
  import { join } from "node:path";
4
4
  import z from "zod";
5
+ import { sanitizeFileName } from "../path_guard";
5
6
 
6
7
  const createId = init({
7
8
  random: Math.random,
@@ -16,7 +17,7 @@ const bunFilesystemAdapter = ({ dir = "./uploads" }: { dir?: string } = {}): Fil
16
17
  meta,
17
18
  dir,
18
19
  write: async (file) => {
19
- const [fileName, ...exts] = file.name.split(".");
20
+ const [fileName, ...exts] = sanitizeFileName(file.name).split(".");
20
21
 
21
22
  const name = `${fileName}_${createId()}.${exts.join(".")}`;
22
23
 
@@ -2,6 +2,7 @@ import type { FileAdapter } from "../../modules/file";
2
2
  import { init } from "@paralleldrive/cuid2";
3
3
  import type { S3Options } from "bun";
4
4
  import z from "zod";
5
+ import { sanitizeFileName } from "../path_guard";
5
6
 
6
7
  const createId = init({
7
8
  random: Math.random,
@@ -40,7 +41,7 @@ const s3Adapter = (options: S3Options & { bucket: string; publicUrl?: string }):
40
41
  return {
41
42
  meta,
42
43
  write: async (file) => {
43
- const [fileName, ...exts] = file.name.split(".");
44
+ const [fileName, ...exts] = sanitizeFileName(file.name).split(".");
44
45
  const key = `${fileName}_${createId()}.${exts.join(".")}`;
45
46
 
46
47
  await bucket.write(key, file, { acl: "public-read", type: file.type });
@@ -1,16 +1,48 @@
1
+ import type { AnyElysia } from "elysia";
1
2
  import type { FileAdapter } from "../modules/file";
2
3
  import { join } from "node:path";
3
4
  import bunFilesystemAdapter from "./adapters/bun_filesystem";
5
+ import type { Helper } from "./helper";
4
6
  import type { makeModel } from "./model";
7
+ import type { Plugin } from "./plugin";
8
+ import type { makeVars } from "./vars";
5
9
 
6
10
  export type ModelFactory = () => Promise<{ default: ReturnType<typeof makeModel> }>;
7
11
 
12
+ // A hook file's only job is the side effect of calling afterCreate()/etc. at import
13
+ // time — its module's exports (if any) are irrelevant, unlike ModelFactory.
14
+ export type HookFactory = () => Promise<unknown>;
15
+
16
+ // An event listener file's only job is the side effect of calling `<event>.listen(...)`
17
+ // at import time — it imports a canonical makeEvent() instance from its own file
18
+ // (src/core/event.ts) and registers a handler. Same shape/reasoning as HookFactory.
19
+ export type EventFactory = () => Promise<unknown>;
20
+
21
+ // A route file exports a full Elysia instance (built via the `route()` helper) —
22
+ // its default export is mounted as-is, same as ModelFactory but for arbitrary endpoints.
23
+ // AnyElysia (not a bare `Elysia`) because each route file's instance carries its own
24
+ // concrete generics (prefix literal, schemas, ...) that a fixed default-generic
25
+ // `Elysia` type can't structurally match — same reasoning as elysia.ts's shared app.
26
+ export type RouteFactory = () => Promise<{ default: AnyElysia }>;
27
+
28
+ // A vars file's default export is one makeVars() group (e.g. "general", "seo") —
29
+ // a singleton settings object, not a list of records like a model.
30
+ export type VarsFactory = () => Promise<{ default: ReturnType<typeof makeVars> }>;
31
+
32
+ // A helper file's default export is one defineHelper() — a stateless function
33
+ // registered as an Edge global by its own name, unlike models/vars which are
34
+ // DB-backed objects registered by prefix.
35
+ export type HelperFactory = () => Promise<{ default: Helper }>;
36
+
8
37
  export type NjinConfig = {
9
38
  port?: number;
10
39
  db?: {
11
40
  path?: string;
12
41
  namespace?: string;
13
42
  database?: string;
43
+ // Root/system auth ({ username, password }) or a bearer token (string). Omit for an
44
+ // anonymous connection — the only mode embedded (rocksdb/mem/surrealkv) engines support.
45
+ auth?: { username: string; password: string } | string;
14
46
  };
15
47
  img?: {
16
48
  hosts?: string[];
@@ -20,15 +52,30 @@ export type NjinConfig = {
20
52
  file?: FileAdapter<any>;
21
53
  };
22
54
  models?: ModelFactory[];
55
+ hooks?: HookFactory[];
56
+ events?: EventFactory[];
57
+ routes?: RouteFactory[];
58
+ vars?: VarsFactory[];
59
+ helpers?: HelperFactory[];
60
+ plugins?: Plugin[];
23
61
  };
24
62
 
25
63
  export type ResolvedConfig = {
26
64
  port: number;
27
- db: { path: string; namespace: string; database: string };
65
+ db: { path: string; namespace: string; database: string; auth?: { username: string; password: string } | string };
28
66
  img: { hosts: string[] };
29
67
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
68
  adapters: { file: FileAdapter<any> };
31
69
  models: ModelFactory[];
70
+ hooks: HookFactory[];
71
+ events: EventFactory[];
72
+ routes: RouteFactory[];
73
+ vars: VarsFactory[];
74
+ helpers: HelperFactory[];
75
+ // Everything else a Plugin contributes (models/vars/hooks/events/routes) is already
76
+ // flattened into the arrays above — init() is the only part that can't be merged away,
77
+ // so it's the only piece of each Plugin that survives resolution on its own.
78
+ pluginInits: Array<() => Promise<void> | void>;
32
79
  };
33
80
 
34
81
  // Identity function — purely for DX (autocomplete/type-checking on the config
@@ -70,16 +117,30 @@ export const loadConfig = async (preloaded?: NjinConfig): Promise<void> => {
70
117
  }
71
118
  }
72
119
 
120
+ const plugins = userConfig.plugins ?? [];
121
+
122
+ // Plugin-contributed models/vars/hooks/events/routes come first, then the project's
123
+ // own — a plugin is the foundation being extended, so its registrations run first and
124
+ // the project's own can build on top of (or follow) whatever the plugin set up. No
125
+ // collision detection: two plugins (or a plugin and the project) registering the same
126
+ // prefix fails the same way it already does today — naturally, at runtime.
73
127
  resolved = {
74
128
  port: userConfig.port ?? 3000,
75
129
  db: {
76
130
  path: userConfig.db?.path ?? "rocksdb://data",
77
131
  namespace: userConfig.db?.namespace ?? "general",
78
132
  database: userConfig.db?.database ?? "general",
133
+ auth: userConfig.db?.auth,
79
134
  },
80
135
  img: { hosts: userConfig.img?.hosts ?? [] },
81
136
  adapters: { file: userConfig.adapters?.file ?? bunFilesystemAdapter() },
82
- models: userConfig.models ?? [],
137
+ models: [...plugins.flatMap((p) => p.models ?? []), ...(userConfig.models ?? [])],
138
+ hooks: [...plugins.flatMap((p) => p.hooks ?? []), ...(userConfig.hooks ?? [])],
139
+ events: [...plugins.flatMap((p) => p.events ?? []), ...(userConfig.events ?? [])],
140
+ routes: [...plugins.flatMap((p) => p.routes ?? []), ...(userConfig.routes ?? [])],
141
+ vars: [...plugins.flatMap((p) => p.vars ?? []), ...(userConfig.vars ?? [])],
142
+ helpers: [...plugins.flatMap((p) => p.helpers ?? []), ...(userConfig.helpers ?? [])],
143
+ pluginInits: plugins.map((p) => p.init).filter((fn): fn is () => Promise<void> | void => typeof fn === "function"),
83
144
  };
84
145
  };
85
146
 
@@ -0,0 +1,45 @@
1
+ import logger from "../modules/logger";
2
+
3
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
4
+ type Handler<T> = (payload: T) => void | Promise<void>;
5
+
6
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
+ const registry = new Map<symbol, Handler<any>[]>();
8
+
9
+ export interface EventBus<T = void> {
10
+ dispatch: (payload: T) => Promise<void>;
11
+ listen: (handler: Handler<T>) => void;
12
+ }
13
+
14
+ // Opens a new channel on the shared event bus — no name/string key: correlation
15
+ // between dispatch() and listen() happens purely via sharing this same returned
16
+ // instance (import it from one canonical file), not via a global string registry.
17
+ // This is what makes T genuinely type-safe — dispatch and listen on one instance
18
+ // always share the same T, there's no way for two unrelated call sites to silently
19
+ // disagree on payload shape. The Symbol key routes through one shared `registry`
20
+ // Map so the whole app still runs on a single underlying bus.
21
+ export const makeEvent = <T = void>(): EventBus<T> => {
22
+ const key = Symbol();
23
+ registry.set(key, []);
24
+
25
+ const listen: EventBus<T>["listen"] = (handler) => {
26
+ registry.get(key)!.push(handler);
27
+ };
28
+
29
+ // Runs every listener sequentially (await each) — a throwing/rejecting listener
30
+ // is caught and logged, NOT propagated: this is a fan-out notification, not a
31
+ // validation/abort hook like hooks.ts, so one broken listener must never stop
32
+ // its siblings or bubble into the dispatch() caller.
33
+ const dispatch: EventBus<T>["dispatch"] = async (payload) => {
34
+ const handlers = registry.get(key) ?? [];
35
+ for (const handler of handlers) {
36
+ try {
37
+ await handler(payload);
38
+ } catch (err) {
39
+ logger().error(err, "Event listener threw");
40
+ }
41
+ }
42
+ };
43
+
44
+ return { dispatch, listen };
45
+ };
@@ -0,0 +1,5 @@
1
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2
+ export type Helper = { name: string; fn: (...args: any[]) => unknown };
3
+
4
+ // Identity function — DX/typing only, same purpose as defineConfig/definePlugin.
5
+ export const defineHelper = (name: string, fn: Helper["fn"]): Helper => ({ name, fn });