@fougere/nuxt 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/dist/module.d.ts +16 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +215 -0
- package/dist/module.js.map +1 -0
- package/package.json +52 -0
- package/src/runtime/composables/useCurrentUser.ts +25 -0
- package/src/runtime/composables/useFormFor.ts +87 -0
- package/src/runtime/composables/useFougereData.ts +128 -0
- package/src/runtime/form/fields.ts +185 -0
- package/src/runtime/plugins/session.server.ts +15 -0
- package/src/runtime/server/api/crud.ts +86 -0
- package/src/runtime/server/auth/middleware/auth.ts +30 -0
- package/src/runtime/server/auth/routes/api/me.get.ts +10 -0
- package/src/runtime/server/auth/routes/auth/[...].ts +8 -0
- package/src/runtime/server/routes/call.post.ts +158 -0
- package/src/runtime/server/routes/session.get.ts +11 -0
- package/src/runtime/server/utils/fougereApp.ts +209 -0
- package/src/runtime/server/utils/fougereAuth.ts +20 -0
- package/src/runtime/server/utils/invoke.ts +42 -0
- package/src/runtime/session/view.ts +20 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fougere server bootstrap — single entry point for app lifecycle in Nuxt.
|
|
3
|
+
*
|
|
4
|
+
* Default path (zero-config): fougere.config.ts declares `db: 'sqlite'` and
|
|
5
|
+
* everything else; this module auto-resolves the storage handle, builds an
|
|
6
|
+
* ormFactory, runs auto-DDL from the entities, and boots the app.
|
|
7
|
+
*
|
|
8
|
+
* Escape hatch: `configureFougere({ db, ormFactory })` can be called from a
|
|
9
|
+
* Nitro plugin if the user wants a custom data layer (alternative driver,
|
|
10
|
+
* managed migrations, etc.).
|
|
11
|
+
*/
|
|
12
|
+
import { createApp, loadCascadedConfig, setModuleLoader, frondAliases, Logger } from '@fougere/core';
|
|
13
|
+
import { createContainer } from '@fougere/container-fougere';
|
|
14
|
+
import type { App, EntityOrm, FougereConfig, Transport } from '@fougere/core';
|
|
15
|
+
import { applyCreate, applyUpdate, type SchemaLike } from '@fougere/schema';
|
|
16
|
+
|
|
17
|
+
// ── Public types ─────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export interface FougereServerConfig {
|
|
20
|
+
/** Storage handle. Forwarded to the auth provider via AuthContext.db. */
|
|
21
|
+
db?: unknown;
|
|
22
|
+
/** Per-entity ORM factory. */
|
|
23
|
+
ormFactory?: (entity: SchemaLike, name: string) => EntityOrm;
|
|
24
|
+
/** Called after app is created. Use for migrations, seeding, etc. */
|
|
25
|
+
afterBoot?: (app: App) => void | Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── State ────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
let _config: FougereServerConfig = {};
|
|
31
|
+
let _appPromise: Promise<App> | null = null;
|
|
32
|
+
|
|
33
|
+
// ── Public API ───────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Override the data layer — only needed if you don't want the convention-driven
|
|
37
|
+
* setup based on `config.db` in fougere.config.ts.
|
|
38
|
+
*/
|
|
39
|
+
export function configureFougere(config: FougereServerConfig) {
|
|
40
|
+
_config = config;
|
|
41
|
+
_appPromise = null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Get the booted Fougere app. Lazy — boots on first call, then caches. */
|
|
45
|
+
export function useFougereApp(): Promise<App> {
|
|
46
|
+
if (!_appPromise) {
|
|
47
|
+
_appPromise = boot();
|
|
48
|
+
}
|
|
49
|
+
return _appPromise;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Boot ─────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
async function boot(): Promise<App> {
|
|
55
|
+
const bootStart = performance.now();
|
|
56
|
+
const log = new Logger('boot', { level: 'debug' });
|
|
57
|
+
|
|
58
|
+
log.info('booting (Nuxt/Nitro)');
|
|
59
|
+
|
|
60
|
+
const { createJiti } = await import('jiti');
|
|
61
|
+
// Nitro serves from a bundle, but the scan still reads frond sources from disk — so the
|
|
62
|
+
// named form a frond uses for its neighbour has to resolve here too.
|
|
63
|
+
const jiti = createJiti(import.meta.url, {
|
|
64
|
+
interopDefault: true,
|
|
65
|
+
alias: await frondAliases(process.env.FOUGERE_ROOT ?? process.cwd()),
|
|
66
|
+
});
|
|
67
|
+
setModuleLoader((filePath) => jiti.import(filePath) as Promise<Record<string, unknown>>);
|
|
68
|
+
|
|
69
|
+
// Config cascades along the workspace→app frontier: the workspace root (via
|
|
70
|
+
// FOUGERE_ROOT, where `remotes`/shared db live) is the base, the app (cwd)
|
|
71
|
+
// overrides. Same boundary the fronds cascade along. No `root` → both equal.
|
|
72
|
+
const configRoot = process.cwd();
|
|
73
|
+
const root = process.env.FOUGERE_ROOT ?? configRoot;
|
|
74
|
+
const fileConfig: FougereConfig = await loadCascadedConfig(root, configRoot);
|
|
75
|
+
|
|
76
|
+
// Auto-resolve the data layer from config.db when the user didn't provide a
|
|
77
|
+
// custom one via configureFougere. The resolution itself lives in @fougere/runtime
|
|
78
|
+
// — this host must not know which storage package backs `db:`.
|
|
79
|
+
let db = _config.db;
|
|
80
|
+
let ormFactory = _config.ormFactory;
|
|
81
|
+
let migrateSchema: ((app: never) => Promise<void> | void) | undefined;
|
|
82
|
+
if (!ormFactory) {
|
|
83
|
+
const { resolveStorage } = await import('@fougere/runtime');
|
|
84
|
+
const storage = resolveStorage(fileConfig.db as never);
|
|
85
|
+
if (storage.ormFactory) {
|
|
86
|
+
log.debug('auto-resolving storage from config.db');
|
|
87
|
+
db = storage.db;
|
|
88
|
+
ormFactory = storage.ormFactory;
|
|
89
|
+
migrateSchema = storage.afterBoot as never;
|
|
90
|
+
} else {
|
|
91
|
+
log.debug('no db declared — falling back to in-memory ORM');
|
|
92
|
+
ormFactory = createMemoryOrm;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Layer-2 wiring: `remotes: { catalog: 'http://...' }` in fougere.config.ts
|
|
97
|
+
// is all the user writes — the default transport comes from here.
|
|
98
|
+
let remoteTransport: ((url: string) => Transport) | undefined;
|
|
99
|
+
if (Object.keys(fileConfig.remotes ?? {}).length > 0) {
|
|
100
|
+
log.debug(`remotes declared (${Object.keys(fileConfig.remotes!).join(', ')}) — wiring HTTP transport`);
|
|
101
|
+
const { createHttpTransport } = await import('@fougere/transport-http');
|
|
102
|
+
remoteTransport = (url) => createHttpTransport(url);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const app = await createApp({
|
|
106
|
+
root,
|
|
107
|
+
createContainer,
|
|
108
|
+
ormFactory,
|
|
109
|
+
db,
|
|
110
|
+
auth: fileConfig.auth,
|
|
111
|
+
remotes: fileConfig.remotes,
|
|
112
|
+
remoteTransport,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Bring the schema up to date — creates missing tables and adds columns an
|
|
116
|
+
// entity gained. Auth entities travel with the app, no synthetic frond needed.
|
|
117
|
+
if (migrateSchema) {
|
|
118
|
+
log.debug('migrating schema from entities');
|
|
119
|
+
await migrateSchema(app as never);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (_config.afterBoot) {
|
|
123
|
+
log.debug('running afterBoot');
|
|
124
|
+
await _config.afterBoot(app);
|
|
125
|
+
log.info('afterBoot done');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const ms = (performance.now() - bootStart).toFixed(0);
|
|
129
|
+
log.info(`ready in ${ms}ms — ${app.fronds.length} frond(s)${app.auth ? ` + auth (${app.auth.basePath})` : ''}`);
|
|
130
|
+
|
|
131
|
+
return app;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Fallback ORM ─────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The store an app with no `db` runs on.
|
|
138
|
+
*
|
|
139
|
+
* It used to ignore both its arguments — `(_entity, _name)` — so it forced the field
|
|
140
|
+
* name `id`, minted a uuid whatever the entity declared, and realized none of the
|
|
141
|
+
* lifecycle rules: `auto()` stamped nothing, a declared default stayed absent. The
|
|
142
|
+
* same page therefore behaved one way here and another way on SQLite.
|
|
143
|
+
*
|
|
144
|
+
* It reads the axes now, through the one realization every storage shares.
|
|
145
|
+
*/
|
|
146
|
+
export function createMemoryOrm(entity: SchemaLike, name: string): EntityOrm {
|
|
147
|
+
const fields = entity.getFields();
|
|
148
|
+
const pk = Object.entries(fields).find(([, field]) => field.role?.primary)?.[0] ?? 'id';
|
|
149
|
+
const store = new Map<string, Record<string, unknown>>();
|
|
150
|
+
// `EntityOrm.findById(id: string)` — but a key can hold a number, and a Map keyed on
|
|
151
|
+
// `1` does not answer `'1'`. SQL never had the question; here the divergence was
|
|
152
|
+
// silent and only on this storage.
|
|
153
|
+
const keyOf = (value: unknown) => String(value);
|
|
154
|
+
const matches = (row: Record<string, unknown>, criteria: Record<string, unknown>) =>
|
|
155
|
+
Object.entries(criteria).every(([key, value]) => Object.is(row[key], value));
|
|
156
|
+
return {
|
|
157
|
+
client: store,
|
|
158
|
+
async list(options?: any) {
|
|
159
|
+
let items = [...store.values()];
|
|
160
|
+
if (options?.where) items = items.filter((row) => matches(row, options.where));
|
|
161
|
+
const limit = options?.limit;
|
|
162
|
+
const offset = options?.page && limit ? (options.page - 1) * limit : options?.offset ?? 0;
|
|
163
|
+
if (offset > 0) items = items.slice(offset);
|
|
164
|
+
const hasMore = limit ? items.length > limit : false;
|
|
165
|
+
if (limit) items = items.slice(0, limit);
|
|
166
|
+
const result = items as any;
|
|
167
|
+
result.hasMore = hasMore;
|
|
168
|
+
result.endCursor = items.length > 0 ? String((items[items.length - 1] as any)[pk] ?? '') : undefined;
|
|
169
|
+
if (options?.count) result.total = store.size;
|
|
170
|
+
return result;
|
|
171
|
+
},
|
|
172
|
+
async findById(id: string) { return store.get(keyOf(id)); },
|
|
173
|
+
async findBy(criteria: Record<string, unknown>) {
|
|
174
|
+
return [...store.values()].find((row) => matches(row, criteria));
|
|
175
|
+
},
|
|
176
|
+
async findAllBy(criteria: Record<string, unknown>) {
|
|
177
|
+
return [...store.values()].filter((row) => matches(row, criteria));
|
|
178
|
+
},
|
|
179
|
+
async create(input: Partial<Record<string, unknown>>) {
|
|
180
|
+
const record = applyCreate(fields, input);
|
|
181
|
+
const id = record[pk] as string | undefined;
|
|
182
|
+
// `primary(text())` declares no generator, so nothing fills the hole and the
|
|
183
|
+
// caller has to. Keying on `undefined` would let the second create overwrite the
|
|
184
|
+
// first, in silence — the old version hid this by inventing an `id` field the
|
|
185
|
+
// entity never declared.
|
|
186
|
+
if (id === undefined) {
|
|
187
|
+
throw new Error(`${name}.create: '${pk}' is the primary key and nothing supplied it — this entity declares no generator for it.`);
|
|
188
|
+
}
|
|
189
|
+
// A create is not an upsert. `Map.set` overwrites, so a second create under the
|
|
190
|
+
// same key answered "created" while destroying the previous row — SQL answers a
|
|
191
|
+
// constraint violation, and a store that loses data silently is worse than one
|
|
192
|
+
// that fails.
|
|
193
|
+
if (store.has(keyOf(id))) {
|
|
194
|
+
throw new Error(`${name}.create: '${pk}' ${JSON.stringify(id)} already exists.`);
|
|
195
|
+
}
|
|
196
|
+
store.set(keyOf(id), record);
|
|
197
|
+
return record;
|
|
198
|
+
},
|
|
199
|
+
async update(id: string, input: Partial<Record<string, unknown>>) {
|
|
200
|
+
const existing = store.get(keyOf(id));
|
|
201
|
+
if (!existing) throw new Error(`Not found: ${id}`);
|
|
202
|
+
const updated = { ...existing, ...applyUpdate(fields, input), [pk]: existing[pk] };
|
|
203
|
+
store.set(keyOf(id), updated);
|
|
204
|
+
return updated;
|
|
205
|
+
},
|
|
206
|
+
async delete(id: string) { return store.delete(keyOf(id)); },
|
|
207
|
+
output(_schema: SchemaLike) { return this; },
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth accessor — reads the AuthRuntime that the core mounted on the app.
|
|
3
|
+
*
|
|
4
|
+
* Auth is no longer a separate singleton: it lives on `app.auth`, built once
|
|
5
|
+
* during boot from the `auth` field of fougere.config.ts. This accessor is a
|
|
6
|
+
* thin async helper for server code (middleware, routes, /api/me).
|
|
7
|
+
*/
|
|
8
|
+
import type { AuthRuntime } from '@fougere/core';
|
|
9
|
+
import { useFougereApp } from './fougereApp';
|
|
10
|
+
|
|
11
|
+
/** Get the auth runtime resolved at boot. Throws if no `auth` was declared in fougere.config.ts. */
|
|
12
|
+
export async function useFougereAuth(): Promise<AuthRuntime> {
|
|
13
|
+
const app = await useFougereApp();
|
|
14
|
+
if (!app.auth) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
'Auth not configured — declare `auth: { provider, ... }` in fougere.config.ts.',
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
return app.auth;
|
|
20
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server dual of the couple — same designation (class + verb), same
|
|
3
|
+
* return, same errors. Fabricates the call value and hands it to the app
|
|
4
|
+
* runner: local façade → direct in-memory execution, frond in `remotes`
|
|
5
|
+
* → JSON-RPC on the wire. The caller never knows which.
|
|
6
|
+
*
|
|
7
|
+
* In request context the current session rides along (event.context →
|
|
8
|
+
* invocation.state); outside a request, state is empty or explicit.
|
|
9
|
+
*/
|
|
10
|
+
import { useEvent } from 'nitropack/runtime';
|
|
11
|
+
import {
|
|
12
|
+
createAppRunner,
|
|
13
|
+
callValueOf,
|
|
14
|
+
type FrondCall,
|
|
15
|
+
type InvocationContext,
|
|
16
|
+
} from '@fougere/core';
|
|
17
|
+
import { useFougereApp } from './fougereApp';
|
|
18
|
+
|
|
19
|
+
type EntityClass = { name: string };
|
|
20
|
+
type CallInput = Partial<InvocationContext>;
|
|
21
|
+
|
|
22
|
+
export async function invoke<T = unknown>(entity: EntityClass, op: string, input?: CallInput): Promise<T>;
|
|
23
|
+
export async function invoke<T = unknown>(call: FrondCall, input?: CallInput): Promise<T>;
|
|
24
|
+
export async function invoke<T = unknown>(
|
|
25
|
+
target: EntityClass | FrondCall,
|
|
26
|
+
opOrInput?: string | CallInput,
|
|
27
|
+
input?: CallInput,
|
|
28
|
+
): Promise<T> {
|
|
29
|
+
const given = typeof opOrInput === 'string' ? input : opOrInput;
|
|
30
|
+
const { call, invocation } = callValueOf(target, opOrInput, input);
|
|
31
|
+
const state = given?.state ?? requestState();
|
|
32
|
+
const app = await useFougereApp();
|
|
33
|
+
return (await createAppRunner(app)(call, { ...invocation, state })) as T;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function requestState(): Record<string, unknown> {
|
|
37
|
+
try {
|
|
38
|
+
return (useEvent()?.context ?? {}) as Record<string, unknown>;
|
|
39
|
+
} catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session view — the one place that turns the server-resolved
|
|
3
|
+
* request context (filled by the auth middleware) into what the client
|
|
4
|
+
* is allowed to see. One resolution, three readers: the page by
|
|
5
|
+
* hydration, the refresh route over the wire, handlers by invocation.
|
|
6
|
+
*
|
|
7
|
+
* The app-declared context (viewer enrichment) will attach here when
|
|
8
|
+
* a real case lands — this function is the seam.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface SessionView {
|
|
12
|
+
user: Record<string, unknown> | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function sessionViewOf(context: Record<string, unknown>): SessionView {
|
|
16
|
+
const raw = context.user as Record<string, unknown> | undefined;
|
|
17
|
+
if (!raw) return { user: null };
|
|
18
|
+
const { passwordHash: _passwordHash, ...user } = raw;
|
|
19
|
+
return { user };
|
|
20
|
+
}
|