@valancex/common 0.0.0-stage → 0.12.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/README.md +44 -2
- package/dist/announce.d.ts +25 -0
- package/dist/announce.js +39 -0
- package/dist/app.d.ts +35 -0
- package/dist/app.js +124 -0
- package/dist/client.d.ts +9 -0
- package/dist/client.js +40 -0
- package/dist/http.d.ts +65 -0
- package/dist/http.js +188 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.js +24 -0
- package/dist/internal.d.ts +7 -0
- package/dist/internal.js +9 -0
- package/dist/model.d.ts +72 -0
- package/dist/model.js +4 -0
- package/dist/primitives.d.ts +2 -0
- package/dist/primitives.js +41 -0
- package/dist/routes.d.ts +9 -0
- package/dist/routes.js +46 -0
- package/dist/scope.d.ts +83 -0
- package/dist/scope.js +134 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +22 -0
- package/dist/state.d.ts +13 -0
- package/dist/state.js +33 -0
- package/dist/transport.d.ts +37 -0
- package/dist/transport.js +1 -0
- package/dist/viewport.d.ts +15 -0
- package/dist/viewport.js +24 -0
- package/dist/vocabulary.d.ts +9 -0
- package/dist/vocabulary.js +3 -0
- package/package.json +75 -3
- package/types/mprx.d.ts +5 -0
package/README.md
CHANGED
|
@@ -1,3 +1,45 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @valancex/common
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
What an application's `src/main.ts` imports (`defineApp`, `component`, `field`), and the runtime a built application runs. It describes the application; the [`vlx` command](../cli) builds, serves and checks what it describes.
|
|
4
|
+
|
|
5
|
+
```console
|
|
6
|
+
$ vlx init my-app --web # a project that already depends on this package
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// src/main.ts
|
|
11
|
+
import { component, defineApp, field } from "@valancex/common";
|
|
12
|
+
import { Schema } from "effect";
|
|
13
|
+
import layout from "./layout.mprx";
|
|
14
|
+
import themeToggle from "./theme-toggle.js";
|
|
15
|
+
|
|
16
|
+
export default defineApp({
|
|
17
|
+
layout: component(layout),
|
|
18
|
+
components: [themeToggle],
|
|
19
|
+
state: { theme: field(Schema.Literal("light", "dark"), "light") },
|
|
20
|
+
commands: (command) => ({
|
|
21
|
+
toggleTheme: command((state) => ({ ...state, theme: state.theme === "light" ? "dark" : "light" })),
|
|
22
|
+
}),
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
| Export | Is |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `defineApp({ layout?, components?, override?, state?, title?, commands? })` | The application: its shell, its components, its state, the title it wants for a state, and the commands that change it. It starts nothing. |
|
|
29
|
+
| `component(template, { props?, events? })` | A component: a `.mprx` template and, optionally, the interface it states (Effect Schemas). Which components its template uses is read from the template. |
|
|
30
|
+
| `field(schema, initial)` | One field of the state: its type and its default, together. |
|
|
31
|
+
|
|
32
|
+
`Schema` is [Effect's](https://effect.website/docs/schema/introduction/); import it from `effect`. This package types against it and does not re-export it.
|
|
33
|
+
|
|
34
|
+
| Entry | Is |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `@valancex/common` | The three exports above. No Node, bundler or terminal import: `src/main.ts` is loaded in the browser as well as at build time. |
|
|
37
|
+
| `@valancex/common/http` | The `Http` capability (`request({ path, response })`), its error type and the web's implementation. Imported by the commands that call a backend; it holds no `fetch` until the page supplies one. |
|
|
38
|
+
| `@valancex/common/client`, `/server` | The browser and server halves of a built application: `start` takes over the server's HTML, `createRenderer` turns a path into a document. The files `vlx` generates import these; you do not. |
|
|
39
|
+
| `@valancex/common/internal` | What the build tools share with the runtime (content model, tag table, the scope a layout reads). For `@valancex/cli`, not for applications. |
|
|
40
|
+
|
|
41
|
+
A built or deployed application depends on this package and `effect`; `@valancex/cli` is only needed to develop it. This package depends on `@valancex/valance`, `@valancex/mesh-runtime` and `@valancex/port-web`.
|
|
42
|
+
|
|
43
|
+
Documentation: [`@valancex/cli`](../cli/README.md) (the project layout, components, state and commands).
|
|
44
|
+
|
|
45
|
+
Pre-1.0: the API may change between minor releases; every change is in the [release notes](../../docs/releases/).
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface Node_ {
|
|
2
|
+
textContent: string | null;
|
|
3
|
+
setAttribute(name: string, value: string): void;
|
|
4
|
+
hasAttribute?(name: string): boolean;
|
|
5
|
+
readonly style?: {
|
|
6
|
+
setProperty(name: string, value: string): void;
|
|
7
|
+
};
|
|
8
|
+
focus?(options?: {
|
|
9
|
+
preventScroll?: boolean;
|
|
10
|
+
}): void;
|
|
11
|
+
}
|
|
12
|
+
export interface AnnounceDocument {
|
|
13
|
+
readonly title: string;
|
|
14
|
+
readonly body: {
|
|
15
|
+
append(node: never): void;
|
|
16
|
+
};
|
|
17
|
+
getElementById(id: string): Node_ | null;
|
|
18
|
+
querySelector(selector: string): Node_ | null;
|
|
19
|
+
createElement(tag: string): Node_;
|
|
20
|
+
}
|
|
21
|
+
export declare const ANNOUNCER_ID = "valance-announcer";
|
|
22
|
+
/** Says the document's title, and focuses `#content` (or `<main>`) when it has a `tabindex`. */
|
|
23
|
+
export declare const announcePage: (document: AnnounceDocument, options?: {
|
|
24
|
+
readonly focus: boolean;
|
|
25
|
+
}) => void;
|
package/dist/announce.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const ANNOUNCER_ID = "valance-announcer";
|
|
2
|
+
/**
|
|
3
|
+
* Visually hidden, still read out. Set through the style object, not a `style` attribute: a page served with the strict policy `vlx build` writes (`style-src 'self'`) refuses an inline style
|
|
4
|
+
* attribute, and setting properties on `element.style` is the one way it allows.
|
|
5
|
+
*/
|
|
6
|
+
const HIDDEN = [
|
|
7
|
+
["position", "absolute"],
|
|
8
|
+
["width", "1px"],
|
|
9
|
+
["height", "1px"],
|
|
10
|
+
["margin", "-1px"],
|
|
11
|
+
["padding", "0"],
|
|
12
|
+
["overflow", "hidden"],
|
|
13
|
+
["clip", "rect(0 0 0 0)"],
|
|
14
|
+
["clip-path", "inset(50%)"],
|
|
15
|
+
["white-space", "nowrap"],
|
|
16
|
+
["border", "0"],
|
|
17
|
+
];
|
|
18
|
+
/** Says the document's title, and focuses `#content` (or `<main>`) when it has a `tabindex`. */
|
|
19
|
+
export const announcePage = (document, options = { focus: true }) => {
|
|
20
|
+
let region = document.getElementById(ANNOUNCER_ID);
|
|
21
|
+
if (region === null) {
|
|
22
|
+
region = document.createElement("div");
|
|
23
|
+
region.setAttribute("id", ANNOUNCER_ID);
|
|
24
|
+
region.setAttribute("role", "status");
|
|
25
|
+
region.setAttribute("aria-live", "polite");
|
|
26
|
+
HIDDEN.forEach(([name, value]) => region.style?.setProperty(name, value));
|
|
27
|
+
document.body.append(region);
|
|
28
|
+
}
|
|
29
|
+
// A region that says the same words again is not announced again: clear it first.
|
|
30
|
+
region.textContent = "";
|
|
31
|
+
region.textContent = document.title;
|
|
32
|
+
if (options.focus) {
|
|
33
|
+
const main = document.getElementById("content") ?? document.querySelector("main");
|
|
34
|
+
// Only an element the author made focusable (a `tabindex`): making one focusable here would put an outline on the whole page.
|
|
35
|
+
if (main !== null && main.hasAttribute?.("tabindex") === true) {
|
|
36
|
+
main.focus?.({ preventScroll: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
package/dist/app.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as Valance from "@valancex/valance";
|
|
2
|
+
import type { Plugin } from "@valancex/valance/web/plugin";
|
|
3
|
+
import { Schema } from "effect";
|
|
4
|
+
import type { DefinedApp, PageState } from "./index.js";
|
|
5
|
+
import type { Content } from "./model.js";
|
|
6
|
+
export type AppState = PageState & Record<string, unknown>;
|
|
7
|
+
/** What the browser needs to know about the built page to keep the document's head in step. */
|
|
8
|
+
export interface Built {
|
|
9
|
+
/** The address of the page script. */
|
|
10
|
+
readonly script: string;
|
|
11
|
+
/** Stylesheet addresses, in order. Empty in development, where the script brings its styles. */
|
|
12
|
+
readonly styles: ReadonlyArray<string>;
|
|
13
|
+
}
|
|
14
|
+
/** A mistake in `src/main.ts`, reported with what to change. */
|
|
15
|
+
export declare class AppDefinitionError extends Error {
|
|
16
|
+
constructor(message: string);
|
|
17
|
+
}
|
|
18
|
+
/** Checks the author's definition against the names the framework owns. */
|
|
19
|
+
export declare const checkDefinition: (author: DefinedApp | undefined) => void;
|
|
20
|
+
/** The names `src/main.ts` adds: state fields (readable in the layout) and commands (runnable from it). */
|
|
21
|
+
export declare const ownNames: (author: DefinedApp | undefined) => {
|
|
22
|
+
readonly state: ReadonlyArray<string>;
|
|
23
|
+
readonly commands: ReadonlyArray<string>;
|
|
24
|
+
};
|
|
25
|
+
/** The scope schema a layout is compiled against: the framework's names and the author's state fields. */
|
|
26
|
+
export declare const scopeSchema: (author: DefinedApp | undefined) => Schema.Schema<any, any, never>;
|
|
27
|
+
export declare const stateSchema: (author: DefinedApp | undefined) => Schema.Schema<any, any, never>;
|
|
28
|
+
/** The command table: the framework's `go`, then the author's. */
|
|
29
|
+
export declare const commandTable: (author: DefinedApp | undefined) => (state: Parameters<typeof Valance.command>[0]) => Valance.CommandTable<any, Valance.Ambient>;
|
|
30
|
+
export declare const initialState: (author: DefinedApp | undefined) => AppState;
|
|
31
|
+
export declare const application: (content: Content, program: Valance.Program, author?: DefinedApp) => Valance.ApplicationDefinition<AppState, any, Valance.Ambient, "layout">;
|
|
32
|
+
/** The state a request for `pathname` starts from. */
|
|
33
|
+
export declare const stateFor: (content: Content, pathname: string, author?: DefinedApp) => AppState;
|
|
34
|
+
/** The plugin that makes the application a website: its tags, its URL policy, and its document head. */
|
|
35
|
+
export declare const plugin: (content: Content, built: Built, author?: DefinedApp) => Plugin<AppState>;
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The application the framework assembles from the three things an author supplies: the content (built from `content/`), the program (built from the templates), and optionally
|
|
2
|
+
// `defineApp`'s state and commands. This is one Valance application (`Valance.define`): the page is its state, going to a page is its `go` command, and the layout is its one view.
|
|
3
|
+
// The server renders it and the browser runs it, from the same function.
|
|
4
|
+
import * as Valance from "@valancex/valance";
|
|
5
|
+
import { Schema } from "effect";
|
|
6
|
+
import { documentTitle, frame, FrameScope, pageAt, RESERVED } from "./scope.js";
|
|
7
|
+
import { primitives } from "./primitives.js";
|
|
8
|
+
import { normalize, routes } from "./routes.js";
|
|
9
|
+
import { badDefaults, stateOf } from "./state.js";
|
|
10
|
+
const PageSchema = Schema.Struct({ page: Schema.String, viewport: Schema.Literal("compact", "medium", "expanded") });
|
|
11
|
+
/** A mistake in `src/main.ts`, reported with what to change. */
|
|
12
|
+
export class AppDefinitionError extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "AppDefinitionError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const authorFields = (author) => Object.keys(stateOf(author)?.schema.fields ?? {});
|
|
19
|
+
/** Checks the author's definition against the names the framework owns. */
|
|
20
|
+
export const checkDefinition = (author) => {
|
|
21
|
+
for (const name of authorFields(author)) {
|
|
22
|
+
if (RESERVED.includes(name)) {
|
|
23
|
+
throw new AppDefinitionError(`src/main.ts: the state field "${name}" is a name the layout already reads from the framework (${RESERVED.join(", ")}); choose another`);
|
|
24
|
+
}
|
|
25
|
+
if (name === "page") {
|
|
26
|
+
throw new AppDefinitionError('src/main.ts: the state field "page" is the framework\'s (which page is shown); choose another');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
for (const name of badDefaults(author)) {
|
|
30
|
+
throw new AppDefinitionError(`src/main.ts: the default of the state field "${name}" is not a value of its type`);
|
|
31
|
+
}
|
|
32
|
+
// Building the table reads no state (a command reads it when it runs), so it can be built here, with nothing, to see its names.
|
|
33
|
+
const own = author?.commands?.(Valance.command({})) ?? {};
|
|
34
|
+
for (const [name, what] of [
|
|
35
|
+
["go", "shows a page"],
|
|
36
|
+
["resize", "tells the layout how much room the page has"],
|
|
37
|
+
]) {
|
|
38
|
+
if (name in own) {
|
|
39
|
+
throw new AppDefinitionError(`src/main.ts: "${name}" is the framework's command (it ${what}); choose another name`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
/** The names `src/main.ts` adds: state fields (readable in the layout) and commands (runnable from it). */
|
|
44
|
+
export const ownNames = (author) => ({
|
|
45
|
+
state: authorFields(author),
|
|
46
|
+
commands: Object.keys(author?.commands?.(Valance.command({})) ?? {}),
|
|
47
|
+
});
|
|
48
|
+
/** The scope schema a layout is compiled against: the framework's names and the author's state fields. */
|
|
49
|
+
export const scopeSchema = (author) => {
|
|
50
|
+
const state = stateOf(author);
|
|
51
|
+
return state === undefined ? FrameScope : Schema.extend(FrameScope, state.schema);
|
|
52
|
+
};
|
|
53
|
+
export const stateSchema = (author) => {
|
|
54
|
+
const state = stateOf(author);
|
|
55
|
+
return state === undefined ? PageSchema : Schema.extend(PageSchema, state.schema);
|
|
56
|
+
};
|
|
57
|
+
/** The command table: the framework's `go`, then the author's. */
|
|
58
|
+
export const commandTable = (author) => (state) => {
|
|
59
|
+
const command = Valance.command(state);
|
|
60
|
+
const own = author?.commands?.(command) ?? {};
|
|
61
|
+
for (const name of ["go", "resize"]) {
|
|
62
|
+
if (name in own) {
|
|
63
|
+
throw new AppDefinitionError(`src/main.ts: "${name}" is the framework's command; choose another name`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
go: command(Schema.Struct({ id: Schema.String }), ({ id }, current) => ({ ...current, page: id })),
|
|
68
|
+
// The window changed size: the browser tells the application, which tells the layout (`viewport`).
|
|
69
|
+
resize: command(Schema.Struct({ viewport: Schema.Literal("compact", "medium", "expanded") }), ({ viewport }, current) => ({ ...current, viewport })),
|
|
70
|
+
...own,
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
export const initialState = (author) => ({ page: "/", viewport: "expanded", ...stateOf(author)?.initial });
|
|
74
|
+
export const application = (content, program, author) => {
|
|
75
|
+
checkDefinition(author);
|
|
76
|
+
return Valance.define({
|
|
77
|
+
name: content.site.name,
|
|
78
|
+
state: { schema: stateSchema(author), initial: initialState(author) },
|
|
79
|
+
views: {
|
|
80
|
+
layout: {
|
|
81
|
+
program,
|
|
82
|
+
scope: (state) => {
|
|
83
|
+
const { page, ...own } = state;
|
|
84
|
+
return { ...frame(content, page, state.viewport), ...own };
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
view: () => "layout",
|
|
89
|
+
commands: commandTable(author),
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
/** The state a request for `pathname` starts from. */
|
|
93
|
+
export const stateFor = (content, pathname, author) => ({ ...initialState(author), page: normalize(pathname, content.site.base) });
|
|
94
|
+
/**
|
|
95
|
+
* The title for a state: the page's and the site's name, or what the author's `title` function says. A function that throws falls back to the default (a title is never worth a failed render),
|
|
96
|
+
* and the failure is reported by its name only.
|
|
97
|
+
*/
|
|
98
|
+
const titleFor = (content, page, state, author) => {
|
|
99
|
+
let parts = {};
|
|
100
|
+
try {
|
|
101
|
+
const made = author?.title?.(state, { path: state.page, title: page?.title ?? "" });
|
|
102
|
+
parts = made === undefined ? {} : typeof made === "string" ? { page: made } : made;
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
console.error(`the title function in src/main.ts failed (${error instanceof Error ? error.name : "error"}); the default title is used`);
|
|
106
|
+
}
|
|
107
|
+
return documentTitle(content, page, parts);
|
|
108
|
+
};
|
|
109
|
+
/** The plugin that makes the application a website: its tags, its URL policy, and its document head. */
|
|
110
|
+
export const plugin = (content, built, author) => ({
|
|
111
|
+
name: "valance-app",
|
|
112
|
+
primitives,
|
|
113
|
+
routes: routes(content),
|
|
114
|
+
head: (state) => {
|
|
115
|
+
const page = pageAt(content, state.page);
|
|
116
|
+
const shown = page ?? { description: "" };
|
|
117
|
+
const canonical = content.site.url === "" || page === undefined ? [] : [{ rel: "canonical", href: `${content.site.url}${content.site.base}${page.path.replace(/^\//, "")}` }];
|
|
118
|
+
return {
|
|
119
|
+
title: titleFor(content, page, state, author),
|
|
120
|
+
meta: shown.description === "" ? [] : [{ name: "description", content: shown.description }],
|
|
121
|
+
links: [...built.styles.map((href) => ({ rel: "stylesheet", href })), ...canonical],
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
});
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type * as Valance from "@valancex/valance";
|
|
2
|
+
import type { DefinedApp } from "./index.js";
|
|
3
|
+
import type { Content } from "./model.js";
|
|
4
|
+
export interface ClientParts {
|
|
5
|
+
readonly content: Content;
|
|
6
|
+
readonly program: Valance.Program;
|
|
7
|
+
readonly author?: DefinedApp | undefined;
|
|
8
|
+
}
|
|
9
|
+
export declare const start: ({ content, program, author }: ClientParts) => Promise<void>;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The browser half, for the generated entry (`.valance/client.ts`): start the MESH runtime and run the application, taking over the HTML the server wrote. An author never imports this.
|
|
2
|
+
import { init } from "@valancex/mesh-runtime";
|
|
3
|
+
import wasmUrl from "@valancex/mesh-runtime/mesh-runtime.wasm?url";
|
|
4
|
+
import * as Web from "@valancex/valance/web";
|
|
5
|
+
import { Effect } from "effect";
|
|
6
|
+
import { webPlatform } from "./http.js";
|
|
7
|
+
import { watchViewport } from "./viewport.js";
|
|
8
|
+
import { application, plugin } from "./app.js";
|
|
9
|
+
export const start = async ({ content, program, author }) => {
|
|
10
|
+
const container = document.getElementById("app");
|
|
11
|
+
try {
|
|
12
|
+
const boot = JSON.parse(document.getElementById("valance-boot").textContent);
|
|
13
|
+
await init(wasmUrl);
|
|
14
|
+
const host = await Web.run(application(content, program, author), {
|
|
15
|
+
container,
|
|
16
|
+
present: "hydrate",
|
|
17
|
+
state: boot.state,
|
|
18
|
+
plugins: [plugin(content, boot.built, author)],
|
|
19
|
+
// What the application's commands may ask the page for: the backend (`Http`), at `api.base` of valance.web.json.
|
|
20
|
+
platform: webPlatform({ base: content.site.api, interceptors: author?.http?.interceptors ?? [] }),
|
|
21
|
+
shutdown: { grace: "250 millis" },
|
|
22
|
+
});
|
|
23
|
+
// The server rendered for a wide window; tell the application how wide this one is (and keep telling it).
|
|
24
|
+
const unwatch = watchViewport(window, (viewport) => {
|
|
25
|
+
container.dataset["viewport"] = viewport; // for a stylesheet or a test; the layout reads the state's own `viewport`
|
|
26
|
+
void Effect.runPromise(host.handle.invoke("app/resize", [{ value: viewport }])).catch(() => undefined);
|
|
27
|
+
});
|
|
28
|
+
container.dataset["valance"] = "running"; // readiness marker for whoever drives the page
|
|
29
|
+
addEventListener("pagehide", (event) => {
|
|
30
|
+
if (!event.persisted) {
|
|
31
|
+
unwatch();
|
|
32
|
+
void host.stop();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
container.dataset["valance"] = "failed";
|
|
38
|
+
console.error("the application did not start", error); // the server's HTML stays as drawn
|
|
39
|
+
}
|
|
40
|
+
};
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import * as Nexus from "@valancex/nexus";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
|
+
import type { HttpError, Interceptor, Method, TransportRequest } from "./transport.js";
|
|
4
|
+
export type { HttpError, Interceptor, Method, Transport, TransportRequest, TransportResponse } from "./transport.js";
|
|
5
|
+
/** One request: where, what is sent, and what the answer must be. */
|
|
6
|
+
export interface RequestSpec<A> {
|
|
7
|
+
readonly method?: Method;
|
|
8
|
+
/** On the API's base: starts with `/` (not `//`), no scheme, no backslash. Anything else is `Unsafe`: a path built from data cannot send the request somewhere else. */
|
|
9
|
+
readonly path: string;
|
|
10
|
+
/** Appended to the address, encoded. `undefined` values are left out. */
|
|
11
|
+
readonly query?: Readonly<Record<string, string | number | boolean | undefined>>;
|
|
12
|
+
/** Sent as JSON. */
|
|
13
|
+
readonly body?: unknown;
|
|
14
|
+
/** What the answer must be. The decoded value is what the command receives; an answer that is not this is `Decode`, and nothing else gets through. */
|
|
15
|
+
readonly response: Schema.Schema<A, any, never>;
|
|
16
|
+
/** Milliseconds before the request is given up (`Timeout`), for each attempt. Default 15 000. */
|
|
17
|
+
readonly timeoutMs?: number;
|
|
18
|
+
/** Interceptors for this request alone: the innermost, the closest to the wire (see `Interceptor`). */
|
|
19
|
+
readonly interceptors?: ReadonlyArray<Interceptor>;
|
|
20
|
+
}
|
|
21
|
+
export interface HttpClient {
|
|
22
|
+
readonly request: <A>(spec: RequestSpec<A>) => Effect.Effect<A, HttpError>;
|
|
23
|
+
}
|
|
24
|
+
/** The capability: ask for it in a command (`Http.request`), and the platform says whether it has one. */
|
|
25
|
+
export declare const Http: Nexus.Capability.Capability<HttpClient>;
|
|
26
|
+
/** Makes a request through the platform's client. Requires the `Http` capability. */
|
|
27
|
+
export declare const request: <A>(spec: RequestSpec<A>) => Effect.Effect<A, HttpError | Nexus.Capability.CapabilityUnavailableError, Nexus.Capability.EnvironmentShape>;
|
|
28
|
+
export interface WebHttpOptions {
|
|
29
|
+
/** `api.base` in `valance.web.json`: `""` (the site's own origin), a path (`/api`) or an absolute https address. */
|
|
30
|
+
readonly base: string;
|
|
31
|
+
/** Default `globalThis.fetch`. Tests pass their own. */
|
|
32
|
+
readonly fetch?: typeof fetch;
|
|
33
|
+
/** Send cookies to an `api.base` on another origin. Default off: a cross-origin backend gets no ambient credentials unless the application says so. */
|
|
34
|
+
readonly credentials?: "same-origin" | "include";
|
|
35
|
+
/** The largest answer read, in characters. Default 5 000 000. */
|
|
36
|
+
readonly maxResponse?: number;
|
|
37
|
+
/** The application's interceptors, outermost first: the ones that hold for every request (`defineApp({ http: { interceptors } })`). Scoped ones are added by `withInterceptors` and `RequestSpec.interceptors`. */
|
|
38
|
+
readonly interceptors?: ReadonlyArray<Interceptor>;
|
|
39
|
+
}
|
|
40
|
+
/** The address for `path` under `base`, or why it cannot be one. */
|
|
41
|
+
export declare const addressOf: (base: string, path: string, query: RequestSpec<unknown>["query"]) => {
|
|
42
|
+
readonly url: string;
|
|
43
|
+
} | {
|
|
44
|
+
readonly unsafe: string;
|
|
45
|
+
};
|
|
46
|
+
/** Several interceptors as one, the first listed outermost: `chain(a, b)(t)` is `a(b(t))`. */
|
|
47
|
+
export declare const chain: (...interceptors: ReadonlyArray<Interceptor>) => Interceptor;
|
|
48
|
+
/** An interceptor that applies only to the requests `applies` accepts; every other request goes straight to the next. This is how a scope narrows: by address, by method, by anything the request says. */
|
|
49
|
+
export declare const only: (applies: (request: TransportRequest) => boolean, interceptor: Interceptor) => Interceptor;
|
|
50
|
+
/**
|
|
51
|
+
* Runs `effect` with `interceptors` added for the requests it makes: a scope narrower than the application's. A scope inside another is inside it (the outer ones see the request first and the
|
|
52
|
+
* answer last). It ends with the effect; a request made outside it is not touched.
|
|
53
|
+
*
|
|
54
|
+
* work: () => withInterceptors(Http.request({ path: "/admin/users", response: Users }), bearer(...adminToken))
|
|
55
|
+
*/
|
|
56
|
+
export declare const withInterceptors: <A, E, R>(effect: Effect.Effect<A, E, R>, ...interceptors: ReadonlyArray<Interceptor>) => Effect.Effect<A, E, R>;
|
|
57
|
+
/** Adds `Authorization: Bearer <token>` to requests for the API's origin and to no other: a credential never follows a request somewhere else. `base` is `api.base`. */
|
|
58
|
+
export declare const bearer: (base: string, token: () => string | undefined | Promise<string | undefined>) => Interceptor;
|
|
59
|
+
/** On a 401, runs `refresh` once however many requests failed together, then sends each of them once more. A second 401 is the answer. */
|
|
60
|
+
export declare const refreshOn401: (refresh: () => Promise<void>) => Interceptor;
|
|
61
|
+
/** Sends a GET again, up to `times` more, when it did not connect or the server failed (5xx). Never another method: a repeated POST is a second order. */
|
|
62
|
+
export declare const retryGet: (times: number) => Interceptor;
|
|
63
|
+
export declare const webHttp: (options: WebHttpOptions) => HttpClient;
|
|
64
|
+
/** The platform for a web page: the `Http` capability, made from `api.base`. Merged with the application's own by `Web.run`. */
|
|
65
|
+
export declare const webPlatform: (options: WebHttpOptions) => Nexus.Application.Platform;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Talking to the application's backend. NEXUS and VALANCE hold no HTTP (their rule): what an application needs from where it runs is a *capability*, a typed contract the application states and the
|
|
2
|
+
// platform supplies. This is that contract for HTTP, and the web's implementation of it. A desktop or mobile platform supplies the same contract with its own client (the OS's networking, a
|
|
3
|
+
// keychain for the token), and no command changes.
|
|
4
|
+
//
|
|
5
|
+
// const load = command.waiting(Schema.Struct({}), {
|
|
6
|
+
// begin: (_, s) => ({ ...s, inbox: { status: "loading" } }),
|
|
7
|
+
// work: () => Http.request({ path: "/inbox", response: Inbox }), // Effect<Inbox, HttpError, the Http capability>
|
|
8
|
+
// wanted: (_, s) => s.inbox.status === "loading",
|
|
9
|
+
// settle: (outcome, _, s) => ({ ...s, inbox: outcome.ok ? { status: "ready", ...outcome.value } : { status: "failed", why: describe(outcome.error) } }),
|
|
10
|
+
// });
|
|
11
|
+
//
|
|
12
|
+
// The defaults are the safe ones: a request goes to the application's own origin (or the one `api.base` names), carries no credentials to another origin, sends and accepts JSON, is
|
|
13
|
+
// bounded in time and size, and its response is *decoded by a Schema* before it can reach state. A failure says what kind it was and never what it carried.
|
|
14
|
+
import * as Nexus from "@valancex/nexus";
|
|
15
|
+
import { Effect, FiberRef, Schema } from "effect";
|
|
16
|
+
/** The capability: ask for it in a command (`Http.request`), and the platform says whether it has one. */
|
|
17
|
+
export const Http = Nexus.Capability.define("valance/http");
|
|
18
|
+
/** Makes a request through the platform's client. Requires the `Http` capability. */
|
|
19
|
+
export const request = (spec) => Effect.flatMap(Nexus.Capability.require(Http), (client) => client.request(spec));
|
|
20
|
+
const UNSAFE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
21
|
+
const DEFAULT_TIMEOUT = 15_000;
|
|
22
|
+
const DEFAULT_MAX = 5_000_000;
|
|
23
|
+
/** The address for `path` under `base`, or why it cannot be one. */
|
|
24
|
+
export const addressOf = (base, path, query) => {
|
|
25
|
+
// oxlint-disable-next-line no-control-regex -- refusing control characters in a path is the point
|
|
26
|
+
if (!path.startsWith("/") || path.startsWith("//") || path.includes("\\") || /[\u0000-\u001f]/.test(path) || /^\/[^/?#]*:\/\//.test(path)) {
|
|
27
|
+
return { unsafe: 'a path starts with a single "/" and holds no scheme, backslash or control character' };
|
|
28
|
+
}
|
|
29
|
+
if (path.split(/[/?#]/).includes("..")) {
|
|
30
|
+
return { unsafe: 'a path does not climb out of the API ("..")' };
|
|
31
|
+
}
|
|
32
|
+
const search = new URLSearchParams();
|
|
33
|
+
for (const [name, value] of Object.entries(query ?? {})) {
|
|
34
|
+
if (value !== undefined) {
|
|
35
|
+
search.append(name, String(value));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const tail = search.size === 0 ? "" : `${path.includes("?") ? "&" : "?"}${search.toString()}`;
|
|
39
|
+
return { url: `${base.replace(/\/+$/, "")}${path}${tail}` };
|
|
40
|
+
};
|
|
41
|
+
// ---- interceptors ------------------------------------------------------------------------------------------------------------------------------------------
|
|
42
|
+
//
|
|
43
|
+
// One shape for all of them. A `Transport` takes the request the client built and checked, and gives the answer before the client decodes it. An `Interceptor` takes the next `Transport` and gives
|
|
44
|
+
// a `Transport`: so every interceptor has the same input and the same output, they chain by plain composition, and the only thing that differs between two is what they do. The client's own
|
|
45
|
+
// checks come first (the path, the method, the body) and last (the answer's status, type and Schema), so no interceptor can skip them. Failures are `HttpError`s, which carry no address, header or body.
|
|
46
|
+
/** Several interceptors as one, the first listed outermost: `chain(a, b)(t)` is `a(b(t))`. */
|
|
47
|
+
export const chain = (...interceptors) => (next) => interceptors.reduceRight((inner, intercept) => intercept(inner), next);
|
|
48
|
+
/** An interceptor that applies only to the requests `applies` accepts; every other request goes straight to the next. This is how a scope narrows: by address, by method, by anything the request says. */
|
|
49
|
+
export const only = (applies, interceptor) => (next) => {
|
|
50
|
+
const inner = interceptor(next);
|
|
51
|
+
return (request) => (applies(request) ? inner(request) : next(request));
|
|
52
|
+
};
|
|
53
|
+
/** The interceptors in force for the requests made inside `withInterceptors`. The client reads it for each request, so a scope reaches every request its effect makes, however deep. */
|
|
54
|
+
const scope = FiberRef.unsafeMake([]);
|
|
55
|
+
/**
|
|
56
|
+
* Runs `effect` with `interceptors` added for the requests it makes: a scope narrower than the application's. A scope inside another is inside it (the outer ones see the request first and the
|
|
57
|
+
* answer last). It ends with the effect; a request made outside it is not touched.
|
|
58
|
+
*
|
|
59
|
+
* work: () => withInterceptors(Http.request({ path: "/admin/users", response: Users }), bearer(...adminToken))
|
|
60
|
+
*/
|
|
61
|
+
export const withInterceptors = (effect, ...interceptors) => Effect.locallyWith(effect, scope, (current) => [...current, ...interceptors]);
|
|
62
|
+
/** Whether the request goes to the origin the application's API is on (the base's, or the page's for a relative base). */
|
|
63
|
+
const sameApi = (base, url) => {
|
|
64
|
+
const origin = (value) => {
|
|
65
|
+
try {
|
|
66
|
+
return new URL(value, "http://page.invalid").origin;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
return origin(url) !== "" && origin(url) === origin(base === "" ? "/" : base);
|
|
73
|
+
};
|
|
74
|
+
/** Adds `Authorization: Bearer <token>` to requests for the API's origin and to no other: a credential never follows a request somewhere else. `base` is `api.base`. */
|
|
75
|
+
export const bearer = (base, token) => (next) => (request) => !sameApi(base, request.url)
|
|
76
|
+
? next(request)
|
|
77
|
+
: Effect.flatMap(Effect.promise(async () => token()), (value) => next(value === undefined ? request : { ...request, headers: { ...request.headers, authorization: `Bearer ${value}` } }));
|
|
78
|
+
/** On a 401, runs `refresh` once however many requests failed together, then sends each of them once more. A second 401 is the answer. */
|
|
79
|
+
export const refreshOn401 = (refresh) => {
|
|
80
|
+
let refreshing;
|
|
81
|
+
return (next) => (request) => Effect.flatMap(next(request), (first) => first.status !== 401
|
|
82
|
+
? Effect.succeed(first)
|
|
83
|
+
: Effect.flatMap(Effect.tryPromise({
|
|
84
|
+
try: async () => {
|
|
85
|
+
refreshing ??= refresh().finally(() => void (refreshing = undefined));
|
|
86
|
+
return refreshing;
|
|
87
|
+
},
|
|
88
|
+
catch: () => ({ _tag: "Offline" }),
|
|
89
|
+
}), () => next(request)));
|
|
90
|
+
};
|
|
91
|
+
/** Sends a GET again, up to `times` more, when it did not connect or the server failed (5xx). Never another method: a repeated POST is a second order. */
|
|
92
|
+
export const retryGet = (times) => (next) => (request) => {
|
|
93
|
+
const attempt = (n) => request.method !== "GET"
|
|
94
|
+
? next(request)
|
|
95
|
+
: next(request).pipe(Effect.flatMap((response) => (response.status >= 500 && n < times ? attempt(n + 1) : Effect.succeed(response))), Effect.catchTag("Offline", (error) => (n < times ? attempt(n + 1) : Effect.fail(error))));
|
|
96
|
+
return attempt(0);
|
|
97
|
+
};
|
|
98
|
+
// ---- the web's implementation ------------------------------------------------------------------------------------------------------------------------------------
|
|
99
|
+
/** The last transport: `fetch`, with the timeout, the size bound and the safe defaults. It returns any status; the client judges it after the interceptors have seen it. */
|
|
100
|
+
const fetchTransport = (options, crossOrigin) => {
|
|
101
|
+
const send = options.fetch ?? ((...args) => globalThis.fetch(...args));
|
|
102
|
+
return (request) => Effect.tryPromise({
|
|
103
|
+
try: async (signal) => {
|
|
104
|
+
const controller = new AbortController();
|
|
105
|
+
const timer = setTimeout(() => controller.abort("timeout"), request.timeoutMs);
|
|
106
|
+
const stop = () => controller.abort("aborted");
|
|
107
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
108
|
+
try {
|
|
109
|
+
const response = await send(request.url, {
|
|
110
|
+
method: request.method,
|
|
111
|
+
headers: { ...request.headers },
|
|
112
|
+
...(request.body === undefined ? {} : { body: request.body }),
|
|
113
|
+
credentials: crossOrigin ? (options.credentials ?? "omit") : "same-origin",
|
|
114
|
+
redirect: "follow",
|
|
115
|
+
referrerPolicy: "strict-origin-when-cross-origin",
|
|
116
|
+
cache: "no-store",
|
|
117
|
+
signal: controller.signal,
|
|
118
|
+
});
|
|
119
|
+
const headers = {};
|
|
120
|
+
response.headers.forEach((value, name) => void (headers[name.toLowerCase()] = value));
|
|
121
|
+
const text = response.status === 204 ? "" : await response.text();
|
|
122
|
+
if (text.length > (options.maxResponse ?? DEFAULT_MAX)) {
|
|
123
|
+
throw { _tag: "TooLarge" };
|
|
124
|
+
}
|
|
125
|
+
return { status: response.status, headers, text };
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
// A fetch that was aborted rejects with the abort's reason, whatever that was: what we passed says who stopped it.
|
|
129
|
+
if (controller.signal.aborted) {
|
|
130
|
+
throw { _tag: controller.signal.reason === "timeout" ? "Timeout" : "Aborted" };
|
|
131
|
+
}
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
signal.removeEventListener("abort", stop);
|
|
137
|
+
if (controller.signal.aborted === false) {
|
|
138
|
+
controller.abort("done");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
catch: (error) => (typeof error === "object" && error !== null && "_tag" in error ? error : { _tag: "Offline" }),
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
export const webHttp = (options) => {
|
|
146
|
+
const crossOrigin = /^https?:\/\//i.test(options.base);
|
|
147
|
+
const wire = fetchTransport(options, crossOrigin);
|
|
148
|
+
const global = chain(...(options.interceptors ?? []));
|
|
149
|
+
return {
|
|
150
|
+
request: (spec) => Effect.gen(function* () {
|
|
151
|
+
const method = spec.method ?? "GET";
|
|
152
|
+
const address = addressOf(options.base, spec.path, spec.query);
|
|
153
|
+
if ("unsafe" in address) {
|
|
154
|
+
return yield* Effect.fail({ _tag: "Unsafe", reason: address.unsafe });
|
|
155
|
+
}
|
|
156
|
+
const body = spec.body === undefined ? undefined : JSON.stringify(spec.body);
|
|
157
|
+
if (method === "GET" && body !== undefined) {
|
|
158
|
+
return yield* Effect.fail({ _tag: "Unsafe", reason: "a GET request has no body" });
|
|
159
|
+
}
|
|
160
|
+
const request = {
|
|
161
|
+
method,
|
|
162
|
+
url: address.url,
|
|
163
|
+
headers: {
|
|
164
|
+
accept: "application/json",
|
|
165
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
166
|
+
// A header a plain cross-site form cannot send: with the backend checking it (or SameSite cookies), a forged request from another page has nothing to ride on.
|
|
167
|
+
...(UNSAFE_METHODS.has(method) ? { "x-requested-with": "valance" } : {}),
|
|
168
|
+
},
|
|
169
|
+
...(body === undefined ? {} : { body }),
|
|
170
|
+
timeoutMs: spec.timeoutMs ?? DEFAULT_TIMEOUT,
|
|
171
|
+
};
|
|
172
|
+
// Whatever the interceptors did to the request, it goes where the API is: the address is checked again as the wire gets it.
|
|
173
|
+
const guarded = (final) => sameApi(options.base, final.url) ? wire(final) : Effect.fail({ _tag: "Unsafe", reason: "an interceptor sent the request outside the API's origin" });
|
|
174
|
+
const scoped = yield* FiberRef.get(scope);
|
|
175
|
+
const response = yield* chain(global, ...scoped, ...(spec.interceptors ?? []))(guarded)(request);
|
|
176
|
+
if (response.status < 200 || response.status >= 300) {
|
|
177
|
+
return yield* Effect.fail({ _tag: "Status", status: response.status });
|
|
178
|
+
}
|
|
179
|
+
if (response.status !== 204 && !/^application\/(?:[\w.+-]+\+)?json\b/i.test(response.headers["content-type"] ?? "")) {
|
|
180
|
+
return yield* Effect.fail({ _tag: "Decode" }); // an HTML error page or a proxy's reply is not the API's answer
|
|
181
|
+
}
|
|
182
|
+
const json = yield* Effect.try({ try: () => (response.text === "" ? undefined : JSON.parse(response.text)), catch: () => ({ _tag: "Decode" }) });
|
|
183
|
+
return yield* Schema.decodeUnknown(spec.response)(json).pipe(Effect.mapError(() => ({ _tag: "Decode" })));
|
|
184
|
+
}),
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
/** The platform for a web page: the `Http` capability, made from `api.base`. Merged with the application's own by `Web.run`. */
|
|
188
|
+
export const webPlatform = (options) => Nexus.Capability.EnvironmentLive(new Map([[Http.id, { _tag: "Available", implementation: webHttp(options) }]]));
|