zitejs 0.10.0-start.6 → 0.10.0-start.7

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.
Files changed (46) hide show
  1. package/dist/cjs/backend/index.d.ts +115 -3
  2. package/dist/cjs/backend/index.test.js +58 -0
  3. package/dist/cjs/integrations/rules.d.ts +13 -0
  4. package/dist/cjs/integrations/rules.js +29 -0
  5. package/dist/cjs/integrations/rules.test.js +1 -0
  6. package/dist/cjs/sync/lib.js +6 -6
  7. package/dist/esm/backend/index.d.ts +115 -3
  8. package/dist/esm/backend/index.test.js +58 -0
  9. package/dist/esm/integrations/rules.d.ts +13 -0
  10. package/dist/esm/integrations/rules.js +29 -0
  11. package/dist/esm/integrations/rules.test.js +1 -0
  12. package/dist/esm/start/authHooks.d.ts +7 -0
  13. package/dist/esm/start/authHooks.js +10 -0
  14. package/dist/esm/start/authHooksModule.d.ts +1 -0
  15. package/dist/esm/start/authHooksModule.js +3 -0
  16. package/dist/esm/start/clientErrorMessage.test.d.ts +1 -0
  17. package/dist/esm/start/clientErrorMessage.test.js +105 -0
  18. package/dist/esm/start/describe.d.ts +17 -1
  19. package/dist/esm/start/describe.js +27 -5
  20. package/dist/esm/start/errors.d.ts +46 -0
  21. package/dist/esm/start/errors.js +99 -0
  22. package/dist/esm/start/errors.test.d.ts +1 -0
  23. package/dist/esm/start/errors.test.js +23 -0
  24. package/dist/esm/start/index.d.ts +3 -19
  25. package/dist/esm/start/index.js +28 -36
  26. package/dist/esm/start/instance.d.ts +1 -2
  27. package/dist/esm/start/instance.js +11 -4
  28. package/dist/esm/start/previewClaim.d.ts +10 -0
  29. package/dist/esm/start/previewClaim.js +32 -0
  30. package/dist/esm/start/redactSecrets.d.ts +7 -0
  31. package/dist/esm/start/redactSecrets.js +131 -0
  32. package/dist/esm/start/redactSecrets.test.d.ts +1 -0
  33. package/dist/esm/start/redactSecrets.test.js +151 -0
  34. package/dist/esm/start/router.d.ts +6 -3
  35. package/dist/esm/start/router.js +47 -7
  36. package/dist/esm/start/runLog.d.ts +7 -4
  37. package/dist/esm/start/runLog.js +64 -6
  38. package/dist/esm/start/server.d.ts +7 -3
  39. package/dist/esm/start/server.js +74 -21
  40. package/dist/esm/start/types.d.ts +28 -3
  41. package/dist/esm/start/version.d.ts +1 -0
  42. package/dist/esm/start/version.js +2 -0
  43. package/dist/esm/start/vite.js +31 -3
  44. package/dist/esm/start/workflowRun.js +2 -1
  45. package/dist/esm/sync/lib.js +6 -6
  46. package/package.json +16 -8
@@ -1,12 +1,11 @@
1
1
  import { type AnySerializationAdapter } from "@tanstack/react-router";
2
2
  import type { SSROption } from "@tanstack/router-core";
3
3
  import type { AnyFunctionMiddleware, AnyRequestMiddleware, StartInstance } from "@tanstack/start-client-core";
4
- import { ZiteError } from "./index.js";
5
4
  /**
6
5
  * The start instance every Start app builds with: the platform's middleware and
7
6
  * adapters first, then whatever the app's own `src/start.ts` sets.
8
7
  */
