@veducio/keja 0.0.0-stage → 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/LICENSE +7 -0
- package/README.md +39 -2
- package/examples/starter/.env.example +6 -0
- package/examples/starter/keja.config.ts +7 -0
- package/examples/starter/keja.versioning.ts +5 -0
- package/examples/starter/migrations/0001_init.sql +1 -0
- package/examples/starter/package.json +14 -0
- package/examples/starter/tsconfig.json +9 -0
- package/package.json +62 -4
- package/src/access/cloudflare.test.ts +131 -0
- package/src/access/cloudflare.ts +193 -0
- package/src/argus/call.test.ts +98 -0
- package/src/argus/call.ts +153 -0
- package/src/argus/heartbeat.ts +65 -0
- package/src/argus/register.test.ts +120 -0
- package/src/argus/register.ts +145 -0
- package/src/cli/banner.test.ts +98 -0
- package/src/cli/banner.ts +209 -0
- package/src/cli/commands/build.ts +12 -0
- package/src/cli/commands/dev.ts +12 -0
- package/src/cli/commands/generate.ts +12 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/migrate.ts +12 -0
- package/src/cli/commands/seed.ts +12 -0
- package/src/cli/commands/start.ts +12 -0
- package/src/cli/dev-reload.test.ts +87 -0
- package/src/cli/dev-reload.ts +182 -0
- package/src/cli/generate-project.ts +39 -0
- package/src/cli/index.ts +29 -0
- package/src/cli/init-project.test.ts +312 -0
- package/src/cli/init-project.ts +416 -0
- package/src/cli/migrate-project.ts +31 -0
- package/src/cli/run-project.test.ts +50 -0
- package/src/cli/run-project.ts +93 -0
- package/src/cli/seed-project.ts +46 -0
- package/src/db/client.test.ts +49 -0
- package/src/db/client.ts +61 -0
- package/src/db/migrate.test.ts +141 -0
- package/src/db/migrate.ts +146 -0
- package/src/db/owned-sql.ts +202 -0
- package/src/db/postgres.ts +90 -0
- package/src/env/required-env.test.ts +64 -0
- package/src/env/required-env.ts +120 -0
- package/src/errors/framework-errors.ts +295 -0
- package/src/grant/grant.test.ts +130 -0
- package/src/grant/grant.ts +268 -0
- package/src/http/create-app.test.ts +340 -0
- package/src/http/create-app.ts +196 -0
- package/src/http/openapi.ts +111 -0
- package/src/process/slot.ts +18 -0
- package/src/project/config.test.ts +143 -0
- package/src/project/config.ts +450 -0
- package/src/project/load.test.ts +60 -0
- package/src/project/load.ts +158 -0
- package/src/project/reload-graph.test.ts +52 -0
- package/src/project/reload-graph.ts +175 -0
- package/src/repository/define.ts +123 -0
- package/src/repository/generate.test.ts +58 -0
- package/src/repository/generate.ts +117 -0
- package/src/repository/query.test.ts +99 -0
- package/src/repository/query.ts +287 -0
- package/src/repository/runtime.test.ts +154 -0
- package/src/repository/runtime.ts +434 -0
- package/src/repository/sql.test.ts +78 -0
- package/src/repository/sql.ts +282 -0
- package/src/route/define.test.ts +65 -0
- package/src/route/define.ts +307 -0
- package/src/route/middleware.test.ts +63 -0
- package/src/route/middleware.ts +144 -0
- package/src/seed/apply.test.ts +65 -0
- package/src/seed/apply.ts +11 -0
- package/src/seed/define.test.ts +51 -0
- package/src/seed/define.ts +55 -0
- package/src/seed/load.test.ts +54 -0
- package/src/seed/load.ts +67 -0
- package/src/seed/report.test.ts +16 -0
- package/src/seed/report.ts +15 -0
- package/src/self-test/checks.ts +88 -0
- package/src/self-test/run.test.ts +128 -0
- package/src/self-test/run.ts +105 -0
- package/src/server/identity.test.ts +57 -0
- package/src/server/identity.ts +116 -0
- package/src/server/index.test.ts +15 -0
- package/src/server/index.ts +171 -0
- package/src/server/swap.test.ts +89 -0
- package/src/server/swap.ts +29 -0
- package/src/typeid/id.test.ts +46 -0
- package/src/typeid/id.ts +136 -0
- package/src/typeid/owned.test.ts +73 -0
- package/src/typeid/owned.ts +193 -0
- package/src/vault/client.test.ts +128 -0
- package/src/vault/client.ts +282 -0
- package/src/versioning/define.test.ts +79 -0
- package/src/versioning/define.ts +173 -0
- package/src/versioning/select.test.ts +79 -0
- package/src/versioning/select.ts +62 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { projectError } from "../errors/framework-errors.ts";
|
|
5
|
+
import { freshImportUrl } from "./reload-graph.ts";
|
|
6
|
+
|
|
7
|
+
const configBrand = "keja.config";
|
|
8
|
+
|
|
9
|
+
const defaultConfig = {
|
|
10
|
+
versioning: "keja.versioning.ts",
|
|
11
|
+
public_path: "src/public",
|
|
12
|
+
service_path: "src/service",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ArgusSettings = {
|
|
16
|
+
url: string;
|
|
17
|
+
env: string;
|
|
18
|
+
username: string | null;
|
|
19
|
+
password: string | null;
|
|
20
|
+
public: string | null;
|
|
21
|
+
service: string | null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type ArgusConfigInput = {
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
url?: string;
|
|
27
|
+
env?: string;
|
|
28
|
+
username?: string;
|
|
29
|
+
password?: string;
|
|
30
|
+
public?: string;
|
|
31
|
+
service?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type ArgusConfig =
|
|
35
|
+
| { enabled: false }
|
|
36
|
+
| ({ enabled: true } & ArgusSettings);
|
|
37
|
+
|
|
38
|
+
export type DatabaseConfigInput = {
|
|
39
|
+
enabled: boolean;
|
|
40
|
+
migrations?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type DatabaseConfig =
|
|
44
|
+
| { enabled: false }
|
|
45
|
+
| { enabled: true; migrations: string };
|
|
46
|
+
|
|
47
|
+
export type VaultSettings = {
|
|
48
|
+
address: string;
|
|
49
|
+
token: string;
|
|
50
|
+
namespace: string | null;
|
|
51
|
+
mount: string;
|
|
52
|
+
transit_key: string | null;
|
|
53
|
+
transit_mount: string;
|
|
54
|
+
grant_max_exp: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type VaultConfigInput = {
|
|
58
|
+
enabled: boolean;
|
|
59
|
+
address?: string;
|
|
60
|
+
token?: string;
|
|
61
|
+
namespace?: string;
|
|
62
|
+
mount?: string;
|
|
63
|
+
transit_key?: string;
|
|
64
|
+
transit_mount?: string;
|
|
65
|
+
grant_max_exp?: number;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type VaultConfig =
|
|
69
|
+
| { enabled: false }
|
|
70
|
+
| ({ enabled: true } & VaultSettings);
|
|
71
|
+
|
|
72
|
+
export type KejaConfigInput = {
|
|
73
|
+
name?: string;
|
|
74
|
+
versioning?: string;
|
|
75
|
+
public_path?: string;
|
|
76
|
+
service_path?: string;
|
|
77
|
+
argus?: ArgusConfigInput;
|
|
78
|
+
db?: DatabaseConfigInput;
|
|
79
|
+
vault?: VaultConfigInput;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type KejaConfig = {
|
|
83
|
+
name: string | null;
|
|
84
|
+
versioning: string;
|
|
85
|
+
public_path: string;
|
|
86
|
+
service_path: string;
|
|
87
|
+
argus: ArgusConfig;
|
|
88
|
+
database: DatabaseConfig;
|
|
89
|
+
vault: VaultConfig;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export function defineConfig(input: KejaConfigInput = {}) {
|
|
93
|
+
const config: KejaConfig = {
|
|
94
|
+
name: optionalText(input.name, "name", "service name"),
|
|
95
|
+
versioning: configPath(
|
|
96
|
+
input.versioning,
|
|
97
|
+
defaultConfig.versioning,
|
|
98
|
+
"versioning",
|
|
99
|
+
),
|
|
100
|
+
public_path: configPath(
|
|
101
|
+
input.public_path,
|
|
102
|
+
defaultConfig.public_path,
|
|
103
|
+
"public_path",
|
|
104
|
+
),
|
|
105
|
+
service_path: configPath(
|
|
106
|
+
input.service_path,
|
|
107
|
+
defaultConfig.service_path,
|
|
108
|
+
"service_path",
|
|
109
|
+
),
|
|
110
|
+
argus: configArgus(input.argus),
|
|
111
|
+
database: configDatabase(input.db),
|
|
112
|
+
vault: configVault(input.vault),
|
|
113
|
+
};
|
|
114
|
+
return Object.assign(config, { [configBrand]: true as const });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function isConfig(value: unknown): value is KejaConfig {
|
|
118
|
+
return (
|
|
119
|
+
typeof value === "object" &&
|
|
120
|
+
value !== null &&
|
|
121
|
+
configBrand in value &&
|
|
122
|
+
(value as Record<string, unknown>)[configBrand] === true &&
|
|
123
|
+
hasPath(value, "versioning") &&
|
|
124
|
+
hasPath(value, "public_path") &&
|
|
125
|
+
hasPath(value, "service_path") &&
|
|
126
|
+
hasName(value) &&
|
|
127
|
+
hasArgus(value) &&
|
|
128
|
+
hasDatabase(value) &&
|
|
129
|
+
hasVault(value)
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type ProjectPaths = {
|
|
134
|
+
config: string | null;
|
|
135
|
+
versioning: string;
|
|
136
|
+
public: string;
|
|
137
|
+
service: string;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export type LoadedConfig = ProjectPaths & {
|
|
141
|
+
name: string | null;
|
|
142
|
+
argus: ArgusConfig;
|
|
143
|
+
database: DatabaseConfig;
|
|
144
|
+
vault: VaultConfig;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export function resolveProjectPaths(root: string, config: KejaConfig) {
|
|
148
|
+
return {
|
|
149
|
+
versioning: path.resolve(root, config.versioning),
|
|
150
|
+
public: path.resolve(root, config.public_path),
|
|
151
|
+
service: path.resolve(root, config.service_path),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function loadConfig(
|
|
156
|
+
root: string,
|
|
157
|
+
generation?: string,
|
|
158
|
+
): Promise<LoadedConfig> {
|
|
159
|
+
const file = path.join(root, "keja.config.ts");
|
|
160
|
+
if (!existsSync(file)) {
|
|
161
|
+
return loadedConfig(root, null, defineConfig());
|
|
162
|
+
}
|
|
163
|
+
const href = generation
|
|
164
|
+
? freshImportUrl(file, generation)
|
|
165
|
+
: pathToFileURL(file).href;
|
|
166
|
+
const loaded = await import(href);
|
|
167
|
+
if (!isConfig(loaded.default)) {
|
|
168
|
+
throw projectError(
|
|
169
|
+
"invalidConfigFile",
|
|
170
|
+
`${file} must default-export defineConfig(...)`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return loadedConfig(root, file, loaded.default);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function configPath(value: unknown, fallback: string, field: string) {
|
|
177
|
+
if (value === undefined) {
|
|
178
|
+
return fallback;
|
|
179
|
+
}
|
|
180
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
181
|
+
throw projectError(
|
|
182
|
+
"invalidConfigFile",
|
|
183
|
+
`keja.config.ts ${field} must be a non-empty path`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return value.trim();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function loadedConfig(
|
|
190
|
+
root: string,
|
|
191
|
+
file: string | null,
|
|
192
|
+
config: KejaConfig,
|
|
193
|
+
): LoadedConfig {
|
|
194
|
+
return {
|
|
195
|
+
config: file,
|
|
196
|
+
...resolveProjectPaths(root, config),
|
|
197
|
+
name: config.name,
|
|
198
|
+
argus: config.argus,
|
|
199
|
+
database: resolvedDatabase(root, config.database),
|
|
200
|
+
vault: config.vault,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function resolvedDatabase(
|
|
205
|
+
root: string,
|
|
206
|
+
database: DatabaseConfig,
|
|
207
|
+
): DatabaseConfig {
|
|
208
|
+
if (!database.enabled) {
|
|
209
|
+
return database;
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
enabled: true,
|
|
213
|
+
migrations: path.resolve(root, database.migrations),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function configDatabase(
|
|
218
|
+
value: DatabaseConfigInput | undefined,
|
|
219
|
+
): DatabaseConfig {
|
|
220
|
+
if (value === undefined) {
|
|
221
|
+
return { enabled: false };
|
|
222
|
+
}
|
|
223
|
+
if (!isEnabledObject(value)) {
|
|
224
|
+
throw projectError(
|
|
225
|
+
"invalidConfigFile",
|
|
226
|
+
"keja.config.ts db must be an object with enabled",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (!value.enabled) {
|
|
230
|
+
return { enabled: false };
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
enabled: true,
|
|
234
|
+
migrations: configPath(value.migrations, "migrations", "db.migrations"),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function configArgus(value: ArgusConfigInput | undefined): ArgusConfig {
|
|
239
|
+
if (value === undefined) {
|
|
240
|
+
return { enabled: false };
|
|
241
|
+
}
|
|
242
|
+
if (!isEnabledObject(value)) {
|
|
243
|
+
throw projectError(
|
|
244
|
+
"invalidConfigFile",
|
|
245
|
+
"keja.config.ts argus must be an object with enabled",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
if (!value.enabled) {
|
|
249
|
+
return { enabled: false };
|
|
250
|
+
}
|
|
251
|
+
const username = optionalText(value.username, "argus.username", "username");
|
|
252
|
+
const password = optionalText(value.password, "argus.password", "password");
|
|
253
|
+
if ((username === null) !== (password === null)) {
|
|
254
|
+
throw projectError(
|
|
255
|
+
"invalidConfigFile",
|
|
256
|
+
"keja.config.ts argus.username and argus.password must both be set",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
enabled: true,
|
|
261
|
+
url: absoluteHttpUrl(value.url, "argus.url"),
|
|
262
|
+
env: requiredText(value.env, "argus.env", "environment name"),
|
|
263
|
+
username,
|
|
264
|
+
password,
|
|
265
|
+
public: optionalText(value.public, "argus.public", "service name"),
|
|
266
|
+
service: optionalText(value.service, "argus.service", "service name"),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function configVault(value: VaultConfigInput | undefined): VaultConfig {
|
|
271
|
+
if (value === undefined) {
|
|
272
|
+
return { enabled: false };
|
|
273
|
+
}
|
|
274
|
+
if (!isEnabledObject(value)) {
|
|
275
|
+
throw projectError(
|
|
276
|
+
"invalidConfigFile",
|
|
277
|
+
"keja.config.ts vault must be an object with enabled",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (!value.enabled) {
|
|
281
|
+
return { enabled: false };
|
|
282
|
+
}
|
|
283
|
+
const namespace =
|
|
284
|
+
value.namespace ?? configEnv("VAULT_NAMESPACE") ?? undefined;
|
|
285
|
+
return {
|
|
286
|
+
enabled: true,
|
|
287
|
+
address: absoluteHttpUrl(
|
|
288
|
+
value.address ?? configEnv("VAULT_ADDR") ?? undefined,
|
|
289
|
+
"vault.address",
|
|
290
|
+
),
|
|
291
|
+
token: requiredText(
|
|
292
|
+
value.token ?? configEnv("VAULT_TOKEN") ?? undefined,
|
|
293
|
+
"vault.token",
|
|
294
|
+
"token",
|
|
295
|
+
),
|
|
296
|
+
namespace: optionalText(namespace, "vault.namespace", "namespace"),
|
|
297
|
+
mount: vaultMount(value.mount ?? configEnv("VAULT_KV_MOUNT") ?? "secret"),
|
|
298
|
+
transit_key:
|
|
299
|
+
value.transit_key === undefined
|
|
300
|
+
? null
|
|
301
|
+
: vaultName(value.transit_key, "vault.transit_key"),
|
|
302
|
+
transit_mount: vaultName(
|
|
303
|
+
value.transit_mount ?? "transit",
|
|
304
|
+
"vault.transit_mount",
|
|
305
|
+
),
|
|
306
|
+
grant_max_exp: grantMaxExp(value.grant_max_exp),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isEnabledObject(value: unknown): value is { enabled: boolean } {
|
|
311
|
+
return (
|
|
312
|
+
typeof value === "object" &&
|
|
313
|
+
value !== null &&
|
|
314
|
+
typeof (value as { enabled?: unknown }).enabled === "boolean"
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function absoluteHttpUrl(value: unknown, field: string) {
|
|
319
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
320
|
+
throw projectError(
|
|
321
|
+
"invalidConfigFile",
|
|
322
|
+
`keja.config.ts ${field} must be an absolute http or https URL`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
const url = new URL(value.trim());
|
|
327
|
+
if (url.protocol === "http:" || url.protocol === "https:") {
|
|
328
|
+
return url.href.replace(/\/$/, "");
|
|
329
|
+
}
|
|
330
|
+
} catch {
|
|
331
|
+
throw projectError(
|
|
332
|
+
"invalidConfigFile",
|
|
333
|
+
`keja.config.ts ${field} must be an absolute http or https URL`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
throw projectError(
|
|
337
|
+
"invalidConfigFile",
|
|
338
|
+
`keja.config.ts ${field} must be an absolute http or https URL`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function vaultMount(value: string) {
|
|
343
|
+
return vaultName(value, "vault.mount");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function vaultName(value: string, field: string) {
|
|
347
|
+
const name = value.trim().replace(/^\/+|\/+$/g, "");
|
|
348
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
|
|
349
|
+
throw projectError(
|
|
350
|
+
"invalidConfigFile",
|
|
351
|
+
`keja.config.ts ${field} must be a name`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
return name;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function grantMaxExp(value: number | undefined) {
|
|
358
|
+
if (value === undefined) {
|
|
359
|
+
return 900;
|
|
360
|
+
}
|
|
361
|
+
if (
|
|
362
|
+
typeof value !== "number" ||
|
|
363
|
+
!Number.isInteger(value) ||
|
|
364
|
+
value < 1 ||
|
|
365
|
+
value > 86400
|
|
366
|
+
) {
|
|
367
|
+
throw projectError(
|
|
368
|
+
"invalidConfigFile",
|
|
369
|
+
"keja.config.ts vault.grant_max_exp must be a whole number of seconds from 1 to 86400",
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
return value;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function configEnv(name: string) {
|
|
376
|
+
const runtime = globalThis as {
|
|
377
|
+
Bun?: { env?: Record<string, string | undefined> };
|
|
378
|
+
};
|
|
379
|
+
const value = runtime.Bun?.env?.[name] ?? process.env[name];
|
|
380
|
+
const trimmed = value?.trim();
|
|
381
|
+
return trimmed ? trimmed : null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function requiredText(value: unknown, field: string, label: string) {
|
|
385
|
+
const text = optionalText(value, field, label);
|
|
386
|
+
if (text === null) {
|
|
387
|
+
throw projectError(
|
|
388
|
+
"invalidConfigFile",
|
|
389
|
+
`keja.config.ts ${field} must be a non-empty ${label}`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
return text;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function optionalText(value: unknown, field: string, label: string) {
|
|
396
|
+
if (value === undefined) {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
400
|
+
throw projectError(
|
|
401
|
+
"invalidConfigFile",
|
|
402
|
+
`keja.config.ts ${field} must be a non-empty ${label}`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
return value.trim();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function hasName(value: object) {
|
|
409
|
+
const name = (value as KejaConfig).name;
|
|
410
|
+
return name === null || typeof name === "string";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function hasArgus(value: object) {
|
|
414
|
+
const argus = (value as KejaConfig).argus;
|
|
415
|
+
return (
|
|
416
|
+
typeof argus === "object" &&
|
|
417
|
+
argus !== null &&
|
|
418
|
+
(argus.enabled === false ||
|
|
419
|
+
(argus.enabled === true &&
|
|
420
|
+
typeof argus.url === "string" &&
|
|
421
|
+
typeof argus.env === "string"))
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function hasVault(value: object) {
|
|
426
|
+
const vault = (value as KejaConfig).vault;
|
|
427
|
+
return (
|
|
428
|
+
typeof vault === "object" &&
|
|
429
|
+
vault !== null &&
|
|
430
|
+
(vault.enabled === false ||
|
|
431
|
+
(vault.enabled === true &&
|
|
432
|
+
typeof vault.address === "string" &&
|
|
433
|
+
typeof vault.token === "string" &&
|
|
434
|
+
typeof vault.mount === "string"))
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function hasDatabase(value: object) {
|
|
439
|
+
const database = (value as KejaConfig).database;
|
|
440
|
+
return (
|
|
441
|
+
typeof database === "object" &&
|
|
442
|
+
database !== null &&
|
|
443
|
+
(database.enabled === false ||
|
|
444
|
+
(database.enabled === true && typeof database.migrations === "string"))
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function hasPath(value: object, key: keyof KejaConfig) {
|
|
449
|
+
return typeof (value as KejaConfig)[key] === "string";
|
|
450
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { defineRoute, response } from "../route/define.ts";
|
|
7
|
+
import { collectRouteFiles, routesFromModule } from "./load.ts";
|
|
8
|
+
|
|
9
|
+
describe("collectRouteFiles", () => {
|
|
10
|
+
it("keeps route modules nested several directories deep", async () => {
|
|
11
|
+
const root = mkdtempSync(path.join(tmpdir(), "keja-routes-"));
|
|
12
|
+
const publicDirectory = path.join(root, "public");
|
|
13
|
+
mkdirSync(path.join(publicDirectory, "orgs", "teams"), {
|
|
14
|
+
recursive: true,
|
|
15
|
+
});
|
|
16
|
+
writeFileSync(path.join(publicDirectory, "_middleware.ts"), "");
|
|
17
|
+
writeFileSync(path.join(publicDirectory, "orgs", "_middleware.ts"), "");
|
|
18
|
+
writeFileSync(
|
|
19
|
+
path.join(publicDirectory, "orgs", "teams", "members.ts"),
|
|
20
|
+
"",
|
|
21
|
+
);
|
|
22
|
+
writeFileSync(
|
|
23
|
+
path.join(publicDirectory, "orgs", "teams", "members.test.ts"),
|
|
24
|
+
"",
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
await expect(collectRouteFiles(publicDirectory)).resolves.toEqual([
|
|
28
|
+
"orgs/teams/members.ts",
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("routesFromModule", () => {
|
|
34
|
+
it("reads a default route and named routes from one module", () => {
|
|
35
|
+
const ready = route("get", "/ready");
|
|
36
|
+
const list = route("get", "/jobs");
|
|
37
|
+
const create = route("post", "/jobs");
|
|
38
|
+
expect(
|
|
39
|
+
routesFromModule({
|
|
40
|
+
default: ready,
|
|
41
|
+
list,
|
|
42
|
+
create,
|
|
43
|
+
schema: z.object({ name: z.string() }),
|
|
44
|
+
}).map((item) => `${item.method} ${item.path}`),
|
|
45
|
+
).toEqual(["get /ready", "get /jobs", "post /jobs"]);
|
|
46
|
+
expect(routesFromModule({ schema: z.string() })).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function route(method: "get" | "post", routePath: string) {
|
|
51
|
+
return defineRoute({
|
|
52
|
+
method,
|
|
53
|
+
path: routePath,
|
|
54
|
+
version: "v1",
|
|
55
|
+
responses: {
|
|
56
|
+
Ok: response({ code: 200, json: z.object({ ok: z.literal(true) }) }),
|
|
57
|
+
},
|
|
58
|
+
handler: ({ respond }) => respond.Ok({ ok: true }),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { projectError } from "../errors/framework-errors.ts";
|
|
6
|
+
import { isRoute, type RouteDefinition } from "../route/define.ts";
|
|
7
|
+
import {
|
|
8
|
+
type MiddlewareDefinition,
|
|
9
|
+
middlewareList,
|
|
10
|
+
} from "../route/middleware.ts";
|
|
11
|
+
import { isVersions, type VersionDefinition } from "../versioning/define.ts";
|
|
12
|
+
import {
|
|
13
|
+
type ArgusConfig,
|
|
14
|
+
type DatabaseConfig,
|
|
15
|
+
loadConfig,
|
|
16
|
+
type ProjectPaths,
|
|
17
|
+
type VaultConfig,
|
|
18
|
+
} from "./config.ts";
|
|
19
|
+
import { beginProjectReload, freshImportUrl } from "./reload-graph.ts";
|
|
20
|
+
|
|
21
|
+
export type LoadedProject = {
|
|
22
|
+
paths: ProjectPaths;
|
|
23
|
+
name: string | null;
|
|
24
|
+
argus: ArgusConfig;
|
|
25
|
+
vault: VaultConfig;
|
|
26
|
+
database: DatabaseConfig;
|
|
27
|
+
versions: VersionDefinition[];
|
|
28
|
+
publicRoutes: RouteDefinition[];
|
|
29
|
+
publicMiddleware: MiddlewareDefinition<object>[];
|
|
30
|
+
serviceRoutes: RouteDefinition[];
|
|
31
|
+
serviceMiddleware: MiddlewareDefinition<object>[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export async function loadProject(
|
|
35
|
+
root: string,
|
|
36
|
+
options?: { generation?: string },
|
|
37
|
+
): Promise<LoadedProject> {
|
|
38
|
+
const generation = options?.generation;
|
|
39
|
+
if (generation) {
|
|
40
|
+
beginProjectReload(root, generation);
|
|
41
|
+
}
|
|
42
|
+
const loaded = await loadConfig(root, generation);
|
|
43
|
+
const paths = loaded;
|
|
44
|
+
if (!existsSync(paths.versioning)) {
|
|
45
|
+
throw projectError("missingVersionFile", `Missing ${paths.versioning}`);
|
|
46
|
+
}
|
|
47
|
+
const versioning = await import(projectHref(paths.versioning, generation));
|
|
48
|
+
if (!isVersions(versioning.default)) {
|
|
49
|
+
throw projectError(
|
|
50
|
+
"invalidVersionFile",
|
|
51
|
+
`${paths.versioning} must default-export defineVersions(...)`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const publicDirectory = paths.public;
|
|
55
|
+
const serviceDirectory = paths.service;
|
|
56
|
+
return {
|
|
57
|
+
paths,
|
|
58
|
+
name: loaded.name,
|
|
59
|
+
argus: loaded.argus,
|
|
60
|
+
vault: loaded.vault,
|
|
61
|
+
database: loaded.database,
|
|
62
|
+
versions: versioning.default.versions,
|
|
63
|
+
publicRoutes: await loadRoutes(publicDirectory, generation),
|
|
64
|
+
publicMiddleware: await loadServerMiddleware(publicDirectory, generation),
|
|
65
|
+
serviceRoutes: await loadRoutes(serviceDirectory, generation),
|
|
66
|
+
serviceMiddleware: await loadServerMiddleware(serviceDirectory, generation),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function projectHref(file: string, generation?: string) {
|
|
71
|
+
return generation
|
|
72
|
+
? freshImportUrl(file, generation)
|
|
73
|
+
: pathToFileURL(file).href;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function loadRoutes(directory: string, generation?: string) {
|
|
77
|
+
if (!existsSync(directory)) {
|
|
78
|
+
throw projectError("missingRouteDirectory", `Missing ${directory}`);
|
|
79
|
+
}
|
|
80
|
+
const routes: RouteDefinition[] = [];
|
|
81
|
+
for (const file of await collectRouteFiles(directory)) {
|
|
82
|
+
const loaded = await import(
|
|
83
|
+
projectHref(path.join(directory, file), generation)
|
|
84
|
+
);
|
|
85
|
+
const exported = routesFromModule(loaded);
|
|
86
|
+
if (exported.length === 0) {
|
|
87
|
+
throw projectError(
|
|
88
|
+
"invalidRouteFile",
|
|
89
|
+
`${file} must export defineRoute(...)`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
routes.push(...exported);
|
|
93
|
+
}
|
|
94
|
+
return routes;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function loadServerMiddleware(directory: string, generation?: string) {
|
|
98
|
+
const file = path.join(directory, "_middleware.ts");
|
|
99
|
+
if (!existsSync(file)) {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
const loaded = await import(projectHref(file, generation));
|
|
103
|
+
const exported = middlewareList(loaded.default);
|
|
104
|
+
if (!exported) {
|
|
105
|
+
throw projectError(
|
|
106
|
+
"invalidMiddlewareFile",
|
|
107
|
+
`${file} must default-export defineMiddleware(...)`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return exported;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function routesFromModule(module: object) {
|
|
114
|
+
const record = module as Record<string, unknown>;
|
|
115
|
+
const names = Object.keys(record).filter((name) => name !== "default");
|
|
116
|
+
const ordered = "default" in record ? ["default", ...names] : names;
|
|
117
|
+
const routes: RouteDefinition[] = [];
|
|
118
|
+
for (const name of ordered) {
|
|
119
|
+
const value = record[name];
|
|
120
|
+
if (isRoute(value)) {
|
|
121
|
+
routes.push(value);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return routes;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function collectRouteFiles(directory: string) {
|
|
128
|
+
const files = await walk(directory, "");
|
|
129
|
+
return files.filter(
|
|
130
|
+
(file) =>
|
|
131
|
+
file.endsWith(".ts") &&
|
|
132
|
+
!file.endsWith(".test.ts") &&
|
|
133
|
+
!isServerMiddlewareFile(file),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function walk(directory: string, prefix: string): Promise<string[]> {
|
|
138
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
139
|
+
const files: string[] = [];
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
142
|
+
if (entry.isDirectory()) {
|
|
143
|
+
files.push(...(await walk(path.join(directory, entry.name), relative)));
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (entry.isFile()) {
|
|
147
|
+
files.push(relative);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return files;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isServerMiddlewareFile(file: string) {
|
|
154
|
+
const normalized = file.replaceAll("\\", "/");
|
|
155
|
+
return (
|
|
156
|
+
normalized === "_middleware.ts" || normalized.endsWith("/_middleware.ts")
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { freshImportUrl, resolveProjectImport } from "./reload-graph.ts";
|
|
4
|
+
|
|
5
|
+
describe("project reload imports", () => {
|
|
6
|
+
const root = path.resolve("keja-app");
|
|
7
|
+
const route = path.join(root, "src", "public", "posts.ts");
|
|
8
|
+
|
|
9
|
+
it("adds a generation to a project file import", () => {
|
|
10
|
+
expect(freshImportUrl(route, "4")).toBe(`keja:${route}?keja=4`);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("follows a relative import out of the route directory", () => {
|
|
14
|
+
const resolved = resolveProjectImport({
|
|
15
|
+
importer: `keja:${route}`,
|
|
16
|
+
specifier: "../data.ts",
|
|
17
|
+
projectRoot: root,
|
|
18
|
+
generation: "4",
|
|
19
|
+
});
|
|
20
|
+
const file = path.join(root, "src", "data.ts");
|
|
21
|
+
expect(resolved).toEqual({
|
|
22
|
+
path: `${file}?keja=4`,
|
|
23
|
+
namespace: "keja",
|
|
24
|
+
file,
|
|
25
|
+
outside: false,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("follows the next file imported by that dependency", () => {
|
|
30
|
+
const data = path.join(root, "src", "data.ts");
|
|
31
|
+
const resolved = resolveProjectImport({
|
|
32
|
+
importer: `keja:${data}?keja=4`,
|
|
33
|
+
specifier: "./schema.ts",
|
|
34
|
+
projectRoot: root,
|
|
35
|
+
generation: "4",
|
|
36
|
+
});
|
|
37
|
+
const file = path.join(root, "src", "schema.ts");
|
|
38
|
+
expect(resolved?.path).toBe(`${file}?keja=4`);
|
|
39
|
+
expect(resolved?.file).toBe(file);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("leaves package imports cached", () => {
|
|
43
|
+
expect(
|
|
44
|
+
resolveProjectImport({
|
|
45
|
+
importer: `keja:${route}`,
|
|
46
|
+
specifier: "@veducio/keja",
|
|
47
|
+
projectRoot: root,
|
|
48
|
+
generation: "4",
|
|
49
|
+
}),
|
|
50
|
+
).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
});
|