@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.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Form contract, pure part — derives what a create/edit form is made of
3
+ * from the entity's field axes. No Vue, no Nuxt: testable headless,
4
+ * usable by any renderer (the page owns the widgets).
5
+ */
6
+ import { inputFields } from '@fougere/schema';
7
+ import type { ValidationError } from '@fougere/schema';
8
+
9
+ /** What an entity class exposes to a form — the schema statics it already has. */
10
+ export interface FormEntity {
11
+ name: string;
12
+ getFields(): Record<string, FieldLike>;
13
+ validate(input: unknown): { success: true; data: unknown } | { success: false; errors: ValidationError[] };
14
+ }
15
+
16
+ interface FieldLike {
17
+ shape?: {
18
+ type?: unknown;
19
+ enum?: readonly unknown[];
20
+ format?: string;
21
+ properties?: unknown;
22
+ minLength?: number;
23
+ maxLength?: number;
24
+ minimum?: number;
25
+ maximum?: number;
26
+ pattern?: string;
27
+ };
28
+ lifecycle?: { create?: unknown };
29
+ role?: { primary?: boolean; relation?: { kind: string } };
30
+ }
31
+
32
+ /**
33
+ * The literal a field is born with, when it declares one.
34
+ *
35
+ * `text({ default: 'x' })` and `oneOf('a', 'b', { default: 'a' })` both compile to
36
+ * `lifecycle.create = { value }` — the create rule that answers the field's absence.
37
+ * The other create rules ('now', { generate }, 'optional') name no literal: their value
38
+ * is decided at write time, so a form has nothing to show for them.
39
+ */
40
+ function defaultOf(field: FieldLike): unknown {
41
+ const create = field.lifecycle?.create;
42
+ return create !== null && typeof create === 'object' && 'value' in create
43
+ ? (create as { value: unknown }).value
44
+ : undefined;
45
+ }
46
+
47
+ export interface FormField {
48
+ name: string;
49
+ /** Rendering hint derived from the shape — the page maps it to widgets. */
50
+ control: 'text' | 'email' | 'url' | 'number' | 'boolean' | 'date' | 'select';
51
+ required: boolean;
52
+ /** i18n key by convention: `entity.field`. The schema never carries display text. */
53
+ labelKey: string;
54
+ /** Fallback label when no i18n message fills the key. */
55
+ label: string;
56
+ /** Enum values, when control is 'select'. */
57
+ options?: string[];
58
+ /**
59
+ * What the browser enforces, under the names it already knows — spread this on the
60
+ * input and the page states no rule of its own.
61
+ *
62
+ * The shape holds `minLength`/`maximum`/`pattern`; a browser holds `minlength`/
63
+ * `max`/`pattern` and enforces them with no JavaScript at all. Carrying them here
64
+ * is a projection, not a second rule: the judge reads the same shape, and a form
65
+ * that ignores these still gets the same verdict — it just gets it later, and a
66
+ * screen reader never gets it at all.
67
+ *
68
+ * `type` is part of the contract, not decoration: `email` and `url` are formats the
69
+ * shape states and the browser checks live, per field, as one types. A page writing
70
+ * `type="email"` by hand is spelling a second time what the card already said.
71
+ *
72
+ * Three deliberate absences, each one a place where the attribute would mean
73
+ * something the shape does not say:
74
+ * - a `date` field gets no `type` — neither `date` nor `datetime-local` produces the
75
+ * RFC 3339 string a `date-time` shape judges, so the browser would accept what the
76
+ * judge refuses;
77
+ * - `select` and `boolean` are not inputs — the page picks the widget, `control` says
78
+ * which;
79
+ * - a required `boolean` gets no `required` — on a checkbox that attribute means
80
+ * "must be CHECKED", where the shape only says the value must be supplied.
81
+ */
82
+ attrs?: {
83
+ type?: 'text' | 'email' | 'url' | 'number';
84
+ required?: boolean;
85
+ minlength?: number;
86
+ maxlength?: number;
87
+ min?: number;
88
+ max?: number;
89
+ pattern?: string;
90
+ };
91
+ /**
92
+ * The value the field is born with — the literal its `lifecycle.create` rule names.
93
+ * Present so the form can SHOW what is about to be written; the storage realizes it
94
+ * either way, so a form that ignores this still produces the same row.
95
+ */
96
+ default?: unknown;
97
+ }
98
+
99
+ /** The base JSON type of a shape — unwraps the `[T,'null']` union. */
100
+ function baseType(type: unknown): string {
101
+ if (Array.isArray(type)) return (type.find((t) => t !== 'null') as string) ?? 'string';
102
+ return (type as string) ?? 'string';
103
+ }
104
+
105
+ /** The formats a browser has an input type for — the rest stay `text`, judged later. */
106
+ const CONTROL_BY_FORMAT: Record<string, FormField['control']> = {
107
+ 'date-time': 'date',
108
+ email: 'email',
109
+ uri: 'url',
110
+ };
111
+
112
+ function controlOf(field: FieldLike): FormField['control'] {
113
+ const shape = field.shape ?? {};
114
+ if (Array.isArray(shape.enum) && shape.enum.length) return 'select';
115
+ const base = baseType(shape.type);
116
+ if (base === 'number' || base === 'integer') return 'number';
117
+ if (base === 'boolean') return 'boolean';
118
+ if (base === 'string' && shape.format) return CONTROL_BY_FORMAT[shape.format] ?? 'text';
119
+ return 'text';
120
+ }
121
+
122
+ /** Controls that ARE an `<input type>` — see the two absences on {@link FormField.attrs}. */
123
+ const INPUT_TYPES = new Set(['text', 'email', 'url', 'number']);
124
+
125
+ /** The shape's bounds, under the names a browser already enforces. */
126
+ function attrsOf(field: FieldLike, control: FormField['control'], required: boolean): NonNullable<FormField['attrs']> {
127
+ const s = field.shape ?? {};
128
+ const attrs = {
129
+ type: INPUT_TYPES.has(control) ? control : undefined,
130
+ required: (required && control !== 'boolean') || undefined,
131
+ minlength: s.minLength,
132
+ maxlength: s.maxLength,
133
+ min: s.minimum,
134
+ max: s.maximum,
135
+ pattern: s.pattern,
136
+ };
137
+ return Object.fromEntries(Object.entries(attrs).filter(([, v]) => v !== undefined));
138
+ }
139
+
140
+ /**
141
+ * The fields a create form is made of: membership from the io projection
142
+ * (`inputFields` — what a client may supply), requiredness from the
143
+ * lifecycle axis (any create rule makes absence legal).
144
+ */
145
+ export function formFieldsOf(entity: FormEntity, entityKey: string): FormField[] {
146
+ return Object.entries(inputFields(entity.getFields() as never)).map(([name, field]) => {
147
+ const f = field as FieldLike;
148
+ const control = controlOf(f);
149
+ const required = f.lifecycle?.create === undefined;
150
+ const attrs = attrsOf(f, control, required);
151
+ return {
152
+ name,
153
+ control,
154
+ required,
155
+ labelKey: `${entityKey}.${name}`,
156
+ label: name.charAt(0).toUpperCase() + name.slice(1),
157
+ ...(Array.isArray(f.shape?.enum)
158
+ ? { options: f.shape.enum.filter((value): value is string => typeof value === 'string') }
159
+ : {}),
160
+ ...(Object.keys(attrs).length ? { attrs } : {}),
161
+ ...(defaultOf(f) !== undefined ? { default: defaultOf(f) } : {}),
162
+ };
163
+ });
164
+ }
165
+
166
+ /**
167
+ * The wire body of the form's values — an empty control is an absent value
168
+ * at the create boundary (absence is judged by the lifecycle axis, an empty
169
+ * string would be judged as a present bad value).
170
+ */
171
+ export function payloadOf(values: Record<string, unknown>): Record<string, unknown> {
172
+ return Object.fromEntries(
173
+ Object.entries(values).filter(([, v]) => v !== undefined && v !== ''),
174
+ );
175
+ }
176
+
177
+ /** Index judge errors by field — local judge and remote judge share this shape. */
178
+ export function errorsByField(errors: ValidationError[]): Record<string, string> {
179
+ const byField: Record<string, string> = {};
180
+ for (const err of errors) {
181
+ const field = err.path.split('.')[0] || err.path;
182
+ byField[field] ??= err.message;
183
+ }
184
+ return byField;
185
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Session hydration — server half of useCurrentUser. The auth middleware
3
+ * already resolved the user onto event.context; this plugin copies the
4
+ * session view into state so it rides the payload to the client. The
5
+ * page arrives already knowing its user — no round-trip.
6
+ */
7
+ import { defineNuxtPlugin, useRequestEvent, useState } from '#imports';
8
+ import { sessionViewOf, type SessionView } from '../session/view.js';
9
+
10
+ export default defineNuxtPlugin(() => {
11
+ const event = useRequestEvent();
12
+ useState<SessionView>('fougere:session', () =>
13
+ sessionViewOf((event?.context ?? {}) as Record<string, unknown>),
14
+ );
15
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * REST catch-all — a thin bridge: parse the URL into a call value,
3
+ * invoke it, format the result for HTTP. Dispatch and errors belong to
4
+ * the runner; only static frond metadata is consulted here.
5
+ */
6
+ import { defineEventHandler, readBody, getQuery, createError } from 'h3';
7
+ import { toHttpError } from '@fougere/core';
8
+ import { useFougereApp } from '../utils/fougereApp';
9
+ import { invoke } from '../utils/invoke';
10
+
11
+ function pluralize(name: string): string {
12
+ return name.endsWith('y') ? name.slice(0, -1) + 'ies' : name + 's';
13
+ }
14
+
15
+ export default defineEventHandler(async (event) => {
16
+ const app = await useFougereApp();
17
+
18
+ // ── parse: /api/{frondName}/{plural}[/{idOrOp}] → FrondCall + invocation
19
+ const path = event.path.replace(/^\/api\//, '').replace(/\?.*$/, '');
20
+ const segments = path.split('/').filter(Boolean);
21
+ if (segments.length < 2) return;
22
+
23
+ const frond = app.fronds.find((f) => f.name === segments[0]);
24
+ if (!frond) return;
25
+ const entity = frond.entities.find((e) => pluralize(e.name) === segments[1]);
26
+ if (!entity) return;
27
+
28
+ // The default handler, explicitly: this catch-all IS the default surface, and
29
+ // the call below lands on the default façade. Without `!h.surface` the op list
30
+ // that arbitrates "segment = op or id" could come from a surface handler while
31
+ // the call went somewhere else — two answers for one route.
32
+ const ops = [
33
+ ...(frond.handlers.find((h) => h.address === entity.name && !h.surface)?.operations.keys() ?? []),
34
+ ];
35
+
36
+ const method = event.method.toUpperCase();
37
+ const extra: string | undefined = segments[2];
38
+ const params: Record<string, string> = {};
39
+ let op: string;
40
+
41
+ if (!extra) {
42
+ op = method === 'GET' ? 'list' : 'create';
43
+ } else {
44
+ // /{idOrOp}: kebab-case → camelCase, known operation wins, else it's an id
45
+ const asOp = extra.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
46
+ if (ops.includes(asOp)) {
47
+ op = asOp;
48
+ } else {
49
+ params.id = extra;
50
+ if (method === 'GET') op = 'findById';
51
+ else if (method === 'PUT' || method === 'PATCH') op = 'update';
52
+ else if (method === 'DELETE') op = 'delete';
53
+ else throw createError({ statusCode: 405, message: 'Method not allowed' });
54
+ }
55
+ }
56
+
57
+ const query = getQuery(event) as Record<string, string>;
58
+ const hasBody = method === 'POST' || method === 'PUT' || method === 'PATCH';
59
+ const body = hasBody ? await readBody(event) : undefined;
60
+
61
+ // ── invoke: the runner routes it — local façade or remote doublure
62
+ let result: unknown;
63
+ try {
64
+ result = await invoke({ entity: entity.name, op }, { params, query, body });
65
+ } catch (err) {
66
+ const { status, body: payload } = toHttpError(err);
67
+ throw createError({ statusCode: status, message: payload.message, data: payload });
68
+ }
69
+
70
+ // ── format
71
+ if (result === null) {
72
+ throw createError({ statusCode: 404, message: 'Not found' });
73
+ }
74
+
75
+ // ListResult → serialize as { items, total, hasMore, endCursor }
76
+ if (op === 'list' && Array.isArray(result)) {
77
+ return {
78
+ items: [...result],
79
+ total: (result as any).total,
80
+ hasMore: (result as any).hasMore,
81
+ endCursor: (result as any).endCursor,
82
+ };
83
+ }
84
+
85
+ return result;
86
+ });
@@ -0,0 +1,30 @@
1
+ import { defineEventHandler, getRequestHeader } from 'h3';
2
+ import { useFougereAuth } from '../../utils/fougereAuth';
3
+
4
+ /**
5
+ * Resolves the current session/user from the request cookie and exposes them
6
+ * on event.context. Auth-related routes are skipped so the catch-all handles them.
7
+ */
8
+ export default defineEventHandler(async (event) => {
9
+ if (event.path.startsWith('/auth/')) return;
10
+ const cookie = getRequestHeader(event, 'cookie');
11
+ if (!cookie) return;
12
+
13
+ let auth;
14
+ try {
15
+ auth = await useFougereAuth();
16
+ } catch {
17
+ return; // no auth configured
18
+ }
19
+
20
+ try {
21
+ // event.headers, not toWebRequest(event).headers — the web request wraps
22
+ // the body stream, and a later readBody would hang on the captured stream
23
+ const result = await (auth.api as { getSession: (opts: { headers: Headers }) => Promise<{ session: { userId: string }; user: Record<string, unknown> } | null> })
24
+ .getSession({ headers: event.headers });
25
+ if (result?.session && result?.user) {
26
+ event.context.user = result.user;
27
+ event.context.session = result.session;
28
+ }
29
+ } catch {}
30
+ });
@@ -0,0 +1,10 @@
1
+ import { defineEventHandler, createError } from 'h3';
2
+
3
+ export default defineEventHandler((event) => {
4
+ const user = event.context.user;
5
+ if (!user) {
6
+ throw createError({ statusCode: 401, message: 'Not logged in' });
7
+ }
8
+ const { passwordHash, ...safe } = user as Record<string, unknown>;
9
+ return safe;
10
+ });
@@ -0,0 +1,8 @@
1
+ import { defineEventHandler } from 'h3';
2
+ import { useFougereAuth } from '../../../utils/fougereAuth';
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const auth = await useFougereAuth();
6
+ const webResponse = await auth.handler(toWebRequest(event));
7
+ return sendWebResponse(event, webResponse);
8
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Receiving end for the browser — same wire as process-to-process
3
+ * (POST /_fougere/call, JSON-RPC), different trust boundary: the browser
4
+ * sits outside the topology, so `state` is stamped from the server-resolved
5
+ * session (event.context), never taken from the wire.
6
+ *
7
+ * The runner follows the app's topology: local façades and remote
8
+ * doublures alike — the browser never knows where a Frond lives.
9
+ */
10
+ import { defineEventHandler } from 'h3';
11
+ import { handleRpc, PARSE_ERROR } from '@fougere/transport-http';
12
+ import { createAppRunner } from '@fougere/core';
13
+ import type { Transport } from '@fougere/core';
14
+ import { useFougereApp } from '../utils/fougereApp';
15
+
16
+ type NodeReq = {
17
+ body?: unknown;
18
+ on?: (event: 'data' | 'end' | 'error', cb: (arg: never) => void) => void;
19
+ };
20
+
21
+ type WebReq = {
22
+ body?: { getReader?: () => { read: () => Promise<{ done: boolean; value?: Uint8Array }>; cancel?: () => Promise<void> } } | null;
23
+ headers?: { get?: (name: string) => string | null };
24
+ text?: () => Promise<string>;
25
+ json?: () => Promise<unknown>;
26
+ };
27
+
28
+ const MAX_BODY_BYTES = 1024 * 1024;
29
+
30
+ function payloadTooLarge(): Error & { statusCode: number; statusMessage: string } {
31
+ return Object.assign(new Error('Payload too large'), { statusCode: 413, statusMessage: 'Payload too large' });
32
+ }
33
+
34
+ function parseRawJson(raw: string): unknown {
35
+ if (Buffer.byteLength(raw) > MAX_BODY_BYTES) throw payloadTooLarge();
36
+ return raw ? JSON.parse(raw) : {};
37
+ }
38
+
39
+ async function readWebBody(req: WebReq): Promise<unknown> {
40
+ const declaredLength = Number(req.headers?.get?.('content-length'));
41
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) throw payloadTooLarge();
42
+
43
+ const reader = req.body?.getReader?.();
44
+ if (reader) {
45
+ const chunks: Buffer[] = [];
46
+ let size = 0;
47
+ for (;;) {
48
+ const { done, value } = await reader.read();
49
+ if (done) break;
50
+ const chunk = Buffer.from(value ?? []);
51
+ size += chunk.length;
52
+ if (size > MAX_BODY_BYTES) {
53
+ await reader.cancel?.();
54
+ throw payloadTooLarge();
55
+ }
56
+ chunks.push(chunk);
57
+ }
58
+ return parseRawJson(Buffer.concat(chunks).toString('utf8'));
59
+ }
60
+
61
+ if (typeof req.text === 'function') return parseRawJson(await req.text());
62
+ // Legacy request-like implementations may expose only json(). The content-length
63
+ // check above still rejects declared oversized payloads; standard Request objects
64
+ // take the streamed branch and enforce the limit while reading.
65
+ if (typeof req.json === 'function') return req.json();
66
+ return {};
67
+ }
68
+
69
+ /**
70
+ * Read the JSON-RPC payload from the h3 event, agnostic to h3 version AND trust
71
+ * boundary. We read the event's shape directly instead of calling `readBody`,
72
+ * whose static `from 'h3'` import can bind to a different h3 major than the one
73
+ * that shaped the event (nitro's runtime is v1 here; devtools drag in v2) —
74
+ * `readBody` v2 on a v1 event throws `event.req.text is not a function`.
75
+ *
76
+ * Two shapes occur, both verified:
77
+ * - SSR internal `$fetch`: a synthetic event whose `node.req.body` is already
78
+ * the raw JSON string — the mock stream is not readable, never drain it.
79
+ * - Browser POST: a real IncomingMessage, drained via stream events (`for
80
+ * await` fails: the SSR mock has no async iterator).
81
+ * The `event.req.json()` branch is the future once nitro is fully on h3 v2.
82
+ */
83
+ async function readJsonBody(event: { req?: unknown; node?: { req?: unknown } }): Promise<unknown> {
84
+ const rawNodeReq = event.node?.req;
85
+ const nodeReq = rawNodeReq && typeof rawNodeReq === 'object' ? rawNodeReq as NodeReq : undefined;
86
+ const preset = nodeReq?.body;
87
+ if (typeof preset === 'string') {
88
+ if (Buffer.byteLength(preset) > MAX_BODY_BYTES) throw payloadTooLarge();
89
+ return parseRawJson(preset);
90
+ }
91
+ if (preset instanceof Uint8Array) {
92
+ if (preset.byteLength > MAX_BODY_BYTES) throw payloadTooLarge();
93
+ const raw = Buffer.from(preset).toString('utf8');
94
+ return parseRawJson(raw);
95
+ }
96
+ const webReq = event.req && typeof event.req === 'object' ? event.req as WebReq : undefined;
97
+ if (
98
+ webReq
99
+ && (webReq.body?.getReader || typeof webReq.text === 'function' || typeof webReq.json === 'function')
100
+ ) return readWebBody(webReq);
101
+ if (nodeReq && typeof nodeReq.on === 'function') {
102
+ const raw = await new Promise<string>((resolve, reject) => {
103
+ const chunks: Buffer[] = [];
104
+ let size = 0;
105
+ let exceeded = false;
106
+ nodeReq.on!('data', (chunk) => {
107
+ if (exceeded) return;
108
+ const buffer = Buffer.from(chunk);
109
+ size += buffer.length;
110
+ if (size > MAX_BODY_BYTES) {
111
+ exceeded = true;
112
+ reject(payloadTooLarge());
113
+ return;
114
+ }
115
+ chunks.push(buffer);
116
+ });
117
+ nodeReq.on!('end', () => { if (!exceeded) resolve(Buffer.concat(chunks).toString('utf8')); });
118
+ nodeReq.on!('error', reject);
119
+ });
120
+ return parseRawJson(raw);
121
+ }
122
+ return {};
123
+ }
124
+
125
+ /**
126
+ * The audience this door serves — the path segment after `/_fougere/call`.
127
+ *
128
+ * The envelope is a surface like REST and GraphQL, so it selects its audience like they
129
+ * do; the difference is only that it takes it from the path instead of an option, because
130
+ * a door is mounted, not called. The same word names the directory
131
+ * (`handlers/public/`), the config key (`surfaces: { public: [...] }`) and this segment —
132
+ * derived, never configured.
133
+ *
134
+ * No escalation to guard: a named surface serves the entities it names and nothing else
135
+ * (closed by naming), so every one of them is a subset of what the bare path already
136
+ * serves.
137
+ */
138
+ function surfaceOf(path: string): string | undefined {
139
+ const named = /^\/_fougere\/call\/([A-Za-z0-9_-]+)/.exec(path.replace(/\?.*$/, ''));
140
+ return named?.[1];
141
+ }
142
+
143
+ export default defineEventHandler(async (event) => {
144
+ const app = await useFougereApp();
145
+ const runner = createAppRunner(app, surfaceOf(event.path));
146
+ const stamped: Transport = (call, invocation) =>
147
+ runner(call, { ...invocation, state: (event.context ?? {}) as Record<string, unknown> });
148
+ try {
149
+ return handleRpc(stamped, await readJsonBody(event));
150
+ } catch (err) {
151
+ if ((err as { statusCode?: number })?.statusCode === 413) throw err;
152
+ return {
153
+ jsonrpc: '2.0' as const,
154
+ id: null,
155
+ error: { code: PARSE_ERROR, message: 'Parse error' },
156
+ };
157
+ }
158
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The session view over the wire — same resolution the hydration reads,
3
+ * for a client refreshing after login/logout. Internal route, same
4
+ * family as /_fougere/call.
5
+ */
6
+ import { defineEventHandler } from 'h3';
7
+ import { sessionViewOf } from '../../session/view.js';
8
+
9
+ export default defineEventHandler((event) =>
10
+ sessionViewOf((event.context ?? {}) as Record<string, unknown>),
11
+ );