@fougere/app 0.5.0-alpha.1 → 0.7.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.
Files changed (59) hide show
  1. package/dist/auth.d.ts +1 -7
  2. package/dist/auth.d.ts.map +1 -1
  3. package/dist/auth.js.map +1 -1
  4. package/dist/boot.d.ts +14 -96
  5. package/dist/boot.d.ts.map +1 -1
  6. package/dist/boot.js +19 -217
  7. package/dist/boot.js.map +1 -1
  8. package/dist/client.d.ts +5 -24
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +6 -32
  11. package/dist/client.js.map +1 -1
  12. package/dist/express.d.ts +2 -12
  13. package/dist/express.d.ts.map +1 -1
  14. package/dist/express.js +4 -52
  15. package/dist/express.js.map +1 -1
  16. package/dist/form.d.ts +11 -56
  17. package/dist/form.d.ts.map +1 -1
  18. package/dist/form.js +15 -33
  19. package/dist/form.js.map +1 -1
  20. package/dist/graphql.d.ts +2 -28
  21. package/dist/graphql.d.ts.map +1 -1
  22. package/dist/graphql.js +1 -6
  23. package/dist/graphql.js.map +1 -1
  24. package/dist/index.d.ts +3 -12
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +4 -12
  27. package/dist/index.js.map +1 -1
  28. package/dist/rest.d.ts +2 -16
  29. package/dist/rest.d.ts.map +1 -1
  30. package/dist/rest.js +4 -34
  31. package/dist/rest.js.map +1 -1
  32. package/dist/serve.d.ts +10 -57
  33. package/dist/serve.d.ts.map +1 -1
  34. package/dist/serve.js +12 -55
  35. package/dist/serve.js.map +1 -1
  36. package/dist/session.d.ts +2 -7
  37. package/dist/session.d.ts.map +1 -1
  38. package/dist/session.js +2 -7
  39. package/dist/session.js.map +1 -1
  40. package/dist/state.d.ts.map +1 -1
  41. package/dist/state.js +1 -11
  42. package/dist/state.js.map +1 -1
  43. package/dist/web.d.ts +3 -19
  44. package/dist/web.d.ts.map +1 -1
  45. package/dist/web.js +4 -32
  46. package/dist/web.js.map +1 -1
  47. package/package.json +11 -10
  48. package/src/auth.ts +1 -7
  49. package/src/boot.ts +31 -260
  50. package/src/client.ts +7 -33
  51. package/src/express.ts +4 -52
  52. package/src/form.ts +20 -73
  53. package/src/graphql.ts +2 -28
  54. package/src/index.ts +4 -12
  55. package/src/rest.ts +4 -34
  56. package/src/serve.ts +13 -60
  57. package/src/session.ts +2 -7
  58. package/src/state.ts +1 -11
  59. package/src/web.ts +4 -32
package/src/form.ts CHANGED
@@ -1,30 +1,15 @@
1
1
  import { Lifecycle } from '@fougere/schema';
2
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).
3
+ * Form contract, pure part — derives what a create/edit form is made of from the entity's field
4
+ * axes.
6
5
  */
7
- import { Anatomy, lowerFirst, Role, Visibility } from '@fougere/schema';
6
+ import { Shapes, lowerFirst, Role, Visibility } from '@fougere/schema';
8
7
  import type { Field, SchemaView, ValidationError, ValidationResult } from '@fougere/schema';
9
8
 
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
- */
9
+ /** What an entity class exposes to a form — the schema statics it already has. */
18
10
  export type FormEntity = SchemaView;
19
11
 
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
- */
12
+ /** The literal a field is born with, when it declares one. */
28
13
  function defaultOf(field: Field): unknown {
29
14
  return Lifecycle.of(field).literal?.value;
30
15
  }
