@wervt/nuxt 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -2
- package/dist/dev.d.ts +22 -0
- package/dist/dev.js +73 -0
- package/dist/module.d.ts +11 -0
- package/dist/module.js +142 -0
- package/dist/provision.d.ts +26 -0
- package/dist/provision.js +123 -0
- package/dist/runtime/app/plugins/user.client.d.ts +4 -0
- package/dist/runtime/app/plugins/user.client.js +18 -0
- package/dist/runtime/app/useShape.d.ts +26 -0
- package/dist/runtime/app/useShape.js +140 -0
- package/dist/runtime/app/useWervtUser.d.ts +13 -0
- package/dist/runtime/app/useWervtUser.js +18 -0
- package/dist/runtime/server/db.d.ts +19 -0
- package/dist/runtime/server/db.js +36 -0
- package/dist/runtime/server/dev-auth.d.ts +4 -0
- package/dist/runtime/server/dev-auth.js +15 -0
- package/dist/runtime/server/index.d.ts +6 -0
- package/dist/runtime/server/index.js +5 -0
- package/dist/runtime/server/shape.d.ts +19 -0
- package/dist/runtime/server/shape.js +55 -0
- package/dist/runtime/server/user-route.d.ts +8 -0
- package/dist/runtime/server/user-route.js +9 -0
- package/dist/runtime/server/user.d.ts +12 -0
- package/dist/runtime/server/user.js +11 -0
- package/dist/runtime/server/validate.d.ts +40 -0
- package/dist/runtime/server/validate.js +51 -0
- package/dist/runtime/shared/user.d.ts +16 -0
- package/dist/runtime/shared/user.js +20 -0
- package/package.json +56 -3
package/README.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @wervt/nuxt
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Nuxt module for apps hosted on [wervt](https://wervt.app): each app runs in its own isolate with
|
|
4
|
+
its own Postgres schema, Drizzle, and live data through Electric shapes.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm create wervt@latest my-app
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
or add it to an existing Nuxt app:
|
|
11
|
+
|
|
12
|
+
```ts [nuxt.config.ts]
|
|
13
|
+
export default defineNuxtConfig({
|
|
14
|
+
modules: ['@wervt/nuxt'],
|
|
15
|
+
})
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- `useDb()` (server): Drizzle on the app's own schema, with migrations from `drizzle/`.
|
|
19
|
+
- `proxyShape()` (server) and `useShape()` (app): live rows from Postgres via Electric.
|
|
20
|
+
- `useWervtUser()`: the signed-in wervt user, on the server and in the app.
|
|
21
|
+
- `useValidatedBody()` / `useValidatedQuery()` / `useValidatedParams()`: valibot validation.
|
|
22
|
+
- `nuxt dev` starts against the local wervt stack (Postgres, Electric) and a dev user.
|
|
23
|
+
|
|
24
|
+
Deploy with [`@wervt/cli`](https://www.npmjs.com/package/@wervt/cli): `wervt deploy`.
|
|
25
|
+
|
|
26
|
+
Docs: https://wervt.app
|
package/dist/dev.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import "nuxt/schema";
|
|
2
|
+
//#region src/dev.d.ts
|
|
3
|
+
export interface DevOptions {
|
|
4
|
+
/** Superuser URL of the local wervt Postgres. */
|
|
5
|
+
adminUrl?: string;
|
|
6
|
+
/** WERVT_DB_SECRET of the local setup. */
|
|
7
|
+
dbSecret?: string;
|
|
8
|
+
electricUrl?: string;
|
|
9
|
+
electricSecret?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Who `/_wervt/login` signs in as during `nuxt dev` (there's no wervt auth locally).
|
|
12
|
+
* Defaults to a dev user with a Twitch account linked.
|
|
13
|
+
*/
|
|
14
|
+
user?: {
|
|
15
|
+
id: string;
|
|
16
|
+
email: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
image?: string;
|
|
19
|
+
accounts?: Record<string, string>;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
//#endregion
|
package/dist/dev.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { appDatabaseUrl, dbRole, provisionDatabase } from "./provision.js";
|
|
2
|
+
import { useLogger } from "nuxt/kit";
|
|
3
|
+
import { existsSync, watch } from "node:fs";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
//#region src/dev.ts
|
|
7
|
+
const DEFAULTS = {
|
|
8
|
+
adminUrl: "postgres://postgres:postgres@localhost:5432/wervt",
|
|
9
|
+
dbSecret: "dev-secret",
|
|
10
|
+
electricUrl: "http://localhost:3030",
|
|
11
|
+
electricSecret: "dev-electric-secret"
|
|
12
|
+
};
|
|
13
|
+
const logger = useLogger("wervt");
|
|
14
|
+
/**
|
|
15
|
+
* `nuxt dev` against the local compose stack: provisions the app's role and schema,
|
|
16
|
+
* runs its migrations (again whenever `drizzle/` changes) and points the dev server
|
|
17
|
+
* at the app's database and Electric.
|
|
18
|
+
*/
|
|
19
|
+
async function setupDev(nuxt, app, options) {
|
|
20
|
+
const config = {
|
|
21
|
+
adminUrl: process.env.WERVT_PG_ADMIN_URL ?? options.adminUrl ?? DEFAULTS.adminUrl,
|
|
22
|
+
dbSecret: process.env.WERVT_DB_SECRET ?? options.dbSecret ?? DEFAULTS.dbSecret,
|
|
23
|
+
electricUrl: process.env.ELECTRIC_URL ?? options.electricUrl ?? DEFAULTS.electricUrl,
|
|
24
|
+
electricSecret: process.env.ELECTRIC_SECRET ?? options.electricSecret ?? DEFAULTS.electricSecret
|
|
25
|
+
};
|
|
26
|
+
process.env.WERVT_DEV_USER ??= JSON.stringify(options.user ?? {
|
|
27
|
+
id: "dev",
|
|
28
|
+
email: "dev@wervt.localhost",
|
|
29
|
+
name: "Dev",
|
|
30
|
+
accounts: { twitch: "dev" }
|
|
31
|
+
});
|
|
32
|
+
const migrationsFolder = join(nuxt.options.rootDir, "drizzle");
|
|
33
|
+
if (!existsSync(migrationsFolder)) return;
|
|
34
|
+
const provision = {
|
|
35
|
+
app,
|
|
36
|
+
adminUrl: config.adminUrl,
|
|
37
|
+
secret: config.dbSecret,
|
|
38
|
+
migrationsFolder
|
|
39
|
+
};
|
|
40
|
+
const migrate = async () => {
|
|
41
|
+
try {
|
|
42
|
+
await provisionDatabase(provision);
|
|
43
|
+
logger.success(`Database \`${dbRole(app)}\` is up to date`);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
logger.warn(`Could not migrate \`${dbRole(app)}\`. Is the wervt stack running (docker compose up -d)?`, e instanceof Error ? e.message : e);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
await migrate();
|
|
49
|
+
process.env.DATABASE_URL ??= await appDatabaseUrl(provision);
|
|
50
|
+
process.env.WERVT_DB_SCHEMA ??= dbRole(app);
|
|
51
|
+
process.env.ELECTRIC_URL ??= config.electricUrl;
|
|
52
|
+
process.env.ELECTRIC_SECRET ??= config.electricSecret;
|
|
53
|
+
let timer;
|
|
54
|
+
const watcher = watch(migrationsFolder, { recursive: true }, (_event, file) => {
|
|
55
|
+
if (!file?.endsWith(".sql")) return;
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
timer = setTimeout(() => {
|
|
58
|
+
logger.info("Migrations changed, migrating…");
|
|
59
|
+
migrate();
|
|
60
|
+
}, 300);
|
|
61
|
+
});
|
|
62
|
+
nuxt.hook("close", () => watcher.close());
|
|
63
|
+
}
|
|
64
|
+
/** App name from the module option or package.json, normalised to `[a-z0-9-]+`. */
|
|
65
|
+
async function resolveAppName(rootDir, name) {
|
|
66
|
+
if (!name) {
|
|
67
|
+
const pkg = JSON.parse(await readFile(join(rootDir, "package.json"), "utf8").catch(() => "{}"));
|
|
68
|
+
name = String(pkg.name ?? "app").replace(/^@[^/]+\//, "");
|
|
69
|
+
}
|
|
70
|
+
return name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
export { resolveAppName, setupDev };
|
package/dist/module.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { DevOptions } from "./dev.js";
|
|
2
|
+
//#region src/module.d.ts
|
|
3
|
+
export interface ModuleOptions {
|
|
4
|
+
/** App name on wervt. Defaults to the package.json name. */
|
|
5
|
+
app?: string;
|
|
6
|
+
/** Local stack used by `nuxt dev`. Defaults match the wervt compose setup. */
|
|
7
|
+
dev?: DevOptions;
|
|
8
|
+
}
|
|
9
|
+
declare const _default: import("nuxt/schema").NuxtModule<ModuleOptions, ModuleOptions, false>;
|
|
10
|
+
//#endregion
|
|
11
|
+
export { _default as default };
|
package/dist/module.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { resolveAppName, setupDev } from "./dev.js";
|
|
2
|
+
import { addImports, addPlugin, addServerHandler, createResolver, defineNuxtModule, hasNuxtModule, installModule } from "nuxt/kit";
|
|
3
|
+
import { builtinModules } from "node:module";
|
|
4
|
+
import evlog from "evlog/nuxt";
|
|
5
|
+
//#region src/module.ts
|
|
6
|
+
const CONTENT_DATABASE = {
|
|
7
|
+
type: "postgres",
|
|
8
|
+
url: "{{DATABASE_URL}}"
|
|
9
|
+
};
|
|
10
|
+
var module_default = defineNuxtModule({
|
|
11
|
+
meta: {
|
|
12
|
+
name: "@wervt/nuxt",
|
|
13
|
+
configKey: "wervt"
|
|
14
|
+
},
|
|
15
|
+
async setup(options, nuxt) {
|
|
16
|
+
const resolver = createResolver(import.meta.url);
|
|
17
|
+
const nitro = nuxt.options.nitro;
|
|
18
|
+
if (!nuxt.options.dev) nitro.preset ||= "deno-server";
|
|
19
|
+
if (!hasNuxtModule("evlog/nuxt", nuxt)) {
|
|
20
|
+
const app = await resolveAppName(nuxt.options.rootDir, options.app);
|
|
21
|
+
const config = nuxt.options;
|
|
22
|
+
config.evlog = {
|
|
23
|
+
env: { service: app },
|
|
24
|
+
redact: true,
|
|
25
|
+
exclude: [
|
|
26
|
+
"/_wervt/**",
|
|
27
|
+
"/_nuxt/**",
|
|
28
|
+
"/__nuxt_error",
|
|
29
|
+
"/_fonts/**"
|
|
30
|
+
],
|
|
31
|
+
...config.evlog
|
|
32
|
+
};
|
|
33
|
+
await installModule(evlog, void 0, nuxt);
|
|
34
|
+
}
|
|
35
|
+
const useContentDatabase = !nuxt.options.dev && hasNuxtModule("@nuxt/content", nuxt);
|
|
36
|
+
if (useContentDatabase) {
|
|
37
|
+
const options = nuxt.options;
|
|
38
|
+
options.content = {
|
|
39
|
+
...options.content,
|
|
40
|
+
database: CONTENT_DATABASE
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
nuxt.hook("nitro:config", (config) => {
|
|
44
|
+
const options = config;
|
|
45
|
+
options.externals = {
|
|
46
|
+
...options.externals,
|
|
47
|
+
inline: [
|
|
48
|
+
...options.externals?.inline ?? [],
|
|
49
|
+
resolver.resolve("./runtime"),
|
|
50
|
+
/(^|\/node_modules\/)(drizzle-orm|postgres|valibot|@electric-sql\/client|evlog)(\/|$)/
|
|
51
|
+
]
|
|
52
|
+
};
|
|
53
|
+
const exclude = options.esbuild?.options?.exclude ?? /node_modules/;
|
|
54
|
+
options.esbuild = {
|
|
55
|
+
...options.esbuild,
|
|
56
|
+
options: {
|
|
57
|
+
...options.esbuild?.options,
|
|
58
|
+
exclude: [exclude].flat().map((pattern) => typeof pattern === "string" ? pattern : exceptRuntime(pattern))
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
if (useContentDatabase) {
|
|
62
|
+
options.experimental = {
|
|
63
|
+
...options.experimental,
|
|
64
|
+
envExpansion: true
|
|
65
|
+
};
|
|
66
|
+
if (options.runtimeConfig?.content) options.runtimeConfig.content.database = CONTENT_DATABASE;
|
|
67
|
+
}
|
|
68
|
+
if (!nuxt.options.dev) options.rollupConfig = {
|
|
69
|
+
...options.rollupConfig,
|
|
70
|
+
plugins: [...[options.rollupConfig?.plugins ?? []].flat(), prefixNodeBuiltins()]
|
|
71
|
+
};
|
|
72
|
+
options.unenv = [...options.unenv ? [options.unenv].flat() : [], { inject: {
|
|
73
|
+
Buffer: ["node:buffer", "Buffer"],
|
|
74
|
+
setImmediate: ["node:timers", "setImmediate"],
|
|
75
|
+
clearImmediate: ["node:timers", "clearImmediate"]
|
|
76
|
+
} }];
|
|
77
|
+
});
|
|
78
|
+
if (nuxt.options.dev && !nuxt.options._prepare) {
|
|
79
|
+
const app = await resolveAppName(nuxt.options.rootDir, options.app);
|
|
80
|
+
await setupDev(nuxt, app, options.dev ?? {});
|
|
81
|
+
}
|
|
82
|
+
if (nuxt.options.dev) for (const route of ["/_wervt/login", "/_wervt/logout"]) addServerHandler({
|
|
83
|
+
route,
|
|
84
|
+
handler: resolver.resolve("./runtime/server/dev-auth")
|
|
85
|
+
});
|
|
86
|
+
addServerHandler({
|
|
87
|
+
route: "/_wervt/user",
|
|
88
|
+
method: "get",
|
|
89
|
+
handler: resolver.resolve("./runtime/server/user-route")
|
|
90
|
+
});
|
|
91
|
+
addPlugin({
|
|
92
|
+
src: resolver.resolve("./runtime/app/plugins/user.client"),
|
|
93
|
+
mode: "client"
|
|
94
|
+
});
|
|
95
|
+
nuxt.options.build.transpile.push(resolver.resolve("./runtime"));
|
|
96
|
+
addImports([
|
|
97
|
+
{
|
|
98
|
+
name: "useShape",
|
|
99
|
+
from: resolver.resolve("./runtime/app/useShape")
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "useWervtUser",
|
|
103
|
+
from: resolver.resolve("./runtime/app/useWervtUser")
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "wervtLoginUrl",
|
|
107
|
+
from: resolver.resolve("./runtime/app/useWervtUser")
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: "wervtLogoutUrl",
|
|
111
|
+
from: resolver.resolve("./runtime/app/useWervtUser")
|
|
112
|
+
}
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
const BUILTINS = new Set(builtinModules.filter((name) => !name.startsWith("_")));
|
|
117
|
+
/** Rewrites `from 'os'` / `import('os')` to `node:os` in the output chunks. */
|
|
118
|
+
function prefixNodeBuiltins() {
|
|
119
|
+
const IMPORT_RE = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(['"])([a-z_/]+)\2/g;
|
|
120
|
+
return {
|
|
121
|
+
name: "wervt:prefix-node-builtins",
|
|
122
|
+
renderChunk(code) {
|
|
123
|
+
let changed = false;
|
|
124
|
+
const out = code.replace(IMPORT_RE, (match, lead, quote, id) => {
|
|
125
|
+
if (!BUILTINS.has(id)) return match;
|
|
126
|
+
changed = true;
|
|
127
|
+
return `${lead}${quote}node:${id}${quote}`;
|
|
128
|
+
});
|
|
129
|
+
return changed ? {
|
|
130
|
+
code: out,
|
|
131
|
+
map: null
|
|
132
|
+
} : null;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** `pattern`, except for files of this module's runtime. */
|
|
137
|
+
function exceptRuntime(pattern) {
|
|
138
|
+
if (pattern.source.startsWith("^")) return pattern;
|
|
139
|
+
return new RegExp(`^(?![\\s\\S]*[\\\\/]@wervt[\\\\/]nuxt[\\\\/]src[\\\\/]runtime[\\\\/])[\\s\\S]*?(?:${pattern.source})`, pattern.flags);
|
|
140
|
+
}
|
|
141
|
+
//#endregion
|
|
142
|
+
export { module_default as default };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//#region src/provision.d.ts
|
|
2
|
+
export interface ProvisionOptions {
|
|
3
|
+
/** App name, `[a-z0-9-]+`. */
|
|
4
|
+
app: string;
|
|
5
|
+
/** Superuser connection URL to the `wervt` database. */
|
|
6
|
+
adminUrl: string;
|
|
7
|
+
/** Secret the role password is derived from (WERVT_DB_SECRET). */
|
|
8
|
+
secret: string;
|
|
9
|
+
/** Drizzle migrations folder. Skipped when undefined. */
|
|
10
|
+
migrationsFolder?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function dbRole(app: string): string;
|
|
13
|
+
export declare function hmac(secret: string, message: string): Promise<string>;
|
|
14
|
+
export declare function dbPassword(app: string, secret: string): Promise<string>;
|
|
15
|
+
/** Connection URL for the app's own role, on the same host and database as `adminUrl`. */
|
|
16
|
+
export declare function appDatabaseUrl({ app, adminUrl, secret }: ProvisionOptions): Promise<string>;
|
|
17
|
+
/**
|
|
18
|
+
* Creates (or updates) the app's role and schema and runs its Drizzle migrations as
|
|
19
|
+
* that role. Each app gets a role and schema named `app_<name>`; the role's
|
|
20
|
+
* search_path is its own schema, it can't create schemas or touch `public`, and it can
|
|
21
|
+
* only connect to the wervt database.
|
|
22
|
+
*/
|
|
23
|
+
export declare function provisionDatabase(options: ProvisionOptions): Promise<void>;
|
|
24
|
+
/** Drops the app's schema (all its data) and role. */
|
|
25
|
+
export declare function dropDatabase({ app, adminUrl }: Pick<ProvisionOptions, 'app' | 'adminUrl'>): Promise<void>;
|
|
26
|
+
//#endregion
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/postgres-js";
|
|
2
|
+
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
|
3
|
+
import postgres from "postgres";
|
|
4
|
+
//#region src/provision.ts
|
|
5
|
+
function dbRole(app) {
|
|
6
|
+
return `app_${app.replaceAll("-", "_")}`;
|
|
7
|
+
}
|
|
8
|
+
async function hmac(secret, message) {
|
|
9
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), {
|
|
10
|
+
name: "HMAC",
|
|
11
|
+
hash: "SHA-256"
|
|
12
|
+
}, false, ["sign"]);
|
|
13
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
|
|
14
|
+
return Array.from(new Uint8Array(sig), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
15
|
+
}
|
|
16
|
+
function dbPassword(app, secret) {
|
|
17
|
+
return hmac(secret, app);
|
|
18
|
+
}
|
|
19
|
+
/** Connection URL for the app's own role, on the same host and database as `adminUrl`. */
|
|
20
|
+
async function appDatabaseUrl({ app, adminUrl, secret }) {
|
|
21
|
+
const url = new URL(adminUrl);
|
|
22
|
+
url.username = dbRole(app);
|
|
23
|
+
url.password = await dbPassword(app, secret);
|
|
24
|
+
return url.href;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Creates (or updates) the app's role and schema and runs its Drizzle migrations as
|
|
28
|
+
* that role. Each app gets a role and schema named `app_<name>`; the role's
|
|
29
|
+
* search_path is its own schema, it can't create schemas or touch `public`, and it can
|
|
30
|
+
* only connect to the wervt database.
|
|
31
|
+
*/
|
|
32
|
+
async function provisionDatabase(options) {
|
|
33
|
+
const { app, adminUrl, secret, migrationsFolder } = options;
|
|
34
|
+
const role = dbRole(app);
|
|
35
|
+
const password = await dbPassword(app, secret);
|
|
36
|
+
const database = new URL(adminUrl).pathname.slice(1);
|
|
37
|
+
const admin = postgres(adminUrl, {
|
|
38
|
+
max: 1,
|
|
39
|
+
onnotice: () => {}
|
|
40
|
+
});
|
|
41
|
+
try {
|
|
42
|
+
const [row] = await admin`select exists(select 1 from pg_roles where rolname = ${role})`;
|
|
43
|
+
const exists = Boolean(row?.exists);
|
|
44
|
+
await admin.unsafe(`${exists ? "alter" : "create"} role "${role}" login password '${password}' connection limit 10`);
|
|
45
|
+
await admin.unsafe(`create schema if not exists "${role}" authorization "${role}"`);
|
|
46
|
+
await claimSchema(admin, role);
|
|
47
|
+
await admin.unsafe(`alter role "${role}" set search_path = "${role}"`);
|
|
48
|
+
for (const db of [
|
|
49
|
+
database,
|
|
50
|
+
"postgres",
|
|
51
|
+
"template1"
|
|
52
|
+
]) await admin.unsafe(`revoke all on database "${db}" from public`);
|
|
53
|
+
await admin.unsafe(`grant connect on database "${database}" to "${role}"`);
|
|
54
|
+
await admin.unsafe(`revoke all on schema public from public`);
|
|
55
|
+
if (!migrationsFolder) return;
|
|
56
|
+
await admin.unsafe(`grant create on database "${database}" to "${role}"`);
|
|
57
|
+
const client = postgres(await appDatabaseUrl(options), {
|
|
58
|
+
max: 1,
|
|
59
|
+
onnotice: () => {}
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
await migrate(drizzle({ client }), {
|
|
63
|
+
migrationsFolder,
|
|
64
|
+
migrationsSchema: role
|
|
65
|
+
});
|
|
66
|
+
} finally {
|
|
67
|
+
await client.end();
|
|
68
|
+
await admin.unsafe(`revoke create on database "${database}" from "${role}"`);
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
await admin.end();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Gives the role its schema and everything in it back. A dump restored with `--no-owner`
|
|
76
|
+
* (Coolify's backups) leaves all of it owned by the restoring superuser.
|
|
77
|
+
*/
|
|
78
|
+
async function claimSchema(admin, role) {
|
|
79
|
+
await admin.unsafe(`alter schema "${role}" owner to "${role}"`);
|
|
80
|
+
const objects = await admin`
|
|
81
|
+
select case c.relkind when 'S' then 'sequence' when 'v' then 'view'
|
|
82
|
+
when 'm' then 'materialized view' when 'f' then 'foreign table' else 'table' end as kind,
|
|
83
|
+
format('%I.%I', n.nspname, c.relname) as name
|
|
84
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
85
|
+
where n.nspname = ${role} and c.relkind in ('r', 'p', 'v', 'm', 'f', 'S')
|
|
86
|
+
and c.relowner <> ${role}::regrole
|
|
87
|
+
-- identity and serial sequences follow their table
|
|
88
|
+
and not exists (select 1 from pg_depend d where d.classid = 'pg_class'::regclass
|
|
89
|
+
and d.objid = c.oid and d.deptype in ('a', 'i'))
|
|
90
|
+
union all
|
|
91
|
+
select case t.typtype when 'd' then 'domain' else 'type' end, format('%I.%I', n.nspname, t.typname)
|
|
92
|
+
from pg_type t join pg_namespace n on n.oid = t.typnamespace
|
|
93
|
+
where n.nspname = ${role} and t.typtype in ('e', 'd')
|
|
94
|
+
and t.typowner <> ${role}::regrole
|
|
95
|
+
union all
|
|
96
|
+
select case p.prokind when 'p' then 'procedure' when 'a' then 'aggregate' else 'function' end,
|
|
97
|
+
p.oid::regprocedure::text
|
|
98
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
99
|
+
where n.nspname = ${role} and p.proowner <> ${role}::regrole`;
|
|
100
|
+
for (const { kind, name } of objects) await admin.unsafe(`alter ${kind} ${name} owner to "${role}"`);
|
|
101
|
+
}
|
|
102
|
+
/** Drops the app's schema (all its data) and role. */
|
|
103
|
+
async function dropDatabase({ app, adminUrl }) {
|
|
104
|
+
const role = dbRole(app);
|
|
105
|
+
const database = new URL(adminUrl).pathname.slice(1);
|
|
106
|
+
const admin = postgres(adminUrl, {
|
|
107
|
+
max: 1,
|
|
108
|
+
onnotice: () => {}
|
|
109
|
+
});
|
|
110
|
+
try {
|
|
111
|
+
await admin`select pg_terminate_backend(pid) from pg_stat_activity where usename = ${role}`;
|
|
112
|
+
await admin.unsafe(`drop schema if exists "${role}" cascade`);
|
|
113
|
+
const [row] = await admin`select exists(select 1 from pg_roles where rolname = ${role})`;
|
|
114
|
+
if (Boolean(row?.exists)) {
|
|
115
|
+
await admin.unsafe(`revoke all on database "${database}" from "${role}"`);
|
|
116
|
+
await admin.unsafe(`drop role "${role}"`);
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
await admin.end();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
//#endregion
|
|
123
|
+
export { appDatabaseUrl, dbPassword, dbRole, dropDatabase, hmac, provisionDatabase };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useWervtUser } from "../useWervtUser.js";
|
|
2
|
+
import { defineNuxtPlugin } from "#app";
|
|
3
|
+
//#region src/runtime/app/plugins/user.client.ts
|
|
4
|
+
var user_client_default = defineNuxtPlugin({
|
|
5
|
+
name: "wervt:user",
|
|
6
|
+
async setup(nuxtApp) {
|
|
7
|
+
if (nuxtApp.payload.serverRendered) return;
|
|
8
|
+
const user = useWervtUser();
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch("/_wervt/user");
|
|
11
|
+
user.value = res.ok ? (await res.json()).user : null;
|
|
12
|
+
} catch {
|
|
13
|
+
user.value = null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
//#endregion
|
|
18
|
+
export { user_client_default as default };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Row } from "@electric-sql/client";
|
|
2
|
+
//#region src/runtime/app/useShape.d.ts
|
|
3
|
+
export interface UseShapeOptions {
|
|
4
|
+
/** Payload key. Defaults to the path. */
|
|
5
|
+
key?: string;
|
|
6
|
+
/** Keep the shape live on the client. Defaults to true. */
|
|
7
|
+
live?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Live rows of an Electric shape served by one of the app's shape routes
|
|
11
|
+
* (see `proxyShape`). The snapshot is loaded during SSR and sent in the payload;
|
|
12
|
+
* the client resumes the stream from the snapshot's offset, so rows are not
|
|
13
|
+
* fetched twice.
|
|
14
|
+
*
|
|
15
|
+
* @example const { data: todos } = await useShape<Todo>('/api/shapes/todos')
|
|
16
|
+
*/
|
|
17
|
+
export declare function useShape<T extends Row<unknown>>(path: string, options?: UseShapeOptions): Promise<{
|
|
18
|
+
data: import("vue").ShallowRef<T[], T[]>;
|
|
19
|
+
error: import("vue").Ref<unknown, unknown>;
|
|
20
|
+
isLive: import("vue").Ref<boolean, boolean>;
|
|
21
|
+
}> & {
|
|
22
|
+
data: import("vue").ShallowRef<T[], T[]>;
|
|
23
|
+
error: import("vue").Ref<unknown, unknown>;
|
|
24
|
+
isLive: import("vue").Ref<boolean, boolean>;
|
|
25
|
+
};
|
|
26
|
+
//#endregion
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { ShapeStream, isChangeMessage, isControlMessage } from "@electric-sql/client";
|
|
2
|
+
import { getCurrentScope, onScopeDispose, ref, shallowRef } from "vue";
|
|
3
|
+
import { onNuxtReady, useAsyncData, useRequestEvent, useRequestFetch } from "#app";
|
|
4
|
+
//#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
|
+
function useShape(path, options = {}) {
|
|
14
|
+
const fetchClient = import.meta.server ? inProcessFetch() : void 0;
|
|
15
|
+
const asyncData = useAsyncData(options.key ?? `wervt-shape:${path}`, () => loadSnapshot(path, fetchClient));
|
|
16
|
+
const rows = /* @__PURE__ */ new Map();
|
|
17
|
+
const data = shallowRef([]);
|
|
18
|
+
const error = ref(null);
|
|
19
|
+
const isLive = ref(false);
|
|
20
|
+
const result = {
|
|
21
|
+
data,
|
|
22
|
+
error,
|
|
23
|
+
isLive
|
|
24
|
+
};
|
|
25
|
+
const ready = Promise.resolve(asyncData).then(() => {
|
|
26
|
+
for (const [key, row] of asyncData.data.value?.rows ?? []) rows.set(key, row);
|
|
27
|
+
data.value = [...rows.values()];
|
|
28
|
+
error.value = asyncData.error.value ?? null;
|
|
29
|
+
if (error.value) console.error(`[wervt] useShape('${path}') failed:`, error.value);
|
|
30
|
+
return result;
|
|
31
|
+
});
|
|
32
|
+
if (import.meta.client && options.live !== false) {
|
|
33
|
+
let stream;
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
onNuxtReady(async () => {
|
|
36
|
+
await ready;
|
|
37
|
+
if (controller.signal.aborted) return;
|
|
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();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return Object.assign(ready, result);
|
|
59
|
+
}
|
|
60
|
+
async function loadSnapshot(path, fetchClient) {
|
|
61
|
+
const rows = /* @__PURE__ */ new Map();
|
|
62
|
+
let fail = () => {};
|
|
63
|
+
const stream = new ShapeStream({
|
|
64
|
+
url: absolute(path),
|
|
65
|
+
subscribe: false,
|
|
66
|
+
fetchClient,
|
|
67
|
+
onError: (e) => fail(e)
|
|
68
|
+
});
|
|
69
|
+
await new Promise((resolve, reject) => {
|
|
70
|
+
fail = reject;
|
|
71
|
+
stream.subscribe((messages) => {
|
|
72
|
+
applyMessages(rows, messages);
|
|
73
|
+
if (stream.isUpToDate) resolve();
|
|
74
|
+
}, (e) => reject(e));
|
|
75
|
+
});
|
|
76
|
+
stream.unsubscribeAll();
|
|
77
|
+
return {
|
|
78
|
+
rows: [...rows],
|
|
79
|
+
offset: stream.lastOffset,
|
|
80
|
+
handle: stream.shapeHandle
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Applies Electric change messages to `rows`. Returns whether anything changed. */
|
|
84
|
+
function applyMessages(rows, messages) {
|
|
85
|
+
let changed = false;
|
|
86
|
+
for (const message of messages) if (isChangeMessage(message)) {
|
|
87
|
+
const { key, value } = message;
|
|
88
|
+
switch (message.headers.operation) {
|
|
89
|
+
case "insert":
|
|
90
|
+
rows.set(key, value);
|
|
91
|
+
break;
|
|
92
|
+
case "update":
|
|
93
|
+
rows.set(key, {
|
|
94
|
+
...rows.get(key),
|
|
95
|
+
...value
|
|
96
|
+
});
|
|
97
|
+
break;
|
|
98
|
+
case "delete": rows.delete(key);
|
|
99
|
+
}
|
|
100
|
+
changed = true;
|
|
101
|
+
} else if (isControlMessage(message) && message.headers.control === "must-refetch") {
|
|
102
|
+
rows.clear();
|
|
103
|
+
changed = true;
|
|
104
|
+
}
|
|
105
|
+
return changed;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* During SSR, calls the shape route in-process with the request's headers (cookies, auth).
|
|
109
|
+
* Nitro 2 (Nuxt 4.6) has `event.fetch`, which returns a Response; on Nitro 3 (Nuxt 5) the
|
|
110
|
+
* request's `$fetch` has `.raw`.
|
|
111
|
+
*/
|
|
112
|
+
function inProcessFetch() {
|
|
113
|
+
const event = useRequestEvent();
|
|
114
|
+
if (typeof event?.fetch === "function") {
|
|
115
|
+
const eventFetch = event.fetch;
|
|
116
|
+
return ((input, init) => {
|
|
117
|
+
const url = new URL(input instanceof Request ? input.url : input);
|
|
118
|
+
return eventFetch(url.pathname + url.search, init);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const requestFetch = useRequestFetch();
|
|
122
|
+
return (async (input, init) => {
|
|
123
|
+
const url = new URL(input instanceof Request ? input.url : input);
|
|
124
|
+
const res = await requestFetch.raw(url.pathname + url.search, {
|
|
125
|
+
method: init?.method ?? "GET",
|
|
126
|
+
headers: init?.headers,
|
|
127
|
+
responseType: "stream",
|
|
128
|
+
ignoreResponseError: true
|
|
129
|
+
});
|
|
130
|
+
return new Response(res._data, {
|
|
131
|
+
status: res.status,
|
|
132
|
+
headers: res.headers
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function absolute(path) {
|
|
137
|
+
return new URL(path, import.meta.client ? location.origin : "http://localhost").href;
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
export { useShape };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { WervtUser } from "../shared/user.js";
|
|
2
|
+
//#region src/runtime/app/useWervtUser.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The signed-in wervt user, or `null`. Read during SSR and kept in the payload; apps without
|
|
5
|
+
* SSR load it from `/_wervt/user` before they render. Public apps get one when the visitor
|
|
6
|
+
* is signed in.
|
|
7
|
+
*/
|
|
8
|
+
export declare function useWervtUser(): import("vue").Ref<WervtUser | null, WervtUser | null>;
|
|
9
|
+
/** Sign in with wervt, then come back to `redirect` (a path in this app). */
|
|
10
|
+
export declare const wervtLoginUrl: (redirect?: string) => string;
|
|
11
|
+
/** Sign out of wervt (all apps), then come back to `redirect`. */
|
|
12
|
+
export declare const wervtLogoutUrl: (redirect?: string) => string;
|
|
13
|
+
//#endregion
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { DEV_LOGIN_COOKIE, devUser, parseWervtUser } from "../shared/user.js";
|
|
2
|
+
import { useCookie, useRequestHeaders, useState } from "#app";
|
|
3
|
+
//#region src/runtime/app/useWervtUser.ts
|
|
4
|
+
/**
|
|
5
|
+
* The signed-in wervt user, or `null`. Read during SSR and kept in the payload; apps without
|
|
6
|
+
* SSR load it from `/_wervt/user` before they render. Public apps get one when the visitor
|
|
7
|
+
* is signed in.
|
|
8
|
+
*/
|
|
9
|
+
function useWervtUser() {
|
|
10
|
+
const devLogin = import.meta.dev ? useCookie(DEV_LOGIN_COOKIE) : void 0;
|
|
11
|
+
return useState("wervt-user", () => import.meta.server ? parseWervtUser(useRequestHeaders(["x-wervt-user"])["x-wervt-user"]) ?? devUser(String(devLogin?.value) === "1") ?? null : null);
|
|
12
|
+
}
|
|
13
|
+
/** Sign in with wervt, then come back to `redirect` (a path in this app). */
|
|
14
|
+
const wervtLoginUrl = (redirect = "/") => `/_wervt/login?redirect=${encodeURIComponent(redirect)}`;
|
|
15
|
+
/** Sign out of wervt (all apps), then come back to `redirect`. */
|
|
16
|
+
const wervtLogoutUrl = (redirect = "/") => `/_wervt/logout?redirect=${encodeURIComponent(redirect)}`;
|
|
17
|
+
//#endregion
|
|
18
|
+
export { useWervtUser, wervtLoginUrl, wervtLogoutUrl };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
2
|
+
import { AnyRelations, EmptyRelations } from "drizzle-orm";
|
|
3
|
+
//#region src/runtime/server/db.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Drizzle client for the app's database. In production the wervt runtime injects
|
|
6
|
+
* DATABASE_URL for the app's own role, whose search_path is its own schema, so
|
|
7
|
+
* tables are declared without a schema.
|
|
8
|
+
*
|
|
9
|
+
* Pass the app's relations (from `defineRelations`) to use `db.query.*`. All clients
|
|
10
|
+
* share one connection pool.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const db = useDb({ relations })
|
|
14
|
+
* const room = await db.query.rooms.findFirst({ where: { id }, with: { players: true } })
|
|
15
|
+
*/
|
|
16
|
+
export declare function useDb<TRelations extends AnyRelations = EmptyRelations>(options?: {
|
|
17
|
+
relations?: TRelations;
|
|
18
|
+
}): PostgresJsDatabase<TRelations>;
|
|
19
|
+
//#endregion
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/postgres-js";
|
|
2
|
+
import postgres from "postgres";
|
|
3
|
+
//#region src/runtime/server/db.ts
|
|
4
|
+
let client;
|
|
5
|
+
const clients = /* @__PURE__ */ new Map();
|
|
6
|
+
/**
|
|
7
|
+
* Drizzle client for the app's database. In production the wervt runtime injects
|
|
8
|
+
* DATABASE_URL for the app's own role, whose search_path is its own schema, so
|
|
9
|
+
* tables are declared without a schema.
|
|
10
|
+
*
|
|
11
|
+
* Pass the app's relations (from `defineRelations`) to use `db.query.*`. All clients
|
|
12
|
+
* share one connection pool.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const db = useDb({ relations })
|
|
16
|
+
* const room = await db.query.rooms.findFirst({ where: { id }, with: { players: true } })
|
|
17
|
+
*/
|
|
18
|
+
function useDb(options) {
|
|
19
|
+
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is not set");
|
|
20
|
+
client ??= postgres(process.env.DATABASE_URL, {
|
|
21
|
+
max: 2,
|
|
22
|
+
idle_timeout: 20
|
|
23
|
+
});
|
|
24
|
+
const relations = options?.relations;
|
|
25
|
+
let db = clients.get(relations);
|
|
26
|
+
if (!db) {
|
|
27
|
+
db = drizzle({
|
|
28
|
+
client,
|
|
29
|
+
relations
|
|
30
|
+
});
|
|
31
|
+
clients.set(relations, db);
|
|
32
|
+
}
|
|
33
|
+
return db;
|
|
34
|
+
}
|
|
35
|
+
//#endregion
|
|
36
|
+
export { useDb };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { DEV_LOGIN_COOKIE } from "../shared/user.js";
|
|
2
|
+
import { defineEventHandler, deleteCookie, getQuery, sendRedirect, setCookie } from "nuxt/server";
|
|
3
|
+
//#region src/runtime/server/dev-auth.ts
|
|
4
|
+
var dev_auth_default = defineEventHandler((event) => {
|
|
5
|
+
const { redirect } = getQuery(event);
|
|
6
|
+
const target = typeof redirect === "string" && redirect.startsWith("/") ? redirect : "/";
|
|
7
|
+
if (new URL(event.req.url).pathname === "/_wervt/login") setCookie(event, DEV_LOGIN_COOKIE, "1", {
|
|
8
|
+
path: "/",
|
|
9
|
+
sameSite: "lax"
|
|
10
|
+
});
|
|
11
|
+
else deleteCookie(event, DEV_LOGIN_COOKIE, { path: "/" });
|
|
12
|
+
return sendRedirect(event, target);
|
|
13
|
+
});
|
|
14
|
+
//#endregion
|
|
15
|
+
export { dev_auth_default as default };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { WervtUser } from "../shared/user.js";
|
|
2
|
+
import { useDb } from "./db.js";
|
|
3
|
+
import { ShapeOptions, proxyShape } from "./shape.js";
|
|
4
|
+
import { useWervtUser } from "./user.js";
|
|
5
|
+
import { SafeResult, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, vh } from "./validate.js";
|
|
6
|
+
export { type SafeResult, type ShapeOptions, type WervtUser, proxyShape, useDb, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, useWervtUser, vh };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { useDb } from "./db.js";
|
|
2
|
+
import { proxyShape } from "./shape.js";
|
|
3
|
+
import { useWervtUser } from "./user.js";
|
|
4
|
+
import { useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, vh } from "./validate.js";
|
|
5
|
+
export { proxyShape, useDb, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, useWervtUser, vh };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { RequestEvent } from "nuxt/server";
|
|
2
|
+
//#region src/runtime/server/shape.d.ts
|
|
3
|
+
export interface ShapeOptions {
|
|
4
|
+
/** Table name without schema. */
|
|
5
|
+
table: string;
|
|
6
|
+
/** SQL where clause. Use `$1`, `$2`, … placeholders with `params` for values. */
|
|
7
|
+
where?: string;
|
|
8
|
+
/** Values for the `$n` placeholders in `where`. */
|
|
9
|
+
params?: (string | number | boolean)[];
|
|
10
|
+
columns?: string[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Serves an Electric shape from a route. Do auth checks before calling it.
|
|
14
|
+
*
|
|
15
|
+
* In production the request goes through the wervt runtime gateway, which pins the
|
|
16
|
+
* table to this app's schema. In dev it goes to Electric directly (ELECTRIC_URL).
|
|
17
|
+
*/
|
|
18
|
+
export declare function proxyShape(event: RequestEvent, { table, where, params, columns }: ShapeOptions): Promise<Response>;
|
|
19
|
+
//#endregion
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//#region src/runtime/server/shape.ts
|
|
2
|
+
const CLIENT_PARAMS = [
|
|
3
|
+
"offset",
|
|
4
|
+
"handle",
|
|
5
|
+
"live",
|
|
6
|
+
"live_sse",
|
|
7
|
+
"cursor",
|
|
8
|
+
"replica",
|
|
9
|
+
"log"
|
|
10
|
+
];
|
|
11
|
+
/**
|
|
12
|
+
* Serves an Electric shape from a route. Do auth checks before calling it.
|
|
13
|
+
*
|
|
14
|
+
* In production the request goes through the wervt runtime gateway, which pins the
|
|
15
|
+
* table to this app's schema. In dev it goes to Electric directly (ELECTRIC_URL).
|
|
16
|
+
*/
|
|
17
|
+
async function proxyShape(event, { table, where, params, columns }) {
|
|
18
|
+
const runtime = process.env.WERVT_RUNTIME_URL;
|
|
19
|
+
const url = runtime ? new URL("/_wervt/electric/v1/shape", runtime) : new URL("/v1/shape", process.env.ELECTRIC_URL ?? "http://localhost:3000");
|
|
20
|
+
const query = new URL(event.req.url).searchParams;
|
|
21
|
+
for (const key of CLIENT_PARAMS) {
|
|
22
|
+
const value = query.get(key);
|
|
23
|
+
if (value !== null) url.searchParams.set(key, value);
|
|
24
|
+
}
|
|
25
|
+
url.searchParams.set("table", runtime ? table : devTable(table));
|
|
26
|
+
if (where) url.searchParams.set("where", where);
|
|
27
|
+
params?.forEach((value, i) => url.searchParams.set(`params[${i + 1}]`, String(value)));
|
|
28
|
+
if (columns) url.searchParams.set("columns", columns.map(quoteIdentifier).join(","));
|
|
29
|
+
if (!runtime && process.env.ELECTRIC_SECRET) url.searchParams.set("secret", process.env.ELECTRIC_SECRET);
|
|
30
|
+
const headers = {};
|
|
31
|
+
if (runtime) {
|
|
32
|
+
headers["x-wervt-app"] = process.env.WERVT_APP ?? "";
|
|
33
|
+
headers["x-wervt-token"] = process.env.WERVT_TOKEN ?? "";
|
|
34
|
+
}
|
|
35
|
+
const res = await fetch(url, {
|
|
36
|
+
headers,
|
|
37
|
+
signal: event.req.signal
|
|
38
|
+
});
|
|
39
|
+
const out = new Headers(res.headers);
|
|
40
|
+
out.delete("content-encoding");
|
|
41
|
+
out.delete("content-length");
|
|
42
|
+
return new Response(res.body, {
|
|
43
|
+
status: res.status,
|
|
44
|
+
headers: out
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function devTable(table) {
|
|
48
|
+
const schema = process.env.WERVT_DB_SCHEMA;
|
|
49
|
+
return schema ? `${schema}.${table}` : table;
|
|
50
|
+
}
|
|
51
|
+
function quoteIdentifier(name) {
|
|
52
|
+
return /^[a-z_][a-z0-9_]*$/.test(name) ? name : `"${name.replaceAll("\"", "\"\"")}"`;
|
|
53
|
+
}
|
|
54
|
+
//#endregion
|
|
55
|
+
export { proxyShape };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { useWervtUser } from "./user.js";
|
|
2
|
+
import { defineEventHandler } from "nuxt/server";
|
|
3
|
+
//#region src/runtime/server/user-route.ts
|
|
4
|
+
var user_route_default = defineEventHandler((event) => {
|
|
5
|
+
event.res.headers.set("cache-control", "private, no-store");
|
|
6
|
+
return { user: useWervtUser(event) ?? null };
|
|
7
|
+
});
|
|
8
|
+
//#endregion
|
|
9
|
+
export { user_route_default as default };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { DEV_LOGIN_COOKIE, WervtUser, parseWervtUser } from "../shared/user.js";
|
|
2
|
+
import "./user.js";
|
|
3
|
+
import { RequestEvent } from "nuxt/server";
|
|
4
|
+
//#region src/runtime/server/user.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* The signed-in wervt user, or `undefined` when nobody is signed in.
|
|
7
|
+
* Private apps always have one (the runtime checked the login and the allow-list); public
|
|
8
|
+
* apps get one when the visitor is signed in. Send people to `/_wervt/login` to sign in.
|
|
9
|
+
*/
|
|
10
|
+
export declare const useWervtUser: (event: RequestEvent) => WervtUser | undefined;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { DEV_LOGIN_COOKIE, type WervtUser, parseWervtUser };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { DEV_LOGIN_COOKIE, devUser, parseWervtUser } from "../shared/user.js";
|
|
2
|
+
import { getCookie } from "nuxt/server";
|
|
3
|
+
//#region src/runtime/server/user.ts
|
|
4
|
+
/**
|
|
5
|
+
* The signed-in wervt user, or `undefined` when nobody is signed in.
|
|
6
|
+
* Private apps always have one (the runtime checked the login and the allow-list); public
|
|
7
|
+
* apps get one when the visitor is signed in. Send people to `/_wervt/login` to sign in.
|
|
8
|
+
*/
|
|
9
|
+
const useWervtUser = (event) => parseWervtUser(event.req.headers.get("x-wervt-user")) ?? devUser(getCookie(event, "wervt_dev_login") === "1");
|
|
10
|
+
//#endregion
|
|
11
|
+
export { DEV_LOGIN_COOKIE, parseWervtUser, useWervtUser };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { RequestEvent } from "nuxt/server";
|
|
2
|
+
import * as v from "valibot";
|
|
3
|
+
//#region src/runtime/server/validate.d.ts
|
|
4
|
+
type Schema = v.GenericSchema | v.GenericSchemaAsync;
|
|
5
|
+
type Shape = v.ObjectEntriesAsync;
|
|
6
|
+
type Input = Schema | Shape;
|
|
7
|
+
type Output<T extends Input> = T extends Schema ? v.InferOutput<T> : T extends Shape ? v.InferOutput<v.ObjectSchemaAsync<T, undefined>> : never;
|
|
8
|
+
export type SafeResult<T extends Input> = {
|
|
9
|
+
success: true;
|
|
10
|
+
data: Output<T>;
|
|
11
|
+
issues?: undefined;
|
|
12
|
+
} | {
|
|
13
|
+
success: false;
|
|
14
|
+
data?: undefined;
|
|
15
|
+
issues: v.FlatErrors<undefined>;
|
|
16
|
+
};
|
|
17
|
+
/** Parsed JSON body. Throws a 400 with flattened issues when invalid. */
|
|
18
|
+
export declare const useValidatedBody: <T extends Input>(event: RequestEvent, schema: T) => Promise<Output<T>>;
|
|
19
|
+
/** Query parameters (strings; use `vh.*` to coerce). Throws a 400 when invalid. */
|
|
20
|
+
export declare const useValidatedQuery: <T extends Input>(event: RequestEvent, schema: T) => Promise<Output<T>>;
|
|
21
|
+
/** Route params like `[id]` (strings; use `vh.*` to coerce). Throws a 400 when invalid. */
|
|
22
|
+
export declare const useValidatedParams: <T extends Input>(event: RequestEvent, schema: T) => Promise<Output<T>>;
|
|
23
|
+
/** Like `useValidatedBody`, but returns `{ success, data | issues }` instead of throwing. */
|
|
24
|
+
export declare const useSafeValidatedBody: <T extends Input>(event: RequestEvent, schema: T) => Promise<SafeResult<T>>;
|
|
25
|
+
export declare const useSafeValidatedQuery: <T extends Input>(event: RequestEvent, schema: T) => Promise<SafeResult<T>>;
|
|
26
|
+
export declare const useSafeValidatedParams: <T extends Input>(event: RequestEvent, schema: T) => Promise<SafeResult<T>>;
|
|
27
|
+
/** Schemas for values that arrive as strings (query, route params, form fields). */
|
|
28
|
+
export declare const vh: {
|
|
29
|
+
/** `'true'` / `'false'` → boolean. */
|
|
30
|
+
boolAsString: v.SchemaWithPipe<readonly [v.PicklistSchema<["true", "false"], undefined>, v.TransformAction<"true" | "false", boolean>]>;
|
|
31
|
+
/** HTML checkbox: `'on'` or missing → boolean. */
|
|
32
|
+
checkboxAsString: v.SchemaWithPipe<readonly [v.OptionalSchema<v.LiteralSchema<"on", undefined>, undefined>, v.TransformAction<"on" | undefined, boolean>]>;
|
|
33
|
+
/** Integer string → number. */
|
|
34
|
+
intAsString: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, undefined>, v.TransformAction<any, number>, v.SafeIntegerAction<number, undefined>]>;
|
|
35
|
+
/** Numeric string → number. */
|
|
36
|
+
numAsString: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.DecimalAction<string, undefined>, v.TransformAction<any, number>]>;
|
|
37
|
+
/** Positive integer id (e.g. a serial primary key) → number. */
|
|
38
|
+
id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.DigitsAction<string, undefined>, v.TransformAction<any, number>, v.SafeIntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>;
|
|
39
|
+
};
|
|
40
|
+
//#endregion
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createError, getQuery, getRouterParams, readBody } from "nuxt/server";
|
|
2
|
+
import * as v from "valibot";
|
|
3
|
+
//#region src/runtime/server/validate.ts
|
|
4
|
+
function toSchema(input) {
|
|
5
|
+
return "kind" in input && input.kind === "schema" ? input : v.objectAsync(input);
|
|
6
|
+
}
|
|
7
|
+
async function safeValidate(input, data) {
|
|
8
|
+
const result = await v.safeParseAsync(toSchema(input), data);
|
|
9
|
+
return result.success ? {
|
|
10
|
+
success: true,
|
|
11
|
+
data: result.output
|
|
12
|
+
} : {
|
|
13
|
+
success: false,
|
|
14
|
+
issues: v.flatten(result.issues)
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
async function validate(input, data) {
|
|
18
|
+
const result = await safeValidate(input, data);
|
|
19
|
+
if (result.success) return result.data;
|
|
20
|
+
throw createError({
|
|
21
|
+
status: 400,
|
|
22
|
+
statusText: "Validation failed",
|
|
23
|
+
message: "Validation failed",
|
|
24
|
+
data: result.issues
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** Parsed JSON body. Throws a 400 with flattened issues when invalid. */
|
|
28
|
+
const useValidatedBody = (event, schema) => readBody(event).then((body) => validate(schema, body));
|
|
29
|
+
/** Query parameters (strings; use `vh.*` to coerce). Throws a 400 when invalid. */
|
|
30
|
+
const useValidatedQuery = (event, schema) => validate(schema, getQuery(event));
|
|
31
|
+
/** Route params like `[id]` (strings; use `vh.*` to coerce). Throws a 400 when invalid. */
|
|
32
|
+
const useValidatedParams = (event, schema) => validate(schema, getRouterParams(event, { decode: true }));
|
|
33
|
+
/** Like `useValidatedBody`, but returns `{ success, data | issues }` instead of throwing. */
|
|
34
|
+
const useSafeValidatedBody = (event, schema) => readBody(event).then((body) => safeValidate(schema, body));
|
|
35
|
+
const useSafeValidatedQuery = (event, schema) => safeValidate(schema, getQuery(event));
|
|
36
|
+
const useSafeValidatedParams = (event, schema) => safeValidate(schema, getRouterParams(event, { decode: true }));
|
|
37
|
+
/** Schemas for values that arrive as strings (query, route params, form fields). */
|
|
38
|
+
const vh = {
|
|
39
|
+
/** `'true'` / `'false'` → boolean. */
|
|
40
|
+
boolAsString: v.pipe(v.picklist(["true", "false"]), v.transform((s) => s === "true")),
|
|
41
|
+
/** HTML checkbox: `'on'` or missing → boolean. */
|
|
42
|
+
checkboxAsString: v.pipe(v.optional(v.literal("on")), v.transform((s) => s === "on")),
|
|
43
|
+
/** Integer string → number. */
|
|
44
|
+
intAsString: v.pipe(v.string(), v.regex(/^-?\d+$/), v.transform(Number), v.safeInteger()),
|
|
45
|
+
/** Numeric string → number. */
|
|
46
|
+
numAsString: v.pipe(v.string(), v.decimal(), v.transform(Number)),
|
|
47
|
+
/** Positive integer id (e.g. a serial primary key) → number. */
|
|
48
|
+
id: v.pipe(v.string(), v.digits(), v.transform(Number), v.safeInteger(), v.minValue(1))
|
|
49
|
+
};
|
|
50
|
+
//#endregion
|
|
51
|
+
export { useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, vh };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region src/runtime/shared/user.d.ts
|
|
2
|
+
export interface WervtUser {
|
|
3
|
+
id: string;
|
|
4
|
+
email: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
image?: string;
|
|
7
|
+
/** Linked login accounts, provider id to the user's id there, e.g. `{ twitch: '1234' }`. */
|
|
8
|
+
accounts?: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
/** Cookie set by the dev stand-in for `/_wervt/login` (see `server/dev-auth.ts`). */
|
|
11
|
+
export declare const DEV_LOGIN_COOKIE = "wervt_dev_login";
|
|
12
|
+
/** Parses the `x-wervt-user` header the runtime sets (and strips from client requests). */
|
|
13
|
+
export declare function parseWervtUser(header: string | null | undefined): WervtUser | undefined;
|
|
14
|
+
/** During `nuxt dev`: the configured dev user, after `/_wervt/login`. */
|
|
15
|
+
export declare function devUser(loggedIn: boolean): WervtUser | undefined;
|
|
16
|
+
//#endregion
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/runtime/shared/user.ts
|
|
2
|
+
/** Cookie set by the dev stand-in for `/_wervt/login` (see `server/dev-auth.ts`). */
|
|
3
|
+
const DEV_LOGIN_COOKIE = "wervt_dev_login";
|
|
4
|
+
/** Parses the `x-wervt-user` header the runtime sets (and strips from client requests). */
|
|
5
|
+
function parseWervtUser(header) {
|
|
6
|
+
if (!header) return void 0;
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(decodeURIComponent(header));
|
|
9
|
+
} catch {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** During `nuxt dev`: the configured dev user, after `/_wervt/login`. */
|
|
14
|
+
function devUser(loggedIn) {
|
|
15
|
+
const user = process.env.WERVT_DEV_USER;
|
|
16
|
+
if (!import.meta.dev || !loggedIn || !user) return void 0;
|
|
17
|
+
return JSON.parse(user);
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { DEV_LOGIN_COOKIE, devUser, parseWervtUser };
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wervt/nuxt",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Nuxt module for apps hosted on wervt: database, live data and runtime setup.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"drizzle",
|
|
7
|
+
"electric",
|
|
8
|
+
"nuxt",
|
|
9
|
+
"nuxt-module",
|
|
10
|
+
"postgres",
|
|
11
|
+
"wervt"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://wervt.app",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "Niki2k1",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/wervt-app/wervt.git",
|
|
19
|
+
"directory": "packages/nuxt"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/module.d.ts",
|
|
29
|
+
"default": "./dist/module.js"
|
|
30
|
+
},
|
|
31
|
+
"./server": {
|
|
32
|
+
"types": "./dist/runtime/server/index.d.ts",
|
|
33
|
+
"default": "./dist/runtime/server/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./provision": {
|
|
36
|
+
"types": "./dist/provision.d.ts",
|
|
37
|
+
"default": "./dist/provision.js"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@electric-sql/client": "^1.5.28",
|
|
42
|
+
"evlog": "^2.30.0",
|
|
43
|
+
"postgres": "^3.4.9",
|
|
44
|
+
"valibot": "^1.5.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"drizzle-orm": "1.0.0-rc.4",
|
|
48
|
+
"nitro": "3.0.260903-beta",
|
|
49
|
+
"nuxt": "npm:nuxt-nightly@5x",
|
|
50
|
+
"vue": "^3.5.43"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"drizzle-orm": "1.0.0-rc.4",
|
|
54
|
+
"nuxt": ">=4.2"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "vp pack"
|
|
58
|
+
}
|
|
6
59
|
}
|