@saypsst/next 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/README.md CHANGED
@@ -1,3 +1,191 @@
1
- # Temporary Holding Version
1
+ # @saypsst/next
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Drop-in route modules that serve [psst](https://saypsst.com) pages on your own domain, inside your Next.js App Router app (Next 15 or 16, any host). psst publishes, updates and withdraws pages under a path prefix it owns (default `/resources`) without another deploy on your side.
4
+
5
+ - Server components and route handlers only: no client component, no client JavaScript added.
6
+ - Full text in the HTML for every visitor and crawler, with canonical, Open Graph, JSON-LD and a `psst-version` meta.
7
+ - Time-based ISR (300 s). If psst is down, your site keeps serving the last good render.
8
+ - No secrets. The only value you configure is your public site id.
9
+ - Runtime dependency: `zod` (pinned). `@psst/content`, the psst content contract, is bundled into `dist/`. No install scripts.
10
+
11
+ `npx saypsst init` writes all of this for you. This README is the reference for what it writes.
12
+
13
+ ## Files you add
14
+
15
+ Paths assume `app/` at the project root and the default prefix `/resources`. With `src/app/`, put `psst.config.ts` in `src/` (the relative imports stay the same). With another prefix, replace `resources` in the folder names and add one `../` per extra segment.
16
+
17
+ > Two Next.js rules shape these files. Route segment config (`revalidate`, `dynamicParams`) must be a literal in the route file, because Next reads it statically: it cannot be re-exported from a package. And a folder whose name starts with `_` is private in the App Router, so the URL segment `/_psst` needs the folder name `%5Fpsst`.
18
+
19
+ ### `psst.config.ts`
20
+
21
+ ```ts
22
+ import { definePsst } from "@saypsst/next";
23
+
24
+ export default definePsst({
25
+ site: "site_xxxxxxxxxxxxxxxx", // public site id from the psst dashboard
26
+ domain: "example.com", // production host, no scheme
27
+ siteName: "Example",
28
+ prefix: "/resources",
29
+ rootPaths: [], // add "/about" and/or "/llms.txt" only when the dashboard claimed them
30
+ });
31
+ ```
32
+
33
+ Optional keys: `edge` (defaults to `https://edge.saypsst.com`; `http:` is accepted only on localhost) and `shell: { nav: [{ label, href }], footer }` for the built-in header/footer. `definePsst` validates everything and throws on a bad value, so a typo fails the build instead of serving broken pages.
34
+
35
+ ### `app/(psst)/resources/[...slug]/page.tsx`
36
+
37
+ ```tsx
38
+ import { psstPage } from "@saypsst/next/page";
39
+ import psst from "../../../../psst.config";
40
+
41
+ const page = psstPage(psst);
42
+
43
+ export const revalidate = 300;
44
+ export const dynamicParams = true;
45
+ export const generateStaticParams = page.generateStaticParams;
46
+ export const generateMetadata = page.generateMetadata;
47
+ export default page.default;
48
+ ```
49
+
50
+ To get your site's header and footer (needed when your root layout renders none), pass a `Shell`. It receives the psst `<main>` element as `children`; do not wrap it in another `<main>`:
51
+
52
+ ```tsx
53
+ import type { PsstShellProps } from "@saypsst/next/page";
54
+ import { SiteHeader, SiteFooter } from "@/components/site";
55
+
56
+ function Shell({ children }: PsstShellProps) {
57
+ return (
58
+ <>
59
+ <SiteHeader />
60
+ {children}
61
+ <SiteFooter />
62
+ </>
63
+ );
64
+ }
65
+
66
+ const page = psstPage(psst, { Shell });
67
+ ```
68
+
69
+ ### `app/(psst)/resources/sitemap.xml/route.ts`
70
+
71
+ ```ts
72
+ import { psstSitemap } from "@saypsst/next/sitemap";
73
+ import psst from "../../../../psst.config";
74
+
75
+ export const revalidate = 300;
76
+ export const GET = psstSitemap(psst);
77
+ ```
78
+
79
+ ### `app/(psst)/resources/%5Fpsst/style.css/route.ts`
80
+
81
+ ```ts
82
+ import { psstStyle } from "@saypsst/next/style";
83
+ import psst from "../../../../../psst.config";
84
+
85
+ export const revalidate = 300;
86
+ export const GET = psstStyle(psst);
87
+ ```
88
+
89
+ ### `app/(psst)/resources/%5Fpsst/verify/route.ts`
90
+
91
+ ```ts
92
+ import { psstVerify } from "@saypsst/next/verify";
93
+ import psst from "../../../../../psst.config";
94
+
95
+ export const revalidate = 300;
96
+ export const GET = psstVerify(psst);
97
+ ```
98
+
99
+ ### `proxy.ts` (Next 16; `middleware.ts` on Next 15)
100
+
101
+ New file:
102
+
103
+ ```ts
104
+ import { withPsst } from "@saypsst/next/proxy";
105
+ import psst from "./psst.config";
106
+
107
+ export const proxy = withPsst(psst);
108
+
109
+ export const config = {
110
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
111
+ };
112
+ ```
113
+
114
+ If you already have a proxy, wrap it and keep your own matcher (make sure it also matches the psst prefix):
115
+
116
+ ```ts
117
+ export const proxy = withPsst(psst, yourProxy);
118
+ ```
119
+
120
+ Then reference the psst sitemap from `robots.txt` (`Sitemap: https://example.com/resources/sitemap.xml`) or from `app/robots.ts`.
121
+
122
+ ### Only when the dashboard claimed a root path
123
+
124
+ `app/about/page.tsx` (add `"/about"` to `rootPaths`):
125
+
126
+ ```tsx
127
+ import { psstPage } from "@saypsst/next/page";
128
+ import psst from "../../psst.config";
129
+
130
+ const page = psstPage(psst, { path: "/about" });
131
+
132
+ export const revalidate = 300;
133
+ export const generateMetadata = page.generateMetadata;
134
+ export default page.default;
135
+ ```
136
+
137
+ `app/llms.txt/route.ts` (add `"/llms.txt"` to `rootPaths`):
138
+
139
+ ```ts
140
+ import { psstLlms } from "@saypsst/next/llms";
141
+ import psst from "../../psst.config";
142
+
143
+ export const revalidate = 300;
144
+ export const GET = psstLlms(psst);
145
+ ```
146
+
147
+ Do not edit these files by hand beyond the `Shell`; psst content is never added locally.
148
+
149
+ ## Exports
150
+
151
+ | Import | Export | What it is |
152
+ | --- | --- | --- |
153
+ | `@saypsst/next` | `definePsst(input)` | Validates and freezes the config. Throws `PsstConfigError`. |
154
+ | | `PsstConfig`, `PsstConfigInput`, `PsstConfigError`, `PSST_REVALIDATE` (300), `DEFAULT_EDGE`, `PSST_NEXT_VERSION` | Types and constants. |
155
+ | `@saypsst/next/page` | `psstPage(config, { Shell?, path? })` | Returns `{ default, generateMetadata, generateStaticParams }` for the page file. |
156
+ | | `PsstShellProps`, `PsstPageOptions`, `PsstPageProps`, `PsstPageModule`, `PsstIntegrityError`, `PSST_REVALIDATE` | |
157
+ | `@saypsst/next/sitemap` | `psstSitemap(config)` | `GET` handler: XML sitemap of the published psst pages. |
158
+ | `@saypsst/next/style` | `psstStyle(config)` | `GET` handler: the psst stylesheet, same-origin, `text/css`. |
159
+ | `@saypsst/next/verify` | `psstVerify(config)` | `GET` handler: the domain-proof text psst checks. |
160
+ | `@saypsst/next/llms` | `psstLlms(config)` | `GET` handler: `/llms.txt`. Throws unless `/llms.txt` is in `rootPaths`. |
161
+ | `@saypsst/next/proxy` | `withPsst(config, proxy?)`, `PsstProxy` | Wraps your proxy: AI-crawler / AI-referral hits, 410 for withdrawn paths. |
162
+
163
+ ## What each module does
164
+
165
+ **Page.** `generateMetadata` and the page fetch `GET {edge}/v1/sites/{site}/pages?path=…` (one request per render; 8 s timeout), validate the response and the document against the content contract (images only from your domain or `edge.saypsst.com`), and check the document's sha256 against the version hash psst approved. The body has the same structure as psst's static renderer: header, `main > article.psst-article` (h1, lede, updated date, blocks, sources), footer, plus one JSON-LD script (escaped; the only script). Metadata replaces your layout's defaults: absolute title, description, canonical `https://{domain}{path}`, Open Graph, Twitter, `robots: index, follow`, and `<meta name="psst-version" content="{12-char hash}">`. `generateStaticParams` prerenders the published pages from the manifest at build, and returns `[]` if psst is unreachable.
166
+
167
+ **Sitemap** renders the manifest's published HTML pages (withdrawn paths are already out). **Style, verify, llms** copy the files psst's edge renders (`GET {edge}/s/{site}{path}`), check the media type and size, and serve them same-origin with `nosniff`.
168
+
169
+ **Proxy.** On every matched request it classifies the `User-Agent` (a claim, not proof) and the `Referer` host. Known AI crawlers and visitors arriving from ChatGPT, Perplexity, Gemini, Copilot or Claude produce one `POST {edge}/v1/hits` with the path and the classes only: no IP, no cookie, no query string, no UA string. The post is not awaited (`event.waitUntil`, 300 ms abort) and errors are swallowed. Requests with `X-Psst-Doctor: 1`, prefetches, RSC navigations, `/_next/*` and static assets are skipped. Then your proxy runs unchanged and its result is returned as is.
170
+
171
+ ## Caching and failure behaviour (Next 16 mechanisms)
172
+
173
+ Verified against the Next 16.4 docs (`incremental-static-regeneration`, `caching-without-cache-components`, `route-segment-config`, `route`, `unstable_rethrow`, `proxy`) and source (`build/templates/app-page-runtime.js`, `app-route.js`, `server/lib/patch-fetch.js`).
174
+
175
+ | Requirement | Mechanism used |
176
+ | --- | --- |
177
+ | Time-based ISR, 300 s | Route segment config `export const revalidate = 300` written as a literal in every route file (page and the four route handlers), plus `dynamicParams = true` on the catch-all. Edge fetches set no `cache` option, so they never enter the Data Cache (only status-200 responses would) and the ISR entry is the one cache layer; using no Request-time API keeps the routes static. Pages missing from `generateStaticParams` render on first request and are then cached the same way. |
178
+ | Keep the last good render on fetch failure | The page and handlers **throw** (`PsstUnavailableError`, `PsstIntegrityError`) on a network error, timeout, non-200/404/410 answer, invalid JSON, contract failure or hash mismatch. Next's response cache keeps serving the previous entry when a background regeneration throws, and retries on the next request. Nothing is caught that Next needs: `notFound()` is called outside any `try`, so `unstable_rethrow` is not needed. |
179
+ | Never-rendered page while psst is down | **A page cannot set a 503 in the App Router** (only `notFound()` 404, redirects, and the experimental 401/403). The closest correct mechanism: the thrown error makes Next answer **500 with `Cache-Control: private, no-cache, no-store`** and a `noindex` robots meta, and the failure is never persisted as ISR output, so no cached 404 and no cached error; the next request retries. There is no `Retry-After` on this response. |
180
+ | psst down during `next build` | Your build never fails because of psst. `generateStaticParams` returns `[]` (the pages render on first request). A fixed-path page (`/about`) that cannot fetch at build calls `connection()` (`next/server`), so Next renders it at request time for that deployment. Route handlers (`process.env.NEXT_PHASE === "phase-production-build"`) return **503, `Retry-After: 300`, `Cache-Control: no-store`**; Next never stores a build-time route response with status ≥ 400 other than 404 (it sets `revalidate: 0`), so those routes are also rendered at request time for that deployment. In that degraded mode every request asks the edge: it answers normally once psst is back (successful handler responses carry `Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400`, so a CDN still caches them), and answers 500 uncached while psst is down. The next deploy restores full ISR. At runtime the handlers never return an error response: they throw, as above, so a good response is never replaced. |
181
+ | Not found / withdrawn | Edge 404 or 410 → `notFound()` (404, cached for one ISR period). A page cannot answer 410, so `withPsst()` answers **410** for paths in the manifest's `withdrawn` list. It keeps that list in memory per instance and refreshes it in the background every 300 s, so no request waits on psst; until the first refresh completes, a withdrawn path answers 404. The sitemap drops withdrawn paths. |
182
+
183
+ Cache Components (`cacheComponents: true`) removes the `revalidate` and `dynamicParams` segment configs, so these route files do not build there; v1 supports apps without Cache Components.
184
+
185
+ Self-hosted with several instances (for example behind CloudFront): the default ISR cache is per instance. psst content then refreshes per instance within 300 s, which is correct but uneven; a shared cache handler fixes that.
186
+
187
+ ## Security notes
188
+
189
+ - Every text field is escaped by React; links are `https:` or same-site paths; images only from your domain or `edge.saypsst.com`; the document is re-validated before rendering, and its hash must match the approved version.
190
+ - JSON-LD is serialized with `<`, `>`, `&`, U+2028 and U+2029 escaped. It is the only script, and it is a data block, so it fits strict CSPs.
191
+ - Edge requests use `redirect: "error"`, size caps (2 MB JSON, 256 KB CSS, 1 KB proof, 512 KB llms.txt), and a `psst-next/<version>` user agent.
@@ -0,0 +1,60 @@
1
+ import { domainSchema, publicIdSchema, isSafeHref, prefixSchema } from './chunk-WHB66OTA.js';
2
+ import { z } from 'zod';
3
+
4
+ var PSST_REVALIDATE = 300;
5
+ var DEFAULT_EDGE = "https://edge.saypsst.com";
6
+ var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
7
+ function isEdgeOrigin(value) {
8
+ let url;
9
+ try {
10
+ url = new URL(value);
11
+ } catch {
12
+ return false;
13
+ }
14
+ if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") return false;
15
+ if (url.pathname !== "/" && url.pathname !== "") return false;
16
+ if (url.protocol === "https:") return true;
17
+ return url.protocol === "http:" && LOCAL_HOSTS.has(url.hostname);
18
+ }
19
+ var configSchema = z.strictObject({
20
+ /** Public site id from the psst dashboard (`site_` + 16 characters). Not a secret. */
21
+ site: publicIdSchema,
22
+ /** Bare production host, e.g. `example.com`. Used for canonical URLs and the image allowlist. */
23
+ domain: domainSchema,
24
+ /** Brand name shown in JSON-LD and Open Graph. */
25
+ siteName: z.string().min(1).max(120),
26
+ /** Path prefix psst owns on the site. */
27
+ prefix: prefixSchema.default("/resources"),
28
+ /** Root paths psst serves outside the prefix (only what the dashboard claimed). */
29
+ rootPaths: z.array(z.enum(["/about", "/llms.txt"])).max(2).default([]),
30
+ /** psst edge origin. Override only for local development. */
31
+ edge: z.string().refine(isEdgeOrigin, "edge must be an https origin (http only on localhost)").default(DEFAULT_EDGE),
32
+ /** Header/footer used when the page module gets no `Shell` component. Same shape as the static renderer's shell. */
33
+ shell: z.strictObject({
34
+ nav: z.array(
35
+ z.strictObject({
36
+ label: z.string().min(1).max(60),
37
+ href: z.string().refine(isSafeHref, "nav href must be https: or a same-site path")
38
+ })
39
+ ).max(12).optional(),
40
+ footer: z.string().min(1).max(300).optional()
41
+ }).optional()
42
+ });
43
+ var PsstConfigError = class extends Error {
44
+ constructor(message) {
45
+ super(message);
46
+ this.name = "PsstConfigError";
47
+ }
48
+ };
49
+ function definePsst(input) {
50
+ const parsed = configSchema.safeParse(input);
51
+ if (!parsed.success) {
52
+ throw new PsstConfigError(
53
+ `invalid psst config: ${parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`
54
+ );
55
+ }
56
+ const c = parsed.data;
57
+ return Object.freeze({ ...c, edge: c.edge.replace(/\/+$/, "") });
58
+ }
59
+
60
+ export { DEFAULT_EDGE, PSST_REVALIDATE, PsstConfigError, definePsst };
@@ -0,0 +1,95 @@
1
+ import { pageResponseSchema, PSST_NEXT_VERSION, manifestResponseSchema } from './chunk-WHB66OTA.js';
2
+ import { cache } from 'react';
3
+
4
+ var PsstUnavailableError = class extends Error {
5
+ constructor(message, options) {
6
+ super(`psst: ${message}`, options);
7
+ this.name = "PsstUnavailableError";
8
+ }
9
+ };
10
+ var RENDER_TIMEOUT_MS = 8e3;
11
+ var MAX_JSON_BYTES = 2 * 1024 * 1024;
12
+ var USER_AGENT = `psst-next/${PSST_NEXT_VERSION}`;
13
+ async function edgeGet(url, accept, timeoutMs) {
14
+ try {
15
+ return await fetch(url, {
16
+ method: "GET",
17
+ headers: { accept, "user-agent": USER_AGENT },
18
+ redirect: "error",
19
+ signal: AbortSignal.timeout(timeoutMs)
20
+ });
21
+ } catch (cause) {
22
+ throw new PsstUnavailableError(`edge unreachable (${url})`, { cause });
23
+ }
24
+ }
25
+ async function readCapped(res, maxBytes) {
26
+ const declared = Number(res.headers.get("content-length") ?? "0");
27
+ if (declared > maxBytes) {
28
+ await res.body?.cancel().catch(() => void 0);
29
+ throw new PsstUnavailableError(`edge response too large (${declared} bytes)`);
30
+ }
31
+ if (!res.body) return "";
32
+ const reader = res.body.getReader();
33
+ const chunks = [];
34
+ let size = 0;
35
+ try {
36
+ for (; ; ) {
37
+ const { done, value } = await reader.read();
38
+ if (done) break;
39
+ size += value.byteLength;
40
+ if (size > maxBytes) {
41
+ await reader.cancel().catch(() => void 0);
42
+ throw new PsstUnavailableError(`edge response too large (more than ${maxBytes} bytes)`);
43
+ }
44
+ chunks.push(value);
45
+ }
46
+ } catch (cause) {
47
+ if (cause instanceof PsstUnavailableError) throw cause;
48
+ throw new PsstUnavailableError("edge response body could not be read", { cause });
49
+ }
50
+ const buf = new Uint8Array(size);
51
+ let offset = 0;
52
+ for (const chunk of chunks) {
53
+ buf.set(chunk, offset);
54
+ offset += chunk.byteLength;
55
+ }
56
+ return new TextDecoder().decode(buf);
57
+ }
58
+ async function readJson(res) {
59
+ const text = await readCapped(res, MAX_JSON_BYTES);
60
+ try {
61
+ return JSON.parse(text);
62
+ } catch (cause) {
63
+ throw new PsstUnavailableError("edge returned invalid JSON", { cause });
64
+ }
65
+ }
66
+ async function loadPage(edge, site, path) {
67
+ const url = `${edge}/v1/sites/${encodeURIComponent(site)}/pages?path=${encodeURIComponent(path)}`;
68
+ const res = await edgeGet(url, "application/json", RENDER_TIMEOUT_MS);
69
+ if (res.status === 404) return { status: "not_found" };
70
+ if (res.status === 410) return { status: "gone" };
71
+ if (res.status !== 200) throw new PsstUnavailableError(`edge answered ${res.status} for ${path}`);
72
+ const parsed = pageResponseSchema.safeParse(await readJson(res));
73
+ if (!parsed.success) throw new PsstUnavailableError(`edge page response failed validation for ${path}`);
74
+ if (parsed.data.publicId !== site || parsed.data.path !== path) {
75
+ throw new PsstUnavailableError(`edge answered for another page (${parsed.data.publicId}${parsed.data.path})`);
76
+ }
77
+ return { status: "ok", page: parsed.data };
78
+ }
79
+ var fetchPage = cache((config, path) => loadPage(config.edge, config.site, path));
80
+ async function loadManifest(edge, site, timeoutMs) {
81
+ const res = await edgeGet(`${edge}/v1/sites/${encodeURIComponent(site)}/manifest`, "application/json", timeoutMs);
82
+ if (res.status !== 200) throw new PsstUnavailableError(`edge answered ${res.status} for the manifest`);
83
+ const parsed = manifestResponseSchema.safeParse(await readJson(res));
84
+ if (!parsed.success) throw new PsstUnavailableError("edge manifest failed validation");
85
+ if (parsed.data.site.publicId !== site) throw new PsstUnavailableError("edge answered with another site's manifest");
86
+ return parsed.data;
87
+ }
88
+ function fetchManifest(config, timeoutMs = RENDER_TIMEOUT_MS) {
89
+ return loadManifest(config.edge, config.site, timeoutMs);
90
+ }
91
+ async function fetchEdgeFile(config, path, accept) {
92
+ return edgeGet(`${config.edge}/s/${encodeURIComponent(config.site)}${path}`, accept, RENDER_TIMEOUT_MS);
93
+ }
94
+
95
+ export { PsstUnavailableError, USER_AGENT, fetchEdgeFile, fetchManifest, fetchPage, readCapped };
@@ -0,0 +1,42 @@
1
+ import { PsstUnavailableError } from './chunk-5X6OYLUN.js';
2
+
3
+ // src/route.ts
4
+ var RETRY_AFTER_SECONDS = 300;
5
+ function isBuildPhase() {
6
+ return process.env["NEXT_PHASE"] === "phase-production-build";
7
+ }
8
+ function psstRoute(handler) {
9
+ return async () => {
10
+ try {
11
+ return await handler();
12
+ } catch (err) {
13
+ if (err instanceof PsstUnavailableError && isBuildPhase()) {
14
+ console.warn(`${err.message}; serving 503 until the next regeneration`);
15
+ return unavailable();
16
+ }
17
+ throw err;
18
+ }
19
+ };
20
+ }
21
+ function unavailable() {
22
+ return new Response("psst is temporarily unavailable\n", {
23
+ status: 503,
24
+ headers: {
25
+ "content-type": "text/plain; charset=utf-8",
26
+ "retry-after": String(RETRY_AFTER_SECONDS),
27
+ "cache-control": "no-store"
28
+ }
29
+ });
30
+ }
31
+ function notFoundResponse() {
32
+ return new Response("Not found\n", { status: 404, headers: { "content-type": "text/plain; charset=utf-8" } });
33
+ }
34
+ var SUCCESS_CACHE_CONTROL = "public, max-age=0, s-maxage=300, stale-while-revalidate=86400";
35
+ function textResponse(body, contentType) {
36
+ return new Response(body, {
37
+ status: 200,
38
+ headers: { "content-type": contentType, "x-content-type-options": "nosniff", "cache-control": SUCCESS_CACHE_CONTROL }
39
+ });
40
+ }
41
+
42
+ export { isBuildPhase, notFoundResponse, psstRoute, textResponse };