@lobb-js/core 0.13.3 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/Lobb.ts +2 -3
- package/src/api/WebServer.ts +3 -45
- package/src/studio/Studio.ts +41 -31
- package/src/types/config/collectionFields.ts +20 -0
- package/src/types/config/collectionsConfig.ts +13 -0
- package/src/workflows/coreWorkflows/index.ts +5 -0
- package/src/workflows/coreWorkflows/processors/hooksWorkflows.ts +60 -0
- package/src/workflows/coreWorkflows/processors/utils.ts +1 -0
- package/src/workflows/coreWorkflows/processors/validatorWorkflows.ts +54 -0
package/package.json
CHANGED
package/src/Lobb.ts
CHANGED
|
@@ -59,7 +59,6 @@ export class Lobb {
|
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
console.log("starting the app");
|
|
63
62
|
const lobb = new Lobb();
|
|
64
63
|
lobb.studio = studio;
|
|
65
64
|
lobb.tempDatabase = isTempDatabase;
|
|
@@ -79,7 +78,7 @@ export class Lobb {
|
|
|
79
78
|
lobb.schemaService = await SchemaService.init();
|
|
80
79
|
const webPort = lobb.configManager.config.web_server.port ?? 3000;
|
|
81
80
|
const studioPort = await studio.start(webPort);
|
|
82
|
-
lobb.webServer = await WebServer.init(studioPort);
|
|
81
|
+
lobb.webServer = await WebServer.init(studio.devPort !== null ? null : studioPort);
|
|
83
82
|
lobb.collectionControllers = collectionControllers;
|
|
84
83
|
lobb.transactions = transactions;
|
|
85
84
|
await lobb.extensionSystem.executeExtensionsInitMethods();
|
|
@@ -90,7 +89,7 @@ export class Lobb {
|
|
|
90
89
|
// generate types
|
|
91
90
|
lobb.typesGenerator = await TypesGenerator.init();
|
|
92
91
|
|
|
93
|
-
console.log(
|
|
92
|
+
console.log(`The app is running on http://localhost:${studioPort ?? lobb.webServer.port}`);
|
|
94
93
|
|
|
95
94
|
return lobb;
|
|
96
95
|
}
|
package/src/api/WebServer.ts
CHANGED
|
@@ -74,53 +74,11 @@ export class WebServer {
|
|
|
74
74
|
this.app.all("/studio/*", studioProxy);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
const serveBase = {
|
|
77
|
+
this.server = Bun.serve({
|
|
79
78
|
port: this.webConfig.port ?? 3000,
|
|
80
79
|
hostname: this.webConfig.host ?? "0.0.0.0",
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// In dev, forward WebSocket upgrade requests to the Vite dev server so that
|
|
84
|
-
// Vite's HMR can connect through the Lobb proxy instead of falling back to
|
|
85
|
-
// a direct connection (which causes a console warning).
|
|
86
|
-
if (isDev && studioPort) {
|
|
87
|
-
type WSData = { path: string; upstream?: WebSocket };
|
|
88
|
-
this.server = Bun.serve<WSData>({
|
|
89
|
-
...serveBase,
|
|
90
|
-
fetch: (req, server) => {
|
|
91
|
-
if (req.headers.get("upgrade") === "websocket") {
|
|
92
|
-
const url = new URL(req.url);
|
|
93
|
-
if (url.pathname.startsWith("/studio")) {
|
|
94
|
-
const upgraded = server.upgrade(req, { data: { path: url.pathname + url.search } });
|
|
95
|
-
if (upgraded) return undefined;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return this.app.fetch(req);
|
|
99
|
-
},
|
|
100
|
-
websocket: {
|
|
101
|
-
open(ws) {
|
|
102
|
-
const upstream = new WebSocket(`ws://localhost:${studioPort}${ws.data.path.replace(/^\/studio/, "") || "/"}`);
|
|
103
|
-
ws.data.upstream = upstream;
|
|
104
|
-
upstream.addEventListener("message", (e) => ws.send(e.data as string | ArrayBuffer));
|
|
105
|
-
upstream.addEventListener("close", (e) => ws.close(e.code, e.reason));
|
|
106
|
-
upstream.addEventListener("error", () => ws.close());
|
|
107
|
-
},
|
|
108
|
-
message(ws, message) {
|
|
109
|
-
if (ws.data.upstream?.readyState === WebSocket.OPEN) {
|
|
110
|
-
ws.data.upstream.send(message as string | ArrayBuffer);
|
|
111
|
-
}
|
|
112
|
-
},
|
|
113
|
-
close(ws, code, reason) {
|
|
114
|
-
ws.data.upstream?.close(code, reason);
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
});
|
|
118
|
-
} else {
|
|
119
|
-
this.server = Bun.serve({
|
|
120
|
-
...serveBase,
|
|
121
|
-
fetch: this.app.fetch,
|
|
122
|
-
});
|
|
123
|
-
}
|
|
80
|
+
fetch: this.app.fetch,
|
|
81
|
+
});
|
|
124
82
|
this.port = this.server.port!;
|
|
125
83
|
}
|
|
126
84
|
}
|
package/src/studio/Studio.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { AddressInfo } from "node:net";
|
|
|
7
7
|
export class Studio {
|
|
8
8
|
private studioDir: string;
|
|
9
9
|
private process: ReturnType<typeof Bun.spawn> | null = null;
|
|
10
|
+
public devPort: number | null = null;
|
|
10
11
|
|
|
11
12
|
constructor(projectDir: string) {
|
|
12
13
|
// Support flat structure (vite.config.ts at project root) and nested (studio/ subdir).
|
|
@@ -21,24 +22,17 @@ export class Studio {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
public async start(webPort: number): Promise<number | null> {
|
|
25
|
+
if (this.process) return null;
|
|
24
26
|
if (!await this.exists()) return null;
|
|
25
27
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const s = await stat(buildDir);
|
|
32
|
-
if (!s.isDirectory()) throw new Error();
|
|
33
|
-
} catch {
|
|
34
|
-
throw new Error(
|
|
35
|
-
"Studio build not found. Run 'bun run build:studio' before starting in production mode."
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
return await this.serveBuild(webPort);
|
|
39
|
-
}
|
|
28
|
+
const buildDir = `${this.studioDir}/build`;
|
|
29
|
+
try {
|
|
30
|
+
const s = await stat(buildDir);
|
|
31
|
+
if (s.isDirectory()) return await this.serveBuild(webPort);
|
|
32
|
+
} catch {}
|
|
40
33
|
|
|
41
|
-
|
|
34
|
+
await this.runDevServer(webPort);
|
|
35
|
+
return this.devPort;
|
|
42
36
|
}
|
|
43
37
|
|
|
44
38
|
private async exists(): Promise<boolean> {
|
|
@@ -51,13 +45,12 @@ export class Studio {
|
|
|
51
45
|
}
|
|
52
46
|
|
|
53
47
|
private async serveBuild(webPort: number): Promise<number> {
|
|
54
|
-
console.log("Studio: running production build.");
|
|
55
48
|
const port = await Studio.getFreePort();
|
|
56
49
|
this.process = Bun.spawn(["bun", "run", "build/index.js"], {
|
|
57
50
|
cwd: this.studioDir,
|
|
58
51
|
env: { ...process.env, PORT: String(port) },
|
|
59
|
-
stdin: "
|
|
60
|
-
stdout: "
|
|
52
|
+
stdin: "ignore",
|
|
53
|
+
stdout: "ignore",
|
|
61
54
|
stderr: "inherit",
|
|
62
55
|
});
|
|
63
56
|
await Studio.waitForPort(port);
|
|
@@ -65,19 +58,18 @@ export class Studio {
|
|
|
65
58
|
return port;
|
|
66
59
|
}
|
|
67
60
|
|
|
68
|
-
private async runDevServer(webPort: number): Promise<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return port;
|
|
61
|
+
private async runDevServer(webPort: number): Promise<void> {
|
|
62
|
+
this.devPort = await Studio.getAvailablePort(5173);
|
|
63
|
+
this.process = Bun.spawn(
|
|
64
|
+
["bun", "run", "dev:studio", "--", "--port", String(this.devPort)],
|
|
65
|
+
{
|
|
66
|
+
cwd: this.studioDir,
|
|
67
|
+
stdin: "ignore",
|
|
68
|
+
stdout: "inherit",
|
|
69
|
+
stderr: "inherit",
|
|
70
|
+
env: { ...process.env, LOBB_PORT: String(webPort) },
|
|
71
|
+
}
|
|
72
|
+
);
|
|
81
73
|
}
|
|
82
74
|
|
|
83
75
|
private static getFreePort(): Promise<number> {
|
|
@@ -93,6 +85,24 @@ export class Studio {
|
|
|
93
85
|
});
|
|
94
86
|
}
|
|
95
87
|
|
|
88
|
+
// Like Vite's port resolution: try preferred port, increment on conflict.
|
|
89
|
+
// Predictable ports preserve browser sessions across restarts.
|
|
90
|
+
private static getAvailablePort(preferred: number): Promise<number> {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const server = createServer();
|
|
93
|
+
server.listen(preferred, "127.0.0.1", () => {
|
|
94
|
+
server.close((err) => {
|
|
95
|
+
if (err) reject(err);
|
|
96
|
+
else resolve(preferred);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
100
|
+
if (err.code === "EADDRINUSE") resolve(Studio.getAvailablePort(preferred + 1));
|
|
101
|
+
else reject(err);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
96
106
|
private static async waitForPort(port: number, maxAttempts = 50): Promise<void> {
|
|
97
107
|
for (let i = 0; i < maxAttempts; i++) {
|
|
98
108
|
const connected = await new Promise<boolean>((resolve) => {
|
|
@@ -15,8 +15,28 @@ const UiSchema = z.object({
|
|
|
15
15
|
input: UiInputSchema.optional(),
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
+
export type FieldHookFn = (ctx: {
|
|
19
|
+
data: Record<string, any>;
|
|
20
|
+
context?: any;
|
|
21
|
+
}) => any;
|
|
22
|
+
|
|
23
|
+
export type FieldValidatorFn = (ctx: {
|
|
24
|
+
value: any;
|
|
25
|
+
data: Record<string, any>;
|
|
26
|
+
context?: any;
|
|
27
|
+
}) => string | undefined | Promise<string | undefined>;
|
|
28
|
+
|
|
29
|
+
const FieldHooksSchema = z.object({
|
|
30
|
+
beforeCreate: z.custom<FieldHookFn>((val) => typeof val === "function").optional(),
|
|
31
|
+
beforeUpdate: z.custom<FieldHookFn>((val) => typeof val === "function").optional(),
|
|
32
|
+
afterCreate: z.custom<FieldHookFn>((val) => typeof val === "function").optional(),
|
|
33
|
+
afterUpdate: z.custom<FieldHookFn>((val) => typeof val === "function").optional(),
|
|
34
|
+
}).optional();
|
|
35
|
+
|
|
18
36
|
// Base field schema
|
|
19
37
|
export const CollectionFieldBaseSchema = z.object({
|
|
38
|
+
hooks: FieldHooksSchema,
|
|
39
|
+
validator: z.custom<FieldValidatorFn>((val) => typeof val === "function").optional(),
|
|
20
40
|
pre_processors: z.record(z.any()).optional(),
|
|
21
41
|
post_processors: z.record(z.any()).optional(),
|
|
22
42
|
validators: z.record(z.any()).optional(),
|
|
@@ -4,6 +4,18 @@ import {
|
|
|
4
4
|
CollectionIntegerFieldSchema,
|
|
5
5
|
} from "./collectionFields.ts";
|
|
6
6
|
|
|
7
|
+
export type CollectionHookFn = (ctx: {
|
|
8
|
+
data: Record<string, any>;
|
|
9
|
+
context?: any;
|
|
10
|
+
}) => void | Promise<void>;
|
|
11
|
+
|
|
12
|
+
const CollectionHooksSchema = z.object({
|
|
13
|
+
beforeCreate: z.custom<CollectionHookFn>((val) => typeof val === "function").optional(),
|
|
14
|
+
afterCreate: z.custom<CollectionHookFn>((val) => typeof val === "function").optional(),
|
|
15
|
+
beforeUpdate: z.custom<CollectionHookFn>((val) => typeof val === "function").optional(),
|
|
16
|
+
afterUpdate: z.custom<CollectionHookFn>((val) => typeof val === "function").optional(),
|
|
17
|
+
}).optional();
|
|
18
|
+
|
|
7
19
|
// CollectionIndexField
|
|
8
20
|
export const CollectionIndexFieldSchema = z.object({
|
|
9
21
|
order: z.enum(["asc", "desc"]),
|
|
@@ -33,6 +45,7 @@ export const CollectionFieldsSchema = z.intersection(
|
|
|
33
45
|
export const CollectionConfigSchema = z.object({
|
|
34
46
|
category: z.string().optional(),
|
|
35
47
|
singleton: z.boolean().optional(),
|
|
48
|
+
hooks: CollectionHooksSchema,
|
|
36
49
|
indexes: CollectionIndexesSchema,
|
|
37
50
|
fields: CollectionFieldsSchema,
|
|
38
51
|
});
|
|
@@ -3,13 +3,18 @@ import { getCollectionsTableWorkflows } from "./collectionsTable/index.ts";
|
|
|
3
3
|
import { getUtilsCoreWorkflows } from "./utilsCoreWorkflows.ts";
|
|
4
4
|
import { postOperationsWorkflows } from "./processors/postOperationsWorkflows.ts";
|
|
5
5
|
import { preOperationsWorkflows } from "./processors/preOperationsWorkflows.ts";
|
|
6
|
+
import { hooksWorkflows } from "./processors/hooksWorkflows.ts";
|
|
7
|
+
import { validatorWorkflows } from "./processors/validatorWorkflows.ts";
|
|
6
8
|
import { coreWorkflowsCollectionWorkflows } from "./workflowsCollection/workflowsCollectionWorkflows.ts";
|
|
7
9
|
import { getQueryCoreWorkflows } from "./queryCoreWorkflows.ts";
|
|
8
10
|
|
|
9
11
|
export function getCoreWorkflows(): Array<Workflow> {
|
|
10
12
|
return [
|
|
13
|
+
...hooksWorkflows.filter((w) => w.name.startsWith("core_hooksBefore")),
|
|
14
|
+
...validatorWorkflows,
|
|
11
15
|
...preOperationsWorkflows,
|
|
12
16
|
...postOperationsWorkflows,
|
|
17
|
+
...hooksWorkflows.filter((w) => w.name.startsWith("core_hooksAfter")),
|
|
13
18
|
...coreWorkflowsCollectionWorkflows,
|
|
14
19
|
...getCollectionsTableWorkflows(),
|
|
15
20
|
...getQueryCoreWorkflows(),
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Workflow } from "../../WorkflowSystem.ts";
|
|
2
|
+
import { Lobb } from "../../../Lobb.ts";
|
|
3
|
+
|
|
4
|
+
async function runFieldHooks(
|
|
5
|
+
hookName: "beforeCreate" | "beforeUpdate" | "afterCreate" | "afterUpdate",
|
|
6
|
+
input: any,
|
|
7
|
+
) {
|
|
8
|
+
const fields = Lobb.instance.configManager.getCollection(input.collectionName).fields;
|
|
9
|
+
for (const [fieldName, fieldConfig] of Object.entries(fields)) {
|
|
10
|
+
const hook = fieldConfig.hooks?.[hookName];
|
|
11
|
+
if (!hook) continue;
|
|
12
|
+
const result = await hook({ data: input.data, context: input.context });
|
|
13
|
+
if (result !== undefined) {
|
|
14
|
+
input.data[fieldName] = result;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const hooksWorkflows: Workflow[] = [
|
|
20
|
+
{
|
|
21
|
+
name: "core_hooksBeforeCreate",
|
|
22
|
+
eventName: "core.store.preCreateOne",
|
|
23
|
+
handler: async (input) => {
|
|
24
|
+
const hooks = Lobb.instance.configManager.getCollection(input.collectionName).hooks;
|
|
25
|
+
await hooks?.beforeCreate?.({ data: input.data, context: input.context });
|
|
26
|
+
await runFieldHooks("beforeCreate", input);
|
|
27
|
+
return input;
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "core_hooksBeforeUpdate",
|
|
32
|
+
eventName: "core.store.preUpdateOne",
|
|
33
|
+
handler: async (input) => {
|
|
34
|
+
const hooks = Lobb.instance.configManager.getCollection(input.collectionName).hooks;
|
|
35
|
+
await hooks?.beforeUpdate?.({ data: input.data, context: input.context });
|
|
36
|
+
await runFieldHooks("beforeUpdate", input);
|
|
37
|
+
return input;
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "core_hooksAfterCreate",
|
|
42
|
+
eventName: "core.store.createOne",
|
|
43
|
+
handler: async (input) => {
|
|
44
|
+
const hooks = Lobb.instance.configManager.getCollection(input.collectionName).hooks;
|
|
45
|
+
await hooks?.afterCreate?.({ data: input.data, context: input.context });
|
|
46
|
+
await runFieldHooks("afterCreate", input);
|
|
47
|
+
return input;
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "core_hooksAfterUpdate",
|
|
52
|
+
eventName: "core.store.updateOne",
|
|
53
|
+
handler: async (input) => {
|
|
54
|
+
const hooks = Lobb.instance.configManager.getCollection(input.collectionName).hooks;
|
|
55
|
+
await hooks?.afterUpdate?.({ data: input.data, context: input.context });
|
|
56
|
+
await runFieldHooks("afterUpdate", input);
|
|
57
|
+
return input;
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Workflow } from "../../WorkflowSystem.ts";
|
|
2
|
+
import { Lobb } from "../../../Lobb.ts";
|
|
3
|
+
import { LobbError } from "../../../LobbError.ts";
|
|
4
|
+
|
|
5
|
+
async function runFieldValidators(input: any, processAllFields: boolean) {
|
|
6
|
+
const fields = Lobb.instance.configManager.getCollection(input.collectionName).fields;
|
|
7
|
+
const data = input.data;
|
|
8
|
+
const errors: Record<string, string[]> = {};
|
|
9
|
+
|
|
10
|
+
const fieldNames = processAllFields ? Object.keys(fields) : Object.keys(data);
|
|
11
|
+
|
|
12
|
+
for (const fieldName of fieldNames) {
|
|
13
|
+
const fieldConfig = fields[fieldName];
|
|
14
|
+
if (!fieldConfig?.validator) continue;
|
|
15
|
+
|
|
16
|
+
const error = await fieldConfig.validator({
|
|
17
|
+
value: data[fieldName],
|
|
18
|
+
data,
|
|
19
|
+
context: input.context,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (error) {
|
|
23
|
+
if (!errors[fieldName]) errors[fieldName] = [];
|
|
24
|
+
errors[fieldName].push(error);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (Object.keys(errors).length) {
|
|
29
|
+
throw new LobbError({
|
|
30
|
+
code: "BAD_REQUEST",
|
|
31
|
+
message: "Validation failed. Please check your inputs and try again.",
|
|
32
|
+
details: errors,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return input;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const validatorWorkflows: Workflow[] = [
|
|
40
|
+
{
|
|
41
|
+
name: "core_validatorOnCreate",
|
|
42
|
+
eventName: "core.store.preCreateOne",
|
|
43
|
+
handler: async (input) => {
|
|
44
|
+
return await runFieldValidators(input, true);
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "core_validatorOnUpdate",
|
|
49
|
+
eventName: "core.store.preUpdateOne",
|
|
50
|
+
handler: async (input) => {
|
|
51
|
+
return await runFieldValidators(input, false);
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
];
|