@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.
- package/README.md +148 -1
- package/package.json +3 -2
- package/src/cli/build.ts +84 -48
- package/src/config/module.ts +11 -1
- package/src/core/adapters/bun_filesystem.ts +2 -1
- package/src/core/adapters/s3.ts +2 -1
- package/src/core/config.ts +54 -2
- package/src/core/event.ts +45 -0
- package/src/core/model/hooks.ts +143 -0
- package/src/core/model/index.ts +44 -11
- package/src/core/path_guard.ts +16 -2
- package/src/core/plugin.ts +13 -0
- package/src/core/route.ts +6 -0
- package/src/core/vars/index.ts +64 -0
- package/src/modules/api.ts +57 -20
- package/src/modules/img.ts +141 -137
- package/src/modules/plugin.ts +17 -0
- package/src/modules/surreal.ts +38 -13
- package/src/modules/vars.ts +34 -0
- package/src/modules/view.ts +9 -4
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)** —
|
|
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,139 @@ 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
|
+
## Events
|
|
135
|
+
|
|
136
|
+
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.
|
|
137
|
+
|
|
138
|
+
`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:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
// src/events/order_paid.ts
|
|
142
|
+
import { makeEvent } from "@njinlabs/njin";
|
|
143
|
+
|
|
144
|
+
const orderPaid = makeEvent<{ orderId: string; total: number }>();
|
|
145
|
+
|
|
146
|
+
export default orderPaid;
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
// src/events/listeners/send_receipt.ts
|
|
151
|
+
import orderPaid from "../order_paid";
|
|
152
|
+
|
|
153
|
+
orderPaid.listen(async (payload) => {
|
|
154
|
+
// errors here are caught and logged, they never bubble back to whoever dispatched
|
|
155
|
+
console.log(`Sending receipt for order ${payload.orderId} ($${payload.total})`);
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Register the listener file in `config.ts` so it's imported (and its `.listen()` call runs) at boot:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
// config.ts
|
|
163
|
+
export default defineConfig({
|
|
164
|
+
events: [
|
|
165
|
+
() => import("./src/events/listeners/send_receipt"),
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Plugins
|
|
171
|
+
|
|
172
|
+
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:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
// my-plugin/index.ts
|
|
176
|
+
import { definePlugin } from "@njinlabs/njin";
|
|
177
|
+
|
|
178
|
+
export default (options: { apiKey: string }) =>
|
|
179
|
+
definePlugin({
|
|
180
|
+
models: [() => import("./models/order")],
|
|
181
|
+
routes: [() => import("./routes/webhook")],
|
|
182
|
+
init: async () => {
|
|
183
|
+
// runs once at boot, before any model/route/hook/event goes live —
|
|
184
|
+
// validate options, construct an SDK client, etc.
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Register it in `config.ts` — the factory is called directly (not wrapped in another thunk), same as `adapters.file`:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// config.ts
|
|
193
|
+
import myPlugin from "my-plugin";
|
|
194
|
+
|
|
195
|
+
export default defineConfig({
|
|
196
|
+
plugins: [myPlugin({ apiKey: process.env.MY_PLUGIN_KEY! })],
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
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.
|
|
201
|
+
|
|
202
|
+
Dispatch from anywhere — for example, composing with a model's `afterCreate` hook:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
// src/models/order.ts
|
|
206
|
+
import { makeModel, afterCreate } from "@njinlabs/njin";
|
|
207
|
+
import orderPaid from "../events/order_paid";
|
|
208
|
+
|
|
209
|
+
const order = makeModel("order", { /* ... */ });
|
|
210
|
+
|
|
211
|
+
afterCreate(order, (record) => {
|
|
212
|
+
orderPaid.dispatch({ orderId: record.id.id, total: record.total });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
export default order;
|
|
216
|
+
```
|
|
217
|
+
|
|
85
218
|
## File-based routing
|
|
86
219
|
|
|
87
220
|
Files in `src/views/pages/` map to routes automatically:
|
|
@@ -179,6 +312,12 @@ export default defineConfig({
|
|
|
179
312
|
path: process.env.DB_PATH ?? "rocksdb://data",
|
|
180
313
|
namespace: process.env.DB_NAMESPACE ?? "general",
|
|
181
314
|
database: process.env.DB_DATABASE ?? "general",
|
|
315
|
+
// only needed for a remote db.path — username/password (root/system auth) or a token
|
|
316
|
+
auth: process.env.DB_TOKEN
|
|
317
|
+
? process.env.DB_TOKEN
|
|
318
|
+
: process.env.DB_USERNAME && process.env.DB_PASSWORD
|
|
319
|
+
? { username: process.env.DB_USERNAME, password: process.env.DB_PASSWORD }
|
|
320
|
+
: undefined,
|
|
182
321
|
},
|
|
183
322
|
img: {
|
|
184
323
|
hosts: ["cdn.example.com"], // external hosts allowed for GET /img besides same-origin/localhost
|
|
@@ -189,9 +328,17 @@ export default defineConfig({
|
|
|
189
328
|
models: [
|
|
190
329
|
() => import("./src/models/post"),
|
|
191
330
|
],
|
|
331
|
+
vars: [
|
|
332
|
+
() => import("./src/vars/general"),
|
|
333
|
+
],
|
|
334
|
+
events: [
|
|
335
|
+
() => import("./src/events/listeners/send_receipt"),
|
|
336
|
+
],
|
|
192
337
|
});
|
|
193
338
|
```
|
|
194
339
|
|
|
340
|
+
`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).
|
|
341
|
+
|
|
195
342
|
To store uploads in S3 (or an S3-compatible service like R2/Spaces/MinIO) instead:
|
|
196
343
|
|
|
197
344
|
```ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@njinlabs/njin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
`
|
|
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
|
-
|
|
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
|
|
121
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
189
|
-
"the
|
|
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 {
|
package/src/config/module.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/core/adapters/s3.ts
CHANGED
|
@@ -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 });
|
package/src/core/config.ts
CHANGED
|
@@ -1,16 +1,42 @@
|
|
|
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";
|
|
4
5
|
import type { makeModel } from "./model";
|
|
6
|
+
import type { Plugin } from "./plugin";
|
|
7
|
+
import type { makeVars } from "./vars";
|
|
5
8
|
|
|
6
9
|
export type ModelFactory = () => Promise<{ default: ReturnType<typeof makeModel> }>;
|
|
7
10
|
|
|
11
|
+
// A hook file's only job is the side effect of calling afterCreate()/etc. at import
|
|
12
|
+
// time — its module's exports (if any) are irrelevant, unlike ModelFactory.
|
|
13
|
+
export type HookFactory = () => Promise<unknown>;
|
|
14
|
+
|
|
15
|
+
// An event listener file's only job is the side effect of calling `<event>.listen(...)`
|
|
16
|
+
// at import time — it imports a canonical makeEvent() instance from its own file
|
|
17
|
+
// (src/core/event.ts) and registers a handler. Same shape/reasoning as HookFactory.
|
|
18
|
+
export type EventFactory = () => Promise<unknown>;
|
|
19
|
+
|
|
20
|
+
// A route file exports a full Elysia instance (built via the `route()` helper) —
|
|
21
|
+
// its default export is mounted as-is, same as ModelFactory but for arbitrary endpoints.
|
|
22
|
+
// AnyElysia (not a bare `Elysia`) because each route file's instance carries its own
|
|
23
|
+
// concrete generics (prefix literal, schemas, ...) that a fixed default-generic
|
|
24
|
+
// `Elysia` type can't structurally match — same reasoning as elysia.ts's shared app.
|
|
25
|
+
export type RouteFactory = () => Promise<{ default: AnyElysia }>;
|
|
26
|
+
|
|
27
|
+
// A vars file's default export is one makeVars() group (e.g. "general", "seo") —
|
|
28
|
+
// a singleton settings object, not a list of records like a model.
|
|
29
|
+
export type VarsFactory = () => Promise<{ default: ReturnType<typeof makeVars> }>;
|
|
30
|
+
|
|
8
31
|
export type NjinConfig = {
|
|
9
32
|
port?: number;
|
|
10
33
|
db?: {
|
|
11
34
|
path?: string;
|
|
12
35
|
namespace?: string;
|
|
13
36
|
database?: string;
|
|
37
|
+
// Root/system auth ({ username, password }) or a bearer token (string). Omit for an
|
|
38
|
+
// anonymous connection — the only mode embedded (rocksdb/mem/surrealkv) engines support.
|
|
39
|
+
auth?: { username: string; password: string } | string;
|
|
14
40
|
};
|
|
15
41
|
img?: {
|
|
16
42
|
hosts?: string[];
|
|
@@ -20,15 +46,28 @@ export type NjinConfig = {
|
|
|
20
46
|
file?: FileAdapter<any>;
|
|
21
47
|
};
|
|
22
48
|
models?: ModelFactory[];
|
|
49
|
+
hooks?: HookFactory[];
|
|
50
|
+
events?: EventFactory[];
|
|
51
|
+
routes?: RouteFactory[];
|
|
52
|
+
vars?: VarsFactory[];
|
|
53
|
+
plugins?: Plugin[];
|
|
23
54
|
};
|
|
24
55
|
|
|
25
56
|
export type ResolvedConfig = {
|
|
26
57
|
port: number;
|
|
27
|
-
db: { path: string; namespace: string; database: string };
|
|
58
|
+
db: { path: string; namespace: string; database: string; auth?: { username: string; password: string } | string };
|
|
28
59
|
img: { hosts: string[] };
|
|
29
60
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
61
|
adapters: { file: FileAdapter<any> };
|
|
31
62
|
models: ModelFactory[];
|
|
63
|
+
hooks: HookFactory[];
|
|
64
|
+
events: EventFactory[];
|
|
65
|
+
routes: RouteFactory[];
|
|
66
|
+
vars: VarsFactory[];
|
|
67
|
+
// Everything else a Plugin contributes (models/vars/hooks/events/routes) is already
|
|
68
|
+
// flattened into the arrays above — init() is the only part that can't be merged away,
|
|
69
|
+
// so it's the only piece of each Plugin that survives resolution on its own.
|
|
70
|
+
pluginInits: Array<() => Promise<void> | void>;
|
|
32
71
|
};
|
|
33
72
|
|
|
34
73
|
// Identity function — purely for DX (autocomplete/type-checking on the config
|
|
@@ -70,16 +109,29 @@ export const loadConfig = async (preloaded?: NjinConfig): Promise<void> => {
|
|
|
70
109
|
}
|
|
71
110
|
}
|
|
72
111
|
|
|
112
|
+
const plugins = userConfig.plugins ?? [];
|
|
113
|
+
|
|
114
|
+
// Plugin-contributed models/vars/hooks/events/routes come first, then the project's
|
|
115
|
+
// own — a plugin is the foundation being extended, so its registrations run first and
|
|
116
|
+
// the project's own can build on top of (or follow) whatever the plugin set up. No
|
|
117
|
+
// collision detection: two plugins (or a plugin and the project) registering the same
|
|
118
|
+
// prefix fails the same way it already does today — naturally, at runtime.
|
|
73
119
|
resolved = {
|
|
74
120
|
port: userConfig.port ?? 3000,
|
|
75
121
|
db: {
|
|
76
122
|
path: userConfig.db?.path ?? "rocksdb://data",
|
|
77
123
|
namespace: userConfig.db?.namespace ?? "general",
|
|
78
124
|
database: userConfig.db?.database ?? "general",
|
|
125
|
+
auth: userConfig.db?.auth,
|
|
79
126
|
},
|
|
80
127
|
img: { hosts: userConfig.img?.hosts ?? [] },
|
|
81
128
|
adapters: { file: userConfig.adapters?.file ?? bunFilesystemAdapter() },
|
|
82
|
-
models: userConfig.models ?? [],
|
|
129
|
+
models: [...plugins.flatMap((p) => p.models ?? []), ...(userConfig.models ?? [])],
|
|
130
|
+
hooks: [...plugins.flatMap((p) => p.hooks ?? []), ...(userConfig.hooks ?? [])],
|
|
131
|
+
events: [...plugins.flatMap((p) => p.events ?? []), ...(userConfig.events ?? [])],
|
|
132
|
+
routes: [...plugins.flatMap((p) => p.routes ?? []), ...(userConfig.routes ?? [])],
|
|
133
|
+
vars: [...plugins.flatMap((p) => p.vars ?? []), ...(userConfig.vars ?? [])],
|
|
134
|
+
pluginInits: plugins.map((p) => p.init).filter((fn): fn is () => Promise<void> | void => typeof fn === "function"),
|
|
83
135
|
};
|
|
84
136
|
};
|
|
85
137
|
|
|
@@ -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
|
+
};
|