@crombolo/common 0.39.1 → 0.40.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,44 @@
1
+ /**
2
+ * Server actions (story 219): a function on the server that a form posts to,
3
+ * or an island calls. The config lists action modules, and each export is an
4
+ * action whose id is the module's key and the export's name, `profile.save`.
5
+ * docs/rendering.md, "Server actions".
6
+ */
7
+ import type { FieldError, Result } from './result.ts';
8
+ import type { RouteContext } from './routes.ts';
9
+ /** The path an action is posted to: `/crombolo/actions/{id}`. */
10
+ export declare const ACTIONS_PATH = "/crombolo/actions";
11
+ /** The anti-forgery header and meta tag `/crombolo/bundle.js` uses, which a call from an island sends too. */
12
+ export declare const ACTION_ANTIFORGERY_HEADER = "Crombolo-Antiforgery";
13
+ /** What an action is given besides the form's data: a route's context, less its params. */
14
+ export type ActionContext<Site = unknown, Member = unknown> = Omit<RouteContext<Site, Member>, 'params'>;
15
+ /** An action: the form's data in, a `Response` or a `Result` out. */
16
+ export type ServerAction<Site = unknown, Member = unknown> = (formData: FormData, context: ActionContext<Site, Member>) => Response | Result<unknown> | Promise<Response | Result<unknown>>;
17
+ /** What an action answered, as the page it returned to reads it with `actionState`. */
18
+ export interface ActionState<T = unknown> {
19
+ ok: boolean;
20
+ /** A success's value. */
21
+ value?: T;
22
+ /** An outcome's status. */
23
+ status?: number;
24
+ message?: string;
25
+ errors?: readonly FieldError[];
26
+ /** What was submitted, so the form can be shown again; passwords and files left out. */
27
+ values?: Record<string, string>;
28
+ }
29
+ type Answer<A> = A extends (...args: never[]) => infer R ? Awaited<R> extends Result<infer T, infer S> ? Result<T, S> : Result<unknown> : never;
30
+ /** An action as an island is given it: called with a form's data, or an object of fields. */
31
+ export type ClientAction<A> = (input: FormData | Record<string, string | Blob>) => Promise<Answer<A>>;
32
+ /** An action given to an island, typed as the browser sees it, where inference falls short. */
33
+ export declare function clientAction<A extends (...args: never[]) => unknown>(action: A): ClientAction<A>;
34
+ /** An action in an island's props, as the server writes it. */
35
+ export interface ActionReference {
36
+ $action: string;
37
+ }
38
+ /**
39
+ * An island's props with each action rebuilt as a function that posts to it
40
+ * and resolves to its `Result`; a redirect navigates. What the React mount
41
+ * does before rendering, and what an `<Island>` module calls itself.
42
+ */
43
+ export declare function reviveActions<T>(props: T, antiforgery?: () => string | null): T;
44
+ export {};
@@ -0,0 +1,64 @@
1
+ import { Outcome, ok } from './result.js';
2
+ /** The path an action is posted to: `/crombolo/actions/{id}`. */
3
+ export const ACTIONS_PATH = '/crombolo/actions';
4
+ /** The anti-forgery header and meta tag `/crombolo/bundle.js` uses, which a call from an island sends too. */
5
+ export const ACTION_ANTIFORGERY_HEADER = 'Crombolo-Antiforgery';
6
+ const ANTIFORGERY_META = 'crombolo-antiforgery';
7
+ /** An action given to an island, typed as the browser sees it, where inference falls short. */
8
+ export function clientAction(action) {
9
+ return action;
10
+ }
11
+ const isReference = (value) => typeof value === 'object' &&
12
+ value !== null &&
13
+ Object.keys(value).length === 1 &&
14
+ typeof value.$action === 'string';
15
+ /**
16
+ * An island's props with each action rebuilt as a function that posts to it
17
+ * and resolves to its `Result`; a redirect navigates. What the React mount
18
+ * does before rendering, and what an `<Island>` module calls itself.
19
+ */
20
+ export function reviveActions(props, antiforgery) {
21
+ const token = antiforgery ??
22
+ (() => globalThis.document
23
+ ?.querySelector(`meta[name="${ANTIFORGERY_META}"]`)
24
+ ?.getAttribute('content') ?? null);
25
+ const revive = (value) => {
26
+ if (isReference(value))
27
+ return callAction(value.$action, token);
28
+ if (Array.isArray(value))
29
+ return value.map(revive);
30
+ if (typeof value === 'object' && value !== null)
31
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, revive(item)]));
32
+ return value;
33
+ };
34
+ return revive(props);
35
+ }
36
+ function callAction(id, token) {
37
+ return async (input) => {
38
+ const body = input instanceof FormData ? input : new FormData();
39
+ if (!(input instanceof FormData))
40
+ for (const [name, value] of Object.entries(input))
41
+ body.append(name, value);
42
+ const headers = { accept: 'application/json' };
43
+ const antiforgery = token();
44
+ if (antiforgery)
45
+ headers[ACTION_ANTIFORGERY_HEADER] = antiforgery;
46
+ const response = await fetch(`${ACTIONS_PATH}/${encodeURIComponent(id)}`, {
47
+ method: 'POST',
48
+ body,
49
+ headers,
50
+ credentials: 'same-origin',
51
+ });
52
+ const answer = (await response.json());
53
+ if (answer.redirect) {
54
+ globalThis.location?.assign(answer.redirect);
55
+ return Outcome.seeOther(answer.redirect);
56
+ }
57
+ if (answer.ok)
58
+ return ok(answer.value);
59
+ return Outcome.of((answer.status ?? response.status), {
60
+ message: answer.message,
61
+ errors: answer.errors ?? [],
62
+ });
63
+ };
64
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './actions.ts';
1
2
  export * from './middleware.ts';
2
3
  export * from './result.ts';
3
4
  export * from './routes.ts';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './actions.js';
1
2
  export * from './middleware.js';
2
3
  export * from './result.js';
3
4
  export * from './routes.js';
package/dist/result.d.ts CHANGED
@@ -69,6 +69,11 @@ export declare class Ok<T> implements ResultMethods<T, never> {
69
69
  unwrap(): T;
70
70
  unwrapOr<U>(_fallback: U): T;
71
71
  }
72
+ interface OutcomeInit {
73
+ message?: string;
74
+ errors?: readonly FieldError[];
75
+ location?: string;
76
+ }
72
77
  /**
73
78
  * An HTTP outcome in place of a value: a redirect, or a refusal the site's
74
79
  * status pages render. Built with `Outcome.notFound()` and its siblings; thrown
@@ -97,6 +102,11 @@ export declare class Outcome<S extends OutcomeStatus = OutcomeStatus> implements
97
102
  match<R>(handlers: MatchHandlers<never, S, R>): R;
98
103
  unwrap(): never;
99
104
  unwrapOr<U>(fallback: U): U;
105
+ /**
106
+ * One by its status, as JSON carried it (story 219: an action's answer in the
107
+ * browser). A redirect's location is checked as the redirect helpers check it.
108
+ */
109
+ static of<S extends OutcomeStatus>(status: S, init?: OutcomeInit): Outcome<S>;
100
110
  /** 307: the visitor is sent on, keeping the request's method. */
101
111
  static redirect(url: string): Outcome<307>;
102
112
  /** 308: the page has moved for good, keeping the request's method. */
package/dist/result.js CHANGED
@@ -134,6 +134,18 @@ export class Outcome {
134
134
  unwrapOr(fallback) {
135
135
  return fallback;
136
136
  }
137
+ /**
138
+ * One by its status, as JSON carried it (story 219: an action's answer in the
139
+ * browser). A redirect's location is checked as the redirect helpers check it.
140
+ */
141
+ static of(status, init = {}) {
142
+ if (!(status in OUTCOME_NAMES))
143
+ throw new Error(`${status} is not an outcome's status.`);
144
+ return new Outcome(status, {
145
+ ...init,
146
+ location: status < 400 ? assertSafeLocation(init.location ?? '') : undefined,
147
+ });
148
+ }
137
149
  /** 307: the visitor is sent on, keeping the request's method. */
138
150
  static redirect(url) {
139
151
  return new Outcome(307, { location: assertSafeLocation(url) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/common",
3
- "version": "0.39.1",
3
+ "version": "0.40.0",
4
4
  "description": "The contracts code outside crombolo is written against: the Result a site's services return, and the middleware chain's contract. No dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",