@wervt/nuxt 0.2.16 → 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 +2 -1
- package/dist/dev.d.ts +5 -0
- package/dist/dev.js +23 -3
- package/dist/module.js +40 -7
- package/dist/provision.d.ts +5 -1
- package/dist/provision.js +12 -1
- package/dist/route-tools.js +44 -0
- package/dist/runtime/app/useShape.d.ts +13 -0
- package/dist/runtime/app/useShape.js +102 -35
- package/dist/runtime/server/boot.d.ts +1 -1
- package/dist/runtime/server/index.d.ts +3 -2
- package/dist/runtime/server/index.js +4 -3
- package/dist/runtime/server/mcp.js +2 -6
- package/dist/runtime/server/seed.d.ts +18 -0
- package/dist/runtime/server/seed.js +27 -0
- package/dist/runtime/server/tool-handlers.d.ts +5 -0
- package/dist/runtime/server/tool-handlers.js +37 -13
- package/dist/runtime/server/tools.d.ts +36 -0
- package/dist/runtime/server/tools.js +53 -1
- package/dist/runtime/server/validate.d.ts +1 -0
- package/dist/runtime/server/validate.js +1 -1
- package/dist/seed.js +52 -0
- package/dist/shapes.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,7 +23,8 @@ export default defineNuxtConfig({
|
|
|
23
23
|
- `wervt: { feedback: { key } }` and `useWervtFeedback()`: the kontor feedback widget, reports go into a kontor project's triage.
|
|
24
24
|
- `defineTool()` (server): tools in `server/tools/` that browser agents call through WebMCP, and
|
|
25
25
|
MCP clients (Claude Code, Cursor, …) at `/mcp`, signed in through wervt's OAuth login.
|
|
26
|
-
- `nuxt dev` starts against the local wervt stack (Postgres, Electric) and a dev user
|
|
26
|
+
- `nuxt dev` starts against the local wervt stack (Postgres, Electric) and a dev user, and runs
|
|
27
|
+
the `db:seed` task (`seedTable()` inserts into empty tables) once on a new database.
|
|
27
28
|
|
|
28
29
|
Deploy with [`@wervt/cli`](https://www.npmjs.com/package/@wervt/cli): `wervt deploy`.
|
|
29
30
|
|
package/dist/dev.d.ts
CHANGED
|
@@ -11,6 +11,11 @@ export interface DevOptions {
|
|
|
11
11
|
s3Url?: string;
|
|
12
12
|
garageAdminUrl?: string;
|
|
13
13
|
garageAdminToken?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Runs the app's `db:seed` task (`server/tasks/db/seed.ts`) once its database was just created
|
|
16
|
+
* and migrated. Defaults to true; `false` leaves seeding to `nuxt task run db:seed`.
|
|
17
|
+
*/
|
|
18
|
+
seed?: boolean;
|
|
14
19
|
/**
|
|
15
20
|
* Who `/_wervt/login` signs in as during `nuxt dev` (there's no wervt auth locally).
|
|
16
21
|
* Defaults to a dev user with a Twitch account linked.
|
package/dist/dev.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createStorage } from "./storage.js";
|
|
2
2
|
import { appDatabaseUrl, dbRole, provisionDatabase, storageCredentials } from "./provision.js";
|
|
3
|
+
import { SEED_TASK, runDevSeed } from "./seed.js";
|
|
3
4
|
import { useLogger } from "nuxt/kit";
|
|
4
5
|
import { existsSync, watch } from "node:fs";
|
|
5
6
|
import { readFile } from "node:fs/promises";
|
|
@@ -17,8 +18,8 @@ const DEFAULTS = {
|
|
|
17
18
|
const logger = useLogger("wervt");
|
|
18
19
|
/**
|
|
19
20
|
* `nuxt dev` against the local compose stack: provisions the app's role and schema,
|
|
20
|
-
* runs its migrations (again whenever `drizzle/` changes)
|
|
21
|
-
* at the app's database and Electric.
|
|
21
|
+
* runs its migrations (again whenever `drizzle/` changes), seeds a new database and
|
|
22
|
+
* points the dev server at the app's database and Electric.
|
|
22
23
|
*/
|
|
23
24
|
async function setupDev(nuxt, app, options, features = {}) {
|
|
24
25
|
const config = {
|
|
@@ -42,10 +43,29 @@ async function setupDev(nuxt, app, options, features = {}) {
|
|
|
42
43
|
secret: config.dbSecret,
|
|
43
44
|
migrationsFolder
|
|
44
45
|
};
|
|
46
|
+
let serverUrl;
|
|
47
|
+
let seedPending = false;
|
|
48
|
+
const seed = async () => {
|
|
49
|
+
if (!seedPending || !serverUrl) return;
|
|
50
|
+
seedPending = false;
|
|
51
|
+
try {
|
|
52
|
+
if ((await runDevSeed(serverUrl)).status === "seeded") logger.success(`Seeded \`${dbRole(app)}\` (${SEED_TASK})`);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
logger.warn(`\`${SEED_TASK}\` failed:`, e instanceof Error ? e.message : e);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
if (options.seed !== false) nuxt.hook("listen", (_server, listener) => {
|
|
58
|
+
serverUrl = listener.url;
|
|
59
|
+
seed();
|
|
60
|
+
});
|
|
45
61
|
const migrate = async () => {
|
|
46
62
|
try {
|
|
47
|
-
await provisionDatabase(provision);
|
|
63
|
+
const { fresh } = await provisionDatabase(provision);
|
|
48
64
|
logger.success(`Database \`${dbRole(app)}\` is up to date`);
|
|
65
|
+
if (fresh && options.seed !== false) {
|
|
66
|
+
seedPending = true;
|
|
67
|
+
seed();
|
|
68
|
+
}
|
|
49
69
|
} catch (e) {
|
|
50
70
|
logger.warn(`Could not migrate \`${dbRole(app)}\`. Is the wervt stack running (docker compose up -d)?`, e instanceof Error ? e.message : e);
|
|
51
71
|
}
|
package/dist/module.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { SEED_TASK, unscheduleSeed } from "./seed.js";
|
|
1
2
|
import { resolveAppName, setupDev } from "./dev.js";
|
|
2
|
-
import {
|
|
3
|
+
import { findRouteTools } from "./route-tools.js";
|
|
4
|
+
import { findShapeRoutes, shapeRoutesTypes, shapesManifest } from "./shapes.js";
|
|
3
5
|
import { addImports, addPlugin, addServerHandler, addServerPlugin, addServerTemplate, addTypeTemplate, createResolver, defineNuxtModule, getNuxtVersion, hasNuxtModule, installModule, updateTemplates, useLogger } from "nuxt/kit";
|
|
4
6
|
import { existsSync } from "node:fs";
|
|
5
7
|
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
@@ -129,6 +131,14 @@ var module_default = defineNuxtModule({
|
|
|
129
131
|
...nitro.experimental,
|
|
130
132
|
tasks: true
|
|
131
133
|
};
|
|
134
|
+
nuxt.hook("nitro:config", (config) => {
|
|
135
|
+
const options = config;
|
|
136
|
+
if (!options.scheduledTasks) return;
|
|
137
|
+
const { schedules, removed } = unscheduleSeed(options.scheduledTasks);
|
|
138
|
+
if (removed.length === 0) return;
|
|
139
|
+
logger.warn(`\`${SEED_TASK}\` is not scheduled (${removed.join(", ")}): seeding only runs on request.`);
|
|
140
|
+
options.scheduledTasks = schedules;
|
|
141
|
+
});
|
|
132
142
|
const nitroVersion = nitroMajor >= 3 ? "nitro3" : "nitro2";
|
|
133
143
|
addServerHandler({
|
|
134
144
|
route: "/_wervt/tasks/**",
|
|
@@ -166,14 +176,20 @@ var module_default = defineNuxtModule({
|
|
|
166
176
|
const toolsDir = join(nuxt.options.serverDir, "tools");
|
|
167
177
|
const webmcp = options.webmcp !== false;
|
|
168
178
|
let mcp = options.mcp !== false;
|
|
169
|
-
const
|
|
179
|
+
const routeDirs = [join(nuxt.options.serverDir, "api"), join(nuxt.options.serverDir, "routes")];
|
|
180
|
+
const routeTools = webmcp || mcp ? (await Promise.all(routeDirs.map((dir) => findRouteTools(dir)))).flat().filter((tool) => {
|
|
181
|
+
if (TOOL_NAME_RE.test(tool.name)) return true;
|
|
182
|
+
logger.warn(`Skipping ${tool.path} as a tool: pass a \`tool.name\` without spaces.`);
|
|
183
|
+
return false;
|
|
184
|
+
}) : [];
|
|
185
|
+
const tools = webmcp || mcp ? [...await findTools(toolsDir), ...routeTools] : [];
|
|
170
186
|
if (tools.length > 0 && mcp && nitroMajor < 3) {
|
|
171
187
|
if (options.mcp) logger.warn("`/mcp` needs Nuxt 5; this app keeps its tools for WebMCP only.");
|
|
172
188
|
mcp = false;
|
|
173
189
|
}
|
|
174
190
|
if (tools.length > 0) addServerTemplate({
|
|
175
191
|
filename: "#wervt/tools",
|
|
176
|
-
getContents: () => `export const tools = {\n${tools.map(({ name, path }) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(path)}),`).join("\n")}\n}\n`
|
|
192
|
+
getContents: () => `export const tools = {\n${tools.map(({ name, path }) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(path)}),`).join("\n")}\n}\nexport const routeMethods = ${JSON.stringify(Object.fromEntries(routeTools.map(({ name, method }) => [name, method ?? null])))}\n`
|
|
177
193
|
});
|
|
178
194
|
if (tools.length > 0 && webmcp) {
|
|
179
195
|
addServerHandler({
|
|
@@ -218,10 +234,16 @@ var module_default = defineNuxtModule({
|
|
|
218
234
|
handler: resolver.resolve("./runtime/server/mcp-auth-server")
|
|
219
235
|
});
|
|
220
236
|
}
|
|
221
|
-
if (nuxt.options.dev && (webmcp || mcp))
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
237
|
+
if (nuxt.options.dev && (webmcp || mcp)) {
|
|
238
|
+
const routeToolPaths = new Set(routeTools.map((tool) => tool.path));
|
|
239
|
+
nuxt.hook("builder:watch", async (event, path) => {
|
|
240
|
+
const absolute = resolve(nuxt.options.srcDir, path);
|
|
241
|
+
const file = relative(toolsDir, absolute);
|
|
242
|
+
if ((event === "add" || event === "unlink") && !file.startsWith("..")) return nuxt.callHook("restart");
|
|
243
|
+
if (!routeDirs.some((dir) => !relative(dir, absolute).startsWith(".."))) return;
|
|
244
|
+
if ((event !== "unlink" && (await readFile(absolute, "utf8").catch(() => "")).includes("defineToolRoute(")) !== routeToolPaths.has(absolute)) await nuxt.callHook("restart");
|
|
245
|
+
});
|
|
246
|
+
}
|
|
225
247
|
if (nuxt.options.dev && nitroMajor >= 3) {
|
|
226
248
|
if (!hasPackage(nuxt.options.rootDir, "vue")) logger.warn("`vue` is not a dependency of this app, so `nuxt dev` fails with \"module is not defined\". Run `pnpm add vue`.");
|
|
227
249
|
}
|
|
@@ -308,12 +330,23 @@ var module_default = defineNuxtModule({
|
|
|
308
330
|
const file = relative(shapesDir, resolve(nuxt.options.srcDir, path));
|
|
309
331
|
if ((event === "add" || event === "unlink") && !file.startsWith("..")) await updateTemplates({ filter: (template) => template.filename === shapeTypes.filename });
|
|
310
332
|
});
|
|
333
|
+
else nuxt.hook("nitro:init", (instance) => {
|
|
334
|
+
const { serverDir } = instance.options.output;
|
|
335
|
+
instance.hooks.hook("compiled", async () => {
|
|
336
|
+
await mkdir(serverDir, { recursive: true });
|
|
337
|
+
await writeFile(join(serverDir, "wervt-shapes.json"), `${JSON.stringify(shapesManifest(await findShapeRoutes(shapesDir)), null, 2)}\n`);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
311
340
|
nuxt.options.build.transpile.push(resolver.resolve("./runtime"));
|
|
312
341
|
addImports([
|
|
313
342
|
{
|
|
314
343
|
name: "useShape",
|
|
315
344
|
from: resolver.resolve("./runtime/app/useShape")
|
|
316
345
|
},
|
|
346
|
+
{
|
|
347
|
+
name: "preloadShape",
|
|
348
|
+
from: resolver.resolve("./runtime/app/useShape")
|
|
349
|
+
},
|
|
317
350
|
{
|
|
318
351
|
name: "useWervtUser",
|
|
319
352
|
from: resolver.resolve("./runtime/app/useWervtUser")
|
package/dist/provision.d.ts
CHANGED
|
@@ -26,8 +26,12 @@ export declare function appDatabaseUrl({ app, adminUrl, secret }: ProvisionOptio
|
|
|
26
26
|
* that role. Each app gets a role and schema named `app_<name>`; the role's
|
|
27
27
|
* search_path is its own schema, it can't create schemas or touch `public`, and it can
|
|
28
28
|
* only connect to the wervt database.
|
|
29
|
+
*
|
|
30
|
+
* `fresh` is true when no migration had been applied before, so `nuxt dev` knows when to seed.
|
|
29
31
|
*/
|
|
30
|
-
export declare function provisionDatabase(options: ProvisionOptions): Promise<
|
|
32
|
+
export declare function provisionDatabase(options: ProvisionOptions): Promise<{
|
|
33
|
+
fresh: boolean;
|
|
34
|
+
}>;
|
|
31
35
|
/** The role PgBouncer signs in with to look up app roles' password hashes (`auth_query`). */
|
|
32
36
|
export declare const POOLER_ROLE = "pgbouncer";
|
|
33
37
|
/**
|
package/dist/provision.js
CHANGED
|
@@ -37,6 +37,8 @@ async function appDatabaseUrl({ app, adminUrl, secret }) {
|
|
|
37
37
|
* that role. Each app gets a role and schema named `app_<name>`; the role's
|
|
38
38
|
* search_path is its own schema, it can't create schemas or touch `public`, and it can
|
|
39
39
|
* only connect to the wervt database.
|
|
40
|
+
*
|
|
41
|
+
* `fresh` is true when no migration had been applied before, so `nuxt dev` knows when to seed.
|
|
40
42
|
*/
|
|
41
43
|
async function provisionDatabase(options) {
|
|
42
44
|
const { app, adminUrl, secret, migrationsFolder } = options;
|
|
@@ -61,7 +63,15 @@ async function provisionDatabase(options) {
|
|
|
61
63
|
]) await admin.unsafe(`revoke all on database "${db}" from public`);
|
|
62
64
|
await admin.unsafe(`grant connect on database "${database}" to "${role}"`);
|
|
63
65
|
await admin.unsafe(`revoke all on schema public from public`);
|
|
64
|
-
if (!migrationsFolder) return;
|
|
66
|
+
if (!migrationsFolder) return { fresh: false };
|
|
67
|
+
const table = `"${role}"."__drizzle_migrations"`;
|
|
68
|
+
const [state] = await admin`
|
|
69
|
+
select to_regclass(${table}) is not null as exists`;
|
|
70
|
+
let fresh = !state?.exists;
|
|
71
|
+
if (!fresh) {
|
|
72
|
+
const [row] = await admin.unsafe(`select exists(select 1 from ${table}) as applied`);
|
|
73
|
+
fresh = !row?.applied;
|
|
74
|
+
}
|
|
65
75
|
await admin.unsafe(`grant create on database "${database}" to "${role}"`);
|
|
66
76
|
const client = postgres(await appDatabaseUrl(options), {
|
|
67
77
|
max: 1,
|
|
@@ -76,6 +86,7 @@ async function provisionDatabase(options) {
|
|
|
76
86
|
await client.end();
|
|
77
87
|
await admin.unsafe(`revoke create on database "${database}" from "${role}"`);
|
|
78
88
|
}
|
|
89
|
+
return { fresh };
|
|
79
90
|
} finally {
|
|
80
91
|
await admin.end();
|
|
81
92
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { extname, join, sep } from "node:path";
|
|
3
|
+
//#region src/route-tools.ts
|
|
4
|
+
const EXTENSIONS = /* @__PURE__ */ new Set([
|
|
5
|
+
".ts",
|
|
6
|
+
".js",
|
|
7
|
+
".mts",
|
|
8
|
+
".mjs"
|
|
9
|
+
]);
|
|
10
|
+
const METHOD_RE = /\.(get|post|put|patch|delete)$/;
|
|
11
|
+
/** The name and method a route file gets as a tool, or undefined for files that aren't routes. */
|
|
12
|
+
function routeToolName(file, prefix = []) {
|
|
13
|
+
const ext = extname(file);
|
|
14
|
+
if (!EXTENSIONS.has(ext) || file.endsWith(`.d${ext}`)) return void 0;
|
|
15
|
+
const parts = file.slice(0, -ext.length).split(sep).join("/").split("/");
|
|
16
|
+
let last = parts.pop();
|
|
17
|
+
const method = last.match(METHOD_RE)?.[1];
|
|
18
|
+
if (method) last = last.slice(0, -method.length - 1);
|
|
19
|
+
if (last !== "index") parts.push(last);
|
|
20
|
+
const segments = [...prefix, ...parts].map((segment) => segment.replace(/^\[(?:\.\.\.)?(.+)\]$/, "$1")).filter(Boolean);
|
|
21
|
+
if (method) segments.push(method);
|
|
22
|
+
return {
|
|
23
|
+
name: segments.join("."),
|
|
24
|
+
method
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** Route files under `dir` that call `defineToolRoute`. */
|
|
28
|
+
async function findRouteTools(dir, prefix = []) {
|
|
29
|
+
const files = await readdir(dir, { recursive: true }).catch(() => []);
|
|
30
|
+
const found = [];
|
|
31
|
+
for (const file of files.toSorted((a, b) => a.localeCompare(b))) {
|
|
32
|
+
const route = routeToolName(file, prefix);
|
|
33
|
+
if (!route) continue;
|
|
34
|
+
const path = join(dir, file);
|
|
35
|
+
if (!(await readFile(path, "utf8")).includes("defineToolRoute(")) continue;
|
|
36
|
+
found.push({
|
|
37
|
+
...route,
|
|
38
|
+
path
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return found;
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
44
|
+
export { findRouteTools, routeToolName };
|
|
@@ -31,4 +31,17 @@ interface ShapeRefs<T> {
|
|
|
31
31
|
*/
|
|
32
32
|
export declare function useShape<P extends keyof WervtShapeRoutes>(path: P, options?: UseShapeOptions): ShapeResult<WervtShapeRoutes[P]>;
|
|
33
33
|
export declare function useShape<T extends Row<unknown>>(path: string, options?: UseShapeOptions): ShapeResult<T>;
|
|
34
|
+
/**
|
|
35
|
+
* Opens a shape ahead of time, e.g. when a link is hovered, so the page that uses it renders
|
|
36
|
+
* from rows that are already here instead of waiting for the snapshot. Resolves once the rows
|
|
37
|
+
* are up to date. Client only; on the server it does nothing.
|
|
38
|
+
*
|
|
39
|
+
* @example <NuxtLink to="/todos" @mouseenter="preloadShape('/api/shapes/todos')">
|
|
40
|
+
*/
|
|
41
|
+
export declare function preloadShape<P extends keyof WervtShapeRoutes>(path: P, options?: {
|
|
42
|
+
key?: string;
|
|
43
|
+
}): Promise<void>;
|
|
44
|
+
export declare function preloadShape(path: string, options?: {
|
|
45
|
+
key?: string;
|
|
46
|
+
}): Promise<void>;
|
|
34
47
|
//#endregion
|
|
@@ -4,53 +4,120 @@ import { getCurrentScope, onScopeDispose, ref, shallowRef } from "vue";
|
|
|
4
4
|
import { onNuxtReady, useAsyncData, useRequestEvent, useRequestFetch } from "#app";
|
|
5
5
|
//#region src/runtime/app/useShape.ts
|
|
6
6
|
function useShape(path, options = {}) {
|
|
7
|
+
const key = options.key ?? `wervt-shape:${path}`;
|
|
7
8
|
const fetchClient = import.meta.server ? inProcessFetch() : void 0;
|
|
8
|
-
const asyncData = useAsyncData(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
9
|
+
const asyncData = useAsyncData(key, () => {
|
|
10
|
+
const live = import.meta.client ? shapes.get(key) : void 0;
|
|
11
|
+
return live?.stream ? live.settled.then(() => snapshotOf(live)) : loadSnapshot(path, fetchClient);
|
|
12
|
+
});
|
|
13
|
+
const shape = import.meta.client && options.live !== false ? acquire(key, path) : (import.meta.client ? shapes.get(key) : void 0) ?? createShape(path);
|
|
13
14
|
const result = {
|
|
14
|
-
data,
|
|
15
|
-
error,
|
|
16
|
-
isLive
|
|
15
|
+
data: shape.data,
|
|
16
|
+
error: shape.error,
|
|
17
|
+
isLive: shape.isLive
|
|
17
18
|
};
|
|
18
19
|
const ready = Promise.resolve(asyncData).then(() => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
if (!shape.stream && !shape.seeded) {
|
|
21
|
+
for (const [rowKey, row] of asyncData.data.value?.rows ?? []) shape.rows.set(rowKey, row);
|
|
22
|
+
shape.data.value = [...shape.rows.values()];
|
|
23
|
+
shape.offset = asyncData.data.value?.offset;
|
|
24
|
+
shape.handle = asyncData.data.value?.handle;
|
|
25
|
+
shape.seeded = true;
|
|
26
|
+
}
|
|
27
|
+
const error = asyncData.error.value ?? null;
|
|
28
|
+
if (error) {
|
|
29
|
+
shape.error.value = error;
|
|
30
|
+
console.error(`[wervt] useShape('${path}') failed:`, error);
|
|
31
|
+
}
|
|
23
32
|
return result;
|
|
24
33
|
});
|
|
25
34
|
if (import.meta.client && options.live !== false) {
|
|
26
|
-
let stream;
|
|
27
|
-
const controller = new AbortController();
|
|
28
35
|
onNuxtReady(async () => {
|
|
29
36
|
await ready;
|
|
30
|
-
|
|
31
|
-
const snapshot = asyncData.data.value;
|
|
32
|
-
stream = new ShapeStream({
|
|
33
|
-
url: absolute(path),
|
|
34
|
-
offset: snapshot?.offset,
|
|
35
|
-
handle: snapshot?.handle,
|
|
36
|
-
parser: shapeParser,
|
|
37
|
-
signal: controller.signal,
|
|
38
|
-
onError: (e) => {
|
|
39
|
-
error.value = e;
|
|
40
|
-
}
|
|
41
|
-
});
|
|
42
|
-
stream.subscribe((messages) => {
|
|
43
|
-
if (applyMessages(rows, messages)) data.value = [...rows.values()];
|
|
44
|
-
isLive.value = stream?.isUpToDate ?? false;
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
if (getCurrentScope()) onScopeDispose(() => {
|
|
48
|
-
controller.abort();
|
|
49
|
-
stream?.unsubscribeAll();
|
|
37
|
+
start(shape);
|
|
50
38
|
});
|
|
39
|
+
if (getCurrentScope()) onScopeDispose(() => release(key, shape, KEEP_AFTER_UNMOUNT_MS));
|
|
51
40
|
}
|
|
52
41
|
return Object.assign(ready, result);
|
|
53
42
|
}
|
|
43
|
+
function preloadShape(path, options = {}) {
|
|
44
|
+
if (!import.meta.client) return Promise.resolve();
|
|
45
|
+
const key = options.key ?? `wervt-shape:${path}`;
|
|
46
|
+
const shape = acquire(key, path);
|
|
47
|
+
start(shape);
|
|
48
|
+
return shape.settled.then(() => release(key, shape, KEEP_AFTER_PRELOAD_MS));
|
|
49
|
+
}
|
|
50
|
+
/** How long a shape keeps streaming once nothing uses it: a page change shouldn't reconnect. */
|
|
51
|
+
const KEEP_AFTER_UNMOUNT_MS = 1e4;
|
|
52
|
+
/** A preloaded shape waits a little longer for the navigation that uses it. */
|
|
53
|
+
const KEEP_AFTER_PRELOAD_MS = 3e4;
|
|
54
|
+
/** The shapes of this tab, one per key. Client only (see `useShape`). */
|
|
55
|
+
const shapes = /* @__PURE__ */ new Map();
|
|
56
|
+
function createShape(path) {
|
|
57
|
+
let settle = () => {};
|
|
58
|
+
const settled = new Promise((resolve) => settle = resolve);
|
|
59
|
+
return {
|
|
60
|
+
path,
|
|
61
|
+
rows: /* @__PURE__ */ new Map(),
|
|
62
|
+
data: shallowRef([]),
|
|
63
|
+
error: ref(null),
|
|
64
|
+
isLive: ref(false),
|
|
65
|
+
seeded: false,
|
|
66
|
+
settled,
|
|
67
|
+
settle,
|
|
68
|
+
users: 0
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function acquire(key, path) {
|
|
72
|
+
let shape = shapes.get(key);
|
|
73
|
+
if (!shape) {
|
|
74
|
+
shape = createShape(path);
|
|
75
|
+
shapes.set(key, shape);
|
|
76
|
+
}
|
|
77
|
+
shape.users++;
|
|
78
|
+
clearTimeout(shape.releaseTimer);
|
|
79
|
+
return shape;
|
|
80
|
+
}
|
|
81
|
+
function release(key, shape, keepMs) {
|
|
82
|
+
shape.users--;
|
|
83
|
+
if (shape.users > 0) return;
|
|
84
|
+
clearTimeout(shape.releaseTimer);
|
|
85
|
+
shape.releaseTimer = setTimeout(() => {
|
|
86
|
+
if (shape.users > 0 || shapes.get(key) !== shape) return;
|
|
87
|
+
shape.controller?.abort();
|
|
88
|
+
shape.stream?.unsubscribeAll();
|
|
89
|
+
shapes.delete(key);
|
|
90
|
+
}, keepMs);
|
|
91
|
+
}
|
|
92
|
+
/** Starts the shape's one stream, if it isn't running yet. */
|
|
93
|
+
function start(shape) {
|
|
94
|
+
if (shape.stream) return;
|
|
95
|
+
shape.controller = new AbortController();
|
|
96
|
+
const stream = new ShapeStream({
|
|
97
|
+
url: absolute(shape.path),
|
|
98
|
+
offset: shape.offset,
|
|
99
|
+
handle: shape.handle,
|
|
100
|
+
parser: shapeParser,
|
|
101
|
+
signal: shape.controller.signal,
|
|
102
|
+
onError: (e) => {
|
|
103
|
+
shape.error.value = e;
|
|
104
|
+
shape.settle();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
shape.stream = stream;
|
|
108
|
+
stream.subscribe((messages) => {
|
|
109
|
+
if (applyMessages(shape.rows, messages)) shape.data.value = [...shape.rows.values()];
|
|
110
|
+
shape.isLive.value = stream.isUpToDate;
|
|
111
|
+
if (stream.isUpToDate) shape.settle();
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function snapshotOf(shape) {
|
|
115
|
+
return {
|
|
116
|
+
rows: [...shape.rows],
|
|
117
|
+
offset: shape.stream?.lastOffset ?? shape.offset ?? "-1",
|
|
118
|
+
handle: shape.stream?.shapeHandle ?? shape.handle
|
|
119
|
+
};
|
|
120
|
+
}
|
|
54
121
|
async function loadSnapshot(path, fetchClient) {
|
|
55
122
|
const rows = /* @__PURE__ */ new Map();
|
|
56
123
|
let fail = () => {};
|
|
@@ -132,4 +199,4 @@ function absolute(path) {
|
|
|
132
199
|
return new URL(path, import.meta.client ? location.origin : "http://localhost").href;
|
|
133
200
|
}
|
|
134
201
|
//#endregion
|
|
135
|
-
export { useShape };
|
|
202
|
+
export { preloadShape, useShape };
|
|
@@ -4,10 +4,11 @@ import { useDb } from "./db.js";
|
|
|
4
4
|
import { createEvlogError, log, useLogger } from "./logger.js";
|
|
5
5
|
import { SendMailOptions, sendMail } from "./mail.js";
|
|
6
6
|
import { McpTokenContext } from "./mcp-auth.js";
|
|
7
|
+
import { seedTable } from "./seed.js";
|
|
7
8
|
import { ShapeResponse } from "../shared/shape.js";
|
|
8
9
|
import { ShapeOptions, ShapeRow, proxyShape } from "./shape.js";
|
|
9
10
|
import { Task, TaskEvent, TaskResult, defineTask } from "./tasks.js";
|
|
10
11
|
import { SafeResult, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, vh } from "./validate.js";
|
|
11
|
-
import { Tool, defineTool } from "./tools.js";
|
|
12
|
+
import { Tool, ToolRoute, defineTool, defineToolRoute } from "./tools.js";
|
|
12
13
|
import { useWervtUser } from "./user.js";
|
|
13
|
-
export { type EmailProps, type McpTokenContext, type RenderedEmail, type SafeResult, type SendMailOptions, type ShapeOptions, type ShapeResponse, type ShapeRow, type Task, type TaskEvent, type TaskResult, type Tool, type WervtUser, createEvlogError, defineTask, defineTool, log, proxyShape, renderEmail, sendMail, useDb, useLogger, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, useWervtUser, vh };
|
|
14
|
+
export { type EmailProps, type McpTokenContext, type RenderedEmail, type SafeResult, type SendMailOptions, type ShapeOptions, type ShapeResponse, type ShapeRow, type Task, type TaskEvent, type TaskResult, type Tool, type ToolRoute, type WervtUser, createEvlogError, defineTask, defineTool, defineToolRoute, log, proxyShape, renderEmail, seedTable, sendMail, useDb, useLogger, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, useWervtUser, vh };
|
|
@@ -2,9 +2,10 @@ import { renderEmail } from "../email/render.js";
|
|
|
2
2
|
import { useDb } from "./db.js";
|
|
3
3
|
import { createEvlogError, log, useLogger } from "./logger.js";
|
|
4
4
|
import { sendMail } from "./mail.js";
|
|
5
|
+
import { seedTable } from "./seed.js";
|
|
5
6
|
import { proxyShape } from "./shape.js";
|
|
6
7
|
import { defineTask } from "./tasks.js";
|
|
7
|
-
import { defineTool } from "./tools.js";
|
|
8
|
-
import { useWervtUser } from "./user.js";
|
|
9
8
|
import { useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, vh } from "./validate.js";
|
|
10
|
-
|
|
9
|
+
import { defineTool, defineToolRoute } from "./tools.js";
|
|
10
|
+
import { useWervtUser } from "./user.js";
|
|
11
|
+
export { createEvlogError, defineTask, defineTool, defineToolRoute, log, proxyShape, renderEmail, seedTable, sendMail, useDb, useLogger, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, useWervtUser, vh };
|
|
@@ -2,10 +2,9 @@ import { devUser } from "../shared/user.js";
|
|
|
2
2
|
import { useLogger } from "./logger.js";
|
|
3
3
|
import { safeValidate } from "./validate.js";
|
|
4
4
|
import { authenticateMcp, mcpEndpoint, setMcpUser } from "./mcp-auth.js";
|
|
5
|
-
import {
|
|
5
|
+
import { allTools, toolInputSchema } from "./tool-handlers.js";
|
|
6
6
|
import { defineEventHandler } from "nuxt/server";
|
|
7
7
|
import { server } from "#wervt/mcp";
|
|
8
|
-
import { tools } from "#wervt/tools";
|
|
9
8
|
import { defineMcpHandler } from "h3-mcp";
|
|
10
9
|
//#region src/runtime/server/mcp.ts
|
|
11
10
|
const MAX_BODY = 4194304;
|
|
@@ -56,10 +55,7 @@ function toMcpTool(name, tool) {
|
|
|
56
55
|
}
|
|
57
56
|
let handler;
|
|
58
57
|
async function createHandler() {
|
|
59
|
-
const definitions =
|
|
60
|
-
const tool = await loadTool(name);
|
|
61
|
-
return tool ? toMcpTool(name, tool) : void 0;
|
|
62
|
-
}))).filter((tool) => tool !== void 0);
|
|
58
|
+
const definitions = [...await allTools()].map(([name, tool]) => toMcpTool(name, tool));
|
|
63
59
|
return defineMcpHandler((event) => ({
|
|
64
60
|
...server,
|
|
65
61
|
tools: definitions,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { InferInsertModel } from "drizzle-orm";
|
|
2
|
+
import { PgTable } from "drizzle-orm/pg-core";
|
|
3
|
+
//#region src/runtime/server/seed.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Inserts `rows` into `table` only while it is empty, so a seed task can run again without
|
|
6
|
+
* duplicating data or touching what users created. Returns how many rows it inserted.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* // server/tasks/db/seed.ts
|
|
10
|
+
* export default defineTask({
|
|
11
|
+
* meta: { description: 'Fill an empty database with example data' },
|
|
12
|
+
* async run() {
|
|
13
|
+
* return { result: { todos: await seedTable(todos, [{ title: 'Try wervt' }]) } }
|
|
14
|
+
* },
|
|
15
|
+
* })
|
|
16
|
+
*/
|
|
17
|
+
export declare function seedTable<T extends PgTable>(table: T, rows: InferInsertModel<T>[]): Promise<number>;
|
|
18
|
+
//#endregion
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useDb } from "./db.js";
|
|
2
|
+
import { sql } from "drizzle-orm";
|
|
3
|
+
//#region src/runtime/server/seed.ts
|
|
4
|
+
/**
|
|
5
|
+
* Inserts `rows` into `table` only while it is empty, so a seed task can run again without
|
|
6
|
+
* duplicating data or touching what users created. Returns how many rows it inserted.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* // server/tasks/db/seed.ts
|
|
10
|
+
* export default defineTask({
|
|
11
|
+
* meta: { description: 'Fill an empty database with example data' },
|
|
12
|
+
* async run() {
|
|
13
|
+
* return { result: { todos: await seedTable(todos, [{ title: 'Try wervt' }]) } }
|
|
14
|
+
* },
|
|
15
|
+
* })
|
|
16
|
+
*/
|
|
17
|
+
async function seedTable(table, rows) {
|
|
18
|
+
if (rows.length === 0) return 0;
|
|
19
|
+
const db = useDb();
|
|
20
|
+
const target = table;
|
|
21
|
+
const [existing] = await db.select({ one: sql`1` }).from(target).limit(1);
|
|
22
|
+
if (existing) return 0;
|
|
23
|
+
await db.insert(target).values(rows);
|
|
24
|
+
return rows.length;
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
export { seedTable };
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { Input } from "./validate.js";
|
|
2
2
|
import { Tool } from "./tools.js";
|
|
3
3
|
//#region src/runtime/server/tool-handlers.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Every tool by the name agents see. A tool may rename itself (`name`), so the file-derived
|
|
6
|
+
* keys of `#wervt/tools` are only a default and the whole list is loaded once.
|
|
7
|
+
*/
|
|
8
|
+
export declare function allTools(): Promise<Map<string, Tool<Input | undefined, unknown>>>;
|
|
4
9
|
export declare function loadTool(name: string): Promise<Tool<Input | undefined, unknown> | undefined>;
|
|
5
10
|
export declare function toolInputSchema(tool: Tool): import("@valibot/to-json-schema").JsonSchema | {
|
|
6
11
|
type: string;
|
|
@@ -1,11 +1,39 @@
|
|
|
1
1
|
import { useLogger } from "./logger.js";
|
|
2
2
|
import { safeValidate, toSchema } from "./validate.js";
|
|
3
|
+
import { TOOL_ROUTE } from "./tools.js";
|
|
3
4
|
import { defineEventHandler } from "nuxt/server";
|
|
4
|
-
import { tools } from "#wervt/tools";
|
|
5
5
|
import { toJsonSchema } from "@valibot/to-json-schema";
|
|
6
|
+
import { routeMethods, tools } from "#wervt/tools";
|
|
6
7
|
//#region src/runtime/server/tool-handlers.ts
|
|
8
|
+
let index;
|
|
9
|
+
/**
|
|
10
|
+
* Every tool by the name agents see. A tool may rename itself (`name`), so the file-derived
|
|
11
|
+
* keys of `#wervt/tools` are only a default and the whole list is loaded once.
|
|
12
|
+
*/
|
|
13
|
+
function allTools() {
|
|
14
|
+
index ??= (async () => {
|
|
15
|
+
const byName = /* @__PURE__ */ new Map();
|
|
16
|
+
for (const [key, load] of Object.entries(tools)) {
|
|
17
|
+
const exported = (await load())?.default;
|
|
18
|
+
const tool = exported?.[TOOL_ROUTE] ?? exported;
|
|
19
|
+
if (!tool) continue;
|
|
20
|
+
const method = Object.hasOwn(routeMethods, key) ? routeMethods[key] : void 0;
|
|
21
|
+
const name = tool.name ?? key;
|
|
22
|
+
if (byName.has(name)) console.warn(`[wervt] Two tools are named ${name}; the last one wins.`);
|
|
23
|
+
byName.set(name, method ? {
|
|
24
|
+
...tool,
|
|
25
|
+
readOnly: tool.readOnly ?? method === "get"
|
|
26
|
+
} : tool);
|
|
27
|
+
}
|
|
28
|
+
return byName;
|
|
29
|
+
})().catch((error) => {
|
|
30
|
+
index = void 0;
|
|
31
|
+
throw error;
|
|
32
|
+
});
|
|
33
|
+
return index;
|
|
34
|
+
}
|
|
7
35
|
async function loadTool(name) {
|
|
8
|
-
return (await (
|
|
36
|
+
return (await allTools()).get(name);
|
|
9
37
|
}
|
|
10
38
|
function toolInputSchema(tool) {
|
|
11
39
|
if (!tool.input) return {
|
|
@@ -22,16 +50,12 @@ function toolInputSchema(tool) {
|
|
|
22
50
|
/** `GET /_wervt/tools`: name, description and JSON Schema of each tool, for the client plugin. */
|
|
23
51
|
const listTools = defineEventHandler(async (event) => {
|
|
24
52
|
event.res.headers.set("cache-control", "no-store");
|
|
25
|
-
return
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
inputSchema: toolInputSchema(tool),
|
|
32
|
-
readOnly: tool.readOnly === true
|
|
33
|
-
};
|
|
34
|
-
}))).filter((tool) => tool !== void 0);
|
|
53
|
+
return [...await allTools()].map(([name, tool]) => ({
|
|
54
|
+
name,
|
|
55
|
+
description: tool.description,
|
|
56
|
+
inputSchema: toolInputSchema(tool),
|
|
57
|
+
readOnly: tool.readOnly === true
|
|
58
|
+
}));
|
|
35
59
|
});
|
|
36
60
|
function crossSite(event) {
|
|
37
61
|
const site = event.req.headers.get("sec-fetch-site");
|
|
@@ -67,4 +91,4 @@ const runTool = defineEventHandler(async (event) => {
|
|
|
67
91
|
return { result: await tool.handler(event, input) ?? null };
|
|
68
92
|
});
|
|
69
93
|
//#endregion
|
|
70
|
-
export { listTools, loadTool, runTool, toolInputSchema };
|
|
94
|
+
export { allTools, listTools, loadTool, runTool, toolInputSchema };
|
|
@@ -2,6 +2,8 @@ import { Input, Output } from "./validate.js";
|
|
|
2
2
|
import { RequestEvent } from "nuxt/server";
|
|
3
3
|
//#region src/runtime/server/tools.d.ts
|
|
4
4
|
export interface Tool<I extends Input | undefined = Input | undefined, R = unknown> {
|
|
5
|
+
/** Overrides the name from the file path. Agents see it, so keep it short and stable. */
|
|
6
|
+
name?: string;
|
|
5
7
|
/** What the tool does and when an agent should call it. Agents read this to pick a tool. */
|
|
6
8
|
description: string;
|
|
7
9
|
/** valibot schema or `{ key: schema }` shape of the arguments. Omit it for no arguments. */
|
|
@@ -12,4 +14,38 @@ export interface Tool<I extends Input | undefined = Input | undefined, R = unkno
|
|
|
12
14
|
}
|
|
13
15
|
/** A tool in `server/tools/` (`server/tools/todos/add.ts` is `todos.add`). */
|
|
14
16
|
export declare function defineTool<R, I extends Input | undefined = undefined>(tool: Tool<I, R>): Tool<I, R>;
|
|
17
|
+
type Values<T extends Input | undefined> = T extends Input ? Output<T> : {};
|
|
18
|
+
export interface ToolRoute<P extends Input | undefined, I extends Input | undefined, R> {
|
|
19
|
+
/** How agents see the route. `readOnly` defaults to true for GET routes. */
|
|
20
|
+
tool: {
|
|
21
|
+
name?: string;
|
|
22
|
+
description: string;
|
|
23
|
+
readOnly?: boolean;
|
|
24
|
+
};
|
|
25
|
+
/** Route params like `[id]` (strings on the route; use `vh.*` to coerce). */
|
|
26
|
+
params?: P;
|
|
27
|
+
/** The body, or the query for GET routes. */
|
|
28
|
+
input?: I;
|
|
29
|
+
/** Gets params and input merged, from the request or from an agent's arguments. */
|
|
30
|
+
handler(event: RequestEvent, input: Values<P> & Values<I>): R | Promise<R>;
|
|
31
|
+
}
|
|
32
|
+
/** Where `defineToolRoute` keeps the tool on the event handler, for the module's tool list. */
|
|
33
|
+
export declare const TOOL_ROUTE: unique symbol;
|
|
34
|
+
/**
|
|
35
|
+
* An API route that agents can call as a tool too, over WebMCP and `/mcp`: no separate file in
|
|
36
|
+
* `server/tools/` repeating the route's logic and validation.
|
|
37
|
+
*
|
|
38
|
+
* ```ts
|
|
39
|
+
* // server/api/todos/[id].patch.ts
|
|
40
|
+
* export default defineToolRoute({
|
|
41
|
+
* tool: { name: 'todos.update', description: 'Mark a todo done or not done.' },
|
|
42
|
+
* params: { id: vh.id },
|
|
43
|
+
* input: { done: v.boolean() },
|
|
44
|
+
* handler: (event, { id, done }) => …,
|
|
45
|
+
* })
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export declare function defineToolRoute<R, P extends Input | undefined = undefined, I extends Input | undefined = undefined>(route: ToolRoute<P, I, R>): import("nuxt/server").EventHandler<Promise<R>> & {
|
|
49
|
+
[TOOL_ROUTE]: Tool<Input | undefined, unknown>;
|
|
50
|
+
};
|
|
15
51
|
//#endregion
|
|
@@ -1,7 +1,59 @@
|
|
|
1
|
+
import { validate } from "./validate.js";
|
|
2
|
+
import { defineEventHandler, getQuery, getRouterParams, readBody } from "nuxt/server";
|
|
3
|
+
import * as v from "valibot";
|
|
1
4
|
//#region src/runtime/server/tools.ts
|
|
2
5
|
/** A tool in `server/tools/` (`server/tools/todos/add.ts` is `todos.add`). */
|
|
3
6
|
function defineTool(tool) {
|
|
4
7
|
return tool;
|
|
5
8
|
}
|
|
9
|
+
/** Where `defineToolRoute` keeps the tool on the event handler, for the module's tool list. */
|
|
10
|
+
const TOOL_ROUTE = Symbol.for("wervt.toolRoute");
|
|
11
|
+
/**
|
|
12
|
+
* An API route that agents can call as a tool too, over WebMCP and `/mcp`: no separate file in
|
|
13
|
+
* `server/tools/` repeating the route's logic and validation.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* // server/api/todos/[id].patch.ts
|
|
17
|
+
* export default defineToolRoute({
|
|
18
|
+
* tool: { name: 'todos.update', description: 'Mark a todo done or not done.' },
|
|
19
|
+
* params: { id: vh.id },
|
|
20
|
+
* input: { done: v.boolean() },
|
|
21
|
+
* handler: (event, { id, done }) => …,
|
|
22
|
+
* })
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
function defineToolRoute(route) {
|
|
26
|
+
const handler = defineEventHandler(async (event) => {
|
|
27
|
+
const params = route.params ? await validate(route.params, getRouterParams(event, { decode: true })) : {};
|
|
28
|
+
const reads = event.req.method === "GET" || event.req.method === "HEAD";
|
|
29
|
+
const input = route.input ? await validate(route.input, reads ? getQuery(event) : await readBody(event) ?? {}) : {};
|
|
30
|
+
return route.handler(event, {
|
|
31
|
+
...params,
|
|
32
|
+
...input
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
const tool = {
|
|
36
|
+
name: route.tool.name,
|
|
37
|
+
description: route.tool.description,
|
|
38
|
+
readOnly: route.tool.readOnly,
|
|
39
|
+
input: toolInput(route.params, route.input),
|
|
40
|
+
handler: (event, input) => route.handler(event, input ?? {})
|
|
41
|
+
};
|
|
42
|
+
return Object.assign(handler, { [TOOL_ROUTE]: tool });
|
|
43
|
+
}
|
|
44
|
+
/** An agent sends params and input as one object, so the tool's schema is both merged. */
|
|
45
|
+
function toolInput(params, input) {
|
|
46
|
+
if (!params) return input;
|
|
47
|
+
if (!input) return params;
|
|
48
|
+
return v.objectAsync({
|
|
49
|
+
...entries(params),
|
|
50
|
+
...entries(input)
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function entries(input) {
|
|
54
|
+
if (!("kind" in input && input.kind === "schema")) return input;
|
|
55
|
+
if ("entries" in input) return input.entries;
|
|
56
|
+
throw new Error("defineToolRoute: `params` and `input` must be objects to merge into a tool.");
|
|
57
|
+
}
|
|
6
58
|
//#endregion
|
|
7
|
-
export { defineTool };
|
|
59
|
+
export { TOOL_ROUTE, defineTool, defineToolRoute };
|
|
@@ -16,6 +16,7 @@ export type SafeResult<T extends Input> = {
|
|
|
16
16
|
};
|
|
17
17
|
export declare function toSchema(input: Input): Schema;
|
|
18
18
|
export declare function safeValidate<T extends Input>(input: T, data: unknown): Promise<SafeResult<T>>;
|
|
19
|
+
export declare function validate<T extends Input>(input: T, data: unknown): Promise<Output<T>>;
|
|
19
20
|
/** Parsed JSON body. Throws a 400 with flattened issues when invalid. */
|
|
20
21
|
export declare const useValidatedBody: <T extends Input>(event: RequestEvent, schema: T) => Promise<Output<T>>;
|
|
21
22
|
/** Query parameters (strings; use `vh.*` to coerce). Throws a 400 when invalid. */
|
|
@@ -48,4 +48,4 @@ const vh = {
|
|
|
48
48
|
id: v.pipe(v.string(), v.digits(), v.transform(Number), v.safeInteger(), v.minValue(1))
|
|
49
49
|
};
|
|
50
50
|
//#endregion
|
|
51
|
-
export { safeValidate, toSchema, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, vh };
|
|
51
|
+
export { safeValidate, toSchema, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, validate, vh };
|
package/dist/seed.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
//#region src/seed.ts
|
|
2
|
+
/** The conventional seed task: `server/tasks/db/seed.ts`. */
|
|
3
|
+
const SEED_TASK = "db:seed";
|
|
4
|
+
/**
|
|
5
|
+
* `nitro.scheduledTasks` without the seed task, and the crons it was listed under. Seeding fills
|
|
6
|
+
* an empty database on request; on a schedule it would run against production data.
|
|
7
|
+
*/
|
|
8
|
+
function unscheduleSeed(schedules) {
|
|
9
|
+
const kept = {};
|
|
10
|
+
const removed = [];
|
|
11
|
+
for (const [cron, entry] of Object.entries(schedules)) {
|
|
12
|
+
const tasks = [entry].flat();
|
|
13
|
+
const rest = tasks.filter((task) => task !== SEED_TASK);
|
|
14
|
+
if (rest.length < tasks.length) removed.push(cron);
|
|
15
|
+
if (rest.length > 0) kept[cron] = Array.isArray(entry) ? rest : rest[0];
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
schedules: kept,
|
|
19
|
+
removed
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Runs `db:seed` through the Nitro dev server's task route, once the server answers. The dev
|
|
24
|
+
* server is still building when it starts listening, so its task list is polled first; that
|
|
25
|
+
* also tells whether the app has a seed task at all.
|
|
26
|
+
*/
|
|
27
|
+
async function runDevSeed(baseUrl, { fetch: request = fetch, timeout = 12e4, interval = 500 } = {}) {
|
|
28
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
29
|
+
const deadline = Date.now() + timeout;
|
|
30
|
+
let tasks;
|
|
31
|
+
while (!tasks) {
|
|
32
|
+
const response = await request(`${base}/_nitro/tasks`).catch(() => void 0);
|
|
33
|
+
const body = response?.ok ? await response.json().catch(() => void 0) : void 0;
|
|
34
|
+
if (body && typeof body === "object" && "tasks" in body) tasks = body.tasks;
|
|
35
|
+
else if (Date.now() >= deadline) throw new Error("the dev server did not answer in time");
|
|
36
|
+
else await new Promise((resolve) => setTimeout(resolve, interval));
|
|
37
|
+
}
|
|
38
|
+
if (!("db:seed" in tasks)) return { status: "missing" };
|
|
39
|
+
const response = await request(`${base}/_nitro/tasks/${SEED_TASK}`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "content-type": "application/json" },
|
|
42
|
+
body: JSON.stringify({ payload: {} })
|
|
43
|
+
});
|
|
44
|
+
const body = await response.json().catch(() => ({}));
|
|
45
|
+
if (!response.ok) throw new Error(body.message ?? `${response.status} ${response.statusText}`);
|
|
46
|
+
return {
|
|
47
|
+
status: "seeded",
|
|
48
|
+
result: body.result
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
export { SEED_TASK, runDevSeed, unscheduleSeed };
|
package/dist/shapes.js
CHANGED
|
@@ -39,6 +39,9 @@ async function findShapeRoutes(dir) {
|
|
|
39
39
|
file: join(dir, entry.file)
|
|
40
40
|
}));
|
|
41
41
|
}
|
|
42
|
+
function shapesManifest(routes) {
|
|
43
|
+
return { shapes: routes.filter((route) => !route.dynamic).map((route) => route.route) };
|
|
44
|
+
}
|
|
42
45
|
/** The `.d.ts` declaring `WervtShapeRoutes`; paths are made relative to `typesDir`. */
|
|
43
46
|
function shapeRoutesTypes(routes, typesDir, shapeTypes) {
|
|
44
47
|
const importPath = (file) => {
|
|
@@ -64,4 +67,4 @@ function shapeRoutesTypes(routes, typesDir, shapeTypes) {
|
|
|
64
67
|
].join("\n");
|
|
65
68
|
}
|
|
66
69
|
//#endregion
|
|
67
|
-
export { findShapeRoutes, shapeRoutes, shapeRoutesTypes };
|
|
70
|
+
export { findShapeRoutes, shapeRoutes, shapeRoutesTypes, shapesManifest };
|