@pylonsync/functions 0.3.292 → 0.3.293

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.
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Is the runtime in dev mode? MUST match the Rust host's `is_dev_mode()`
3
+ * (crates/runtime/src/frontend.rs): `PYLON_DEV_MODE` is on ONLY for the exact
4
+ * strings "1" or "true" (case-insensitive). A bare `if (process.env.PYLON_DEV_MODE)`
5
+ * is WRONG — the string "false"/"0" is truthy in JS, so an explicit
6
+ * `PYLON_DEV_MODE=false` on a PROD machine would wrongly enable dev behavior
7
+ * (e.g. the live-reload `<script>` was being injected into prod pages, whose
8
+ * EventSource then 404-retried `/_pylon/dev/live` forever).
9
+ */
10
+ export declare function isDevMode(): boolean;
11
+ /**
12
+ * The message payload the host sends. Matches RenderRouteMessage in
13
+ * crates/functions/src/protocol.rs.
14
+ */
15
+ export interface RenderRouteMessage {
16
+ type: "render_route";
17
+ call_id: string;
18
+ /**
19
+ * Project-relative module path (e.g. "app/hello/page"). The
20
+ * adapter joins cwd + this + the right extension (.tsx → .ts).
21
+ */
22
+ component: string;
23
+ /**
24
+ * Project-relative module paths for the layout chain, walked
25
+ * root → leaf. Each layout's default export wraps the next as
26
+ * `children`. Absent / empty when no layouts apply.
27
+ *
28
+ * Example:
29
+ * layouts: ["app/layout", "app/blog/layout"]
30
+ * component: "app/blog/[slug]/page"
31
+ *
32
+ * Resolves to:
33
+ * <RootLayout>
34
+ * <BlogLayout>
35
+ * <Page {...props} />
36
+ * </BlogLayout>
37
+ * </RootLayout>
38
+ */
39
+ layouts?: string[];
40
+ /** The matched route pattern (e.g. `/blog/:slug`). */
41
+ route_path: string;
42
+ /** The incoming URL path (e.g. `/blog/hello-world`). */
43
+ url: string;
44
+ /** Dynamic-segment matches keyed by name (e.g. `{slug: "hello-world"}`). */
45
+ params: Record<string, string>;
46
+ /** Parsed query string. */
47
+ search_params: Record<string, string>;
48
+ /** Lowercased header names → values. */
49
+ headers: Record<string, string>;
50
+ /** Parsed cookies. */
51
+ cookies: Record<string, string>;
52
+ /** Pylon auth context. */
53
+ auth: {
54
+ user_id: string | null;
55
+ is_admin: boolean;
56
+ tenant_id: string | null;
57
+ roles: string[];
58
+ };
59
+ /**
60
+ * Initial HTTP status the response controller starts at (default 200).
61
+ * The host sets this to 404 when dispatching a `not-found.tsx` render
62
+ * for an unmatched URL, so the boundary streams at 404 without the
63
+ * component having to call `response.setStatus`. A page can still
64
+ * override it via `response.setStatus`.
65
+ */
66
+ initial_status?: number;
67
+ }
68
+ type Send = (msg: Record<string, unknown>) => void;
69
+ /**
70
+ * Control-flow signal a page or layout throws to short-circuit the
71
+ * render: `response.redirect(url)` / `response.notFound()`. The adapter
72
+ * catches it and turns it into a 3xx + Location or a 404 instead of a
73
+ * normal 200 body. Extends Error so React's stream rejects cleanly when
74
+ * it's thrown during the shell render. (Throw it OUTSIDE an error
75
+ * boundary — an enclosing boundary would swallow the signal.)
76
+ */
77
+ export declare class PylonRouteControl extends Error {
78
+ kind: "redirect" | "notFound";
79
+ url?: string;
80
+ redirectStatus?: number;
81
+ constructor(kind: "redirect" | "notFound");
82
+ }
83
+ /**
84
+ * Normalize a thrown value into a route-control signal: either the framework's
85
+ * own `PylonRouteControl` (`response.redirect()` / `response.notFound()`) or a
86
+ * branded `NotFoundError` thrown by `@pylonsync/react`'s `notFound()` from a
87
+ * page/layout render. Returns `null` for an ordinary error so the caller falls
88
+ * through to its real error-handling path.
89
+ */
90
+ export declare function asRouteControl(err: unknown): PylonRouteControl | null;
91
+ export interface SsrCookieOptions {
92
+ path?: string;
93
+ domain?: string;
94
+ maxAge?: number;
95
+ expires?: Date | string;
96
+ /** Defaults to true (secure default). Pass false for a client-readable cookie. */
97
+ httpOnly?: boolean;
98
+ secure?: boolean;
99
+ /** Defaults to "lax". */
100
+ sameSite?: "strict" | "lax" | "none";
101
+ }
102
+ export interface ResponseState {
103
+ status: number;
104
+ headers: Record<string, string>;
105
+ cookies: string[];
106
+ }
107
+ /**
108
+ * The per-render `response` controller handed to every page + layout in
109
+ * props. Pylon already has a backend for data/mutations, so SSR's job is
110
+ * just the response envelope: status, redirects, 404, and the occasional
111
+ * Set-Cookie.
112
+ *
113
+ * IMPORTANT — call these during the SYNCHRONOUS shell render (the
114
+ * component body, before any `await` / Suspense boundary). The HTTP head
115
+ * is committed when the shell is ready; status/headers/cookies set from a
116
+ * suspended subtree that streams in later are lost, and a redirect()/
117
+ * notFound() thrown below a Suspense boundary is caught by React's error
118
+ * handling rather than turned into a 3xx/404 (same constraint as Next's
119
+ * streaming SSR). Per render, not shared across requests.
120
+ */
121
+ export interface SsrResponse {
122
+ /** Set the HTTP status (100–599). Default 200. */
123
+ setStatus(code: number): void;
124
+ /** Set a response header (name must be a token; value CR/LF/NUL-free). */
125
+ setHeader(name: string, value: string): void;
126
+ /** Append a Set-Cookie. Defaults: HttpOnly + SameSite=Lax. */
127
+ setCookie(name: string, value: string, opts?: SsrCookieOptions): void;
128
+ /** Throw to send a 3xx (default 307) + Location, no body. Shell-render only. */
129
+ redirect(url: string, status?: number): never;
130
+ /**
131
+ * Throw to send a 404. Renders the nearest `not-found.tsx` (walking up
132
+ * from the page's directory, wrapped in the route's layout chain), or a
133
+ * minimal framework body if none is defined. Shell-render only — a throw
134
+ * below a Suspense boundary is swallowed by React.
135
+ */
136
+ notFound(): never;
137
+ }
138
+ export declare function makeResponseController(state: ResponseState, defaultRedirectStatus?: number): SsrResponse;
139
+ /**
140
+ * Merge page-set headers + cookies into the response_start header map.
141
+ * Cookies are newline-joined under `set-cookie`; the host splits them
142
+ * into one `Set-Cookie` header each (newline is forbidden inside a
143
+ * cookie, so it can't be turned into header injection).
144
+ */
145
+ export declare function finalizeHeaders(state: ResponseState, extra?: Record<string, string>): Record<string, string>;
146
+ /**
147
+ * Phase 1 SSR handler. Resolves the component, renders it via
148
+ * react-dom/server.renderToReadableStream, pumps chunks back to the
149
+ * host as base64-encoded NDJSON.
150
+ *
151
+ * Errors fall back to a type:"error" frame so the host can return a
152
+ * 500 with the error body. Mid-stream errors (after the first chunk
153
+ * has flushed) are uncatchable here — React's `onError` would have
154
+ * to feed into a separate signal, deferred to Phase 1.5.
155
+ */
156
+ /**
157
+ * Page SEO metadata. A page exports `export const metadata = {...}`
158
+ * (static) or `export async function generateMetadata(props)` (dynamic,
159
+ * e.g. param-derived titles). Kept flat — no deep nesting beyond og/twitter.
160
+ *
161
+ * React 19 hoists the resulting <title>/<meta>/<link> into <head>. A page
162
+ * `title` overrides a layout's static `<title>` (both render; the browser
163
+ * uses the last, which is the page's). React does NOT dedupe arbitrary
164
+ * `<meta>`, so set `description`/OG in EITHER the layout OR page metadata,
165
+ * not both, to avoid duplicate tags.
166
+ */
167
+ /** A single OpenGraph image (for `openGraph.images` — multiple images). */
168
+ export interface OgImage {
169
+ url: string;
170
+ secureUrl?: string;
171
+ type?: string;
172
+ width?: number;
173
+ height?: number;
174
+ alt?: string;
175
+ }
176
+ export interface SsrMetadata {
177
+ title?: string;
178
+ description?: string;
179
+ keywords?: string | string[];
180
+ canonical?: string;
181
+ robots?: string;
182
+ /** `<meta name="author">` — one tag per author. */
183
+ authors?: string | string[];
184
+ /** `<meta name="theme-color">` — browser UI tint for the page. */
185
+ themeColor?: string;
186
+ openGraph?: {
187
+ title?: string;
188
+ description?: string;
189
+ image?: string;
190
+ /** `og:image:secure_url` — set automatically to the https image URL. */
191
+ imageSecureUrl?: string;
192
+ /** `og:image:type` (e.g. "image/png"). */
193
+ imageType?: string;
194
+ /** `og:image:width` / `og:image:height` in pixels. */
195
+ imageWidth?: number;
196
+ imageHeight?: number;
197
+ imageAlt?: string;
198
+ /** Additional images beyond the primary `image` (each emits its own
199
+ * `og:image` + dimensions). Provide absolute URLs. */
200
+ images?: OgImage[];
201
+ url?: string;
202
+ type?: string;
203
+ /** `og:locale` (e.g. "en_US"). */
204
+ locale?: string;
205
+ /** `og:site_name` — the brand the page belongs to (e.g. "Pylon").
206
+ * Discord and other unfurlers show this above the title. */
207
+ siteName?: string;
208
+ /** `article:*` tags for `og:type=article` pages. */
209
+ article?: {
210
+ author?: string | string[];
211
+ publishedTime?: string;
212
+ modifiedTime?: string;
213
+ section?: string;
214
+ tags?: string | string[];
215
+ };
216
+ };
217
+ twitter?: {
218
+ card?: string;
219
+ title?: string;
220
+ description?: string;
221
+ image?: string;
222
+ /** `twitter:site` / `twitter:creator` — @handles. */
223
+ site?: string;
224
+ creator?: string;
225
+ /** `twitter:image:alt` — alt text for the card image. */
226
+ imageAlt?: string;
227
+ };
228
+ /** `<link rel="icon">` / `<link rel="apple-touch-icon">`. Auto-wired
229
+ * from the app/icon.* + app/apple-icon.* + app/favicon.ico file
230
+ * conventions, or set explicitly. */
231
+ icons?: {
232
+ icon?: {
233
+ url: string;
234
+ type?: string;
235
+ sizes?: string;
236
+ };
237
+ apple?: {
238
+ url: string;
239
+ type?: string;
240
+ sizes?: string;
241
+ };
242
+ };
243
+ /** Alternate URLs. `languages` emits `<link rel="alternate" hreflang>`
244
+ * per locale; `canonical` is an alias for the top-level `canonical`. */
245
+ alternates?: {
246
+ canonical?: string;
247
+ languages?: Record<string, string>;
248
+ };
249
+ /** Structured data, emitted as `<script type="application/ld+json">`.
250
+ * One object or an array (each item gets its own script). Serialized with
251
+ * `<` escaped so values can't break out of the script element. */
252
+ jsonLd?: Record<string, unknown> | Record<string, unknown>[];
253
+ }
254
+ /**
255
+ * Build a React fragment of <title>/<meta>/<link> from a page's metadata.
256
+ * React 19 auto-hoists these into <head> wherever they render, and the
257
+ * host's </head> splice preserves them. React escapes all text/attrs, so
258
+ * there's no manual XSS handling. Returns null when there's nothing to emit.
259
+ */
260
+ export declare function renderMetadata(React: any, m: SsrMetadata | undefined): any;
261
+ /** Import a project-relative module, trying each common extension. */
262
+ export declare function importModule(cwd: string, relPath: string): Promise<any>;
263
+ /** Pure origin resolution (exported for tests).
264
+ *
265
+ * SECURITY: the request `Host` (and `X-Forwarded-Proto`) is attacker-
266
+ * controlled. It's only trusted to build the absolute origin baked into
267
+ * `og:image` / canonical URLs when it's in the allowlist — the configured
268
+ * public/canonical host, an explicit `PYLON_TRUSTED_HOSTS` entry, or
269
+ * loopback. An untrusted (or absent) Host falls back to the configured
270
+ * public origin. Without this, `Host: evil.com` on a cacheable
271
+ * (force-static / `revalidate`) render bakes `https://evil.com/_pylon/og…`
272
+ * into the HTML, which is then teed into the shared ISR/CDN cache and
273
+ * served to every subsequent visitor (cache poisoning). */
274
+ export declare function resolveOrigin(opts: {
275
+ host?: string;
276
+ forwardedProto?: string;
277
+ publicUrl?: string;
278
+ canonicalHost?: string;
279
+ trustedHostsCsv?: string;
280
+ }): string;
281
+ /** SECURITY: validate a `response.redirect()` target to prevent OPEN REDIRECTS.
282
+ * Mirrors the OAuth-layer `validate_trusted_redirect` (crates/auth). A relative
283
+ * same-site path is always allowed; an absolute URL only when it's http(s) to a
284
+ * trusted host — the app's public/canonical origin, a `PYLON_TRUSTED_HOSTS`
285
+ * entry, or loopback. Everything else is rejected: protocol-relative
286
+ * `//evil.com`, backslash tricks (`/\evil.com` — browsers normalize `\`→`/`),
287
+ * other-origin absolutes, and `javascript:`/`data:` schemes. So the natural
288
+ * `response.redirect(searchParams.get("next"))` can't be turned into an
289
+ * off-site redirect by attacker-supplied input. Exported for tests. */
290
+ export declare function isSafeRedirect(url: string, opts: {
291
+ publicUrl?: string;
292
+ canonicalHost?: string;
293
+ trustedHostsCsv?: string;
294
+ }): boolean;
295
+ /** Merge auto-discovered favicons (icon.* / apple-icon.* / favicon.ico)
296
+ * into a page's metadata. Explicit `metadata.icons.*` wins. */
297
+ export declare function applyAutoIcons(component: string, metadata: SsrMetadata | undefined): SsrMetadata | undefined;
298
+ export declare function applyAutoSocialImages(component: string, headers: Record<string, string> | undefined, metadata: SsrMetadata | undefined): SsrMetadata | undefined;
299
+ /**
300
+ * Build the hydration tail appended after React's stream EOFs: the
301
+ * `__PYLON_DATA__` JSON blob (props + ssrData) + the per-route entry
302
+ * `<script>` that hydrates it, + (dev) the live-reload snippet. Shared by the
303
+ * page render AND the now-hydrated boundary render (#279) so a boundary
304
+ * hydrates through the EXACT same path as a page.
305
+ *
306
+ * `kind` marks an error/not-found boundary so the client knows whether to
307
+ * synthesize a `reset()`. For an error boundary, `errorForClient` is the SAFE
308
+ * projection ({message, digest}) — the raw `Error` (and its stack) is NEVER
309
+ * serialized (the dev overlay owns dev stacks; preserves the #270 posture).
310
+ */
311
+ export declare function buildHydrationTail(args: {
312
+ component: string;
313
+ layouts: string[];
314
+ props: any;
315
+ ssrData: Record<string, any>;
316
+ manifestRoute: {
317
+ file: string;
318
+ imports: string[];
319
+ css: string[];
320
+ } | null;
321
+ publicPrefix: string;
322
+ manifestErr: string | null;
323
+ kind?: "error" | "not-found";
324
+ errorForClient?: {
325
+ message: string;
326
+ digest?: string;
327
+ };
328
+ }): string;
329
+ /**
330
+ * A short, non-reversible correlation id for an error — surfaced to the
331
+ * client error boundary as `error.digest` (matching server logs) WITHOUT
332
+ * carrying any stack content. FNV-1a over message+stack, 8 hex chars.
333
+ */
334
+ export declare function errorDigest(err: any): string;
335
+ /**
336
+ * #278: does this route STREAM (vs buffer the whole document)? Streaming is
337
+ * opt-in: a `loading.tsx` (route-level Suspense) or `export const streaming =
338
+ * true` (inner-boundary). Pure for testing.
339
+ */
340
+ export declare function computeWantsStream(hasLoading: boolean, mod: any): boolean;
341
+ /**
342
+ * #277: how long an opt-in page stays cacheable, in seconds — or null if it
343
+ * never opted in. `export const revalidate = N` (N>0) → N; `dynamic:
344
+ * "force-static"` → a year (only a deploy invalidates); else null. Pure.
345
+ */
346
+ export declare function computeRevalidateSecs(mod: any): number | null;
347
+ /**
348
+ * #277 cache verdict — the security-critical predicate, extracted pure so the
349
+ * leak class (a personalized/streaming render marked cacheable) is a TEST, not
350
+ * a mental walkthrough. INVARIANT: result ⟹ !wantsStream (a streaming render
351
+ * commits its head before auth/cookies/status are final, so it can never be
352
+ * cached). Fail-closed: every condition must hold.
353
+ */
354
+ export declare function computeCacheVerdict(args: {
355
+ revalidateSecs: number | null;
356
+ forceDynamic: boolean;
357
+ authTouched: boolean;
358
+ cookieCount: number;
359
+ strictPolicies: boolean;
360
+ wantsStream: boolean;
361
+ status: number;
362
+ }): boolean;
363
+ /**
364
+ * #278: diff the response head committed at `response_start` against the final
365
+ * state after EOF, to catch a late response.* mutation from a suspended subtree
366
+ * that the already-sent head couldn't carry. Returns the dropped pieces, or
367
+ * null if nothing was lost. Pure.
368
+ */
369
+ export declare function diffCommittedResponse(snapshot: {
370
+ status: number;
371
+ cookies: string[];
372
+ headerKeys: string[];
373
+ }, final: {
374
+ status: number;
375
+ cookies: string[];
376
+ headers: Record<string, string>;
377
+ }): {
378
+ droppedCookies: string[];
379
+ statusChanged: boolean;
380
+ newHeaderKeys: string[];
381
+ } | null;
382
+ /** One URL entry in a sitemap (mirrors Next's `MetadataRoute.Sitemap[number]`). */
383
+ export interface SitemapEntry {
384
+ url: string;
385
+ lastModified?: string | Date;
386
+ changeFrequency?: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
387
+ priority?: number;
388
+ /** hreflang alternates, e.g. `{ languages: { "en-US": "https://…/en" } }`. */
389
+ alternates?: {
390
+ languages?: Record<string, string>;
391
+ };
392
+ }
393
+ /** Return type of a default export in `app/sitemap.ts`. */
394
+ export type Sitemap = SitemapEntry[];
395
+ export interface RobotsRule {
396
+ userAgent?: string | string[];
397
+ allow?: string | string[];
398
+ disallow?: string | string[];
399
+ crawlDelay?: number;
400
+ }
401
+ /** Return type of a default export in `app/robots.ts`. */
402
+ export interface Robots {
403
+ rules: RobotsRule | RobotsRule[];
404
+ sitemap?: string | string[];
405
+ host?: string;
406
+ }
407
+ /** Serialize sitemap entries to a sitemaps.org 0.9 XML document. */
408
+ export declare function serializeSitemap(entries: Sitemap | undefined): string;
409
+ /** Serialize a robots config to robots.txt text. */
410
+ export declare function serializeRobots(robots: Robots | undefined): string;
411
+ /**
412
+ * Import app/sitemap or app/robots, run its default export, serialize the
413
+ * result, and stream it back with the right content-type. Errors surface as a
414
+ * 500 with a short plain-text message (so a broken sitemap doesn't wedge the
415
+ * runner). 1-hour cache — sitemaps/robots change rarely; tune via a CDN.
416
+ */
417
+ export declare function handleDataRoute(msg: RenderRouteMessage, kind: "sitemap" | "robots", send: Send): Promise<void>;
418
+ export declare function handleRenderRoute(msg: RenderRouteMessage, send: Send): Promise<void>;
419
+ export {};
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Test-side helpers for `pylon test`.
3
+ *
4
+ * The CLI already starts each test FILE with a fresh in-memory database
5
+ * (`PYLON_IN_MEMORY=1`), so tests across files never cross-contaminate.
6
+ * This module covers the finer-grained case: isolating individual
7
+ * `test(...)` blocks within a single file.
8
+ *
9
+ * Two integration patterns are supported:
10
+ *
11
+ * 1. **Manual** — call `resetDb()` from a `beforeEach` hook.
12
+ * 2. **Automatic** — call `installTestIsolation()` at the top of the file
13
+ * and every `test()` block runs with a reset store.
14
+ *
15
+ * Both require the test file to run under `pylon test` (not raw
16
+ * `bun test`), because resetDb talks to the server via HTTP.
17
+ */
18
+ /**
19
+ * Reset the in-memory database to empty. Returns when the server confirms.
20
+ *
21
+ * Only works when the server is running in in-memory dev mode — production
22
+ * deployments refuse this call. Safe to no-op when the reset endpoint is
23
+ * unreachable so tests using this helper still work under raw `bun test`
24
+ * (they just won't reset between cases).
25
+ */
26
+ export declare function resetDb(baseUrl?: string): Promise<void>;
27
+ /**
28
+ * Bun-friendly `beforeEach(resetDb)` installer. Looks up Bun's global
29
+ * `beforeEach` via `globalThis`; no-ops under other runners.
30
+ */
31
+ export declare function installTestIsolation(baseUrl?: string): void;