@hollware/client 0.10.0 → 1.0.0-rc.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/dist/index.d.mts +218 -1
- package/dist/index.mjs +254 -3
- package/package.json +3 -2
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,209 @@
|
|
|
1
1
|
import { WebhookCheck, describeSite, getEntry, getTranslations, listEntries, search, signWebhook, verifyWebhook } from "@hollware/core";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
+
//#region src/preview.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* What a site does with a preview link (T-103 of docs/security/threat-model.md): the token travels
|
|
6
|
+
* in a URL once, and in a cookie after that, so it does not stay in the address bar, the history,
|
|
7
|
+
* a `Referer` or a copied link. These are the pure parts, shared by `@hollware/nuxt` and
|
|
8
|
+
* `@hollware/astro`: the integrations only wire them to their framework's requests.
|
|
9
|
+
*/
|
|
10
|
+
/** The cookie that holds the token of a preview, on the site's own origin. */
|
|
11
|
+
export declare const PREVIEW_COOKIE = "hollware_preview";
|
|
12
|
+
/** How long the cookie lives, in seconds: the hour of the token. */
|
|
13
|
+
export declare const PREVIEW_COOKIE_MAX_AGE = 3600;
|
|
14
|
+
/** Whether a value is shaped like a preview token. A value that is not is never kept. */
|
|
15
|
+
export declare function isPreviewToken(value: unknown): value is string;
|
|
16
|
+
/**
|
|
17
|
+
* The preview token of a `Cookie` request header, or `undefined`: none, or one that is not shaped
|
|
18
|
+
* like a token.
|
|
19
|
+
*/
|
|
20
|
+
export declare function previewTokenFromCookie(header: string | null | undefined): string | undefined;
|
|
21
|
+
/** What to answer a request that carries `?hollware-preview=`. */
|
|
22
|
+
interface PreviewExchange {
|
|
23
|
+
/** The path and query of the same URL without the parameter, other parameters kept in order. */
|
|
24
|
+
readonly location: string;
|
|
25
|
+
/** The token, when it is shaped like one; `undefined` when it is not, and no cookie is set. */
|
|
26
|
+
readonly token: string | undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The exchange of a URL that carries `?hollware-preview=<token>`: where to redirect (`303`) and
|
|
30
|
+
* the token to keep in a cookie, or `undefined` when the URL has no such parameter. The token is
|
|
31
|
+
* not checked here: only Hollware can, when the page reads.
|
|
32
|
+
*/
|
|
33
|
+
export declare function exchangePreviewLink(url: URL | string, makeNonce?: () => string): PreviewExchange | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* Whether the cookie is `Secure` on a request for this URL: always, but on `http://localhost` and
|
|
36
|
+
* `http://127.0.0.1`, so local development works over plain HTTP.
|
|
37
|
+
*/
|
|
38
|
+
export declare function isSecureRequest(url: URL | string): boolean;
|
|
39
|
+
/** The `Set-Cookie` that keeps a token for its hour: no script reads it, and only this site gets it. */
|
|
40
|
+
export declare function previewCookie(token: string, options: {
|
|
41
|
+
readonly secure: boolean;
|
|
42
|
+
}): string;
|
|
43
|
+
/** The `Set-Cookie` that removes the cookie. */
|
|
44
|
+
export declare function clearedPreviewCookie(options: {
|
|
45
|
+
readonly secure: boolean;
|
|
46
|
+
}): string;
|
|
47
|
+
/** The headers of the redirect that exchanges a preview link: no cache, and no `Referer` to follow. */
|
|
48
|
+
export declare function exchangeHeaders(exchange: PreviewExchange, url: URL | string): Array<[string, string]>;
|
|
49
|
+
/**
|
|
50
|
+
* Whether an error is Hollware's refusal of a token as expired or invalid, its code
|
|
51
|
+
* `invalid_preview` (over the binding, or over HTTP as a `HollwareClientError`), looking through
|
|
52
|
+
* `cause`. A token for another entry is no refusal: Hollware answers it as no entry, and it must
|
|
53
|
+
* stay so.
|
|
54
|
+
*/
|
|
55
|
+
export declare function isPreviewRefusal(error: unknown): boolean;
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/hollware.d.ts
|
|
58
|
+
/** Where to read from: a reader (a service binding is one), or a Hollware URL and a read token. */
|
|
59
|
+
type ReaderOptions = {
|
|
60
|
+
/**
|
|
61
|
+
* A service binding to the `Hollware` entrypoint, or a function that gives it: on Workers a
|
|
62
|
+
* binding exists only while a request runs, so a module-level `createHollware` passes
|
|
63
|
+
* `() => hollwareBinding(env.HOLLWARE)`.
|
|
64
|
+
*/
|
|
65
|
+
readonly reader: HollwareReader | (() => HollwareReader);
|
|
66
|
+
} | {
|
|
67
|
+
/** The public URL of the Hollware. */
|
|
68
|
+
readonly url: string;
|
|
69
|
+
/** A read token (`hw_read_`, from `hollware token create --read`). */
|
|
70
|
+
readonly token: string;
|
|
71
|
+
/** `fetch`, when not the global one. */
|
|
72
|
+
readonly fetch?: (request: Request) => Promise<Response>;
|
|
73
|
+
};
|
|
74
|
+
/** The options of `createHollware`: the key of the site, and where to read it from. */
|
|
75
|
+
type HollwareOptions = ReaderOptions & {
|
|
76
|
+
/** The key of the site in the Hollware config. */
|
|
77
|
+
readonly site: string;
|
|
78
|
+
};
|
|
79
|
+
/** What a page shows of an entry in a listing: no verdict, no revision, no state of review. */
|
|
80
|
+
interface PageSummary {
|
|
81
|
+
readonly id: string;
|
|
82
|
+
readonly collection: string;
|
|
83
|
+
readonly locale: string;
|
|
84
|
+
readonly slug: string;
|
|
85
|
+
readonly translationGroup: string | null;
|
|
86
|
+
readonly frontmatter: Record<string, unknown>;
|
|
87
|
+
readonly tags: readonly string[];
|
|
88
|
+
readonly publishedAt: string | null;
|
|
89
|
+
readonly updatedAt: string;
|
|
90
|
+
}
|
|
91
|
+
/** The document of an entry: what `<HollwareContent>` of `@hollware/react` and `@hollware/vue` renders. */
|
|
92
|
+
interface PageDocument {
|
|
93
|
+
readonly nodes: readonly unknown[];
|
|
94
|
+
readonly frontmatter: Record<string, unknown>;
|
|
95
|
+
readonly meta: Record<string, unknown>;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* An entry for a page. A framework that renders on the server and hydrates in the browser sends
|
|
99
|
+
* what a loader returns to the browser, so the entry holds only what a reader sees: its verdict,
|
|
100
|
+
* its revision and its last publication stay on the server.
|
|
101
|
+
*/
|
|
102
|
+
interface PageEntry extends PageSummary {
|
|
103
|
+
readonly document?: PageDocument;
|
|
104
|
+
}
|
|
105
|
+
/** What `getEntry` answers: the entry, and whether it is a preview. */
|
|
106
|
+
interface EntryAnswer {
|
|
107
|
+
readonly entry: PageEntry;
|
|
108
|
+
/**
|
|
109
|
+
* `true` when the entry was read with a preview token, so it may be a draft and nothing may keep
|
|
110
|
+
* the page: answer it `Cache-Control: no-store` (`previewHeaders` does).
|
|
111
|
+
*/
|
|
112
|
+
readonly preview: boolean;
|
|
113
|
+
}
|
|
114
|
+
/** The options of `getEntry`. */
|
|
115
|
+
interface GetEntryOptions {
|
|
116
|
+
/**
|
|
117
|
+
* The incoming request: its `hollware_preview` cookie, when it has one, is the preview token
|
|
118
|
+
* (`src/preview.ts`).
|
|
119
|
+
*/
|
|
120
|
+
readonly request?: Request;
|
|
121
|
+
/**
|
|
122
|
+
* The value of the `hollware_preview` cookie, when the framework gives cookies on their own
|
|
123
|
+
* (`cookies()` of Next.js): it wins over `request`, and a value that is no token is ignored.
|
|
124
|
+
*/
|
|
125
|
+
readonly preview?: string;
|
|
126
|
+
}
|
|
127
|
+
/** What a listing narrows to. */
|
|
128
|
+
interface ListEntriesOptions {
|
|
129
|
+
readonly locale?: string;
|
|
130
|
+
readonly tag?: string;
|
|
131
|
+
/** The most entries to return, 100 at the most; Hollware's default when not set. */
|
|
132
|
+
readonly limit?: number;
|
|
133
|
+
/** The `nextCursor` of the previous page. */
|
|
134
|
+
readonly cursor?: string;
|
|
135
|
+
}
|
|
136
|
+
/** A page of a listing: published entries, newest first. */
|
|
137
|
+
interface EntryPage {
|
|
138
|
+
readonly entries: PageSummary[];
|
|
139
|
+
readonly nextCursor: string | null;
|
|
140
|
+
}
|
|
141
|
+
/** The same entry in another locale. */
|
|
142
|
+
interface Translation {
|
|
143
|
+
readonly locale: string;
|
|
144
|
+
readonly slug: string;
|
|
145
|
+
}
|
|
146
|
+
/** The read side of one Hollware site, for any code that turns a `Request` into data. */
|
|
147
|
+
interface Hollware {
|
|
148
|
+
/**
|
|
149
|
+
* The entry in its locale, with its Comark document, or `undefined` when it is not published
|
|
150
|
+
* (or, with a preview token, does not exist). With `request`, a preview token in its cookie
|
|
151
|
+
* reads the latest revision of the entry the token names, a draft included. A token that is
|
|
152
|
+
* expired or not valid is an error, never the published version: send it to
|
|
153
|
+
* `previewHeaders(request, error)` to clear the cookie.
|
|
154
|
+
*/
|
|
155
|
+
getEntry(collection: string, locale: string, slug: string, options?: GetEntryOptions): Promise<EntryAnswer | undefined>;
|
|
156
|
+
/** The published entries of a collection, newest first. */
|
|
157
|
+
listEntries(collection: string, options?: ListEntriesOptions): Promise<EntryPage>;
|
|
158
|
+
/** The published entries of a translation group of a collection, one per locale. */
|
|
159
|
+
getTranslations(collection: string, group: string): Promise<Translation[]>;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The read side of a Hollware site for any app that renders on a server, whatever its framework: a React Router
|
|
163
|
+
* loader, a Next.js Server Component, a TanStack Start server function. It reads published
|
|
164
|
+
* content, through a service binding or over HTTP, and it never runs in the browser: a loader's
|
|
165
|
+
* data goes there, and `PageEntry` holds only what a reader sees.
|
|
166
|
+
*
|
|
167
|
+
* ```ts
|
|
168
|
+
* const hollware = createHollware({ site: 'my-site', reader: () => hollwareBinding(env.HOLLWARE) })
|
|
169
|
+
* const answer = await hollware.getEntry('blog', 'en', slug, { request })
|
|
170
|
+
* ```
|
|
171
|
+
*/
|
|
172
|
+
export declare function createHollware(options: HollwareOptions): Hollware;
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region src/preview-request.d.ts
|
|
175
|
+
/**
|
|
176
|
+
* Previews of drafts (T-103 of docs/security/threat-model.md) for any code that handles a
|
|
177
|
+
* `Request`: the link of the admin's "Preview on the site" carries `?hollware-preview=<token>`,
|
|
178
|
+
* and the site exchanges it once for an `HttpOnly` cookie, so the token does not stay in the
|
|
179
|
+
* address bar, the history or a `Referer`. The shape of the token, the cookie and the URL without
|
|
180
|
+
* the parameter are pure functions of `@hollware/client`.
|
|
181
|
+
*/
|
|
182
|
+
/**
|
|
183
|
+
* The answer to the link of a preview: a `303` to the same URL without the parameter, with the
|
|
184
|
+
* token in the `hollware_preview` cookie (an hour, `HttpOnly`, `SameSite=Lax`, `Secure` but on
|
|
185
|
+
* `http://localhost`), `no-store` and `Referrer-Policy: no-referrer`. `null` when the request is
|
|
186
|
+
* not a `GET` or `HEAD` with that parameter. The token is not checked: Hollware does, when the
|
|
187
|
+
* page reads. A value not shaped like a token sets no cookie.
|
|
188
|
+
*/
|
|
189
|
+
export declare function previewRedirect(request: Request): Response | null;
|
|
190
|
+
/**
|
|
191
|
+
* The `Set-Cookie` that removes the preview cookie when `error` says Hollware refused the token
|
|
192
|
+
* as expired or not valid, and `null` for any other error. A token for another entry is no
|
|
193
|
+
* refusal: Hollware answers it as no entry, and the cookie stays.
|
|
194
|
+
*/
|
|
195
|
+
export declare function previewCookieClear(error: unknown, request: Request): string | null;
|
|
196
|
+
/** Whether the request carries a preview token in its cookie. */
|
|
197
|
+
export declare function isPreviewRequest(request: Request): boolean;
|
|
198
|
+
/**
|
|
199
|
+
* The headers a page of a visitor who previews must carry: `Cache-Control: no-store` for any
|
|
200
|
+
* request that holds a token, whatever Hollware answered, and the `Set-Cookie` that clears it
|
|
201
|
+
* when `error` is a refusal. Without a token it is empty, so a published page is cached as
|
|
202
|
+
* usual. Merge it into the headers of the response (a `Headers` is what `data()`, `Response`
|
|
203
|
+
* and `NextResponse` take).
|
|
204
|
+
*/
|
|
205
|
+
export declare function previewHeaders(request: Request, error?: unknown): Headers;
|
|
206
|
+
//#endregion
|
|
3
207
|
//#region src/index.d.ts
|
|
4
208
|
export declare const name = "@hollware/client";
|
|
5
209
|
type Output<Spec extends {
|
|
@@ -17,6 +221,13 @@ export interface HollwareReader {
|
|
|
17
221
|
getEntry(site: string, collection: string, slug: string, locale: string, options?: {
|
|
18
222
|
readonly formats?: readonly Format[];
|
|
19
223
|
readonly related?: boolean;
|
|
224
|
+
/**
|
|
225
|
+
* A preview token (`hw_prv_…`, made in the admin, or the `hollware-preview` of a page's
|
|
226
|
+
* URL): the entry it names is read in its latest revision, a draft included, and the answer
|
|
227
|
+
* must never be cached. A token that does not hold is an `invalid_input` error (a
|
|
228
|
+
* `HollwareClientError` over HTTP), never the published version in its place.
|
|
229
|
+
*/
|
|
230
|
+
readonly preview?: string;
|
|
20
231
|
}): Promise<Output<typeof getEntry>>;
|
|
21
232
|
listEntries(site: string, collection: string, options?: {
|
|
22
233
|
readonly locale?: string;
|
|
@@ -59,5 +270,11 @@ export declare class HollwareClientError extends Error {
|
|
|
59
270
|
* of the HTTP reader only has to be that site's key.
|
|
60
271
|
*/
|
|
61
272
|
export declare function createClient(options: ClientOptions): HollwareReader;
|
|
273
|
+
/**
|
|
274
|
+
* A service binding to the `Hollware` entrypoint as a reader. `wrangler types` cannot see across
|
|
275
|
+
* Workers, so the binding comes typed as `Service`: this checks it is there, and gives the
|
|
276
|
+
* mistake a name when it is not. `@hollware/astro`, `@hollware/react` and `@hollware/vue` re-export it.
|
|
277
|
+
*/
|
|
278
|
+
export declare function hollwareBinding(binding: unknown, name?: string): HollwareReader;
|
|
62
279
|
//#endregion
|
|
63
|
-
export { type WebhookCheck, signWebhook, verifyWebhook };
|
|
280
|
+
export { type EntryAnswer, type EntryPage, type GetEntryOptions, type Hollware, type HollwareOptions, type ListEntriesOptions, type PageDocument, type PageEntry, type PageSummary, type PreviewExchange, type ReaderOptions, type Translation, type WebhookCheck, signWebhook, verifyWebhook };
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,242 @@
|
|
|
1
|
-
import { signWebhook, verifyWebhook } from "@hollware/core";
|
|
1
|
+
import { PREVIEW_QUERY_PARAM, signWebhook, verifyWebhook } from "@hollware/core";
|
|
2
|
+
//#region src/preview.ts
|
|
3
|
+
/**
|
|
4
|
+
* What a site does with a preview link (T-103 of docs/security/threat-model.md): the token travels
|
|
5
|
+
* in a URL once, and in a cookie after that, so it does not stay in the address bar, the history,
|
|
6
|
+
* a `Referer` or a copied link. These are the pure parts, shared by `@hollware/nuxt` and
|
|
7
|
+
* `@hollware/astro`: the integrations only wire them to their framework's requests.
|
|
8
|
+
*/
|
|
9
|
+
/** The cookie that holds the token of a preview, on the site's own origin. */
|
|
10
|
+
const PREVIEW_COOKIE = "hollware_preview";
|
|
11
|
+
/** How long the cookie lives, in seconds: the hour of the token. */
|
|
12
|
+
const PREVIEW_COOKIE_MAX_AGE = 3600;
|
|
13
|
+
/** The shape of a token, `hw_prv_<base64url>.<base64url>`. The site cannot check more: Hollware does. */
|
|
14
|
+
const TOKEN_SHAPE = /^hw_prv_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
15
|
+
/** Whether a value is shaped like a preview token. A value that is not is never kept. */
|
|
16
|
+
function isPreviewToken(value) {
|
|
17
|
+
return typeof value === "string" && TOKEN_SHAPE.test(value);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The preview token of a `Cookie` request header, or `undefined`: none, or one that is not shaped
|
|
21
|
+
* like a token.
|
|
22
|
+
*/
|
|
23
|
+
function previewTokenFromCookie(header) {
|
|
24
|
+
if (header === null || header === void 0) return void 0;
|
|
25
|
+
for (const part of header.split(";")) {
|
|
26
|
+
const at = part.indexOf("=");
|
|
27
|
+
if (at === -1 || part.slice(0, at).trim() !== "hollware_preview") continue;
|
|
28
|
+
const value = part.slice(at + 1).trim();
|
|
29
|
+
return isPreviewToken(value) ? value : void 0;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The exchange of a URL that carries `?hollware-preview=<token>`: where to redirect (`303`) and
|
|
34
|
+
* the token to keep in a cookie, or `undefined` when the URL has no such parameter. The token is
|
|
35
|
+
* not checked here: only Hollware can, when the page reads.
|
|
36
|
+
*/
|
|
37
|
+
function exchangePreviewLink(url, makeNonce = nonce) {
|
|
38
|
+
const { pathname, search } = typeof url === "string" ? new URL(url) : url;
|
|
39
|
+
const kept = [];
|
|
40
|
+
let found;
|
|
41
|
+
let seen = false;
|
|
42
|
+
for (const pair of search.slice(1).split("&")) {
|
|
43
|
+
if (pair === "") continue;
|
|
44
|
+
const at = pair.indexOf("=");
|
|
45
|
+
if (safeDecode(at === -1 ? pair : pair.slice(0, at)) !== PREVIEW_QUERY_PARAM) {
|
|
46
|
+
kept.push(pair);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (seen) continue;
|
|
50
|
+
seen = true;
|
|
51
|
+
found = at === -1 ? "" : safeDecode(pair.slice(at + 1));
|
|
52
|
+
}
|
|
53
|
+
if (!seen) return void 0;
|
|
54
|
+
if (isPreviewToken(found)) kept.push(`${PREVIEW_VIEW_PARAM}=${makeNonce()}`);
|
|
55
|
+
return {
|
|
56
|
+
location: kept.length === 0 ? pathname : `${pathname}?${kept.join("&")}`,
|
|
57
|
+
token: isPreviewToken(found) ? found : void 0
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** The parameter that keeps a previewed page out of every cache: a random value, no secret. */
|
|
61
|
+
const PREVIEW_VIEW_PARAM = "hollware-view";
|
|
62
|
+
function nonce() {
|
|
63
|
+
const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(9));
|
|
64
|
+
return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_");
|
|
65
|
+
}
|
|
66
|
+
function safeDecode(text) {
|
|
67
|
+
try {
|
|
68
|
+
return decodeURIComponent(text.replaceAll("+", " "));
|
|
69
|
+
} catch {
|
|
70
|
+
return text;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Whether the cookie is `Secure` on a request for this URL: always, but on `http://localhost` and
|
|
75
|
+
* `http://127.0.0.1`, so local development works over plain HTTP.
|
|
76
|
+
*/
|
|
77
|
+
function isSecureRequest(url) {
|
|
78
|
+
const { protocol, hostname } = typeof url === "string" ? new URL(url) : url;
|
|
79
|
+
return !(protocol === "http:" && (hostname === "localhost" || hostname === "127.0.0.1"));
|
|
80
|
+
}
|
|
81
|
+
/** The `Set-Cookie` that keeps a token for its hour: no script reads it, and only this site gets it. */
|
|
82
|
+
function previewCookie(token, options) {
|
|
83
|
+
return cookieLine(token, PREVIEW_COOKIE_MAX_AGE, options.secure);
|
|
84
|
+
}
|
|
85
|
+
/** The `Set-Cookie` that removes the cookie. */
|
|
86
|
+
function clearedPreviewCookie(options) {
|
|
87
|
+
return cookieLine("", 0, options.secure);
|
|
88
|
+
}
|
|
89
|
+
function cookieLine(value, maxAge, secure) {
|
|
90
|
+
return `${PREVIEW_COOKIE}=${value}; Path=/; HttpOnly;${secure ? " Secure;" : ""} SameSite=Lax; Max-Age=${maxAge}`;
|
|
91
|
+
}
|
|
92
|
+
/** The headers of the redirect that exchanges a preview link: no cache, and no `Referer` to follow. */
|
|
93
|
+
function exchangeHeaders(exchange, url) {
|
|
94
|
+
const headers = [
|
|
95
|
+
["location", exchange.location],
|
|
96
|
+
["cache-control", "no-store"],
|
|
97
|
+
["referrer-policy", "no-referrer"]
|
|
98
|
+
];
|
|
99
|
+
if (exchange.token !== void 0) headers.push(["set-cookie", previewCookie(exchange.token, { secure: isSecureRequest(url) })]);
|
|
100
|
+
return headers;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Whether an error is Hollware's refusal of a token as expired or invalid, its code
|
|
104
|
+
* `invalid_preview` (over the binding, or over HTTP as a `HollwareClientError`), looking through
|
|
105
|
+
* `cause`. A token for another entry is no refusal: Hollware answers it as no entry, and it must
|
|
106
|
+
* stay so.
|
|
107
|
+
*/
|
|
108
|
+
function isPreviewRefusal(error) {
|
|
109
|
+
let current = error;
|
|
110
|
+
for (let depth = 0; depth < 4 && typeof current === "object" && current !== null; depth++) {
|
|
111
|
+
if (Reflect.get(current, "code") === "invalid_preview") return true;
|
|
112
|
+
current = Reflect.get(current, "cause");
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/hollware.ts
|
|
118
|
+
function readerOf(options) {
|
|
119
|
+
if ("reader" in options) return typeof options.reader === "function" ? options.reader() : options.reader;
|
|
120
|
+
return createClient({
|
|
121
|
+
url: options.url,
|
|
122
|
+
token: options.token,
|
|
123
|
+
fetch: options.fetch
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/** The fields of a summary that a page may see, picked one by one so no other one travels. */
|
|
127
|
+
function summaryOf(entry) {
|
|
128
|
+
return {
|
|
129
|
+
id: entry.id,
|
|
130
|
+
collection: entry.collection,
|
|
131
|
+
locale: entry.locale,
|
|
132
|
+
slug: entry.slug,
|
|
133
|
+
translationGroup: entry.translationGroup,
|
|
134
|
+
frontmatter: entry.frontmatter,
|
|
135
|
+
tags: entry.tags,
|
|
136
|
+
publishedAt: entry.publishedAt,
|
|
137
|
+
updatedAt: entry.updatedAt
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The read side of a Hollware site for any app that renders on a server, whatever its framework: a React Router
|
|
142
|
+
* loader, a Next.js Server Component, a TanStack Start server function. It reads published
|
|
143
|
+
* content, through a service binding or over HTTP, and it never runs in the browser: a loader's
|
|
144
|
+
* data goes there, and `PageEntry` holds only what a reader sees.
|
|
145
|
+
*
|
|
146
|
+
* ```ts
|
|
147
|
+
* const hollware = createHollware({ site: 'my-site', reader: () => hollwareBinding(env.HOLLWARE) })
|
|
148
|
+
* const answer = await hollware.getEntry('blog', 'en', slug, { request })
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
function createHollware(options) {
|
|
152
|
+
const { site } = options;
|
|
153
|
+
return {
|
|
154
|
+
async getEntry(collection, locale, slug, getOptions = {}) {
|
|
155
|
+
const preview = isPreviewToken(getOptions.preview) ? getOptions.preview : previewTokenFromCookie(getOptions.request?.headers.get("cookie"));
|
|
156
|
+
const answer = await readerOf(options).getEntry(site, collection, slug, locale, {
|
|
157
|
+
formats: ["document"],
|
|
158
|
+
...preview === void 0 ? {} : { preview }
|
|
159
|
+
});
|
|
160
|
+
if (answer.outcome === "not_found" || answer.entry === void 0) return void 0;
|
|
161
|
+
const { document } = answer.entry;
|
|
162
|
+
return {
|
|
163
|
+
entry: {
|
|
164
|
+
...summaryOf(answer.entry),
|
|
165
|
+
...document === void 0 ? {} : { document }
|
|
166
|
+
},
|
|
167
|
+
preview: preview !== void 0
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
async listEntries(collection, listOptions = {}) {
|
|
171
|
+
const page = await readerOf(options).listEntries(site, collection, listOptions);
|
|
172
|
+
return {
|
|
173
|
+
entries: page.entries.map(summaryOf),
|
|
174
|
+
nextCursor: page.nextCursor
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
async getTranslations(collection, group) {
|
|
178
|
+
return ((await readerOf(options).getTranslations(site, collection, group)).entries ?? []).map((entry) => ({
|
|
179
|
+
locale: entry.locale,
|
|
180
|
+
slug: entry.slug
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
//#endregion
|
|
186
|
+
//#region src/preview-request.ts
|
|
187
|
+
/**
|
|
188
|
+
* Previews of drafts (T-103 of docs/security/threat-model.md) for any code that handles a
|
|
189
|
+
* `Request`: the link of the admin's "Preview on the site" carries `?hollware-preview=<token>`,
|
|
190
|
+
* and the site exchanges it once for an `HttpOnly` cookie, so the token does not stay in the
|
|
191
|
+
* address bar, the history or a `Referer`. The shape of the token, the cookie and the URL without
|
|
192
|
+
* the parameter are pure functions of `@hollware/client`.
|
|
193
|
+
*/
|
|
194
|
+
/**
|
|
195
|
+
* The answer to the link of a preview: a `303` to the same URL without the parameter, with the
|
|
196
|
+
* token in the `hollware_preview` cookie (an hour, `HttpOnly`, `SameSite=Lax`, `Secure` but on
|
|
197
|
+
* `http://localhost`), `no-store` and `Referrer-Policy: no-referrer`. `null` when the request is
|
|
198
|
+
* not a `GET` or `HEAD` with that parameter. The token is not checked: Hollware does, when the
|
|
199
|
+
* page reads. A value not shaped like a token sets no cookie.
|
|
200
|
+
*/
|
|
201
|
+
function previewRedirect(request) {
|
|
202
|
+
if (request.method !== "GET" && request.method !== "HEAD") return null;
|
|
203
|
+
const url = new URL(request.url);
|
|
204
|
+
const exchange = exchangePreviewLink(url);
|
|
205
|
+
if (exchange === void 0) return null;
|
|
206
|
+
return new Response(null, {
|
|
207
|
+
status: 303,
|
|
208
|
+
headers: exchangeHeaders(exchange, url)
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The `Set-Cookie` that removes the preview cookie when `error` says Hollware refused the token
|
|
213
|
+
* as expired or not valid, and `null` for any other error. A token for another entry is no
|
|
214
|
+
* refusal: Hollware answers it as no entry, and the cookie stays.
|
|
215
|
+
*/
|
|
216
|
+
function previewCookieClear(error, request) {
|
|
217
|
+
if (!isPreviewRefusal(error)) return null;
|
|
218
|
+
return clearedPreviewCookie({ secure: isSecureRequest(request.url) });
|
|
219
|
+
}
|
|
220
|
+
/** Whether the request carries a preview token in its cookie. */
|
|
221
|
+
function isPreviewRequest(request) {
|
|
222
|
+
return previewTokenFromCookie(request.headers.get("cookie")) !== void 0;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The headers a page of a visitor who previews must carry: `Cache-Control: no-store` for any
|
|
226
|
+
* request that holds a token, whatever Hollware answered, and the `Set-Cookie` that clears it
|
|
227
|
+
* when `error` is a refusal. Without a token it is empty, so a published page is cached as
|
|
228
|
+
* usual. Merge it into the headers of the response (a `Headers` is what `data()`, `Response`
|
|
229
|
+
* and `NextResponse` take).
|
|
230
|
+
*/
|
|
231
|
+
function previewHeaders(request, error) {
|
|
232
|
+
const headers = new Headers();
|
|
233
|
+
if (!isPreviewRequest(request)) return headers;
|
|
234
|
+
headers.set("cache-control", "no-store");
|
|
235
|
+
const clear = previewCookieClear(error, request);
|
|
236
|
+
if (clear !== null) headers.append("set-cookie", clear);
|
|
237
|
+
return headers;
|
|
238
|
+
}
|
|
239
|
+
//#endregion
|
|
2
240
|
//#region src/index.ts
|
|
3
241
|
const name = "@hollware/client";
|
|
4
242
|
/** An answer of the HTTP read API that is no result: its code, message and fix. */
|
|
@@ -38,7 +276,8 @@ function httpReader(url, token, send) {
|
|
|
38
276
|
return {
|
|
39
277
|
getEntry: (_site, collection, slug, locale, options) => get(`${segment(collection)}/${segment(locale)}/${segment(slug)}`, {
|
|
40
278
|
formats: options?.formats?.join(","),
|
|
41
|
-
related: options?.related === true ? "true" : void 0
|
|
279
|
+
related: options?.related === true ? "true" : void 0,
|
|
280
|
+
preview: options?.preview
|
|
42
281
|
}),
|
|
43
282
|
listEntries: (_site, collection, options = {}) => get(segment(collection), {
|
|
44
283
|
locale: options.locale,
|
|
@@ -78,5 +317,17 @@ function createClient(options) {
|
|
|
78
317
|
function narrow(body) {
|
|
79
318
|
return body;
|
|
80
319
|
}
|
|
320
|
+
function isReader(value) {
|
|
321
|
+
return typeof value === "object" && value !== null && "getEntry" in value;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* A service binding to the `Hollware` entrypoint as a reader. `wrangler types` cannot see across
|
|
325
|
+
* Workers, so the binding comes typed as `Service`: this checks it is there, and gives the
|
|
326
|
+
* mistake a name when it is not. `@hollware/astro`, `@hollware/react` and `@hollware/vue` re-export it.
|
|
327
|
+
*/
|
|
328
|
+
function hollwareBinding(binding, name = "HOLLWARE") {
|
|
329
|
+
if (isReader(binding)) return binding;
|
|
330
|
+
throw new Error(`Hollware: the Worker has no service binding ${name} to the Hollware entrypoint: add it to services in wrangler.jsonc.`);
|
|
331
|
+
}
|
|
81
332
|
//#endregion
|
|
82
|
-
export { HollwareClientError, createClient, name, signWebhook, verifyWebhook };
|
|
333
|
+
export { HollwareClientError, PREVIEW_COOKIE, PREVIEW_COOKIE_MAX_AGE, clearedPreviewCookie, createClient, createHollware, exchangeHeaders, exchangePreviewLink, hollwareBinding, isPreviewRefusal, isPreviewRequest, isPreviewToken, isSecureRequest, name, previewCookie, previewCookieClear, previewHeaders, previewRedirect, previewTokenFromCookie, signWebhook, verifyWebhook };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hollware/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0-rc.0",
|
|
4
4
|
"description": "Typed Hollware client over a service binding or HTTP.",
|
|
5
5
|
"homepage": "https://hollware.com",
|
|
6
6
|
"bugs": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"dist"
|
|
17
17
|
],
|
|
18
18
|
"type": "module",
|
|
19
|
+
"sideEffects": false,
|
|
19
20
|
"exports": {
|
|
20
21
|
".": {
|
|
21
22
|
"types": "./dist/index.d.mts",
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
"provenance": true
|
|
29
30
|
},
|
|
30
31
|
"dependencies": {
|
|
31
|
-
"@hollware/core": "0.
|
|
32
|
+
"@hollware/core": "1.0.0-rc.0"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
34
35
|
"@types/node": "24.19.2",
|