@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 +144 -0
- package/head.js +132 -0
- package/index.js +96 -0
- package/media.js +637 -0
- package/package.json +31 -0
- package/regions.js +155 -0
- package/time.js +187 -0
- package/vitals.js +721 -0
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";
|