@wervt/nuxt 0.2.15 → 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 +50 -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 +32 -11
- package/dist/runtime/app/useShape.js +104 -42
- package/dist/runtime/email/components.d.ts +71 -10
- package/dist/runtime/email/components.js +445 -98
- package/dist/runtime/email/render.js +4 -0
- package/dist/runtime/server/index.d.ts +5 -3
- 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/shape.d.ts +12 -5
- package/dist/runtime/server/shape.js +3 -1
- 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/runtime/shared/shape.d.ts +18 -0
- package/dist/runtime/shared/shape.js +16 -0
- package/dist/seed.js +52 -0
- package/dist/shapes.js +70 -0
- 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,8 @@
|
|
|
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";
|
|
5
|
+
import { addImports, addPlugin, addServerHandler, addServerPlugin, addServerTemplate, addTypeTemplate, createResolver, defineNuxtModule, getNuxtVersion, hasNuxtModule, installModule, updateTemplates, useLogger } from "nuxt/kit";
|
|
3
6
|
import { existsSync } from "node:fs";
|
|
4
7
|
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
5
8
|
import { builtinModules, createRequire } from "node:module";
|
|
@@ -128,6 +131,14 @@ var module_default = defineNuxtModule({
|
|
|
128
131
|
...nitro.experimental,
|
|
129
132
|
tasks: true
|
|
130
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
|
+
});
|
|
131
142
|
const nitroVersion = nitroMajor >= 3 ? "nitro3" : "nitro2";
|
|
132
143
|
addServerHandler({
|
|
133
144
|
route: "/_wervt/tasks/**",
|
|
@@ -165,14 +176,20 @@ var module_default = defineNuxtModule({
|
|
|
165
176
|
const toolsDir = join(nuxt.options.serverDir, "tools");
|
|
166
177
|
const webmcp = options.webmcp !== false;
|
|
167
178
|
let mcp = options.mcp !== false;
|
|
168
|
-
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] : [];
|
|
169
186
|
if (tools.length > 0 && mcp && nitroMajor < 3) {
|
|
170
187
|
if (options.mcp) logger.warn("`/mcp` needs Nuxt 5; this app keeps its tools for WebMCP only.");
|
|
171
188
|
mcp = false;
|
|
172
189
|
}
|
|
173
190
|
if (tools.length > 0) addServerTemplate({
|
|
174
191
|
filename: "#wervt/tools",
|
|
175
|
-
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`
|
|
176
193
|
});
|
|
177
194
|
if (tools.length > 0 && webmcp) {
|
|
178
195
|
addServerHandler({
|
|
@@ -217,10 +234,16 @@ var module_default = defineNuxtModule({
|
|
|
217
234
|
handler: resolver.resolve("./runtime/server/mcp-auth-server")
|
|
218
235
|
});
|
|
219
236
|
}
|
|
220
|
-
if (nuxt.options.dev && (webmcp || mcp))
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
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
|
+
}
|
|
224
247
|
if (nuxt.options.dev && nitroMajor >= 3) {
|
|
225
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`.");
|
|
226
249
|
}
|
|
@@ -298,12 +321,32 @@ var module_default = defineNuxtModule({
|
|
|
298
321
|
mode: "client"
|
|
299
322
|
});
|
|
300
323
|
}
|
|
324
|
+
const shapesDir = join(nuxt.options.serverDir, "api/shapes");
|
|
325
|
+
const shapeTypes = addTypeTemplate({
|
|
326
|
+
filename: "types/wervt-shapes.d.ts",
|
|
327
|
+
getContents: async () => shapeRoutesTypes(await findShapeRoutes(shapesDir), join(nuxt.options.buildDir, "types"), resolver.resolve("./runtime/shared/shape"))
|
|
328
|
+
});
|
|
329
|
+
if (nuxt.options.dev) nuxt.hook("builder:watch", async (event, path) => {
|
|
330
|
+
const file = relative(shapesDir, resolve(nuxt.options.srcDir, path));
|
|
331
|
+
if ((event === "add" || event === "unlink") && !file.startsWith("..")) await updateTemplates({ filter: (template) => template.filename === shapeTypes.filename });
|
|
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
|
+
});
|
|
301
340
|
nuxt.options.build.transpile.push(resolver.resolve("./runtime"));
|
|
302
341
|
addImports([
|
|
303
342
|
{
|
|
304
343
|
name: "useShape",
|
|
305
344
|
from: resolver.resolve("./runtime/app/useShape")
|
|
306
345
|
},
|
|
346
|
+
{
|
|
347
|
+
name: "preloadShape",
|
|
348
|
+
from: resolver.resolve("./runtime/app/useShape")
|
|
349
|
+
},
|
|
307
350
|
{
|
|
308
351
|
name: "useWervtUser",
|
|
309
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 };
|
|
@@ -1,26 +1,47 @@
|
|
|
1
1
|
import { Row } from "@electric-sql/client";
|
|
2
|
+
import { Ref, ShallowRef } from "vue";
|
|
2
3
|
//#region src/runtime/app/useShape.d.ts
|
|
4
|
+
declare global {
|
|
5
|
+
/**
|
|
6
|
+
* Shape routes and their rows, generated from `server/api/shapes/` (`.nuxt/types/wervt-shapes.d.ts`).
|
|
7
|
+
*/
|
|
8
|
+
interface WervtShapeRoutes {}
|
|
9
|
+
}
|
|
3
10
|
export interface UseShapeOptions {
|
|
4
11
|
/** Payload key. Defaults to the path. */
|
|
5
12
|
key?: string;
|
|
6
13
|
/** Keep the shape live on the client. Defaults to true. */
|
|
7
14
|
live?: boolean;
|
|
8
15
|
}
|
|
16
|
+
type ShapeResult<T> = Promise<ShapeRefs<T>> & ShapeRefs<T>;
|
|
17
|
+
interface ShapeRefs<T> {
|
|
18
|
+
data: ShallowRef<T[]>;
|
|
19
|
+
error: Ref<unknown>;
|
|
20
|
+
isLive: Ref<boolean>;
|
|
21
|
+
}
|
|
9
22
|
/**
|
|
10
23
|
* Live rows of an Electric shape served by one of the app's shape routes
|
|
11
24
|
* (see `proxyShape`). The snapshot is loaded during SSR and sent in the payload;
|
|
12
25
|
* the client resumes the stream from the snapshot's offset, so rows are not
|
|
13
|
-
* fetched twice.
|
|
26
|
+
* fetched twice. Timestamps arrive as Dates, like Drizzle reads them.
|
|
27
|
+
*
|
|
28
|
+
* The row type comes from the route when it passes a Drizzle table to `proxyShape`.
|
|
29
|
+
*
|
|
30
|
+
* @example const { data: todos } = await useShape('/api/shapes/todos')
|
|
31
|
+
*/
|
|
32
|
+
export declare function useShape<P extends keyof WervtShapeRoutes>(path: P, options?: UseShapeOptions): ShapeResult<WervtShapeRoutes[P]>;
|
|
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.
|
|
14
38
|
*
|
|
15
|
-
* @example
|
|
39
|
+
* @example <NuxtLink to="/todos" @mouseenter="preloadShape('/api/shapes/todos')">
|
|
16
40
|
*/
|
|
17
|
-
export declare function
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
error: import("vue").Ref<unknown, unknown>;
|
|
24
|
-
isLive: import("vue").Ref<boolean, boolean>;
|
|
25
|
-
};
|
|
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>;
|
|
26
47
|
//#endregion
|
|
@@ -1,62 +1,123 @@
|
|
|
1
|
+
import { shapeParser } from "../shared/shape.js";
|
|
1
2
|
import { ShapeStream, isChangeMessage, isControlMessage } from "@electric-sql/client";
|
|
2
3
|
import { getCurrentScope, onScopeDispose, ref, shallowRef } from "vue";
|
|
3
4
|
import { onNuxtReady, useAsyncData, useRequestEvent, useRequestFetch } from "#app";
|
|
4
5
|
//#region src/runtime/app/useShape.ts
|
|
5
|
-
/**
|
|
6
|
-
* Live rows of an Electric shape served by one of the app's shape routes
|
|
7
|
-
* (see `proxyShape`). The snapshot is loaded during SSR and sent in the payload;
|
|
8
|
-
* the client resumes the stream from the snapshot's offset, so rows are not
|
|
9
|
-
* fetched twice.
|
|
10
|
-
*
|
|
11
|
-
* @example const { data: todos } = await useShape<Todo>('/api/shapes/todos')
|
|
12
|
-
*/
|
|
13
6
|
function useShape(path, options = {}) {
|
|
7
|
+
const key = options.key ?? `wervt-shape:${path}`;
|
|
14
8
|
const fetchClient = import.meta.server ? inProcessFetch() : void 0;
|
|
15
|
-
const asyncData = useAsyncData(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
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);
|
|
20
14
|
const result = {
|
|
21
|
-
data,
|
|
22
|
-
error,
|
|
23
|
-
isLive
|
|
15
|
+
data: shape.data,
|
|
16
|
+
error: shape.error,
|
|
17
|
+
isLive: shape.isLive
|
|
24
18
|
};
|
|
25
19
|
const ready = Promise.resolve(asyncData).then(() => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
+
}
|
|
30
32
|
return result;
|
|
31
33
|
});
|
|
32
34
|
if (import.meta.client && options.live !== false) {
|
|
33
|
-
let stream;
|
|
34
|
-
const controller = new AbortController();
|
|
35
35
|
onNuxtReady(async () => {
|
|
36
36
|
await ready;
|
|
37
|
-
|
|
38
|
-
const snapshot = asyncData.data.value;
|
|
39
|
-
stream = new ShapeStream({
|
|
40
|
-
url: absolute(path),
|
|
41
|
-
offset: snapshot?.offset,
|
|
42
|
-
handle: snapshot?.handle,
|
|
43
|
-
signal: controller.signal,
|
|
44
|
-
onError: (e) => {
|
|
45
|
-
error.value = e;
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
stream.subscribe((messages) => {
|
|
49
|
-
if (applyMessages(rows, messages)) data.value = [...rows.values()];
|
|
50
|
-
isLive.value = stream?.isUpToDate ?? false;
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
if (getCurrentScope()) onScopeDispose(() => {
|
|
54
|
-
controller.abort();
|
|
55
|
-
stream?.unsubscribeAll();
|
|
37
|
+
start(shape);
|
|
56
38
|
});
|
|
39
|
+
if (getCurrentScope()) onScopeDispose(() => release(key, shape, KEEP_AFTER_UNMOUNT_MS));
|
|
57
40
|
}
|
|
58
41
|
return Object.assign(ready, result);
|
|
59
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
|
+
}
|
|
60
121
|
async function loadSnapshot(path, fetchClient) {
|
|
61
122
|
const rows = /* @__PURE__ */ new Map();
|
|
62
123
|
let fail = () => {};
|
|
@@ -64,6 +125,7 @@ async function loadSnapshot(path, fetchClient) {
|
|
|
64
125
|
url: absolute(path),
|
|
65
126
|
subscribe: false,
|
|
66
127
|
fetchClient,
|
|
128
|
+
parser: shapeParser,
|
|
67
129
|
onError: (e) => fail(e)
|
|
68
130
|
});
|
|
69
131
|
await new Promise((resolve, reject) => {
|
|
@@ -137,4 +199,4 @@ function absolute(path) {
|
|
|
137
199
|
return new URL(path, import.meta.client ? location.origin : "http://localhost").href;
|
|
138
200
|
}
|
|
139
201
|
//#endregion
|
|
140
|
-
export { useShape };
|
|
202
|
+
export { preloadShape, useShape };
|