9
- export declare function createZiteStart<const TAdapters extends ReadonlyArray<AnySerializationAdapter> = [], TDefaultSsr extends SSROption = SSROption, const TRequest extends ReadonlyArray<AnyRequestMiddleware> = [], const TFunction extends ReadonlyArray<AnyFunctionMiddleware> = []>(app?: StartInstance<TAdapters, TDefaultSsr, TRequest, TFunction>): StartInstance<readonly [import("@tanstack/react-router").SerializationAdapter<ZiteError, {
8
+ export declare function createZiteStart<const TAdapters extends ReadonlyArray<AnySerializationAdapter> = [], TDefaultSsr extends SSROption = SSROption, const TRequest extends ReadonlyArray<AnyRequestMiddleware> = [], const TFunction extends ReadonlyArray<AnyFunctionMiddleware> = []>(app?: StartInstance<TAdapters, TDefaultSsr, TRequest, TFunction>): StartInstance<readonly [import("@tanstack/react-router").SerializationAdapter<Error, {
10
9
  code: "BAD_REQUEST" | "UNAUTHORIZED" | "FORBIDDEN" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMITED" | "INTERNAL_ERROR" | "INVALID_INPUT";
11
10
  message: string;
12
11
  }, never>, ...TAdapters], TDefaultSsr, readonly [import("@tanstack/react-start").RequestMiddlewareAfterServer<{}, undefined, undefined>, ...TRequest], readonly [import("@tanstack/react-start").FunctionMiddlewareAfterClient<{}, unknown, undefined, undefined, undefined>, import("@tanstack/react-start").FunctionMiddlewareAfterServer<{}, unknown, undefined, undefined, undefined, undefined, undefined>, ...TFunction]>;
@@ -2,7 +2,8 @@
2
2
  // `src/start.ts`, which the generated instance composes after the platform's.
3
3
  import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
4
4
  import { createSerializationAdapter, redirect } from "@tanstack/react-router";
5
- import { isSignInRequired, loginHref, ZiteError } from "./index.js";
5
+ import { toClientError, ZiteError } from "./errors.js";
6
+ import { isSignInRequired, loginHref } from "./index.js";
6
7
  import { errorOf, preview } from "./runLog.js";
7
8
  import { getHost, inScope } from "./scope.js";
8
9
  // TanStack drops its default CSRF check once an app has a start instance.
@@ -47,11 +48,17 @@ const runCapture = createMiddleware({ type: "function" }).server(async ({ next,
47
48
  throw error;
48
49
  }
49
50
  });
50
- /** Carries a ZiteError to the browser as itself, so `instanceof ZiteError` and `.code` work there. */
51
+ /**
52
+ * Every error a server function or a page's loader sends the browser arrives as a ZiteError, with its code and the
53
+ * message classic would show. Anything else on it, a stack or an SDK's request, stays on the server.
54
+ */
51
55
  const ziteErrorAdapter = createSerializationAdapter({
52
56
  key: "zite-error",
53
- test: (value) => value instanceof ZiteError,
54
- toSerializable: (error) => ({ code: error.code, message: error.message }),
57
+ test: (value) => value instanceof Error,
58
+ toSerializable: (error) => {
59
+ const { code, message } = toClientError(error);
60
+ return { code, message };
61
+ },
55
62
  fromSerializable: ({ code, message }) => new ZiteError(code, message),
56
63
  });
57
64
  /**
@@ -0,0 +1,10 @@
1
+ export declare const PREVIEW_CLAIM_PARAM = "zite_preview";
2
+ export declare const PREVIEW_CLAIM_HEADER = "x-zite-preview";
3
+ /**
4
+ * An editor preview's access, which the host keeps on the URL because the editor's frame may drop its cookie (WebKit
5
+ * drops even Partitioned ones). On the server the host hands it over; in the browser it's on the URL the host sent
6
+ * the page to, read once, before the router hides it.
7
+ */
8
+ export declare const previewClaim: () => string | undefined;
9
+ /** Every same-origin fetch from the page carries the claim: server functions and the app's own routes alike. */
10
+ export declare const carryPreviewClaim: (claim: string) => void;
@@ -0,0 +1,32 @@
1
+ import { currentHost } from "./host.js";
2
+ // The host's names (apps/zite-host/src/previewClaim.ts).
3
+ export const PREVIEW_CLAIM_PARAM = "zite_preview";
4
+ export const PREVIEW_CLAIM_HEADER = "x-zite-preview";
5
+ let captured;
6
+ /**
7
+ * An editor preview's access, which the host keeps on the URL because the editor's frame may drop its cookie (WebKit
8
+ * drops even Partitioned ones). On the server the host hands it over; in the browser it's on the URL the host sent
9
+ * the page to, read once, before the router hides it.
10
+ */
11
+ export const previewClaim = () => {
12
+ if (typeof window === "undefined")
13
+ return currentHost()?.previewClaim;
14
+ captured ??= new URL(window.location.href).searchParams.get(PREVIEW_CLAIM_PARAM);
15
+ return captured ?? undefined;
16
+ };
17
+ let carrying = false;
18
+ /** Every same-origin fetch from the page carries the claim: server functions and the app's own routes alike. */
19
+ export const carryPreviewClaim = (claim) => {
20
+ if (carrying || typeof window === "undefined")
21
+ return;
22
+ carrying = true;
23
+ const original = window.fetch.bind(window);
24
+ window.fetch = (input, init) => {
25
+ const target = new URL(input instanceof Request ? input.url : String(input), window.location.href);
26
+ if (target.origin !== window.location.origin)
27
+ return original(input, init);
28
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
29
+ headers.set(PREVIEW_CLAIM_HEADER, claim);
30
+ return original(input, { ...init, headers });
31
+ };
32
+ };
@@ -0,0 +1,7 @@
1
+ export declare const REDACTED = "REDACTED";
2
+ /**
3
+ * Replace any credentials in `text` with a placeholder, leaving the rest
4
+ * intact. Returns the input unchanged when there is nothing to redact, so
5
+ * callers that pass clean text see byte-identical output.
6
+ */
7
+ export declare function redactSecrets(text: string): string;
@@ -0,0 +1,131 @@
1
+ // Ported from restly's shared/src/redactSecrets.ts, so a live Start app redacts
2
+ // exactly what a classic one does. Keep the two in step.
3
+ export const REDACTED = "REDACTED";
4
+ /**
5
+ * A run of characters that can plausibly be a secret's value. Stops at
6
+ * whitespace and at the punctuation that normally *ends* a value in a sentence
7
+ * or a serialized payload, so the redaction does not swallow the rest of the
8
+ * text after it.
9
+ */
10
+ const VALUE_RUN = String.raw `[^\s,;)\]}"'<>]+`;
11
+ const CREDENTIAL_KEY = String.raw `(?:password|passwd|secret|api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|auth[_-]?token|client[_-]?secret|private[_-]?key)`;
12
+ // The scheme is length-capped because, unbounded, every position in a long
13
+ // alphanumeric run (hex, a base64 blob) rescans to the end of the run —
14
+ // quadratic, and minutes of blocked event loop on a megabyte of output.
15
+ const URL_USERINFO = String.raw `([a-z][a-z0-9+.-]{0,31}:\/\/[^\s:/@]+:)`;
16
+ const JSON_MEMBER = String.raw `("${CREDENTIAL_KEY}"\s*:\s*")`;
17
+ const JSON_VALUE = String.raw `(?:[^"\\]|\\.)*`;
18
+ const ESCAPED_JSON_MEMBER = String.raw `(\\"${CREDENTIAL_KEY}\\"\s*:\s*\\")`;
19
+ const ESCAPED_JSON_VALUE = String.raw `(?:[^"\\]|\\\\(?:\\["\\]|[^"\\]))*`;
20
+ const PATTERNS = [
21
+ {
22
+ // URL userinfo: scheme://user:password@host. Keeps the scheme, the user,
23
+ // and the host — the parts that identify *which* connection failed.
24
+ pattern: new RegExp(URL_USERINFO + String.raw `[^\s@/]+(@)`, "gi"),
25
+ replace: `$1${REDACTED}$2`,
26
+ },
27
+ {
28
+ // Bearer tokens. RFC 6750 `token68` permits a single character, so a length
29
+ // floor lets short opaque tokens through. Same shape guard as Basic below:
30
+ // reject a value that is one alphabetic word, so "Bearer token required"
31
+ // survives while anything mixing case or digits is redacted. Case-sensitive
32
+ // for the same reason as Basic, so the scheme is spelled out.
33
+ //
34
+ // `Token` is deliberately not treated as a scheme: it is a GitHub-ism
35
+ // rather than a registered one, and "Token" is a common enough English word
36
+ // that it matched the next word in prose. The credentials it precedes are
37
+ // already covered by the `gh*_` prefixes below.
38
+ pattern: new RegExp(String.raw `\b([Bb]earer)\s+(?![A-Za-z][a-z]*(?![A-Za-z0-9._~+/=-]))([A-Za-z0-9._~+/=-]+)`, "g"),
39
+ replace: `$1 ${REDACTED}`,
40
+ },
41
+ {
42
+ // Basic credentials are base64 of "user:pass" and can be legitimately
43
+ // short — base64("u:pw") is four characters — so the 16-char floor used for
44
+ // Bearer would let real credentials through.
45
+ //
46
+ // Length alone therefore cannot separate a credential from prose, and
47
+ // "Basic plan" / "Basic tier" / "Basic Auth" are exactly the kind of copy a
48
+ // subscription app writes. The guard is shape instead: reject the value if
49
+ // it is a single alphabetic word (one letter followed by lowercase to the
50
+ // end of the token). Base64 of any realistic "user:pass" mixes case or
51
+ // digits and so survives the guard, while an English word does not.
52
+ //
53
+ // Case-sensitive by necessity — an `i` flag would make `[a-z]` match
54
+ // uppercase and collapse that distinction — so the scheme is spelled out
55
+ // to still accept the lowercase form.
56
+ pattern: new RegExp(String.raw `\b([Bb]asic)\s+(?![A-Za-z][a-z]*(?![A-Za-z0-9+/=]))([A-Za-z0-9+/=]{4,})`, "g"),
57
+ replace: `$1 ${REDACTED}`,
58
+ },
59
+ {
60
+ // JWTs, which carry claims and are frequently the credential itself. The
61
+ // header is capped for the same reason as the scheme above (a run of
62
+ // `-eyJ` fragments restarts at each one); a JWT has to fit in an HTTP
63
+ // header, so 8KB loses nothing real.
64
+ pattern: /\beyJ[A-Za-z0-9_-]{8,8192}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+/g,
65
+ replace: REDACTED,
66
+ },
67
+ {
68
+ // Provider secret keys, matched on each vendor's published prefix.
69
+ // OpenAI/Anthropic `sk-`, Stripe `sk_live_`/`sk_test_`/`rk_live_`.
70
+ // Stripe's publishable `pk_` keys are deliberately absent: they are
71
+ // designed to ship to browsers, so redacting them only loses detail.
72
+ pattern: /\b(?:sk-(?:ant-)?|sk_live_|sk_test_|rk_live_)[A-Za-z0-9_-]{16,}/g,
73
+ replace: REDACTED,
74
+ },
75
+ {
76
+ // AWS access key ids (AKIA long-lived, ASIA temporary) and Google API keys.
77
+ // Google's are documented as AIza + 35 chars; the open-ended quantifier
78
+ // means an unexpectedly long variant is redacted whole rather than having
79
+ // its first 39 characters replaced and the remainder left exposed.
80
+ pattern: /\b(?:A[KS]IA[0-9A-Z]{16}\b|AIza[0-9A-Za-z_-]{35,})/g,
81
+ replace: REDACTED,
82
+ },
83
+ {
84
+ // GitHub (`ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`) and Slack (`xoxb-` etc).
85
+ pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|xox[abprs]-[A-Za-z0-9-]{10,})\b/g,
86
+ replace: REDACTED,
87
+ },
88
+ {
89
+ // Credential-bearing key/value pairs, in query strings, connection strings
90
+ // and serialized config alike. Anchored on the key name plus its separator,
91
+ // and the key must be a whole token so an app's "reset_password_url=..."
92
+ // style key is not mistaken for the secret itself.
93
+ //
94
+ // The leading boundary is a captured alternation rather than a lookbehind:
95
+ // this module is exported from @fillout/shared and so can be bundled into
96
+ // the frontend, where an unsupported lookbehind would throw while building
97
+ // the RegExp at module load and take the whole bundle down with it.
98
+ pattern: new RegExp(String.raw `(^|[^\w-])(${CREDENTIAL_KEY})(\s*[:=]\s*)` + VALUE_RUN, "gi"),
99
+ replace: `$1$2$3${REDACTED}`,
100
+ },
101
+ {
102
+ // The same keys as JSON string members. The closing quote after the key
103
+ // sits between it and the colon, so the pattern above never sees them.
104
+ pattern: new RegExp(JSON_MEMBER + JSON_VALUE + '"', "gi"),
105
+ replace: `$1${REDACTED}"`,
106
+ },
107
+ {
108
+ // ...and as members of JSON serialized into another JSON string, where
109
+ // every quote arrives as \" and the value's own escapes are doubled, so a
110
+ // \\\" inside the value is an escaped quote rather than its end.
111
+ pattern: new RegExp(ESCAPED_JSON_MEMBER + ESCAPED_JSON_VALUE + String.raw `\\"`, "gi"),
112
+ replace: `$1${REDACTED}\\"`,
113
+ },
114
+ ];
115
+ /**
116
+ * Replace any credentials in `text` with a placeholder, leaving the rest
117
+ * intact. Returns the input unchanged when there is nothing to redact, so
118
+ * callers that pass clean text see byte-identical output.
119
+ */
120
+ export function redactSecrets(text) {
121
+ if (!text)
122
+ return text;
123
+ let result = text;
124
+ for (const { pattern, replace } of PATTERNS) {
125
+ // The patterns are global, so reset lastIndex — these RegExp objects are
126
+ // module-level and reused across calls.
127
+ pattern.lastIndex = 0;
128
+ result = result.replace(pattern, replace);
129
+ }
130
+ return result;
131
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,151 @@
1
+ // Classic's cases (restly shared/src/redactSecrets.test.ts), for the port beside it.
2
+ import { describe, it, expect } from 'vitest';
3
+ import { redactSecrets, REDACTED } from './redactSecrets.js';
4
+ describe('redactSecrets', () => {
5
+ describe('keeps the surrounding message', () => {
6
+ it('redacts a connection string password but keeps scheme, user and host', () => {
7
+ expect(redactSecrets('Failed to sync roster: postgres://svc:hunter2@db.internal:5432/prod')).toBe(`Failed to sync roster: postgres://svc:${REDACTED}@db.internal:5432/prod`);
8
+ });
9
+ it('leaves the sentence readable around a redacted key', () => {
10
+ expect(redactSecrets('Stripe rejected the charge (key sk_live_abcdefghijklmnop1234) — retry later')).toBe(`Stripe rejected the charge (key ${REDACTED}) — retry later`);
11
+ });
12
+ it('does not swallow text following a key=value pair', () => {
13
+ expect(redactSecrets('config invalid: api_key=abc123def456, region=us-west-2')).toBe(`config invalid: api_key=${REDACTED}, region=us-west-2`);
14
+ });
15
+ });
16
+ describe('credential formats', () => {
17
+ it('redacts bearer tokens', () => {
18
+ expect(redactSecrets('Authorization: Bearer abcdef1234567890XYZ')).toBe(`Authorization: Bearer ${REDACTED}`);
19
+ });
20
+ it('redacts a short opaque bearer token', () => {
21
+ // RFC 6750 token68 permits values this short, so shape decides, not length.
22
+ expect(redactSecrets('Authorization: Bearer abc123')).toBe(`Authorization: Bearer ${REDACTED}`);
23
+ });
24
+ it('redacts short Basic credentials, which base64 makes legitimately tiny', () => {
25
+ // base64("u:pw") — four characters.
26
+ expect(redactSecrets('Authorization: Basic dTpw')).toBe(`Authorization: Basic ${REDACTED}`);
27
+ expect(redactSecrets('basic YWI6Y2Q=')).toBe(`basic ${REDACTED}`);
28
+ });
29
+ it('redacts a full-length Basic credential', () => {
30
+ expect(redactSecrets('Basic YWxhZGRpbjpvcGVuc2VzYW1l')).toBe(`Basic ${REDACTED}`);
31
+ });
32
+ it('redacts JWTs', () => {
33
+ const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dQw4w9WgXcQ';
34
+ expect(redactSecrets(`token rejected: ${jwt}`)).toBe(`token rejected: ${REDACTED}`);
35
+ });
36
+ it('redacts OpenAI and Anthropic style keys', () => {
37
+ expect(redactSecrets('sk-ant-api03-abcdefghijklmnopqrstuv')).toBe(REDACTED);
38
+ expect(redactSecrets('sk-abcdefghijklmnopqrstuvwxyz')).toBe(REDACTED);
39
+ });
40
+ it('redacts AWS access key ids and Google API keys', () => {
41
+ expect(redactSecrets('using AKIAIOSFODNN7EXAMPLE for s3')).toBe(`using ${REDACTED} for s3`);
42
+ expect(redactSecrets('key AIzaSyA1234567890abcdefghijklmnopqrstuvw rejected')).toBe(`key ${REDACTED} rejected`);
43
+ });
44
+ it('redacts GitHub and Slack tokens', () => {
45
+ expect(redactSecrets('ghp_abcdefghijklmnopqrstuvwxyz0123456789')).toBe(REDACTED);
46
+ expect(redactSecrets('xoxb-1234567890-abcdefghij')).toBe(REDACTED);
47
+ });
48
+ it('redacts credential key/value pairs in either separator style', () => {
49
+ expect(redactSecrets('password: hunter2')).toBe(`password: ${REDACTED}`);
50
+ expect(redactSecrets('client_secret=s3cr3tvalue')).toBe(`client_secret=${REDACTED}`);
51
+ });
52
+ it('redacts the secret in a URL-encoded token exchange body', () => {
53
+ expect(redactSecrets('grant_type=client_credentials&client_id=3MVG9abc&client_secret=8F2A1B9C')).toBe(`grant_type=client_credentials&client_id=3MVG9abc&client_secret=${REDACTED}`);
54
+ });
55
+ it('redacts credential members of a JSON body, keeping the rest', () => {
56
+ expect(redactSecrets('{"client_id":"3MVG9abc","client_secret":"8F2A1B9C","grant_type":"client_credentials"}')).toBe(`{"client_id":"3MVG9abc","client_secret":"${REDACTED}","grant_type":"client_credentials"}`);
57
+ expect(redactSecrets('{"refreshToken": "a\\"b", "page": 2}')).toBe(`{"refreshToken": "${REDACTED}", "page": 2}`);
58
+ });
59
+ it('redacts credential members of JSON serialized inside a JSON string', () => {
60
+ expect(redactSecrets(String.raw `{"body":"{\"client_secret\":\"8F2A1B9C\",\"grant_type\":\"client_credentials\"}"}`)).toBe(String.raw `{"body":"{\"client_secret\":\"${REDACTED}\",\"grant_type\":\"client_credentials\"}"}`);
61
+ expect(redactSecrets(String.raw `{\"password\":\"a\\\"b\\\\\",\"page\":2}`)).toBe(String.raw `{\"password\":\"${REDACTED}\",\"page\":2}`);
62
+ });
63
+ it('redacts a key at the start of a line in a multi-line message', () => {
64
+ expect(redactSecrets('connection failed\napi_key=abc123def456')).toBe(`connection failed\napi_key=${REDACTED}`);
65
+ });
66
+ it('redacts every occurrence, not just the first', () => {
67
+ expect(redactSecrets('api_key=aaa111 and api_key=bbb222')).toBe(`api_key=${REDACTED} and api_key=${REDACTED}`);
68
+ });
69
+ });
70
+ describe('leaves app-authored prose alone', () => {
71
+ it('does not touch an ordinary user-facing message', () => {
72
+ const message = 'CAPACITY EXCEEDED: The following week(s) are now full: Week #11. Please select different weeks or join the waitlist.';
73
+ expect(redactSecrets(message)).toBe(message);
74
+ });
75
+ it('does not touch a parseable frontend signal', () => {
76
+ const message = 'CLOTHES_CHANGE_PENDING:recSHyUztFA6rF5UA';
77
+ expect(redactSecrets(message)).toBe(message);
78
+ });
79
+ it('does not redact prose that merely mentions a password', () => {
80
+ const message = 'Your password must be at least 8 characters long.';
81
+ expect(redactSecrets(message)).toBe(message);
82
+ });
83
+ it('does not redact the word "Bearer" without a token-shaped value', () => {
84
+ const message = 'Include a Bearer token in the Authorization header.';
85
+ expect(redactSecrets(message)).toBe(message);
86
+ });
87
+ it('does not redact "Basic" followed by an ordinary word', () => {
88
+ // The reason the Basic matcher guards on shape rather than length: a
89
+ // subscription app's own copy reads exactly like a short credential.
90
+ for (const message of [
91
+ 'Your Basic plan does not include this feature.',
92
+ 'Basic tier limits reached.',
93
+ 'Basic Auth is required for this endpoint.',
94
+ 'Upgrade from Basic Plan to continue.',
95
+ ]) {
96
+ expect(redactSecrets(message)).toBe(message);
97
+ }
98
+ });
99
+ it('does not redact a Stripe publishable key, which is meant to be public', () => {
100
+ const message = 'pk_live_abcdefghijklmnop1234 is not a secret key';
101
+ expect(redactSecrets(message)).toBe(message);
102
+ });
103
+ it('does not treat a URL with a port as userinfo', () => {
104
+ const message = 'GET https://api.example.com:8443/v1/records failed';
105
+ expect(redactSecrets(message)).toBe(message);
106
+ });
107
+ it('does not match a credential key embedded in a longer name', () => {
108
+ // "reset_password_url" ends in "password" but names a link, not a secret.
109
+ const message = 'reset_password_url=https://example.org/reset';
110
+ expect(redactSecrets(message)).toBe(message);
111
+ const json = '{"reset_password_url":"https://example.org/reset"}';
112
+ expect(redactSecrets(json)).toBe(json);
113
+ });
114
+ it('returns short or empty input untouched', () => {
115
+ expect(redactSecrets('')).toBe('');
116
+ expect(redactSecrets('nope')).toBe('nope');
117
+ });
118
+ });
119
+ describe('repeated use', () => {
120
+ it('is stable across calls despite module-level global regexes', () => {
121
+ const message = 'api_key=abc123def456';
122
+ const first = redactSecrets(message);
123
+ const second = redactSecrets(message);
124
+ expect(first).toBe(second);
125
+ expect(first).toBe(`api_key=${REDACTED}`);
126
+ });
127
+ it('is idempotent', () => {
128
+ const once = redactSecrets('postgres://svc:hunter2@db.internal/prod');
129
+ expect(redactSecrets(once)).toBe(once);
130
+ });
131
+ });
132
+ describe('large input', () => {
133
+ // It runs synchronously on raw sandbox output, so anything worse than
134
+ // linear blocks the event loop long enough to fail health checks. Both
135
+ // inputs took several seconds at this size before the patterns were capped.
136
+ it.each([
137
+ ['an unbroken alphanumeric run', 'aB3dE9fG1h'],
138
+ ['a run of -eyJ fragments', '-eyJabcdefgh'],
139
+ ])('stays fast on %s', (_, unit) => {
140
+ const text = unit.repeat(10_000);
141
+ const started = performance.now();
142
+ expect(redactSecrets(text)).toBe(text);
143
+ expect(performance.now() - started).toBeLessThan(2000);
144
+ });
145
+ it('still redacts a long scheme and a JWT after the caps', () => {
146
+ expect(redactSecrets('averyveryveryveryveryveryverylongscheme://svc:hunter2@db.internal')).toBe(`averyveryveryveryveryveryverylongscheme://svc:${REDACTED}@db.internal`);
147
+ const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U';
148
+ expect(redactSecrets(`x-${jwt}`)).toBe(`x-${REDACTED}`);
149
+ });
150
+ });
151
+ });
@@ -1,17 +1,20 @@
1
+ import { QueryClient } from "@tanstack/react-query";
1
2
  import { type AnyRoute, type RouterConstructorOptions, type RouterHistory } from "@tanstack/react-router";
2
3
  import type { User } from "./types.js";
3
4
  /** The router context every Zite app's root route declares: `createRootRouteWithContext<ZiteRouterContext>()`. */
4
5
  export type ZiteRouterContext = {
5
6
  user: User | null;
7
+ queryClient: QueryClient;
6
8
  };
7
9
  type Dehydrated = {
8
10
  user: User | null;
9
11
  };
10
12
  type ZiteRouterOptions<TRouteTree extends AnyRoute> = Omit<RouterConstructorOptions<TRouteTree, "never", false, RouterHistory, Dehydrated>, "context" | "dehydrate" | "hydrate">;
11
13
  /**
12
- * The router for `src/router.tsx`. The signed-in user is in `context.user`: on
13
- * the server from the platform, in the browser from the page it rendered, so
14
- * no request asks for it again.
14
+ * The router for `src/router.tsx`. The signed-in user is in `context.user`: on the server from the platform, in the
15
+ * browser from the page it rendered, so no request asks for it again. `context.queryClient` is new for each request
16
+ * on the server, so a page never renders with another visitor's cached queries, and its queries reach the browser
17
+ * with the page.
15
18
  */
16
19
  export declare function createZiteRouter<TRouteTree extends AnyRoute>(options: ZiteRouterOptions<TRouteTree>): import("@tanstack/router-core").RouterCore<TRouteTree, "never", false, RouterHistory, Dehydrated>;
17
20
  export {};
@@ -1,35 +1,75 @@
1
- import { createElement as h } from "react";
1
+ import { createElement as h, useEffect, useState } from "react";
2
+ import { QueryClient } from "@tanstack/react-query";
2
3
  import { createRouter, Link, useRouter, } from "@tanstack/react-router";
4
+ import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
5
+ import { toClientError } from "./errors.js";
6
+ import { carryPreviewClaim, PREVIEW_CLAIM_PARAM, previewClaim } from "./previewClaim.js";
3
7
  import { currentHost } from "./host.js";
8
+ const ziteWindow = () => (typeof window === "undefined" ? undefined : window);
4
9
  const box = { maxWidth: 480, margin: "15vh auto", padding: 24, fontFamily: "system-ui, sans-serif", textAlign: "center" };
10
+ // As classic's crash screen: the message, "Fix it for me" in the editor, and a reload. The server renders from the
11
+ // app's own error and the browser from the one that crossed, already mapped, so both show the same text.
5
12
  function ZiteErrorComponent({ error }) {
6
13
  const router = useRouter();
7
- const message = error instanceof Error && error.name === "ZiteError" ? error.message : "Something went wrong.";
8
- return h("div", { role: "alert", style: box }, h("p", { style: { fontWeight: 600 } }, message), h("button", { type: "button", onClick: () => router.invalidate() }, "Try again"));
14
+ const [allowAiFix, setAllowAiFix] = useState(false);
15
+ useEffect(() => setAllowAiFix(ziteWindow()?.__ziteAllowAiFix === true), []);
16
+ const message = typeof window === "undefined" ? toClientError(error).message : error instanceof Error ? error.message : String(error);
17
+ const reload = () => {
18
+ const onReload = ziteWindow()?._ziteOnReload;
19
+ if (onReload)
20
+ onReload();
21
+ else
22
+ router.invalidate();
23
+ };
24
+ return h("div", { role: "alert", style: box }, h("p", { style: { fontWeight: 600 } }, "Something went wrong"), h("pre", { style: { whiteSpace: "pre-wrap", textAlign: "left" } }, message), allowAiFix && h("button", { type: "button", onClick: () => ziteWindow()?._ziteOnFixError?.(error) }, "Fix it for me"), h("button", { type: "button", onClick: reload }, "Try again"));
9
25
  }
10
26
  function ZiteNotFound() {
11
27
  return h("div", { style: box }, h("p", { style: { fontWeight: 600 } }, "Page not found"), h(Link, { to: "/" }, "Go home"));
12
28
  }
29
+ function ZitePending() {
30
+ return h("div", { "aria-busy": true, style: { ...box, color: "#888" } }, "Loading…");
31
+ }
32
+ // The claim stays on every URL the router writes, and the app's own routes never see it.
33
+ const keepPreviewClaim = (claim, own) => ({
34
+ input: ({ url }) => {
35
+ url.searchParams.delete(PREVIEW_CLAIM_PARAM);
36
+ return own?.input?.({ url }) ?? url;
37
+ },
38
+ output: ({ url }) => {
39
+ const rewritten = own?.output?.({ url }) ?? url;
40
+ const out = new URL(rewritten.toString(), url);
41
+ out.searchParams.set(PREVIEW_CLAIM_PARAM, claim);
42
+ return out;
43
+ },
44
+ });
13
45
  /**
14
- * The router for `src/router.tsx`. The signed-in user is in `context.user`: on
15
- * the server from the platform, in the browser from the page it rendered, so
16
- * no request asks for it again.
46
+ * The router for `src/router.tsx`. The signed-in user is in `context.user`: on the server from the platform, in the
47
+ * browser from the page it rendered, so no request asks for it again. `context.queryClient` is new for each request
48
+ * on the server, so a page never renders with another visitor's cached queries, and its queries reach the browser
49
+ * with the page.
17
50
  */
18
51
  export function createZiteRouter(options) {
19
- const context = { user: currentHost()?.user ?? null };
52
+ const queryClient = new QueryClient();
53
+ const claim = previewClaim();
54
+ if (claim)
55
+ carryPreviewClaim(claim);
56
+ const context = { user: currentHost()?.user ?? null, queryClient };
20
57
  const router = createRouter({
21
58
  defaultPreload: "intent",
22
59
  defaultPreloadStaleTime: 0,
23
60
  scrollRestoration: true,
24
61
  defaultErrorComponent: ZiteErrorComponent,
25
62
  defaultNotFoundComponent: ZiteNotFound,
63
+ defaultPendingComponent: ZitePending,
26
64
  ...options,
65
+ ...(claim && { rewrite: keepPreviewClaim(claim, options.rewrite) }),
27
66
  context,
28
67
  dehydrate: () => ({ user: context.user }),
29
68
  hydrate: (dehydrated) => {
30
69
  context.user = dehydrated.user;
31
70
  },
32
71
  });
72
+ setupRouterSsrQueryIntegration({ router, queryClient });
33
73
  const host = currentHost();
34
74
  if (host)
35
75
  host.router = router;
@@ -1,5 +1,8 @@
1
+ import type { RunRecord, ZiteRequestContext } from "./types.js";
1
2
  export declare const preview: (value: unknown) => string | undefined;
2
- export declare const errorOf: (error: unknown) => {
3
- code: string;
4
- message: string;
5
- };
3
+ export declare const errorOf: (error: unknown) => NonNullable<RunRecord["error"]>;
4
+ /**
5
+ * A server route's run, which run capture doesn't see: its body in, and its status and body out. The bodies are
6
+ * read from copies after the fact, so the response never waits; the run log waits for them before it flushes.
7
+ */
8
+ export declare function recordRoute<T>(host: ZiteRequestContext | undefined, request: Request, run: () => Promise<T>): Promise<T>;
@@ -1,5 +1,7 @@
1
- import { ZiteError } from "./index.js";
2
- const PREVIEW_LIMIT = 8_000;
1
+ import { asZiteError } from "./errors.js";
2
+ // Classic's per-field cap. The host caps again; this keeps a large value from crossing at all.
3
+ const RUN_FIELD_LIMIT = 50 * 1024;
4
+ const capped = (text) => (text.length > RUN_FIELD_LIMIT ? `${text.slice(0, RUN_FIELD_LIMIT)}…` : text);
3
5
  export const preview = (value) => {
4
6
  if (value === undefined)
5
7
  return undefined;
@@ -7,12 +9,68 @@ export const preview = (value) => {
7
9
  return "[stream]";
8
10
  try {
9
11
  const json = JSON.stringify(value);
10
- return json && json.length > PREVIEW_LIMIT ? `${json.slice(0, PREVIEW_LIMIT)}…` : json;
12
+ return json === undefined ? undefined : capped(json);
11
13
  }
12
14
  catch {
13
15
  return "[unserializable]";
14
16
  }
15
17
  };
16
- export const errorOf = (error) => error instanceof ZiteError
17
- ? { code: error.code, message: error.message }
18
- : { code: "INTERNAL_ERROR", message: error instanceof Error ? error.message : String(error) };
18
+ export const errorOf = (error) => ({
19
+ code: asZiteError(error)?.code ?? "INTERNAL_ERROR",
20
+ message: error instanceof Error ? error.message : String(error),
21
+ ...(error instanceof Error && error.stack ? { stack: capped(error.stack) } : {}),
22
+ });
23
+ const TEXT_BODY = /^(application\/(json|x-www-form-urlencoded)|text\/(?!event-stream))/i;
24
+ // Up to the cap of a copy of the body, so the app still reads the original. Uploads and streams aren't read.
25
+ const bodyText = async (message) => {
26
+ if (!message.body || !TEXT_BODY.test(message.headers.get("content-type") ?? ""))
27
+ return undefined;
28
+ const reader = message.clone().body.pipeThrough(new TextDecoderStream()).getReader();
29
+ let text = "";
30
+ try {
31
+ while (text.length <= RUN_FIELD_LIMIT) {
32
+ const { done, value } = await reader.read();
33
+ if (done)
34
+ break;
35
+ text += value;
36
+ }
37
+ }
38
+ finally {
39
+ reader.cancel().catch(() => { });
40
+ }
41
+ return capped(text);
42
+ };
43
+ /**
44
+ * A server route's run, which run capture doesn't see: its body in, and its status and body out. The bodies are
45
+ * read from copies after the fact, so the response never waits; the run log waits for them before it flushes.
46
+ */
47
+ export async function recordRoute(host, request, run) {
48
+ if (!host)
49
+ return run();
50
+ const startedAt = Date.now();
51
+ const path = new URL(request.url).pathname;
52
+ const input = bodyText(request).catch(() => undefined);
53
+ const record = (outcome) => {
54
+ const durationMs = Date.now() - startedAt;
55
+ const recorded = (async () => {
56
+ const fields = { input: await input, ...(await outcome) };
57
+ host.runs.push({ fn: `route:${request.method} ${path}`, name: path, nested: false, startedAt, durationMs, ...fields });
58
+ })();
59
+ (host.pending ??= []).push(recorded.catch(() => { }));
60
+ };
61
+ try {
62
+ const out = await run();
63
+ const response = out instanceof Response ? out : out?.response;
64
+ if (response instanceof Response) {
65
+ record(bodyText(response).then((body) => ({ output: preview({ status: response.status, body }) }), () => ({})));
66
+ }
67
+ else {
68
+ record(Promise.resolve({}));
69
+ }
70
+ return out;
71
+ }
72
+ catch (error) {
73
+ record(Promise.resolve({ error: errorOf(error) }));
74
+ throw error;
75
+ }
76
+ }
@@ -12,19 +12,23 @@ export declare function installPlatformTransport(): void;
12
12
  /** What the host dispatched this request with. Headers are ignored: props are the only thing it alone can set. */
13
13
  export declare function hostContextFromProps(request: Request, props: HostProps): ZiteRequestContext;
14
14
  /**
15
- * A request the host didn't dispatch: `zitejs describe` under `vite preview`,
16
- * and tests, which pass their own platform. Deployed builds have no public URL,
17
- * so in production every request carries props.
15
+ * A request the host didn't dispatch: the host's warm-up, `zitejs describe` under `vite preview`, and tests. Only the
16
+ * host can set props, so without them a build answers warm and describe and nobody is signed in. An entry that passes
17
+ * its own platform (tests, local dev) owns its requests and may say who is calling in `x-zite-*` headers.
18
18
  */
19
19
  export declare function hostContextFromRequest(request: Request, platform?: PlatformBinding): ZiteRequestContext;
20
20
  /** Every server function TanStack compiled, written in by `zite()` at build. Empty in dev, where TanStack doesn't call its id hook. */
21
21
  export declare const builtFunctionIds: () => string[];
22
22
  /** Each server function's `description` option, written in by `zite()` at build. */
23
23
  export declare const builtFunctionDescriptions: () => Record<string, string>;
24
+ /** Each route's file, by route id, written in by `zite()` at build. */
25
+ export declare const builtRouteFiles: () => Record<string, string>;
24
26
  type Fetch = (request: Request, ...rest: unknown[]) => Promise<Response> | Response;
25
27
  type WorkerContext = {
26
28
  waitUntil(promise: Promise<unknown>): void;
27
29
  };
30
+ /** On describe's answer: the zitejs this build's server code was bundled from. */
31
+ export declare const RUNTIME_VERSION_HEADER = "x-zitejs-version";
28
32
  export type ZiteServerOptions = {
29
33
  handler: {
30
34
  fetch: Fetch;