@@ -41,28 +26,8 @@ export interface FormField {
41
26
  /** Enum values, when control is 'select'. */
42
27
  options?: string[];
43
28
  /**
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.
29
+ * What the browser enforces, under the names it already knows — spread this on the input and the
30
+ * page states no rule of its own.
66
31
  */
67
32
  attrs?: {
68
33
  type?: 'text' | 'email' | 'url' | 'number';
@@ -73,11 +38,7 @@ export interface FormField {
73
38
  max?: number;
74
39
  pattern?: string;
75
40
  };
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
- */
41
+ /** The value the field is born with — the literal its `lifecycle.create` rule names. */
81
42
  default?: unknown;
82
43
  }
83
44
 
@@ -87,7 +48,7 @@ function baseType(type: unknown): string {
87
48
  return (type as string) ?? 'string';
88
49
  }
89
50
 
90
- /** The formats a browser has an input type for — the rest stay `text`, judged later. */
51
+ /** The formats a browser has an input type for — the rest stay `text`, validated later. */
91
52
  const CONTROL_BY_FORMAT: Record<string, FormField['control']> = {
92
53
  'date-time': 'date',
93
54
  email: 'email',
@@ -98,7 +59,7 @@ function controlOf(field: Field): FormField['control'] {
98
59
  // Through `anatomy`, never `shape.type` directly: the nullable form is the `[T,'null']`
99
60
  // union, which a direct comparison misses in silence. It is also what narrows the shape
100
61
  // union, so `enum` and `format` are only reachable on the branches that carry them.
101
- const base = Anatomy.of(field.shape).base;
62
+ const base = Shapes.of(field.shape).base;
102
63
  if (base?.type === 'string' && base.enum?.length) return 'select';
103
64
  if (base?.type === 'number' || base?.type === 'integer') return 'number';
104
65
  if (base?.type === 'boolean') return 'boolean';
@@ -108,7 +69,7 @@ function controlOf(field: Field): FormField['control'] {
108
69
 
109
70
  /** A closed set's members, when the shape declares one — `oneOf('draft','live')`. */
110
71
  function enumOf(field: Field): readonly (string | null)[] | undefined {
111
- const base = Anatomy.of(field.shape).base;
72
+ const base = Shapes.of(field.shape).base;
112
73
  return base?.type === 'string' ? base.enum : undefined;
113
74
  }
114
75
 
@@ -117,7 +78,7 @@ const INPUT_TYPES = new Set(['text', 'email', 'url', 'number']);
117
78
 
118
79
  /** The shape's bounds, under the names a browser already enforces. */
119
80
  function attrsOf(field: Field, control: FormField['control'], required: boolean): NonNullable<FormField['attrs']> {
120
- const base = Anatomy.of(field.shape).base;
81
+ const base = Shapes.of(field.shape).base;
121
82
  const text = base?.type === 'string' ? base : undefined;
122
83
  const numeric = base?.type === 'number' || base?.type === 'integer' ? base : undefined;
123
84
  const attrs = {
@@ -140,11 +101,7 @@ function labelOf(name: string, entityKey: string): Pick<FormField, 'labelKey' |
140
101
  return { labelKey: `${entityKey}.${name}`, label: name.charAt(0).toUpperCase() + name.slice(1) };
141
102
  }
142
103
 
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
- */
104
+ /** The fields a create form is made of. */
148
105
  export function formFieldsOf(entity: FormEntity, entityKey: string): FormField[] {
149
106
  return Object.entries(Visibility.of(entity.getFields()).input).map(([name, field]) => {
150
107
  const f = field;
@@ -175,18 +132,14 @@ export interface TableColumn {
175
132
  /** The same key a form uses for the same field — one convention, two projections. */
176
133
  labelKey: string;
177
134
  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
- */
135
+ /** The entity a `link` points at, under the key its door is named by. */
183
136
  to?: string;
184
137
  }
185
138
 
186
139
  /** Asked of the relation before the shape: a reference's own shape is a bare string. */
187
140
  function renderOf(field: Field): TableColumn['render'] {
188
141
  if (Role.of(field).isReference) return 'link';
189
- const base = Anatomy.of(field.shape).base;
142
+ const base = Shapes.of(field.shape).base;
190
143
  if (base?.type === 'number' || base?.type === 'integer') return 'number';
191
144
  if (base?.type === 'boolean') return 'boolean';
192
145
  if (base?.type === 'object' || base?.type === 'array') return 'json';
@@ -194,13 +147,7 @@ function renderOf(field: Field): TableColumn['render'] {
194
147
  return 'text';
195
148
  }
196
149
 
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
- */
150
+ /** The columns a list is made of. */
204
151
  export function tableColumnsOf(entity: FormEntity, entityKey: string): TableColumn[] {
205
152
  return Object.entries(Visibility.of(entity.getFields()).output)
206
153
  .filter(([, field]) => !Role.of(field).isCollection)
@@ -216,9 +163,9 @@ export function tableColumnsOf(entity: FormEntity, entityKey: string): TableColu
216
163
  }
217
164
 
218
165
  /**
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).
166
+ * The wire body of the form's values — an empty control is an absent value at the create boundary
167
+ * (absence is validated by the lifecycle axis, an empty string would be validated as a present bad
168
+ * value).
222
169
  */
223
170
  export function payloadOf(values: Record<string, unknown>): Record<string, unknown> {
224
171
  return Object.fromEntries(
@@ -226,7 +173,7 @@ export function payloadOf(values: Record<string, unknown>): Record<string, unkno
226
173
  );
227
174
  }
228
175
 
229
- /** Index judge errors by field — local judge and remote judge share this shape. */
176
+ /** Index validator errors by field — local validator and remote validator share this shape. */
230
177
  export function errorsByField(errors: ValidationError[]): Record<string, string> {
231
178
  const byField: Record<string, string> = {};
232
179
  for (const err of errors) {
package/src/graphql.ts CHANGED
@@ -1,25 +1,4 @@
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
- */
1
+ /** The GraphQL door — declared like the others, mounted like the others. */
23
2
  import type { App } from '@fougere/core';
24
3
  import type { Outcome } from './serve.js';
25
4
 
@@ -56,12 +35,7 @@ async function executor(): Promise<ExecuteOn> {
56
35
  }
57
36
  }
58
37
 
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
- */
38
+ /** Execute a GraphQL request against the app's own operations. */
65
39
  export async function serveGraphQL(app: App, request: GraphQLRequest): Promise<Outcome> {
66
40
  if (!app.adapters?.graphql) return { kind: 'pass' };
67
41
 
package/src/index.ts CHANGED
@@ -1,20 +1,9 @@
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
- */
1
+ /** `@fougere/app` — what an app host needs and what no host owns. */
12
2
  export {
13
3
  configureFougere,
14
4
  extendFougere,
15
5
  useFougereApp,
16
6
  reloadFougere,
17
- createMemoryOrm,
18
7
  type FougereServerConfig,
19
8
  } from './boot.js';
20
9
 
@@ -53,3 +42,6 @@ export { sessionViewOf, type SessionView } from './session.js';
53
42
  export { stateFor } from './state.js';
54
43
 
55
44
  export { serveGraphQL, type GraphQLRequest } from './graphql.js';
45
+
46
+ // Re-exported where every host already looks for it; it lives in its own adapter now.
47
+ export { createMemoryStorage } from '@fougere/adapter-memory';
package/src/rest.ts CHANGED
@@ -1,12 +1,4 @@
1
- /**
2
- * The REST table this app serves, and the rule that matches a request against it.
3
- *
4
- * Separated from the h3 handler on purpose: this file holds the whole of what the door
5
- * DECIDES — which operation a verb and a path name — while `api/crud.ts` only translates
6
- * that decision into h3. The rule was previously inline, untested, and had drifted into a
7
- * second REST projection that answered differently from `schema-rest` on all three counts
8
- * (verb, path, exposure).
9
- */
1
+ /** The REST table this app serves, and the rule that matches a request against it. */
10
2
  import { generateRoutes } from '@fougere/adapter-rest';
11
3
  import type { App } from '@fougere/core';
12
4
 
@@ -31,14 +23,7 @@ export type RouteMatch =
31
23
  // it changes exactly when the app does.
32
24
  const tables = new WeakMap<App, Matchable[]>();
33
25
 
34
- /**
35
- * The table, per frond.
36
- *
37
- * `generateRoutes` prefixes every path the same way, while this door addresses a frond by
38
- * name (`/api/{frond}/{plural}`) — so it runs once per frond, each with its own prefix and
39
- * a filter naming it. That frond loop is the only thing this file knows that `schema-rest`
40
- * does not; the verbs, the paths and the membership rule all come from there.
41
- */
26
+ /** The table, per frond. */
42
27
  export function tableOf(app: App): Matchable[] {
43
28
  const cached = tables.get(app);
44
29
  if (cached) return cached;
@@ -78,25 +63,10 @@ function openness(route: Matchable): number {
78
63
  return route.segments.filter((s) => s.startsWith(':')).length;
79
64
  }
80
65
 
81
- /**
82
- * Verbs this door accepts in place of the one the table names.
83
- *
84
- * `deriveMethod` gives `update` a single verb, PUT, while this door has always served
85
- * PATCH on it too — and says so (`docs/infra/surfaces`, "PUT · PATCH"). An alias keeps
86
- * that promise without giving the table a second row: both are mutations on a row, so
87
- * nothing is widened, and the one thing the table decides — WHICH operation a path names —
88
- * is still decided there alone.
89
- */
66
+ /** Verbs this door accepts in place of the one the table names. */
90
67
  const ALIASES: Record<string, string> = { PATCH: 'PUT' };
91
68
 
92
- /**
93
- * Path first, method second — a router's order, and what makes a 405 possible at all.
94
- *
95
- * The order matters where the two overlap: `/posts/publish` and `/posts/:id` both accept
96
- * `GET /posts/publish`. Taking the most specific path first means the answer is "that verb
97
- * is refused here", not `findById('publish')` — and never `publish()`, which is what this
98
- * door used to do with the caller's session cookie attached.
99
- */
69
+ /** Path first, method second — a router's order, and what makes a 405 possible at all. */
100
70
  export function matchRoute(table: Matchable[], method: string, segments: string[]): RouteMatch {
101
71
  const matches = table
102
72
  .map((route) => ({ route, params: paramsOf(route, segments) }))
package/src/serve.ts CHANGED
@@ -1,16 +1,4 @@
1
- /**
2
- * The three doors, decided — and nothing about how a request arrives.
3
- *
4
- * A host (Nuxt, Next) owns exactly two translations: read the request into the
5
- * plain values below, and write the outcome back out. Everything between — which
6
- * operation a verb and a path name, which audience a segment selects, what a
7
- * refusal becomes — is decided here, once, for every host.
8
- *
9
- * The split matters because the alternative was measured in this repo: two copies
10
- * of a REST rule drifted until the Nuxt door answered differently from
11
- * `schema-rest` on the verb, the path AND the exposure. A second host would have
12
- * been a third copy.
13
- */
1
+ /** The three doors, decided — and nothing about how a request arrives. */
14
2
  import {
15
3
  createAppRunner,
16
4
  callValueOf,
@@ -29,11 +17,7 @@ export interface DoorRequest {
29
17
  path: string;
30
18
  query: Record<string, string>;
31
19
  body?: unknown;
32
- /**
33
- * The server-resolved session. Stamped by the host from what IT resolved —
34
- * never taken from the wire, which is the whole trust boundary of the browser
35
- * door (`transport/http/src/server.ts` carries the same warning for the split).
36
- */
20
+ /** The server-resolved session. */
37
21
  state: Record<string, unknown>;
38
22
  }
39
23
 
@@ -46,31 +30,16 @@ export type Outcome =
46
30
 
47
31
  // ── The call envelope ────────────────────────────
48
32
 
49
- /**
50
- * The audience this door serves — the path segment after `/_fougere/call`.
51
- *
52
- * The envelope is a surface like REST and GraphQL, so it selects its audience like
53
- * they do; the difference is only that it takes it from the path instead of an
54
- * option, because a door is mounted, not called. The same word names the directory
55
- * (`handlers/public/`), the config key (`surfaces: { public: [...] }`) and this
56
- * segment — derived, never configured.
57
- *
58
- * No escalation to guard: a named surface serves the entities it names and nothing
59
- * else (closed by naming), so every one of them is a subset of what the bare path
60
- * already serves.
61
- */
33
+ /** The audience this door serves — the path segment after `/_fougere/call`. */
62
34
  export function surfaceOf(path: string): string | undefined {
63
35
  const named = /^\/_fougere\/call\/([A-Za-z0-9_-]+)/.exec(path.replace(/\?.*$/, ''));
64
36
  return named?.[1];
65
37
  }
66
38
 
67
39
  /**
68
- * Receiving end for the browser — same wire as process-to-process (JSON-RPC),
69
- * different trust boundary: the browser sits outside the topology, so `state` is
70
- * whatever the host resolved server-side, never what the payload claims.
71
- *
72
- * The runner follows the app's topology: local façades and remote doublures alike
73
- * — the browser never knows where a Frond lives.
40
+ * Receiving end for the browser — same wire as process-to-process (JSON-RPC), different trust
41
+ * boundary: the browser sits outside the topology, so `state` is whatever the host resolved
42
+ * server-side, never what the payload claims.
74
43
  */
75
44
  export async function serveRpc(app: App, request: Pick<DoorRequest, 'path' | 'body' | 'state'>): Promise<unknown> {
76
45
  const runner = createAppRunner(app, surfaceOf(request.path));
@@ -85,12 +54,7 @@ export function rpcParseError() {
85
54
  // ── REST ─────────────────────────────────────────
86
55
 
87
56
  /**
88
- * Match the URL against the canonical table, invoke the call it names, shape the
89
- * result for HTTP. The decision lives in `rest.ts`; dispatch belongs to the runner.
90
- *
91
- * `path` is what follows the REST mount point, so `/api/blog/posts/1` arrives as
92
- * `blog/posts/1`. A path this door does not serve returns `pass`, and that is what
93
- * lets an app keep its own `/api/*` handlers.
57
+ * Match the URL against the canonical table, invoke the call it names, shape the result for HTTP.
94
58
  */
95
59
  export async function serveRest(app: App, request: DoorRequest): Promise<Outcome> {
96
60
  // The app decides, not the host. A route file may exist and a middleware may be
@@ -123,7 +87,7 @@ export async function serveRest(app: App, request: DoorRequest): Promise<Outcome
123
87
  result = await invokeOn(
124
88
  app,
125
89
  { entity: route.entityName, op: route.operationName },
126
- { params, query: request.query, body: request.body },
90
+ { params, query: request.query, input: request.body },
127
91
  undefined,
128
92
  request.state,
129
93
  );
@@ -135,13 +99,7 @@ export async function serveRest(app: App, request: DoorRequest): Promise<Outcome
135
99
  return shapeRest(route.operationName, result);
136
100
  }
137
101
 
138
- /**
139
- * What an operation's return becomes on the wire.
140
- *
141
- * Separate from `serveRest` because it is a DECISION and dispatch is not: it can be
142
- * pinned without a runner, and both hosts get it whether the call ran in memory or
143
- * came back over JSON-RPC.
144
- */
102
+ /** What an operation's return becomes on the wire. */
145
103
  export function shapeRest(operationName: string, result: unknown): Outcome {
146
104
  if (result === null) return { kind: 'error', status: 404, body: { message: 'Not found' } };
147
105
 
@@ -168,13 +126,8 @@ type EntityClass = { name: string };
168
126
  type CallInput = Partial<InvocationContext>;
169
127
 
170
128
  /**
171
- * Name a call server-side and let the runner place it — local façade → direct
172
- * in-memory execution, a frond in `remotes` → JSON-RPC on the wire. The caller
173
- * never knows which.
174
- *
175
- * `state` is explicit here. Each host wraps this with its own way of finding the
176
- * current request (Nitro's async context, Next's `headers()` scope), because that
177
- * is the one part a host actually owns.
129
+ * Name a call server-side and let the runner place it — local façade → direct in-memory execution,
130
+ * a frond in `remotes` → JSON-RPC on the wire.
178
131
  */
179
132
  export async function invokeOn<T = unknown>(
180
133
  app: App,
@@ -186,6 +139,6 @@ export async function invokeOn<T = unknown>(
186
139
  const { call, invocation } = callValueOf(target, opOrInput, input);
187
140
  // An explicit `state` on the input wins over the request's — the caller who spells
188
141
  // it is answering for it, which is what makes a call outside any request possible.
189
- const given = typeof opOrInput === 'string' ? input : opOrInput;
190
- return (await createAppRunner(app)(call, { ...invocation, state: given?.state ?? state })) as T;
142
+ const explicit = typeof opOrInput === 'string' ? input : opOrInput;
143
+ return (await createAppRunner(app)(call, { ...invocation, state: explicit?.state ?? state })) as T;
191
144
  }
package/src/session.ts CHANGED
@@ -1,11 +1,6 @@
1
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.
2
+ * The session view — the one place that turns the server-resolved request context (filled by the
3
+ * auth middleware) into what the client is allowed to see.
9
4
  */
10
5
 
11
6
  export interface SessionView {
package/src/state.ts CHANGED
@@ -1,14 +1,4 @@
1
- /**
2
- * Who the caller is, resolved server-side from the request's own headers.
3
- *
4
- * Nuxt answers this with a Nitro middleware that stamps `event.context`; a
5
- * Web-standard host has no such seam on a route handler, so it resolves here
6
- * instead. Same source (the auth runtime mounted on `app.auth`), same result shape
7
- * (`{ user, session }`), so `serveRpc` and `serveRest` cannot tell hosts apart.
8
- *
9
- * What must stay true in both: this is what the SERVER resolved. A browser sits
10
- * outside the topology, so nothing here may come from the payload.
11
- */
1
+ /** Who the caller is, resolved server-side from the request's own headers. */
12
2
  import { useFougereApp } from './boot.js';
13
3
 
14
4
  type SessionApi = {
package/src/web.ts CHANGED
@@ -1,16 +1,4 @@
1
- /**
2
- * The three doors as Web-standard handlers: `Request` in, `Response` out.
3
- *
4
- * This is the whole server surface for any host built on fetch semantics — Next
5
- * route handlers, TanStack Start server routes, Hono, a bare `Deno.serve`. Such a
6
- * host mounts these; it does not translate anything, because there is nothing left
7
- * to translate.
8
- *
9
- * Nuxt is the exception and keeps its own translation, for a reason worth stating:
10
- * an h3 event is not a `Request`, and reading its body has to straddle two h3
11
- * majors (`server/routes/call.post.ts` carries that hundred lines). That file is
12
- * what a NON-Web-standard host costs.
13
- */
1
+ /** The three doors as Web-standard handlers: `Request` in, `Response` out. */
14
2
  import { serveRest, serveRpc, rpcParseError, useFougereApp, serveGraphQL } from './index.js';
15
3
  import { MAX_BODY_BYTES } from '@fougere/core';
16
4
  import { sessionViewOf } from './session.js';
@@ -18,11 +6,7 @@ import { stateFor } from './state.js';
18
6
 
19
7
  const WITH_BODY = new Set(['POST', 'PUT', 'PATCH']);
20
8
 
21
- /**
22
- * The call envelope. A named surface is the path segment after the door —
23
- * `/_fougere/call/public` serves the `public` audience — and `surfaceOf` reads it,
24
- * so a host only has to mount this at a path that keeps the segments.
25
- */
9
+ /** The call envelope. */
26
10
  export async function fougereCall(request: Request): Promise<Response> {
27
11
  const declared = Number(request.headers.get('content-length'));
28
12
  if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
@@ -46,14 +30,7 @@ export async function fougereCall(request: Request): Promise<Response> {
46
30
  );
47
31
  }
48
32
 
49
- /**
50
- * The REST projection, mounted under `/api`.
51
- *
52
- * `pass` — a path this app does not serve — becomes a 404 here rather than a
53
- * fall-through, because a Web handler has nobody to fall through to. Hosts that
54
- * resolve a static route before a catch-all (Next does) still let an app keep its
55
- * own `/api/*` handlers: they are reached BEFORE this one, not after.
56
- */
33
+ /** The REST projection, mounted under `/api`. */
57
34
  export async function fougereRest(request: Request): Promise<Response> {
58
35
  const app = await useFougereApp();
59
36
  const url = new URL(request.url);
@@ -81,12 +58,7 @@ export async function fougereSession(request: Request): Promise<Response> {
81
58
  return Response.json(sessionViewOf(await stateFor(request.headers)));
82
59
  }
83
60
 
84
- /**
85
- * GraphQL, at whatever path the host mounted it — `/graphql` by convention.
86
- *
87
- * Answers `404` when the app declares no GraphQL adapter, because unlike REST this
88
- * door is mounted at a path of its own: there is no app route underneath it to pass to.
89
- */
61
+ /** GraphQL, at whatever path the host mounted it — `/graphql` by convention. */
90
62
  export async function fougereGraphQL(request: Request): Promise<Response> {
91
63
  const app = await useFougereApp();
92
64
  const body = (await request.json().catch(() => ({}))) as {