@fougere/app 0.2.0-alpha.2 → 0.4.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/src/express.ts ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The doors as Express middlewares — the form an Express app expects.
3
+ *
4
+ * ```ts
5
+ * const app = express();
6
+ * app.use(express.json());
7
+ * app.use(fougere()); // ← one line, like cors() or express.json()
8
+ * ```
9
+ *
10
+ * This replaces an earlier `mountDoors(createExpressRouter(app))`, which was wrong in
11
+ * two ways at once. It handed the app INTO a function instead of adding something to
12
+ * the app, and it made the caller name Express 5's wildcard syntax (`/api/*splat`) —
13
+ * a detail that belongs to the framework, not to its user. Worse, it mounted all
14
+ * three doors together with no way to take two of them.
15
+ *
16
+ * A middleware fixes both by construction, because Express already says WHERE:
17
+ *
18
+ * ```ts
19
+ * app.use(fougere()); // the three doors, at the paths the client knows
20
+ * app.use('/admin', fougereRest()); // REST only, wherever you want it
21
+ * ```
22
+ *
23
+ * And `next()` is the passthrough these doors were already imitating: `serveRest`
24
+ * answers `{ kind: 'pass' }` for a path it does not serve, which is exactly what
25
+ * Express means by calling the next handler. The middleware stops inventing it.
26
+ *
27
+ * The names mirror `@fougere/app/web` on purpose — same doors, host-shaped. `/web`
28
+ * gives you `Request` → `Response` handlers; this gives you middlewares.
29
+ *
30
+ * Nothing here imports express: the shapes are structural, so the package keeps its
31
+ * dependency list and a test can hand it a plain object.
32
+ */
33
+ import { readExpressBody } from '@fougere/http';
34
+ import { serveRest, serveRpc, rpcParseError } from './serve.js';
35
+ import { serveGraphQL } from './graphql.js';
36
+ import { sessionViewOf } from './session.js';
37
+ import { useFougereApp } from './boot.js';
38
+
39
+ /** What a middleware reads off an Express request. Structural, not imported. */
40
+ interface ExpressRequest {
41
+ method: string;
42
+ path?: string;
43
+ originalUrl?: string;
44
+ url?: string;
45
+ query?: Record<string, unknown>;
46
+ headers?: Record<string, unknown>;
47
+ body?: unknown;
48
+ /** Filled by an auth middleware that ran before us, if any. */
49
+ fougereState?: Record<string, unknown>;
50
+ user?: unknown;
51
+ }
52
+
53
+ interface ExpressResponse {
54
+ status(code: number): ExpressResponse;
55
+ set(field: string, value: unknown): ExpressResponse;
56
+ json(body: unknown): unknown;
57
+ }
58
+
59
+ type Next = (err?: unknown) => void;
60
+ export type ExpressMiddleware = (req: any, res: any, next: Next) => void;
61
+
62
+ /**
63
+ * Who the caller is, from what ran before.
64
+ *
65
+ * Express has no ambient request, so an app that resolves its own session says so by
66
+ * putting it on the request — `req.fougereState`, or a bare `req.user`, which is what
67
+ * passport and most middlewares already set. Nothing is taken from the payload: the
68
+ * browser sits outside the topology.
69
+ */
70
+ function stateOf(req: ExpressRequest): Record<string, unknown> {
71
+ if (req.fougereState) return req.fougereState;
72
+ return req.user ? { user: req.user } : {};
73
+ }
74
+
75
+ function pathOf(req: ExpressRequest): string {
76
+ return req.path ?? String(req.originalUrl ?? req.url ?? '').split('?')[0]!;
77
+ }
78
+
79
+ function queryOf(req: ExpressRequest): Record<string, string> {
80
+ return Object.fromEntries(
81
+ Object.entries(req.query ?? {}).map(([key, value]) => [
82
+ key,
83
+ String(Array.isArray(value) ? value[0] : value),
84
+ ]),
85
+ );
86
+ }
87
+
88
+ /** Turn a thrown failure into Express's own error pipeline, unless it is a refusal we own. */
89
+ function fail(res: ExpressResponse, next: Next, err: unknown): void {
90
+ const code = (err as { name?: string })?.name === 'MalformedJsonError' ? 400 : 0;
91
+ if (code === 400) {
92
+ res.status(400).json({ code: 'BAD_REQUEST', message: 'Malformed JSON body' });
93
+ return;
94
+ }
95
+ next(err);
96
+ }
97
+
98
+ /**
99
+ * The call envelope, at `/_fougere/call` — the door the browser primitives use.
100
+ *
101
+ * Mounted with `app.use()`, Express strips nothing, so the path still carries the
102
+ * audience segment (`/_fougere/call/public`) that `surfaceOf` reads.
103
+ */
104
+ export function fougereCall(mountPath = '/_fougere/call'): ExpressMiddleware {
105
+ return (req, res, next) => {
106
+ const path = pathOf(req);
107
+ if (req.method !== 'POST' || !path.startsWith(mountPath)) return next();
108
+
109
+ void (async () => {
110
+ try {
111
+ const app = await useFougereApp();
112
+ let body: unknown;
113
+ try {
114
+ body = await readExpressBody(req);
115
+ } catch {
116
+ res.status(200).json(rpcParseError());
117
+ return;
118
+ }
119
+ res.status(200).json(await serveRpc(app, { path, body, state: stateOf(req) }));
120
+ } catch (err) {
121
+ fail(res, next, err);
122
+ }
123
+ })();
124
+ };
125
+ }
126
+
127
+ /** The session view, at `/_fougere/session`. */
128
+ export function fougereSession(mountPath = '/_fougere/session'): ExpressMiddleware {
129
+ return (req, res, next) => {
130
+ if (req.method !== 'GET' || pathOf(req) !== mountPath) return next();
131
+ res.status(200).json(sessionViewOf(stateOf(req)));
132
+ };
133
+ }
134
+
135
+ /**
136
+ * The REST projection, under `/api` by default.
137
+ *
138
+ * A path this app does not serve calls `next()` — so an app's own `/api/health` keeps
139
+ * answering whether it was registered before or after this middleware.
140
+ */
141
+ export function fougereRest(mountPath = '/api'): ExpressMiddleware {
142
+ return (req, res, next) => {
143
+ const path = pathOf(req);
144
+ if (!path.startsWith(`${mountPath}/`)) return next();
145
+
146
+ void (async () => {
147
+ try {
148
+ const app = await useFougereApp();
149
+ const outcome = await serveRest(app, {
150
+ method: req.method,
151
+ path: path.slice(mountPath.length + 1),
152
+ query: queryOf(req),
153
+ body: await readExpressBody(req),
154
+ state: stateOf(req),
155
+ });
156
+
157
+ // Not ours — Express's own passthrough, which is what `pass` always meant.
158
+ if (outcome.kind === 'pass') return next();
159
+
160
+ if (outcome.kind === 'error' && outcome.headers) {
161
+ for (const [key, value] of Object.entries(outcome.headers)) res.set(key, value);
162
+ }
163
+ res.status(outcome.status).json(outcome.body);
164
+ } catch (err) {
165
+ fail(res, next, err);
166
+ }
167
+ })();
168
+ };
169
+ }
170
+
171
+ /** GraphQL, at `/graphql` by default. Declines when the app declares no such adapter. */
172
+ export function fougereGraphQL(mountPath = '/graphql'): ExpressMiddleware {
173
+ return (req, res, next) => {
174
+ if (req.method !== 'POST' || pathOf(req) !== mountPath) return next();
175
+
176
+ void (async () => {
177
+ try {
178
+ const app = await useFougereApp();
179
+ const body = ((await readExpressBody(req)) ?? {}) as {
180
+ query?: string;
181
+ variables?: Record<string, unknown>;
182
+ operationName?: string;
183
+ };
184
+ const outcome = await serveGraphQL(app, { ...body, state: stateOf(req) });
185
+ if (outcome.kind === 'pass') return next();
186
+ res.status(outcome.status).json(outcome.body);
187
+ } catch (err) {
188
+ fail(res, next, err);
189
+ }
190
+ })();
191
+ };
192
+ }
193
+
194
+ /** Every door, for an app that wants all of them. What each one SERVES is still the
195
+ * app's declaration — mounting is not publishing. */
196
+ export function fougere(): ExpressMiddleware {
197
+ const doors = [fougereCall(), fougereSession(), fougereRest(), fougereGraphQL()];
198
+ return (req, res, next) => {
199
+ let index = 0;
200
+ const step = (err?: unknown) => {
201
+ if (err) return next(err);
202
+ const door = doors[index++];
203
+ if (!door) return next();
204
+ door(req, res, step);
205
+ };
206
+ step();
207
+ };
208
+ }
package/src/form.ts ADDED
@@ -0,0 +1,237 @@
1
+ import { Lifecycle } from '@fougere/schema';
2
+ /**
3
+ * Form contract, pure part — derives what a create/edit form is made of
4
+ * from the entity's field axes. No Vue, no Nuxt: testable headless,
5
+ * usable by any renderer (the page owns the widgets).
6
+ */
7
+ import { Anatomy, lowerFirst, Role, Visibility } from '@fougere/schema';
8
+ import type { Field, SchemaView, ValidationError, ValidationResult } from '@fougere/schema';
9
+
10
+ /**
11
+ * What an entity class exposes to a form — the schema statics it already has.
12
+ *
13
+ * `SchemaView` and the real `Field`, not a local re-description of the axes: this file
14
+ * used to declare its own `FieldLike` with `shape?` optional, so it kept judging by a
15
+ * looser contract than the schema's own and would never have seen `shape` become
16
+ * required.
17
+ */
18
+ export type FormEntity = SchemaView;
19
+
20
+ /**
21
+ * The literal a field is born with, when it declares one.
22
+ *
23
+ * `text({ default: 'x' })` and `oneOf('a', 'b', { default: 'a' })` both compile to
24
+ * `lifecycle.create = { value }` — the create rule that answers the field's absence.
25
+ * The other create rules ('now', { generate }, 'optional') name no literal: their value
26
+ * is decided at write time, so a form has nothing to show for them.
27
+ */
28
+ function defaultOf(field: Field): unknown {
29
+ return Lifecycle.of(field).literal?.value;
30
+ }
31
+
32
+ export interface FormField {
33
+ name: string;
34
+ /** Rendering hint derived from the shape — the page maps it to widgets. */
35
+ control: 'text' | 'email' | 'url' | 'number' | 'boolean' | 'date' | 'select';
36
+ required: boolean;
37
+ /** i18n key by convention: `entity.field`. The schema never carries display text. */
38
+ labelKey: string;
39
+ /** Fallback label when no i18n message fills the key. */
40
+ label: string;
41
+ /** Enum values, when control is 'select'. */
42
+ options?: string[];
43
+ /**
44
+ * What the browser enforces, under the names it already knows — spread this on the
45
+ * input and the page states no rule of its own.
46
+ *
47
+ * The shape holds `minLength`/`maximum`/`pattern`; a browser holds `minlength`/
48
+ * `max`/`pattern` and enforces them with no JavaScript at all. Carrying them here
49
+ * is a projection, not a second rule: the judge reads the same shape, and a form
50
+ * that ignores these still gets the same verdict — it just gets it later, and a
51
+ * screen reader never gets it at all.
52
+ *
53
+ * `type` is part of the contract, not decoration: `email` and `url` are formats the
54
+ * shape states and the browser checks live, per field, as one types. A page writing
55
+ * `type="email"` by hand is spelling a second time what the card already said.
56
+ *
57
+ * Three deliberate absences, each one a place where the attribute would mean
58
+ * something the shape does not say:
59
+ * - a `date` field gets no `type` — neither `date` nor `datetime-local` produces the
60
+ * RFC 3339 string a `date-time` shape judges, so the browser would accept what the
61
+ * judge refuses;
62
+ * - `select` and `boolean` are not inputs — the page picks the widget, `control` says
63
+ * which;
64
+ * - a required `boolean` gets no `required` — on a checkbox that attribute means
65
+ * "must be CHECKED", where the shape only says the value must be supplied.
66
+ */
67
+ attrs?: {
68
+ type?: 'text' | 'email' | 'url' | 'number';
69
+ required?: boolean;
70
+ minlength?: number;
71
+ maxlength?: number;
72
+ min?: number;
73
+ max?: number;
74
+ pattern?: string;
75
+ };
76
+ /**
77
+ * The value the field is born with — the literal its `lifecycle.create` rule names.
78
+ * Present so the form can SHOW what is about to be written; the storage realizes it
79
+ * either way, so a form that ignores this still produces the same row.
80
+ */
81
+ default?: unknown;
82
+ }
83
+
84
+ /** The base JSON type of a shape — unwraps the `[T,'null']` union. */
85
+ function baseType(type: unknown): string {
86
+ if (Array.isArray(type)) return (type.find((t) => t !== 'null') as string) ?? 'string';
87
+ return (type as string) ?? 'string';
88
+ }
89
+
90
+ /** The formats a browser has an input type for — the rest stay `text`, judged later. */
91
+ const CONTROL_BY_FORMAT: Record<string, FormField['control']> = {
92
+ 'date-time': 'date',
93
+ email: 'email',
94
+ uri: 'url',
95
+ };
96
+
97
+ function controlOf(field: Field): FormField['control'] {
98
+ // Through `anatomy`, never `shape.type` directly: the nullable form is the `[T,'null']`
99
+ // union, which a direct comparison misses in silence. It is also what narrows the shape
100
+ // union, so `enum` and `format` are only reachable on the branches that carry them.
101
+ const base = Anatomy.of(field.shape).base;
102
+ if (base?.type === 'string' && base.enum?.length) return 'select';
103
+ if (base?.type === 'number' || base?.type === 'integer') return 'number';
104
+ if (base?.type === 'boolean') return 'boolean';
105
+ if (base?.type === 'string' && base.format) return CONTROL_BY_FORMAT[base.format] ?? 'text';
106
+ return 'text';
107
+ }
108
+
109
+ /** A closed set's members, when the shape declares one — `oneOf('draft','live')`. */
110
+ function enumOf(field: Field): readonly (string | null)[] | undefined {
111
+ const base = Anatomy.of(field.shape).base;
112
+ return base?.type === 'string' ? base.enum : undefined;
113
+ }
114
+
115
+ /** Controls that ARE an `<input type>` — see the two absences on {@link FormField.attrs}. */
116
+ const INPUT_TYPES = new Set(['text', 'email', 'url', 'number']);
117
+
118
+ /** The shape's bounds, under the names a browser already enforces. */
119
+ function attrsOf(field: Field, control: FormField['control'], required: boolean): NonNullable<FormField['attrs']> {
120
+ const base = Anatomy.of(field.shape).base;
121
+ const text = base?.type === 'string' ? base : undefined;
122
+ const numeric = base?.type === 'number' || base?.type === 'integer' ? base : undefined;
123
+ const attrs = {
124
+ type: INPUT_TYPES.has(control) ? control : undefined,
125
+ required: (required && control !== 'boolean') || undefined,
126
+ minlength: text?.minLength,
127
+ maxlength: text?.maxLength,
128
+ min: numeric?.minimum,
129
+ max: numeric?.maximum,
130
+ pattern: text?.pattern,
131
+ };
132
+ return Object.fromEntries(Object.entries(attrs).filter(([, v]) => v !== undefined));
133
+ }
134
+
135
+ /**
136
+ * The label convention, spelled once for both projections: an i18n key by convention and
137
+ * the field's own name as the fallback. The schema never carries display text.
138
+ */
139
+ function labelOf(name: string, entityKey: string): Pick<FormField, 'labelKey' | 'label'> {
140
+ return { labelKey: `${entityKey}.${name}`, label: name.charAt(0).toUpperCase() + name.slice(1) };
141
+ }
142
+
143
+ /**
144
+ * The fields a create form is made of: membership from the io projection
145
+ * (`Visibility.input` — what a client may supply), requiredness from the
146
+ * lifecycle axis (any create rule makes absence legal).
147
+ */
148
+ export function formFieldsOf(entity: FormEntity, entityKey: string): FormField[] {
149
+ return Object.entries(Visibility.of(entity.getFields()).input).map(([name, field]) => {
150
+ const f = field;
151
+ const control = controlOf(f);
152
+ const required = Lifecycle.of(f).requiredAtCreate;
153
+ const attrs = attrsOf(f, control, required);
154
+ return {
155
+ name,
156
+ control,
157
+ required,
158
+ ...labelOf(name, entityKey),
159
+ ...(Array.isArray(enumOf(f))
160
+ ? { options: enumOf(f)!.filter((value): value is string => typeof value === 'string') }
161
+ : {}),
162
+ ...(Object.keys(attrs).length ? { attrs } : {}),
163
+ ...(defaultOf(f) !== undefined ? { default: defaultOf(f) } : {}),
164
+ };
165
+ });
166
+ }
167
+
168
+ export interface TableColumn {
169
+ name: string;
170
+ /**
171
+ * How to print the value — the dual of {@link FormField.control}, and deliberately not
172
+ * the same list: a closed set prints as its value, a reference prints as a link.
173
+ */
174
+ render: 'text' | 'number' | 'boolean' | 'date' | 'json' | 'link';
175
+ /** The same key a form uses for the same field — one convention, two projections. */
176
+ labelKey: string;
177
+ label: string;
178
+ /**
179
+ * The entity a `link` points at, under the key its door is named by. Always present on a
180
+ * reference: the card carries the target's name, and a card rebuilt with no sibling to
181
+ * resolve to keeps it as a stand-in rather than losing it.
182
+ */
183
+ to?: string;
184
+ }
185
+
186
+ /** Asked of the relation before the shape: a reference's own shape is a bare string. */
187
+ function renderOf(field: Field): TableColumn['render'] {
188
+ if (Role.of(field).isReference) return 'link';
189
+ const base = Anatomy.of(field.shape).base;
190
+ if (base?.type === 'number' || base?.type === 'integer') return 'number';
191
+ if (base?.type === 'boolean') return 'boolean';
192
+ if (base?.type === 'object' || base?.type === 'array') return 'json';
193
+ if (base?.type === 'string' && base.format === 'date-time') return 'date';
194
+ return 'text';
195
+ }
196
+
197
+ /**
198
+ * The columns a list is made of: membership from the io projection (`Visibility.output` — what
199
+ * may leave), minus collections, because a cell holds one value and a `many()` is a page.
200
+ *
201
+ * Which column identifies the row is NOT answered here — `FieldSet.primary` answers it for a
202
+ * shape, and its own doc records what five private copies of that loop cost.
203
+ */
204
+ export function tableColumnsOf(entity: FormEntity, entityKey: string): TableColumn[] {
205
+ return Object.entries(Visibility.of(entity.getFields()).output)
206
+ .filter(([, field]) => !Role.of(field).isCollection)
207
+ .map(([name, field]) => {
208
+ const target = Role.of(field).target;
209
+ return {
210
+ name,
211
+ render: renderOf(field),
212
+ ...labelOf(name, entityKey),
213
+ ...(target ? { to: lowerFirst(target.name) } : {}),
214
+ };
215
+ });
216
+ }
217
+
218
+ /**
219
+ * The wire body of the form's values — an empty control is an absent value
220
+ * at the create boundary (absence is judged by the lifecycle axis, an empty
221
+ * string would be judged as a present bad value).
222
+ */
223
+ export function payloadOf(values: Record<string, unknown>): Record<string, unknown> {
224
+ return Object.fromEntries(
225
+ Object.entries(values).filter(([, v]) => v !== undefined && v !== ''),
226
+ );
227
+ }
228
+
229
+ /** Index judge errors by field — local judge and remote judge share this shape. */
230
+ export function errorsByField(errors: ValidationError[]): Record<string, string> {
231
+ const byField: Record<string, string> = {};
232
+ for (const err of errors) {
233
+ const field = err.path.split('.')[0] || err.path;
234
+ byField[field] ??= err.message;
235
+ }
236
+ return byField;
237
+ }
package/src/graphql.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The GraphQL door — declared like the others, mounted like the others.
3
+ *
4
+ * Until now GraphQL was the odd one out: `adapters: { graphql: true }` was
5
+ * declarable and nothing read it, while an app that wanted a schema wrote fifteen
6
+ * lines of its own (a Pothos builder, a query type, a mutation type, `registerAll`,
7
+ * then `registerGraphQL` on a router). Every one of those lines is convention —
8
+ * there is no decision in them — so the app declaring the adapter is enough.
9
+ *
10
+ * The package is imported LAZILY, the same shape `db: 'sqlite'` uses (`resolveStorage`
11
+ * pulls `@fougere/adapter-sql` only when a database is declared). A schema builder is
12
+ * heavy and most apps serve none, so a host must not carry one to find out. Declaring
13
+ * the adapter without installing it is refused by name, the way an unresolvable dialect is.
14
+ *
15
+ * This file used to BUILD the schema — a Pothos builder, `registerAll`, then `graphql()`
16
+ * — which made it the only schema constructor in the repo, in the package least entitled
17
+ * to be one. Two costs, one cause: the derivation sat away from the adapter whose job it
18
+ * is, and `graphql` guards its types with `instanceOf`, so a schema built on one side of
19
+ * the package boundary was refused on the other as coming *"from another module or
20
+ * realm"*. Both are gone: `@fougere/adapter-graphql` derives and executes, this door
21
+ * translates the result into an `Outcome`.
22
+ */
23
+ import type { App } from '@fougere/core';
24
+ import type { Outcome } from './serve.js';
25
+
26
+ /** What a GraphQL request carries, whatever host read it. */
27
+ export interface GraphQLRequest {
28
+ query?: string;
29
+ variables?: Record<string, unknown>;
30
+ operationName?: string;
31
+ /** The audience, when the door was mounted per surface. */
32
+ surface?: string;
33
+ state: Record<string, unknown>;
34
+ }
35
+
36
+ type ExecuteOn = (app: unknown, request: {
37
+ query: string;
38
+ variables?: Record<string, unknown>;
39
+ operationName?: string;
40
+ surface?: string;
41
+ state?: Record<string, unknown>;
42
+ }) => Promise<unknown>;
43
+
44
+ async function executor(): Promise<ExecuteOn> {
45
+ try {
46
+ const { executeOn } = await import('@fougere/adapter-graphql');
47
+ return executeOn as unknown as ExecuteOn;
48
+ } catch (cause) {
49
+ throw new Error(
50
+ "adapters: { graphql: true } is declared, but the package that serves it is not " +
51
+ 'installed. Add `@fougere/adapter-graphql` — it is not a dependency of the host, ' +
52
+ 'because an app that serves no GraphQL should not carry a schema builder to find ' +
53
+ 'that out.',
54
+ { cause },
55
+ );
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Execute a GraphQL request against the app's own operations.
61
+ *
62
+ * `pass` when the app declares no GraphQL adapter — the same answer `serveRest` gives,
63
+ * so a host that mounted the door takes nothing away from an app that did not ask for it.
64
+ */
65
+ export async function serveGraphQL(app: App, request: GraphQLRequest): Promise<Outcome> {
66
+ if (!app.adapters?.graphql) return { kind: 'pass' };
67
+
68
+ if (!request.query) {
69
+ return { kind: 'error', status: 400, body: { message: 'Missing query' } };
70
+ }
71
+
72
+ const executeOn = await executor();
73
+ const result = await executeOn(app, {
74
+ query: request.query,
75
+ variables: request.variables,
76
+ operationName: request.operationName,
77
+ surface: request.surface,
78
+ // The same state every other door stamps: what the server resolved, never the wire.
79
+ state: request.state,
80
+ });
81
+
82
+ // A GraphQL error is not an HTTP error: the transport succeeded, and the errors ride
83
+ // in the body where a client is required to look for them.
84
+ return { kind: 'ok', status: 200, body: result as Record<string, unknown> };
85
+ }
package/src/index.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `@fougere/app` — what an app host needs and what no host owns.
3
+ *
4
+ * Two adapters read this package: `@fougere/nuxt` and `@fougere/next`. Between
5
+ * them they share the boot, the three doors' decisions, the REST table, the form
6
+ * contract and the session view; what they do NOT share is how a request arrives,
7
+ * how routes are mounted, and how a value becomes reactive.
8
+ *
9
+ * The root entry pulls in the boot, which reads the filesystem — client code wants
10
+ * `@fougere/app/client`.
11
+ */
12
+ export {
13
+ configureFougere,
14
+ extendFougere,
15
+ useFougereApp,
16
+ reloadFougere,
17
+ createMemoryOrm,
18
+ type FougereServerConfig,
19
+ } from './boot.js';
20
+
21
+ export { useFougereAuth } from './auth.js';
22
+
23
+ export {
24
+ tableOf,
25
+ matchRoute,
26
+ type Matchable,
27
+ type RouteMatch,
28
+ } from './rest.js';
29
+
30
+ export {
31
+ serveRest,
32
+ shapeRest,
33
+ serveRpc,
34
+ surfaceOf,
35
+ rpcParseError,
36
+ invokeOn,
37
+ type DoorRequest,
38
+ type Outcome,
39
+ } from './serve.js';
40
+
41
+ export {
42
+ formFieldsOf,
43
+ tableColumnsOf,
44
+ payloadOf,
45
+ errorsByField,
46
+ type FormEntity,
47
+ type FormField,
48
+ type TableColumn,
49
+ } from './form.js';
50
+
51
+ export { sessionViewOf, type SessionView } from './session.js';
52
+
53
+ export { stateFor } from './state.js';
54
+
55
+ export { serveGraphQL, type GraphQLRequest } from './graphql.js';