@uniflowed/web 0.0.0-alpha.18

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/cookie.js ADDED
@@ -0,0 +1,144 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/web/cookie`: reading a cookie from a component.
4
+ //
5
+ // A cookie is one value with two readers. On a server it is on the request,
6
+ // and `@uniflowed/server` has it; in a browser it is on `document`, and a
7
+ // component can read it directly. `useCookie` is the same call in both places,
8
+ // which is the only reason it is worth having.
9
+ //
10
+ // This module deliberately does *not* import `@uniflowed/server`: that package
11
+ // imports `node:async_hooks`, and importing it here would drag a Node builtin
12
+ // into every browser bundle that renders a page. The server value arrives
13
+ // through the render instead — the router puts it there — and the browser reads
14
+ // `document.cookie`. Two sources, one call, and no server module in the client
15
+ // graph.
16
+ //
17
+ // # What belongs in this module
18
+ //
19
+ // The browser half of a cookie, and the name under which a server render hands
20
+ // its half over. A cookie is a small enough subject to be one file and a
21
+ // distinct enough one to be its own: it is the only value in this package that
22
+ // is *written* as well as read, and the only one whose two readers are
23
+ // different APIs rather than the same API in two environments.
24
+ //
25
+ // Not here: anything that reads a request. Headers, the URL, and the session
26
+ // are `@uniflowed/server`'s, and the paragraph above is the reason this module
27
+ // must not grow an import of it.
28
+
29
+ import * as React from "@uniflowed/react";
30
+
31
+ /** How a cookie is written, in the browser. */
32
+ export type CookieOptions = {
33
+ /** Seconds until it expires. Absent means a session cookie. */
34
+ readonly maxAge?: number,
35
+ readonly path?: string,
36
+ readonly sameSite?: "lax" | "strict" | "none",
37
+ readonly secure?: boolean,
38
+ };
39
+
40
+ /** Where a server-rendered page's cookies are handed to the client half. */
41
+ const SERVER_COOKIES: string = "__ufServerCookies";
42
+
43
+ /**
44
+ * Parse a `Cookie`-shaped header into a plain object.
45
+ *
46
+ * `Object.create(null)`, because `__proto__` is a cookie name an attacker can
47
+ * set and on an ordinary object it would not be a key at all. The same
48
+ * reasoning as `@uniflowed/server`'s parser, and deliberately a second copy:
49
+ * sharing it would mean importing that package here.
50
+ */
51
+ export function parse(header: string | null): { [string]: string } {
52
+ const out: { [string]: string } = Object.create(null);
53
+ if (header == null || header === "") {
54
+ return out;
55
+ }
56
+ for (const pair of header.split(";")) {
57
+ const at = pair.indexOf("=");
58
+ if (at < 0) {
59
+ continue;
60
+ }
61
+ const name = pair.slice(0, at).trim();
62
+ if (name === "" || Object.hasOwn(out, name)) {
63
+ continue;
64
+ }
65
+ let value = pair.slice(at + 1).trim();
66
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
67
+ value = value.slice(1, -1);
68
+ }
69
+ try {
70
+ out[name] = decodeURIComponent(value);
71
+ } catch {
72
+ // A malformed cookie is not a reason to fail a render; the value is
73
+ // simply not what the sender meant.
74
+ out[name] = value;
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /** Serialise one cookie for `document.cookie`. */
81
+ export function serialize(name: string, value: string, options?: CookieOptions): string {
82
+ let out = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
83
+ out += `; Path=${options?.path ?? "/"}`;
84
+ if (options?.maxAge != null) {
85
+ out += `; Max-Age=${Math.trunc(options.maxAge)}`;
86
+ }
87
+ out += `; SameSite=${options?.sameSite ?? "lax"}`;
88
+ if (options?.secure === true) {
89
+ out += "; Secure";
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /** Whatever cookies are readable here, server or browser. */
95
+ function readAll(): { [string]: string } {
96
+ const host = (globalThis: $FlowFixMe);
97
+ if (host.document != null) {
98
+ return parse(host.document.cookie);
99
+ }
100
+ // Server-rendered: the router leaves the request's cookies where both halves
101
+ // can see them, so the first render matches what the browser will read.
102
+ return parse(host[SERVER_COOKIES] ?? null);
103
+ }
104
+
105
+ /**
106
+ * Read a cookie, and write it in the browser.
107
+ *
108
+ * The value is `null` when the cookie is not set, rather than `undefined`, so a
109
+ * component can tell "no cookie" from "a cookie whose value is empty" without
110
+ * two comparisons.
111
+ *
112
+ * Writing is browser-only and says so: setting a cookie during a server render
113
+ * has no defined moment to take effect — the headers may already be on the wire
114
+ * — so a route handler or a server action is where that belongs.
115
+ */
116
+ export function useCookie(
117
+ name: string,
118
+ ): [string | null, (next: string, options?: CookieOptions) => void] {
119
+ const [value, setValue] = React.useState<string | null>(() => {
120
+ const all = readAll();
121
+ return Object.hasOwn(all, name) ? all[name] : null;
122
+ });
123
+
124
+ const write = React.useCallback(
125
+ (next: string, options?: CookieOptions) => {
126
+ const host = (globalThis: $FlowFixMe);
127
+ if (host.document == null) {
128
+ throw new Error(
129
+ `useCookie: ${name} cannot be written during a server render. ` +
130
+ "The response's headers may already have gone; set it from a route " +
131
+ "handler or a server action instead.",
132
+ );
133
+ }
134
+ host.document.cookie = serialize(name, next, options);
135
+ setValue(next);
136
+ },
137
+ [name],
138
+ );
139
+
140
+ return [value, write];
141
+ }
142
+
143
+ /** Where the server leaves the request's cookies for the first render. */
144
+ export const SERVER_COOKIE_GLOBAL: string = SERVER_COOKIES;
package/head.js ADDED
@@ -0,0 +1,132 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/web/head`: the document head, from a component.
4
+ //
5
+ // A page's title and its meta tags are decided by whatever is rendering, which
6
+ // is usually not the thing that owns `<head>`. `useHead` lets a component say
7
+ // what it needs and puts it there.
8
+ //
9
+ // # What this does and does not do
10
+ //
11
+ // In a browser it applies. On a server it does not, and that is deliberate
12
+ // rather than unfinished: the head is written before the body is streamed, so a
13
+ // component that renders *after* the head has gone cannot change it. uf's
14
+ // answer for server-rendered metadata is the route's own `metadata` export,
15
+ // which the router resolves before it renders anything — `useHead` is for what
16
+ // a page decides while it is running.
17
+ //
18
+ // Saying so matters because the alternative is a silent one. A `useHead` that
19
+ // pretended to work on a server would produce a page whose title is right in a
20
+ // browser and wrong in a crawler, which is the case nobody checks.
21
+ //
22
+ // # What belongs in this module
23
+ //
24
+ // Head tags a component decides while it is running: the title, `<meta>`,
25
+ // canonical and alternate `<link>`s. The test is that the value is not known
26
+ // until something has rendered.
27
+ //
28
+ // Not here: a preload. It is a `<link>` and it still belongs to `media.js`,
29
+ // because what is difficult about a preload is the request it starts — `as`,
30
+ // `crossOrigin`, the second download when they disagree — and not the element
31
+ // it is spelled as. Route metadata is not here either: the router resolves a
32
+ // route's `metadata` export before it renders anything, which is what makes it
33
+ // work in a crawler, and that is exactly the case this module cannot serve.
34
+
35
+ import * as React from "@uniflowed/react";
36
+
37
+ /** One `<meta>`, by whichever attribute names it. */
38
+ export type Meta = {
39
+ readonly name?: string,
40
+ readonly property?: string,
41
+ readonly content: string,
42
+ };
43
+
44
+ /** One `<link>`. */
45
+ export type Link = {
46
+ readonly rel: string,
47
+ readonly href: string,
48
+ readonly type?: string,
49
+ readonly sizes?: string,
50
+ };
51
+
52
+ /** What a component wants in the head. */
53
+ export type Head = {
54
+ readonly title?: string,
55
+ readonly meta?: $ReadOnlyArray<Meta>,
56
+ readonly links?: $ReadOnlyArray<Link>,
57
+ };
58
+
59
+ /** Marks the elements this module owns, so it can remove its own and no others. */
60
+ const OWNED = "data-uf-head";
61
+
62
+ /** The attribute that names one `<meta>`, and the value that identifies it. */
63
+ function metaKey(meta: Meta): string {
64
+ return meta.name != null ? `name=${meta.name}` : `property=${meta.property ?? ""}`;
65
+ }
66
+
67
+ /**
68
+ * Put `head` in the document, and take it out again when the component goes.
69
+ *
70
+ * Every element it adds is marked, and only marked elements are removed — a
71
+ * component that unmounts must not take the document's own `<meta charset>`
72
+ * with it.
73
+ *
74
+ * The title is restored rather than removed, because a document with no title
75
+ * shows its URL in the tab, which looks like a bug.
76
+ */
77
+ export function useHead(head: Head): void {
78
+ // The whole head as a string: an object literal is a new object every render,
79
+ // and depending on it would re-apply on every one.
80
+ const key = JSON.stringify(head);
81
+
82
+ React.useEffect(() => {
83
+ const document = (globalThis: $FlowFixMe).document;
84
+ if (document == null) {
85
+ return undefined;
86
+ }
87
+
88
+ const previousTitle = document.title;
89
+ if (head.title != null) {
90
+ document.title = head.title;
91
+ }
92
+
93
+ const added: Array<mixed> = [];
94
+ for (const meta of head.meta ?? []) {
95
+ const element = document.createElement("meta");
96
+ if (meta.name != null) {
97
+ element.setAttribute("name", meta.name);
98
+ }
99
+ if (meta.property != null) {
100
+ element.setAttribute("property", meta.property);
101
+ }
102
+ element.setAttribute("content", meta.content);
103
+ element.setAttribute(OWNED, metaKey(meta));
104
+ document.head.appendChild(element);
105
+ added.push(element);
106
+ }
107
+ for (const link of head.links ?? []) {
108
+ const element = document.createElement("link");
109
+ element.setAttribute("rel", link.rel);
110
+ element.setAttribute("href", link.href);
111
+ if (link.type != null) {
112
+ element.setAttribute("type", link.type);
113
+ }
114
+ if (link.sizes != null) {
115
+ element.setAttribute("sizes", link.sizes);
116
+ }
117
+ element.setAttribute(OWNED, `${link.rel}:${link.href}`);
118
+ document.head.appendChild(element);
119
+ added.push(element);
120
+ }
121
+
122
+ return () => {
123
+ if (head.title != null) {
124
+ document.title = previousTitle;
125
+ }
126
+ for (const element of added) {
127
+ (element: $FlowFixMe).remove();
128
+ }
129
+ };
130
+ // eslint-disable-next-line react-hooks/exhaustive-deps
131
+ }, [key]);
132
+ }
package/index.js ADDED
@@ -0,0 +1,96 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/web`: the primitives a page is built from.
4
+ //
5
+ // The small number of things every web page needs and nearly every project
6
+ // reimplements: an image that does not shift the layout, a date that survives
7
+ // hydration, a live region a screen reader actually announces, a cookie that
8
+ // reads the same on both sides.
9
+ //
10
+ // Each is here because the naive version is wrong in a way that only shows up
11
+ // later — a font preload without `crossOrigin` that silently downloads twice, a
12
+ // `toLocaleString` that makes the server and the browser disagree, a live
13
+ // region rendered at the same moment as its text and therefore never read. The
14
+ // per-component documentation says which mistake it exists to prevent.
15
+ //
16
+ // # How the package is laid out
17
+ //
18
+ // Six modules beside this one, each named after the mistake it prevents
19
+ // rather than after its place in a build:
20
+ //
21
+ // - `media.js` — the elements that load bytes, and the layout shifts and
22
+ // double downloads they cause. Also the icon sprite and the Open Graph card,
23
+ // which are the two things a build knows and a runtime cannot: which icons
24
+ // an application imported, and what a card the crawler will fetch says.
25
+ // - `time.js` — an instant, rendered the same way on a server and in a
26
+ // browser.
27
+ // - `regions.js` — the elements that exist for a screen reader: landmarks,
28
+ // live regions, and the link that reaches them.
29
+ // - `cookie.js` — one value with two readers, and no `node:async_hooks` in a
30
+ // browser bundle.
31
+ // - `head.js` — what a component decides to put in `<head>` while it runs.
32
+ // - `vitals.js` — the five measurements the browser already takes of the
33
+ // page, and the only place in the package that could send anything anywhere.
34
+ //
35
+ // `vitals.js` is the odd one out and belongs here anyway. The other five are
36
+ // about not causing a bad number; it is about reading the number, and it is
37
+ // the same subject seen from the other end — an `Image` without dimensions and
38
+ // a `Font` without `crossOrigin` are exactly what an LCP measures. Splitting
39
+ // measurement into its own package would have put the diagnosis one install
40
+ // away from the cure.
41
+ //
42
+ // They sit here, not under an `internal/`, and each has its own subpath. The
43
+ // directory is the table of contents: a reader who opens the package sees five
44
+ // subjects and can guess which file holds the thing they came for. Nothing in
45
+ // them needs hiding — they contain the same components this file re-exports —
46
+ // and calling them internal while exporting every one of their names would
47
+ // have been a claim the package does not keep. `internal/` is for a module
48
+ // consumers must *not* reach, and this package has none.
49
+ //
50
+ // This file stays a barrel because five modules genuinely need one. That is
51
+ // aggregation, not indirection: `import { Image, Time } from "@uniflowed/web"`
52
+ // is one import for a page that wants both, and the subpaths are there for a
53
+ // bundle that wants exactly one.
54
+ //
55
+ // Routing is not here, and not re-exported either. `Link`, `useRoute` and
56
+ // `useRouter` are `@uniflowed/router`'s, and passing them through this package
57
+ // would give each of them two import paths, two places to document, and two
58
+ // places to be wrong. It would also put this package's `Page` — a `<main>`
59
+ // landmark — into the same import as the router's `PageProps`, which is a
60
+ // route module's props and an entirely different idea.
61
+ //
62
+ // One name, one home. Import routing from the router.
63
+
64
+ export type {
65
+ FontAsset,
66
+ FontFace,
67
+ IconAsset,
68
+ ImageAsset,
69
+ Loading,
70
+ OgAsset,
71
+ Source,
72
+ SpriteAsset,
73
+ } from "./media.js";
74
+ export { Font, Icon, IconSprite, Image, OgImage, Picture } from "./media.js";
75
+
76
+ export type { TimeFormat, TimeValue } from "./time.js";
77
+ export { Time, asInstant, relative } from "./time.js";
78
+
79
+ export { Announcer, Layout, Page, SkipLink } from "./regions.js";
80
+
81
+ export type { Head, Link as HeadLink, Meta } from "./head.js";
82
+ export { useHead } from "./head.js";
83
+
84
+ export type { CookieOptions } from "./cookie.js";
85
+ export { useCookie } from "./cookie.js";
86
+
87
+ export type {
88
+ CollectOptions,
89
+ NavigationType,
90
+ Rating,
91
+ Vital,
92
+ VitalName,
93
+ VitalsReport,
94
+ VitalsReporter,
95
+ } from "./vitals.js";
96
+ export { VITALS_ENDPOINT, collectVitals, useVitals, vitalsBeacon } from "./vitals.js";