@tdacorp/hyle-sdk 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +168 -2
- package/dist/chunk-7VCMJUPC.js +35 -0
- package/dist/chunk-HFUJRNFP.js +290 -0
- package/dist/chunk-MHVWHSKC.js +736 -0
- package/dist/index.cjs +324 -0
- package/dist/index.d.cts +131 -0
- package/dist/index.d.ts +131 -0
- package/dist/index.js +22 -0
- package/dist/next.cjs +1208 -0
- package/dist/next.d.cts +142 -0
- package/dist/next.d.ts +142 -0
- package/dist/next.js +214 -0
- package/dist/react.cjs +774 -0
- package/dist/react.d.cts +121 -0
- package/dist/react.d.ts +121 -0
- package/dist/react.js +30 -0
- package/dist/resolve-B_p0p4Zn.d.cts +29 -0
- package/dist/resolve-fMx2W8RR.d.ts +29 -0
- package/dist/revalidation-callback-D7Ms_Puz.d.cts +42 -0
- package/dist/revalidation-callback-D7Ms_Puz.d.ts +42 -0
- package/dist/styles.css +403 -0
- package/dist/types-BNaGecO7.d.ts +92 -0
- package/dist/types-DLWvqanV.d.cts +92 -0
- package/dist/types-DjXf6iPo.d.cts +105 -0
- package/dist/types-DjXf6iPo.d.ts +105 -0
- package/dist/use-cache.cjs +145 -0
- package/dist/use-cache.d.cts +20 -0
- package/dist/use-cache.d.ts +20 -0
- package/dist/use-cache.js +31 -0
- package/package.json +113 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TDACorp
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,169 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tdacorp/hyle-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Mount hyle content in a site with one route. The SDK calls hyle's resolve endpoint (`GET /cp1/read/resolve`, ADR 0012), renders the page, follows redirects, answers 404, emits metadata and revalidates when hyle publishes.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm add @tdacorp/hyle-sdk
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
MIT licensed. Node 22.12 or later. Semver from 0.1.0 (see `CHANGELOG.md`); until 1.0 a minor bump may change the API.
|
|
10
|
+
|
|
11
|
+
## Quickstart (Next.js 16, React 19)
|
|
12
|
+
|
|
13
|
+
Environment (server-side only, never `NEXT_PUBLIC_`):
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
HYLE_TARGET_BASE_URL=https://hyle.tdacorp.in
|
|
17
|
+
HYLE_TARGET_TOKEN=hyle_tr_... # this workspace's target credential
|
|
18
|
+
HYLE_WORKSPACE_ID=<workspace uuid>
|
|
19
|
+
HYLE_WEBHOOK_SECRET=<signing secret>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`app/layout.tsx`: `import "@tdacorp/hyle-sdk/styles.css";`
|
|
23
|
+
|
|
24
|
+
`app/learn/[[...hyle]]/page.tsx`:
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { hylePage, hyleMetadata, type HyleNextOptions } from "@tdacorp/hyle-sdk/next";
|
|
28
|
+
|
|
29
|
+
const options: HyleNextOptions = {
|
|
30
|
+
baseUrl: process.env.HYLE_TARGET_BASE_URL!,
|
|
31
|
+
token: process.env.HYLE_TARGET_TOKEN!,
|
|
32
|
+
workspaceId: process.env.HYLE_WORKSPACE_ID!,
|
|
33
|
+
basePath: "/learn", // must equal the workspace's site path prefix in hyle
|
|
34
|
+
};
|
|
35
|
+
export default hylePage(options);
|
|
36
|
+
export const generateMetadata = hyleMetadata(options);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`app/api/hyle/revalidate/route.ts`:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { createRevalidateRoute } from "@tdacorp/hyle-sdk/next";
|
|
43
|
+
export const { POST } = createRevalidateRoute({ workspaceId: process.env.HYLE_WORKSPACE_ID, secrets: [process.env.HYLE_WEBHOOK_SECRET] });
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Register that URL as the workspace's webhook endpoint in hyle. Set `metadataBase` in your root layout so canonical and hreflang URLs are absolute.
|
|
47
|
+
|
|
48
|
+
## Entry points
|
|
49
|
+
|
|
50
|
+
| Import | Contents | Needs |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| `@tdacorp/hyle-sdk` | `createHyleClient` (typed `resolve`), `listPublicationPaths`, `Cp1ClientError`, `verifyHyleSignature`, `parseSignatureHeader`, `isValidCacheTag`, `DeliveryDedupe`, `createRevalidationCallbackRoute`, the result types | nothing (no framework) |
|
|
53
|
+
| `@tdacorp/hyle-sdk/react` | `ArticlePage`, `ListPage`, `DocsPage`, `NotFound`, `Redirecting`, `Unavailable`, `HastContent`, `TrustedServerHtml`, `sanitizeHast` | react >= 19 |
|
|
54
|
+
| `@tdacorp/hyle-sdk/next` | `hylePage`, `hyleMetadata`, `hyleSitemap`, `createRevalidateRoute`, `fetchResolver`, `workspaceTags` | next >= 16 |
|
|
55
|
+
| `@tdacorp/hyle-sdk/next/use-cache` | `cachedResolver` | next >= 16 with `cacheComponents: true` |
|
|
56
|
+
| `@tdacorp/hyle-sdk/styles.css` | the stylesheet | |
|
|
57
|
+
|
|
58
|
+
The only runtime dependency is `hast-util-to-jsx-runtime` (MIT, unified collective, the library behind react-markdown): it maps hast nodes and properties to React elements correctly, which is more than a short hand-rolled function would get right. Everything else is hand-written.
|
|
59
|
+
|
|
60
|
+
## Locale
|
|
61
|
+
|
|
62
|
+
hyle serves the workspace's default locale unless you pass one. A multilingual site must pass `locale`:
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
// app/[locale]/learn/[[...hyle]]/page.tsx
|
|
66
|
+
const options = { ..., locale: (params) => String(params.locale), localePrefix: (l) => `/${l}` };
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
hyle's paths and redirect targets never contain your locale prefix. `localePrefix(locale)` returns the prefix your router uses (`"/fr"`, or `""` for a default locale served unprefixed); the SDK puts it back on redirect targets, canonical URLs, hreflang alternates and in-page links. When the requested locale has a published translation at another path, hyle answers a 307 and the SDK issues a temporary `redirect()`; a 301 (retired slug, non-canonical spelling) becomes `permanentRedirect()`. When no translation exists the page is shown in the default locale with a "not translated yet" notice (the response's `fallback` field).
|
|
70
|
+
|
|
71
|
+
## Redirect safety
|
|
72
|
+
|
|
73
|
+
A redirect target is followed only if it is a same-origin path: one leading `/`, no `//`, no backslash, no control character or whitespace. It is checked on hyle's `to` and again on the final target after `localePrefix`; anything else raises a `Cp1ClientError` (`INVALID_RESPONSE`) instead of redirecting. A `locale` that is not a plain language tag (`en`, `pt-BR`) is treated as unset. A target equal to the current URL is treated as not found, so it cannot loop.
|
|
74
|
+
|
|
75
|
+
## Caching (read this before shipping)
|
|
76
|
+
|
|
77
|
+
hyle tags every response with `cache.tags` and, on publish, calls your revalidate route with the tags to purge. Next's fetch cache needs the tags when the request is made, but the response (which carries the tags) does not exist yet. The SDK offers two strategies. Both were run against a real `next build` and `next start` (Next 16.3.4) with a mock hyle: a second request is served from cache, a signed webhook purges, the next request refetches.
|
|
78
|
+
|
|
79
|
+
1. **Fetch cache (default, no host configuration).** The fetch is tagged with what is known beforehand, a fixed and small set whatever URLs visitors request: the workspace tag, the default section `posts` and the sections you list (`hyle:1:sec:{workspaceId}:{section}`). Publish, unpublish, rename and edit all purge the section tag (cp1-spec section 5.8), so the page refreshes without knowing its own publication tag. Pass `sections: ["docs"]` for every other section the mount shows. Limits: a page in a section you did not list, and a page that renders a wikilink to a document in another section, refreshes only when `revalidate` (default 300 s) elapses; the per-publication tags in the response are not used. Redirects and 404s return real HTTP 308, 307 and 404.
|
|
80
|
+
2. **`'use cache'` (exact tags, opt in).** `cachedResolver` fetches inside a `'use cache'` function and tags the entry with the response's own `cache.tags`. The host must set `cacheComponents: true` in `next.config`, and must wrap the page in `<Suspense>` (Next requires it for route params under Cache Components). Because the page then streams, a redirect reaches the browser as a meta refresh and a not found as a noindex page, both with HTTP 200 (observed). Import it only when opting in: Next compiles the directive inside this package, and with `cacheComponents` off even importing the module fails the build ("To use "use cache", please enable the feature flag cacheComponents", observed), which is why it is a separate entry point.
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
import { hylePage } from "@tdacorp/hyle-sdk/next";
|
|
84
|
+
import { cachedResolver } from "@tdacorp/hyle-sdk/next/use-cache";
|
|
85
|
+
const Page = hylePage({ basePath: "/learn", resolver: cachedResolver({ baseUrl, workspaceId, token, revalidate: 300 }) });
|
|
86
|
+
export default function Wrapper(props) { return <Suspense fallback={null}><Page {...props} /></Suspense>; }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The credential is never part of a cache key: entries are keyed by `baseUrl` plus `workspaceId` (pass both to `cachedResolver`), and the token stays in a module-level map. A switched-off read surface (`kind: "disabled"`) is cached for at most 30 s, so turning it back on is not delayed by the full window.
|
|
90
|
+
|
|
91
|
+
The revalidate route expires tags immediately (`revalidateTag(tag, { expire: 0 })`), so no stale page is served after a purge. `HYLE_CONTENT_ENABLED=0` (or `false`) answers 404 from the route, the site-side rollback switch; nothing configured is also 404; partly configured is 503.
|
|
92
|
+
|
|
93
|
+
### List pages
|
|
94
|
+
|
|
95
|
+
`list: { title, description }` sets the list page's h1, `<title>`, description and Open Graph. Each wins over hyle's section title (which is the slug, "Posts") and falls back separately: no `list.title` keeps the section title, no `list.description` shows none. Articles are unaffected.
|
|
96
|
+
|
|
97
|
+
List pages show the first page of results (`listLimit`, server default 20, maximum 100). `paginate: "searchParams"` turns on cursor pagination: `?cursor=` is read (it must be 1 to 256 characters of `A-Za-z0-9_-`, otherwise it is ignored; a well-formed cursor hyle rejects is a 404), sent to hyle, and "Older posts" links to `?cursor=<next_cursor>`. It is off by default because reading `searchParams` makes the route dynamic: it can no longer be statically generated or served from ISR (hyle's fetch cache and tags still apply per request). Leave it off when one page is enough. `generateMetadata` does not read the cursor, so page 2 shares page 1's title.
|
|
98
|
+
|
|
99
|
+
## Errors
|
|
100
|
+
|
|
101
|
+
How `hylePage` treats a hyle failure is chosen so a bad moment never replaces a good cached page:
|
|
102
|
+
|
|
103
|
+
| Failure | Behaviour |
|
|
104
|
+
|---|---|
|
|
105
|
+
| A 4xx rejection of the request (`VALIDATION_ERROR`, `NOT_FOUND`, any 4xx except 401, 403 and 429), a path over 1024 characters, a non-NFC segment | `notFound()` (a real 404; the last two never reach hyle) |
|
|
106
|
+
| Network error, timeout, 5xx, 401, 403, 429, a malformed response, a refused redirect target | **Rethrown.** Under ISR, Next keeps serving the last good page when a revalidation fails; a failure on a first render is an uncached 500 |
|
|
107
|
+
| `notFound()` and redirects | Next control flow, always propagated (`unstable_rethrow`) |
|
|
108
|
+
|
|
109
|
+
Why rethrow: a rendered "unavailable" page is a normal 200 render, so ISR would cache it and replace the last good article for everyone for the revalidate window. `onError: (error) => ReactElement` is the opt-in for a custom UI anyway; its element IS cached like any render, and the `error` it receives can contain `Cp1ClientError.message` and `requestId`, so never render it (only its name is logged, never the token or message). The `Unavailable` component (en, fr, es; `data-hyle-page="unavailable"`, `role="status"`, no error text) is exported from `./react` for `onError` or your own `error.tsx`. `hyleMetadata` follows the same split: a 4xx rejection gives `{}`, anything else is rethrown, so a transient outage never caches a noindex page.
|
|
110
|
+
|
|
111
|
+
## Sitemap
|
|
112
|
+
|
|
113
|
+
The contract has no "list every page" endpoint, so the helper pages the list result at each section index (100 per page, `next_cursor`, at most 20 pages per index by default). Spread it into your sitemap:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { hyleSitemap } from "@tdacorp/hyle-sdk/next";
|
|
117
|
+
export default async function sitemap() {
|
|
118
|
+
return [...staticEntries, ...(await hyleSitemap({ ...options, origin: "https://tdacorp.in", sections: ["docs"] }))];
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`origin` must be a bare https origin (anything else throws). URLs are percent-encoded and use `localePrefix`; the locale is `options.locale` when it is a string, and a function of the route params is never called (the sitemap is for the default locale). `lastModified` is `published_at` (omitted if it is not an ISO date): the list carries no `updated_at`. Only the default section and the `sections` you list are covered (a section's index resolves at `basePath/<slug>`), at most 2000 pages per index. `onIncomplete` decides what a hyle failure (or a listing cut off by the page limit) does: `"partial"` returns what was collected, `"throw"` throws so a build during an outage fails; the default throws only when nothing was collected. The core `listPublicationPaths({ client, basePath, sections?, locale?, maxPages? })` never throws and returns `{ paths: { path, publishedAt }[], complete }`, where `complete: false` means `paths` is partial.
|
|
123
|
+
|
|
124
|
+
## Building blocks
|
|
125
|
+
|
|
126
|
+
`fetchResolver(options)` is the default fetch-cache resolver, and `workspaceTags(workspaceId, sections?)` builds its tags (`hyle:1:ws:<id>`, `hyle:1:sec:<ws>:<section>`). Wrap `fetchResolver` to adjust a result (pass the wrapper as `resolver`) instead of copying it.
|
|
127
|
+
|
|
128
|
+
## Host requirements
|
|
129
|
+
|
|
130
|
+
No layout or page above the mount may export `dynamicParams = false`. hyle content (an article published after your last build) is by definition not known at build time, and Next inherits a parent's `false` for every child route: the route is compiled with `"fallback": false`, so each path not generated at build time is a hard 404 that falls through to any `[locale]/[...rest]` catch-all (observed: `x-matched-path: /[locale]/[...rest]`, content looks missing). Setting `dynamicParams = true` on the page does not override a parent's `false`.
|
|
131
|
+
|
|
132
|
+
Verify after `next build`: `.next/prerender-manifest.json` must not show `"fallback": false` for the route that mounts `hylePage`, and a request for a published article that did not exist at build time must return 200.
|
|
133
|
+
|
|
134
|
+
Locale-prefixed hosts: you may keep `generateStaticParams` for the locales, but validate the locale in the layout (call `notFound()` for an unknown one) and set `dynamicParams = true` there, so unknown locales are still rejected.
|
|
135
|
+
|
|
136
|
+
## Styling
|
|
137
|
+
|
|
138
|
+
Import `@tdacorp/hyle-sdk/styles.css`. It is the design kit's stylesheet: every value comes from a public `--hyle-*` custom property with a SIGNAL fallback (`--sig-*` from `@tdacorp/signal-tokens`, so dark and light acts flip with no extra code). Set variables on `:root`, `body` or any wrapper above the mount; the SDK never declares them:
|
|
139
|
+
|
|
140
|
+
```css
|
|
141
|
+
:root { --hyle-accent: #ff8a3d; --hyle-radius: 12px; --hyle-font-heading: "Fraunces", serif; }
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The 52 variables are listed in the token block at the top of `styles.css`. Layout reacts to the width of the mount (container queries), not the window. Code uses highlight.js token classes (`hljs-*`, mapped to `--hyle-syn-*`). Math arrives as KaTeX HTML from the server: load `katex/dist/katex.min.css` in the host (it brings the fonts and resolves their URLs relative to itself). It is not bundled here because the fonts are about 1 MB of binary assets that would tie this package's version to KaTeX's and double-load in hosts that already use KaTeX.
|
|
145
|
+
|
|
146
|
+
## Customization
|
|
147
|
+
|
|
148
|
+
`components` replaces how a part is drawn, `slots` fills named places. Both are props of the pages and options of `hylePage`.
|
|
149
|
+
|
|
150
|
+
- Component slots: `Link` (every internal link, breadcrumb and in-page link: pass your router's Link), `Image`, `CodeBlock` (receives `code`, `language`, and the default `children`; the stock block has no copy button because that needs client JavaScript), `Mermaid` (receives hyle's sandboxed `srcDoc`), `Callout` (GFM alerts such as `> [!NOTE]`). The raw element overrides `a`, `img`, `code`, `pre`, `table`, `blockquote` and `h1` to `h6` also work.
|
|
151
|
+
- Visual slots: `article.before`, `article.meta`, `article.aside`, `article.after`, `list.header`, `list.item` (a component that receives the item), `list.empty`, `docs.sidebar-header`, `docs.version`, `docs.sidebar-footer`, `docs.after`, `notfound.body`, `notfound.actions`, `redirecting.body`. An unfilled slot renders nothing.
|
|
152
|
+
- Layout: `layout: "docs"` renders articles as `DocsPage` (tree navigation from `ancestors`, `children` and `breadcrumbs`).
|
|
153
|
+
|
|
154
|
+
Chrome strings exist for en, fr and es (fr and es are unreviewed drafts). The page title is the only `h1`: a body `h1` becomes `h2`, and a leading body `h1` that repeats the title is dropped.
|
|
155
|
+
|
|
156
|
+
## Security model
|
|
157
|
+
|
|
158
|
+
- The token is sent only as an `Authorization` header from the server. It is never placed in a URL, logged or put in an error message. Keep it out of client components. The client requires an https `baseUrl` (http only for localhost, or with `allowInsecureHttp`), refuses redirects, aborts after `timeoutMs` (default 10 s) and refuses responses over `maxResponseBytes` (default 5 MiB).
|
|
159
|
+
- The path is sent as a query parameter (encoded once); catch-all segments are decoded and refused if they contain `/`, `\`, control characters, `.` or `..`.
|
|
160
|
+
- The `hast` tree is rendered through an allowlist (`sanitizeHast`) even though hyle sanitizes server-side: only known tags and properties with accepted value shapes survive; no `on*` attributes, `style` only inside KaTeX output and only for the properties KaTeX emits with small values (no `position:absolute`, `z-index`, `left` or large sizes), ids and in-page fragments scoped with the server's `user-content-` prefix (so heading ids, footnote links and TOC links still resolve), `href` and `src` limited to site-relative paths and `http(s)` (and `mailto` for links), unknown tags are unwrapped, `script` and `style` are dropped, and an `iframe` is kept only with `sandbox="allow-scripts"` and a `srcdoc` that is exactly the document hyle's renderer builds for a Mermaid fence (pinned script URL, at most 64 KiB); the SDK adds `referrerpolicy="no-referrer"` and a `csp` that allows only that script (checked in headless Chromium: the diagram still renders). A tree over 20,000 nodes or 512 KiB of text fails closed with `HastLimitError` and a safe notice. `dangerouslySetInnerHTML` is used in exactly one place, `TrustedServerHtml`, the escape hatch for `content.format === "html"`: it renders HTML as is, so pass it only HTML that came straight from hyle's resolve endpoint, never user input.
|
|
161
|
+
- Webhooks are verified with HMAC-SHA256 over the raw body (read with a running 64 KiB cap), a 300 s timestamp tolerance and constant-time comparison, with dual-secret rotation (empty secrets are never candidates), a payload shape check (400, not 500), tag validation, and delivery-id dedupe that records an id only after every purge succeeded, so a failed delivery can be retried.
|
|
162
|
+
|
|
163
|
+
## Not included
|
|
164
|
+
|
|
165
|
+
Docs versions, reading time, scroll-spy, "closest matches" on the 404 page and a copy-code button: hyle does not provide that data or the page would need client JavaScript. Image URLs in content such as `/assets/<id>` are passed through unchanged; map them with the `Image` slot if they live on hyle's origin.
|
|
166
|
+
|
|
167
|
+
## Contract
|
|
168
|
+
|
|
169
|
+
Types in `src/types.ts` mirror `packages/cp1-read/src/resolve.ts`. `src/__tests__/contract.test.ts` pins them against fixture JSON for every result kind, so a server-side shape change fails a test here.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHyleClient,
|
|
3
|
+
isValidCacheTag
|
|
4
|
+
} from "./chunk-HFUJRNFP.js";
|
|
5
|
+
|
|
6
|
+
// src/next/resolve.ts
|
|
7
|
+
var DEFAULT_REVALIDATE = 300;
|
|
8
|
+
var MAX_TAGS = 64;
|
|
9
|
+
function workspaceTags(workspaceId, sections = []) {
|
|
10
|
+
const workspaceTag = `hyle:1:ws:${workspaceId}`;
|
|
11
|
+
if (!isValidCacheTag(workspaceTag)) throw new TypeError("hyle: `workspaceId` is not a valid workspace id (a uuid)");
|
|
12
|
+
const slugs = /* @__PURE__ */ new Set(["posts", ...sections]);
|
|
13
|
+
const tags = [...slugs].map((slug) => `hyle:1:sec:${workspaceId}:${slug}`);
|
|
14
|
+
if (!tags.every(isValidCacheTag)) throw new TypeError("hyle: a configured `sections` slug cannot be used in a cache tag (printable ASCII, at most 200 characters)");
|
|
15
|
+
return [workspaceTag, ...tags].slice(0, MAX_TAGS);
|
|
16
|
+
}
|
|
17
|
+
function fetchResolver(options) {
|
|
18
|
+
if (!options.workspaceId) throw new TypeError("hylePage: `workspaceId` is required for the fetch cache (or pass a cachedResolver)");
|
|
19
|
+
const client = createHyleClient({ baseUrl: options.baseUrl, token: options.token });
|
|
20
|
+
const revalidate = options.revalidate ?? DEFAULT_REVALIDATE;
|
|
21
|
+
const tags = workspaceTags(options.workspaceId, options.sections);
|
|
22
|
+
return (path, locale, limit, cursor) => client.resolve({
|
|
23
|
+
path,
|
|
24
|
+
...locale !== void 0 ? { locale } : {},
|
|
25
|
+
...limit !== void 0 ? { limit } : {},
|
|
26
|
+
...cursor !== void 0 ? { cursor } : {},
|
|
27
|
+
init: { next: { revalidate, tags } }
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
DEFAULT_REVALIDATE,
|
|
33
|
+
workspaceTags,
|
|
34
|
+
fetchResolver
|
|
35
|
+
};
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var Cp1ClientError = class extends Error {
|
|
3
|
+
code;
|
|
4
|
+
status;
|
|
5
|
+
requestId;
|
|
6
|
+
details;
|
|
7
|
+
constructor(code, message, status, requestId, details) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "Cp1ClientError";
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.requestId = requestId;
|
|
13
|
+
this.details = details;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/client.ts
|
|
18
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
19
|
+
var DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
20
|
+
var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
21
|
+
async function readCapped(res, max) {
|
|
22
|
+
const tooLarge = () => new Cp1ClientError("RESPONSE_TOO_LARGE", `hyle response exceeds ${max} bytes`, res.status, "");
|
|
23
|
+
if (Number(res.headers.get("content-length")) > max) throw tooLarge();
|
|
24
|
+
if (!res.body) return "";
|
|
25
|
+
const reader = res.body.getReader();
|
|
26
|
+
const chunks = [];
|
|
27
|
+
let total = 0;
|
|
28
|
+
for (; ; ) {
|
|
29
|
+
const { done, value } = await reader.read();
|
|
30
|
+
if (done) break;
|
|
31
|
+
total += value.byteLength;
|
|
32
|
+
if (total > max) {
|
|
33
|
+
await reader.cancel();
|
|
34
|
+
throw tooLarge();
|
|
35
|
+
}
|
|
36
|
+
chunks.push(value);
|
|
37
|
+
}
|
|
38
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
39
|
+
}
|
|
40
|
+
var KINDS = /* @__PURE__ */ new Set(["article", "list", "redirect", "notfound", "disabled"]);
|
|
41
|
+
function createHyleClient(options) {
|
|
42
|
+
let origin;
|
|
43
|
+
try {
|
|
44
|
+
origin = new URL(options.baseUrl);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new TypeError("baseUrl must be an https origin");
|
|
47
|
+
}
|
|
48
|
+
if (origin.protocol !== "https:" && !(origin.protocol === "http:" && (options.allowInsecureHttp || LOOPBACK.has(origin.hostname)))) {
|
|
49
|
+
throw new TypeError("baseUrl must be https (http is allowed only for localhost, or with allowInsecureHttp)");
|
|
50
|
+
}
|
|
51
|
+
if (!options.token) throw new TypeError("token is required");
|
|
52
|
+
const base = options.baseUrl.replace(/\/+$/, "");
|
|
53
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
54
|
+
async function resolve(opts) {
|
|
55
|
+
const url = new URL(`${base}/cp1/read/resolve`);
|
|
56
|
+
url.searchParams.set("path", opts.path);
|
|
57
|
+
if (opts.locale !== void 0) url.searchParams.set("locale", opts.locale);
|
|
58
|
+
if (opts.format !== void 0) url.searchParams.set("format", opts.format);
|
|
59
|
+
if (opts.cursor !== void 0) url.searchParams.set("cursor", opts.cursor);
|
|
60
|
+
if (opts.limit !== void 0) url.searchParams.set("limit", String(opts.limit));
|
|
61
|
+
const headers = new Headers(opts.init?.headers);
|
|
62
|
+
headers.set("authorization", `Bearer ${options.token}`);
|
|
63
|
+
const timeout = AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
64
|
+
const signal = opts.init?.signal ? AbortSignal.any([opts.init.signal, timeout]) : timeout;
|
|
65
|
+
const res = await fetchImpl(url.toString(), { ...opts.init, method: "GET", headers, redirect: "error", signal });
|
|
66
|
+
const text = await readCapped(res, options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES);
|
|
67
|
+
let parsed;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(text);
|
|
70
|
+
} catch {
|
|
71
|
+
parsed = void 0;
|
|
72
|
+
}
|
|
73
|
+
const data = parsed?.data;
|
|
74
|
+
if (data && data.cp === 1 && typeof data.kind === "string" && KINDS.has(data.kind)) return data;
|
|
75
|
+
const err = parsed?.error;
|
|
76
|
+
throw new Cp1ClientError(
|
|
77
|
+
err?.code ?? "INVALID_RESPONSE",
|
|
78
|
+
err?.message ?? `Unexpected response from hyle (HTTP ${res.status})`,
|
|
79
|
+
res.status,
|
|
80
|
+
err?.request_id ?? "",
|
|
81
|
+
err?.details
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return { resolve };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/delivery-dedupe.ts
|
|
88
|
+
var DeliveryDedupe = class {
|
|
89
|
+
constructor(capacity = 1e3) {
|
|
90
|
+
this.capacity = capacity;
|
|
91
|
+
}
|
|
92
|
+
capacity;
|
|
93
|
+
seen = /* @__PURE__ */ new Map();
|
|
94
|
+
/** True if this id was already recorded (a duplicate delivery). */
|
|
95
|
+
has(deliveryId) {
|
|
96
|
+
return this.seen.has(deliveryId);
|
|
97
|
+
}
|
|
98
|
+
add(deliveryId) {
|
|
99
|
+
this.seen.delete(deliveryId);
|
|
100
|
+
this.seen.set(deliveryId, true);
|
|
101
|
+
if (this.seen.size > this.capacity) {
|
|
102
|
+
const oldest = this.seen.keys().next().value;
|
|
103
|
+
if (oldest !== void 0) this.seen.delete(oldest);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// src/signature.ts
|
|
109
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
110
|
+
var SIGNATURE_HEX = /^[0-9a-f]{64}$/;
|
|
111
|
+
function parseSignatureHeader(header) {
|
|
112
|
+
let timestamp;
|
|
113
|
+
const signatures = [];
|
|
114
|
+
for (const part of header.split(",")) {
|
|
115
|
+
const [key, value] = part.split("=", 2);
|
|
116
|
+
if (key === "t" && value) {
|
|
117
|
+
const parsed = Number(value);
|
|
118
|
+
if (Number.isFinite(parsed)) timestamp = parsed;
|
|
119
|
+
} else if (key === "v1" && value && SIGNATURE_HEX.test(value)) {
|
|
120
|
+
signatures.push(value);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (timestamp === void 0 || signatures.length === 0) return void 0;
|
|
124
|
+
return { timestamp, signatures };
|
|
125
|
+
}
|
|
126
|
+
function computeSignature(secret, timestamp, rawBody) {
|
|
127
|
+
return createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
|
|
128
|
+
}
|
|
129
|
+
function signaturesEqual(a, b) {
|
|
130
|
+
const bufA = Buffer.from(a, "hex");
|
|
131
|
+
const bufB = Buffer.from(b, "hex");
|
|
132
|
+
if (bufA.length !== bufB.length) return false;
|
|
133
|
+
return timingSafeEqual(bufA, bufB);
|
|
134
|
+
}
|
|
135
|
+
function verifyHyleSignature(params) {
|
|
136
|
+
const secrets = params.secrets.filter((s) => s !== "");
|
|
137
|
+
if (!params.header || secrets.length === 0) return false;
|
|
138
|
+
const parsed = parseSignatureHeader(params.header);
|
|
139
|
+
if (!parsed) return false;
|
|
140
|
+
const tolerance = params.toleranceSeconds ?? 300;
|
|
141
|
+
const now = (params.now ?? Date.now)() / 1e3;
|
|
142
|
+
if (Math.abs(now - parsed.timestamp) > tolerance) return false;
|
|
143
|
+
for (const secret of secrets) {
|
|
144
|
+
const expected = computeSignature(secret, parsed.timestamp, params.rawBody);
|
|
145
|
+
for (const candidate of parsed.signatures) {
|
|
146
|
+
if (signaturesEqual(candidate, expected)) return true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/tag-validation.ts
|
|
153
|
+
var CACHE_TAG_PATTERN = /^hyle:1:(pub|doc|sec|link|ws):[0-9a-f-]{36}(:[\x21-\x7e]{1,200})?$/;
|
|
154
|
+
function isValidCacheTag(tag) {
|
|
155
|
+
return CACHE_TAG_PATTERN.test(tag);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/revalidation-callback.ts
|
|
159
|
+
var MAX_BODY_BYTES = 65536;
|
|
160
|
+
var MAX_TAGS = 1e3;
|
|
161
|
+
function createRevalidationCallbackRoute(options) {
|
|
162
|
+
const dedupe = new DeliveryDedupe(options.dedupeCapacity);
|
|
163
|
+
return {
|
|
164
|
+
async POST(req) {
|
|
165
|
+
const rawBody = await readCapped2(req);
|
|
166
|
+
if (rawBody === void 0) return new Response(null, { status: 413 });
|
|
167
|
+
const verified = verifyHyleSignature({
|
|
168
|
+
header: req.headers.get("hyle-signature"),
|
|
169
|
+
rawBody,
|
|
170
|
+
secrets: options.secrets,
|
|
171
|
+
...options.toleranceSeconds !== void 0 ? { toleranceSeconds: options.toleranceSeconds } : {},
|
|
172
|
+
...options.now !== void 0 ? { now: options.now } : {}
|
|
173
|
+
});
|
|
174
|
+
if (!verified) {
|
|
175
|
+
return new Response(null, { status: 401 });
|
|
176
|
+
}
|
|
177
|
+
let payload;
|
|
178
|
+
try {
|
|
179
|
+
payload = JSON.parse(rawBody);
|
|
180
|
+
} catch {
|
|
181
|
+
return new Response(null, { status: 400 });
|
|
182
|
+
}
|
|
183
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return new Response(null, { status: 400 });
|
|
184
|
+
const body = payload;
|
|
185
|
+
if (body.cp !== 1) {
|
|
186
|
+
return okResponse(0);
|
|
187
|
+
}
|
|
188
|
+
const tags = body.tags ?? [];
|
|
189
|
+
if (typeof body.workspace_id !== "string" || !Array.isArray(tags) || tags.length > MAX_TAGS || !tags.every((t) => typeof t === "string")) {
|
|
190
|
+
return new Response(null, { status: 400 });
|
|
191
|
+
}
|
|
192
|
+
if (body.workspace_id !== options.workspaceId) {
|
|
193
|
+
return okResponse(0);
|
|
194
|
+
}
|
|
195
|
+
const deliveryId = req.headers.get("hyle-delivery-id");
|
|
196
|
+
if (deliveryId && dedupe.has(deliveryId)) {
|
|
197
|
+
return okResponse(0);
|
|
198
|
+
}
|
|
199
|
+
let purged = 0;
|
|
200
|
+
try {
|
|
201
|
+
for (const tag of tags) {
|
|
202
|
+
if (!isValidCacheTag(tag)) continue;
|
|
203
|
+
options.revalidateTag(tag);
|
|
204
|
+
purged += 1;
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
return new Response(null, { status: 500 });
|
|
208
|
+
}
|
|
209
|
+
if (deliveryId) dedupe.add(deliveryId);
|
|
210
|
+
return okResponse(purged);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
async function readCapped2(req) {
|
|
215
|
+
const declared = Number(req.headers.get("content-length"));
|
|
216
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) return void 0;
|
|
217
|
+
if (!req.body) return "";
|
|
218
|
+
const reader = req.body.getReader();
|
|
219
|
+
const chunks = [];
|
|
220
|
+
let total = 0;
|
|
221
|
+
for (; ; ) {
|
|
222
|
+
const { done, value } = await reader.read();
|
|
223
|
+
if (done) break;
|
|
224
|
+
total += value.byteLength;
|
|
225
|
+
if (total > MAX_BODY_BYTES) {
|
|
226
|
+
await reader.cancel();
|
|
227
|
+
return void 0;
|
|
228
|
+
}
|
|
229
|
+
chunks.push(value);
|
|
230
|
+
}
|
|
231
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
232
|
+
}
|
|
233
|
+
function okResponse(purged) {
|
|
234
|
+
return new Response(JSON.stringify({ ok: true, purged }), {
|
|
235
|
+
status: 200,
|
|
236
|
+
headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// src/sitemap.ts
|
|
241
|
+
var PAGE_LIMIT = 100;
|
|
242
|
+
var DEFAULT_MAX_PAGES = 20;
|
|
243
|
+
var SECTION_SLUG = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
244
|
+
var UNSAFE_PATH = /[\\?#<>&"'\u0000- \u007f]/;
|
|
245
|
+
async function listPublicationPaths(options) {
|
|
246
|
+
const maxPages = options.maxPages ?? DEFAULT_MAX_PAGES;
|
|
247
|
+
const prefix = `${options.basePath}/`;
|
|
248
|
+
const indexes = [options.basePath || "/"];
|
|
249
|
+
for (const slug of options.sections ?? []) if (slug !== "posts" && SECTION_SLUG.test(slug)) indexes.push(`${options.basePath}/${slug}`);
|
|
250
|
+
const found = /* @__PURE__ */ new Map();
|
|
251
|
+
let complete = true;
|
|
252
|
+
try {
|
|
253
|
+
for (const index of new Set(indexes)) {
|
|
254
|
+
let cursor;
|
|
255
|
+
for (let page = 0; page < maxPages; page++) {
|
|
256
|
+
const result = await options.client.resolve({
|
|
257
|
+
path: index,
|
|
258
|
+
limit: PAGE_LIMIT,
|
|
259
|
+
...options.locale !== void 0 ? { locale: options.locale } : {},
|
|
260
|
+
...cursor !== void 0 ? { cursor } : {}
|
|
261
|
+
});
|
|
262
|
+
if (result.kind !== "list") {
|
|
263
|
+
complete = false;
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
for (const item of result.items) {
|
|
267
|
+
if (item.canonical_path.startsWith(prefix) && !UNSAFE_PATH.test(item.canonical_path)) found.set(item.canonical_path, item.published_at);
|
|
268
|
+
}
|
|
269
|
+
if (!result.next_cursor) break;
|
|
270
|
+
if (page === maxPages - 1) complete = false;
|
|
271
|
+
cursor = result.next_cursor;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
complete = false;
|
|
276
|
+
}
|
|
277
|
+
return { paths: [...found].map(([path, publishedAt]) => ({ path, publishedAt })), complete };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export {
|
|
281
|
+
Cp1ClientError,
|
|
282
|
+
createHyleClient,
|
|
283
|
+
DeliveryDedupe,
|
|
284
|
+
parseSignatureHeader,
|
|
285
|
+
verifyHyleSignature,
|
|
286
|
+
CACHE_TAG_PATTERN,
|
|
287
|
+
isValidCacheTag,
|
|
288
|
+
createRevalidationCallbackRoute,
|
|
289
|
+
listPublicationPaths
|
|
290
|
+
};
|