zitejs 0.10.0-start.2 → 0.10.0-start.3

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.
@@ -12,6 +12,7 @@ declare module "@tanstack/start-client-core" {
12
12
  }
13
13
  declare const STATUS: {
14
14
  readonly BAD_REQUEST: 400;
15
+ readonly INVALID_INPUT: 400;
15
16
  readonly UNAUTHORIZED: 401;
16
17
  readonly FORBIDDEN: 403;
17
18
  readonly NOT_FOUND: 404;
@@ -20,6 +21,8 @@ declare const STATUS: {
20
21
  readonly INTERNAL_ERROR: 500;
21
22
  };
22
23
  export type ZiteErrorCode = keyof typeof STATUS;
24
+ /** @internal The HTTP status each code answers with. */
25
+ export declare const ZITE_ERROR_STATUS: Readonly<Record<ZiteErrorCode, number>>;
23
26
  /** Fail with a status: `throw new ZiteError('FORBIDDEN', 'Only the owner can edit this')`. */
24
27
  export declare class ZiteError extends Error {
25
28
  readonly code: ZiteErrorCode;
@@ -7,6 +7,7 @@ export { createZiteRouter } from "./router.js";
7
7
  export { createWorkflow } from "./workflow.js";
8
8
  const STATUS = {
9
9
  BAD_REQUEST: 400,
10
+ INVALID_INPUT: 400,
10
11
  UNAUTHORIZED: 401,
11
12
  FORBIDDEN: 403,
12
13
  NOT_FOUND: 404,
@@ -14,6 +15,8 @@ const STATUS = {
14
15
  RATE_LIMITED: 429,
15
16
  INTERNAL_ERROR: 500,
16
17
  };
18
+ /** @internal The HTTP status each code answers with. */
19
+ export const ZITE_ERROR_STATUS = STATUS;
17
20
  /** Fail with a status: `throw new ZiteError('FORBIDDEN', 'Only the owner can edit this')`. */
18
21
  export class ZiteError extends Error {
19
22
  code;
@@ -63,6 +66,29 @@ const host = () => {
63
66
  throw new ZiteError("UNAUTHORIZED", "Missing platform context");
64
67
  return current;
65
68
  };
69
+ // TanStack fails a validator with a plain Error whose message is the issues as JSON.
70
+ const validationIssues = (error) => {
71
+ if (!(error instanceof Error) || error.constructor !== Error)
72
+ return undefined;
73
+ try {
74
+ const issues = JSON.parse(error.message);
75
+ return Array.isArray(issues) && issues.length > 0 && issues.every((issue) => typeof issue?.message === "string")
76
+ ? issues
77
+ : undefined;
78
+ }
79
+ catch {
80
+ return undefined;
81
+ }
82
+ };
83
+ const invalidInput = (issues) => {
84
+ if (getHost()?.mode === "live")
85
+ return new ZiteError("INVALID_INPUT", "Invalid input");
86
+ const describe = ({ message, path = [] }) => {
87
+ const at = path.map((segment) => String(typeof segment === "object" ? segment.key : segment)).join(".");
88
+ return at ? `${at}: ${message}` : message;
89
+ };
90
+ return new ZiteError("INVALID_INPUT", issues.map(describe).join("; "));
91
+ };
66
92
  // Server functions report a status through TanStack's error envelope; server
67
93
  // routes need a Response. A nested call's failure is the caller's to handle.
68
94
  const withStatus = async (isServerFn, fn) => {
@@ -70,7 +96,9 @@ const withStatus = async (isServerFn, fn) => {
70
96
  try {
71
97
  return await fn();
72
98
  }
73
- catch (error) {
99
+ catch (caught) {
100
+ const issues = validationIssues(caught);
101
+ const error = issues ? invalidInput(issues) : caught;
74
102
  if (!(error instanceof ZiteError))
75
103
  throw error;
76
104
  if (!isServerFn)
@@ -88,10 +116,10 @@ export const signedIn = tag(createMiddleware().server(({ next, serverFnMeta }) =
88
116
  return runInScope({ kind: "signedIn", user }, () => next({ context: { user } }));
89
117
  })), { kind: "signedIn" });
90
118
  /** Anyone, signed in or not. Handlers get `context.user: User | null`. */
91
- export const anyone = tag(createMiddleware().server(({ next }) => {
119
+ export const anyone = tag(createMiddleware().server(({ next, serverFnMeta }) => withStatus(!!serverFnMeta, async () => {
92
120
  const { user } = host();
93
121
  return runInScope({ kind: "anyone", user }, () => next({ context: { user } }));
94
- }), { kind: "anyone" });
122
+ })), { kind: "anyone" });
95
123
  /** Webhooks delivered by the platform, which already checked the URL token. */
96
124
  export const webhook = tag(createMiddleware().server(({ next }) => {
97
125
  if (host().invoke?.kind !== "webhook")
@@ -7,6 +7,6 @@ import { ZiteError } from "./index.js";
7
7
  * adapters first, then whatever the app's own `src/start.ts` sets.
8
8
  */
9
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, {
10
- code: "BAD_REQUEST" | "UNAUTHORIZED" | "FORBIDDEN" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMITED" | "INTERNAL_ERROR";
10
+ code: "BAD_REQUEST" | "UNAUTHORIZED" | "FORBIDDEN" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMITED" | "INTERNAL_ERROR" | "INVALID_INPUT";
11
11
  message: string;
12
12
  }, 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]>;
@@ -30,5 +30,8 @@ export function createZiteRouter(options) {
30
30
  context.user = dehydrated.user;
31
31
  },
32
32
  });
33
+ const host = currentHost();
34
+ if (host)
35
+ host.router = router;
33
36
  return router;
34
37
  }
@@ -1,5 +1,5 @@
1
- import type { User, ZiteRequestContext } from "./types.js";
2
- export type AccessKind = "signedIn" | "anyone" | "webhook" | "workflow";
1
+ import type { AccessKind, User, ZiteRequestContext } from "./types.js";
2
+ export type { AccessKind } from "./types.js";
3
3
  export type AccessScope = {
4
4
  kind: AccessKind;
5
5
  user: User | null;
@@ -1,44 +1,32 @@
1
- import { type AccessKind } from "./scope.js";
2
- import type { RunRecord, ZiteRequestContext } from "./types.js";
1
+ import type { HostProps, PlatformBinding, ZiteRequestContext } from "./types.js";
3
2
  export { runWithHost } from "./scope.js";
4
3
  export { describeApp } from "./describe.js";
5
- export type { RunRecord } from "./types.js";
6
- export type PlatformCallInput = {
7
- integrationId: string;
8
- className: string;
9
- methodName: string;
10
- params: unknown;
11
- access: AccessKind;
12
- userId: string | null;
13
- requestId: string;
14
- };
15
- /** The `ZITE` service binding the host's Platform entrypoint gives every app script. */
16
- export type PlatformBinding = {
17
- call(call: PlatformCallInput): Promise<unknown>;
18
- /** One batch per request, after its response has ended. */
19
- runs(batch: {
20
- requestId: string;
21
- runs: RunRecord[];
22
- }): Promise<void>;
23
- };
4
+ export type { HostProps, PlatformBinding, PlatformCallInput, PlatformCallResult, PlatformSession, RunRecord } from "./types.js";
24
5
  /**
25
- * Routes every zitejs data, integration and email call through `call`, and only
26
- * inside an access scope. The access kind travels with the call so the platform
27
- * can record what a page read and, later, narrow anonymous reads.
6
+ * Routes every zitejs data, integration and email call to this request's
7
+ * platform, on one session, and only inside an access scope. The access kind
8
+ * travels with the call so the platform can record what a page read and narrow
9
+ * anonymous reads.
28
10
  */
29
- export declare function installPlatformTransport(call: (input: PlatformCallInput) => Promise<unknown>): void;
30
- /** The host's `x-zite-*` headers. Only the host can set them: the front strips them from public traffic. */
31
- export declare function hostContextFromRequest(request: Request): ZiteRequestContext;
11
+ export declare function installPlatformTransport(): void;
12
+ /** What the host dispatched this request with. Headers are ignored: props are the only thing it alone can set. */
13
+ export declare function hostContextFromProps(request: Request, props: HostProps): ZiteRequestContext;
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.
18
+ */
19
+ export declare function hostContextFromRequest(request: Request, platform?: PlatformBinding): ZiteRequestContext;
32
20
  /** Every server function TanStack compiled, written in by `zite()` at build. Empty in dev, where TanStack doesn't call its id hook. */
33
21
  export declare const builtFunctionIds: () => string[];
34
22
  /** Each server function's `description` option, written in by `zite()` at build. */
35
23
  export declare const builtFunctionDescriptions: () => Record<string, string>;
36
24
  type Fetch = (request: Request, ...rest: unknown[]) => Promise<Response> | Response;
37
25
  /**
38
- * The fetch handler of a Start app's server entry. Describe calls get the app's
39
- * description; everything else runs TanStack with the host's identity in
40
- * server-only storage. Platform calls go to the `ZITE` binding from the Worker's
41
- * env, or to `platform` when a test or local dev supplies one.
26
+ * The fetch handler of a Start app's server entry. A request the host
27
+ * dispatched carries its identity and platform in props; anything else (describe
28
+ * under `vite preview`, tests) reads `x-zite-*` headers and uses `platform`
29
+ * if one is given.
42
30
  */
43
31
  export declare function createZiteFetch(options: {
44
32
  handler: {
@@ -1,3 +1,4 @@
1
+ import { ZiteError, ZITE_ERROR_STATUS } from "./index.js";
1
2
  import { describeApp } from "./describe.js";
2
3
  import { preview } from "./runLog.js";
3
4
  import { getHost, requireScope, runWithHost } from "./scope.js";
@@ -5,28 +6,47 @@ import { loadWorkflows, loggedInput, runWorkflow } from "./workflowRun.js";
5
6
  export { runWithHost } from "./scope.js";
6
7
  export { describeApp } from "./describe.js";
7
8
  /**
8
- * Routes every zitejs data, integration and email call through `call`, and only
9
- * inside an access scope. The access kind travels with the call so the platform
10
- * can record what a page read and, later, narrow anonymous reads.
9
+ * Routes every zitejs data, integration and email call to this request's
10
+ * platform, on one session, and only inside an access scope. The access kind
11
+ * travels with the call so the platform can record what a page read and narrow
12
+ * anonymous reads.
11
13
  */
12
- export function installPlatformTransport(call) {
13
- const sdkCall = (integrationId, className, methodName, params) => {
14
+ export function installPlatformTransport() {
15
+ const sdkCall = async (integrationId, className, methodName, params) => {
14
16
  const scope = requireScope(`${className}.${methodName}`);
15
- return call({
16
- integrationId,
17
- className,
18
- methodName,
19
- params,
20
- access: scope.kind,
21
- userId: scope.user?.id ?? null,
22
- requestId: getHost()?.requestId ?? "",
23
- });
17
+ const host = getHost();
18
+ // Fails closed: no platform means no platform access, never a fallback.
19
+ if (!host?.platform)
20
+ throw new Error("The Zite platform binding is missing");
21
+ host.session ??= Promise.resolve(host.platform.session());
22
+ const result = await (await host.session).call({ integrationId, className, methodName, params, access: scope.kind });
23
+ if ("value" in result)
24
+ return result.value;
25
+ const { code, message } = result.error;
26
+ throw new ZiteError(code in ZITE_ERROR_STATUS ? code : "INTERNAL_ERROR", message);
24
27
  };
25
28
  globalThis.__wrapSdkCall = sdkCall;
26
29
  }
27
30
  const decodeUser = (value) => JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0))));
28
- /** The host's `x-zite-*` headers. Only the host can set them: the front strips them from public traffic. */
29
- export function hostContextFromRequest(request) {
31
+ const INVOKE_KINDS = new Set(["webhook", "describe", "workflow", "warm"]);
32
+ /** What the host dispatched this request with. Headers are ignored: props are the only thing it alone can set. */
33
+ export function hostContextFromProps(request, props) {
34
+ return {
35
+ requestId: props.requestId,
36
+ url: request.url,
37
+ user: props.user,
38
+ mode: props.mode,
39
+ invoke: props.invoke,
40
+ runs: [],
41
+ platform: props.platform,
42
+ };
43
+ }
44
+ /**
45
+ * A request the host didn't dispatch: `zitejs describe` under `vite preview`,
46
+ * and tests, which pass their own platform. Deployed builds have no public URL,
47
+ * so in production every request carries props.
48
+ */
49
+ export function hostContextFromRequest(request, platform) {
30
50
  const { headers } = request;
31
51
  const user = headers.get("x-zite-user");
32
52
  const [kind, name = ""] = headers.get("x-zite-invoke")?.split(":") ?? [];
@@ -34,8 +54,10 @@ export function hostContextFromRequest(request) {
34
54
  requestId: headers.get("x-zite-request-id") ?? crypto.randomUUID(),
35
55
  url: request.url,
36
56
  user: user ? decodeUser(user) : null,
37
- invoke: kind === "webhook" || kind === "describe" || kind === "workflow" ? { kind, name } : null,
57
+ mode: "live",
58
+ invoke: INVOKE_KINDS.has(kind) ? { kind: kind, name } : null,
38
59
  runs: [],
60
+ platform,
39
61
  };
40
62
  }
41
63
  /** Every server function TanStack compiled, written in by `zite()` at build. Empty in dev, where TanStack doesn't call its id hook. */
@@ -83,27 +105,35 @@ async function invokeWorkflow(request, host, workflows) {
83
105
  });
84
106
  return Response.json(result, { status: result.ok ? 200 : 500 });
85
107
  }
108
+ const isZiteError = (error) => error instanceof Error && error.name === "ZiteError" && error.code in ZITE_ERROR_STATUS;
109
+ // TanStack renders any loader error as a 500. A page refused with a ZiteError
110
+ // (a missing role, a record that isn't there) answers with that error's status.
111
+ const pageStatus = (host) => {
112
+ const matches = host.router?.state?.matches;
113
+ const failed = matches?.find((match) => match.status === "error")?.error;
114
+ return isZiteError(failed) ? ZITE_ERROR_STATUS[failed.code] : undefined;
115
+ };
86
116
  /**
87
- * The fetch handler of a Start app's server entry. Describe calls get the app's
88
- * description; everything else runs TanStack with the host's identity in
89
- * server-only storage. Platform calls go to the `ZITE` binding from the Worker's
90
- * env, or to `platform` when a test or local dev supplies one.
117
+ * The fetch handler of a Start app's server entry. A request the host
118
+ * dispatched carries its identity and platform in props; anything else (describe
119
+ * under `vite preview`, tests) reads `x-zite-*` headers and uses `platform`
120
+ * if one is given.
91
121
  */
92
122
  export function createZiteFetch(options) {
93
- let binding = options.platform;
94
- // Fails closed: no binding means no platform access, never a fallback.
95
- const platform = () => {
96
- if (!binding)
97
- throw new Error("The Zite platform binding is missing");
98
- return binding;
99
- };
100
- installPlatformTransport((call) => platform().call(call));
123
+ installPlatformTransport();
101
124
  // Loaded on first use, so a page request never pays for importing workflow modules.
102
125
  let workflows;
103
126
  const loadedWorkflows = () => (workflows ??= loadWorkflows());
104
127
  return async (request, ...rest) => {
105
- binding ??= rest[0]?.ZITE;
106
- if (request.headers.get("x-zite-invoke") === "describe") {
128
+ const ctx = rest[1];
129
+ const host = ctx?.props?.platform
130
+ ? hostContextFromProps(request, ctx.props)
131
+ : hostContextFromRequest(request, options.platform);
132
+ // Starting this isolate was the point: the platform warms a build before its
133
+ // first visitors, and no app code runs.
134
+ if (host.invoke?.kind === "warm")
135
+ return new Response(null, { status: 204 });
136
+ if (host.invoke?.kind === "describe") {
107
137
  return Response.json(await describeApp({
108
138
  fnIds: builtFunctionIds(),
109
139
  descriptions: builtFunctionDescriptions(),
@@ -112,15 +142,18 @@ export function createZiteFetch(options) {
112
142
  workflows: await loadedWorkflows(),
113
143
  }));
114
144
  }
115
- const host = hostContextFromRequest(request);
116
- const response = await runWithHost(host, async () => host.invoke?.kind === "workflow" ? invokeWorkflow(request, host, await loadedWorkflows()) : options.handler.fetch(request));
145
+ let response = await runWithHost(host, async () => host.invoke?.kind === "workflow" ? invokeWorkflow(request, host, await loadedWorkflows()) : options.handler.fetch(request));
146
+ const status = response.status === 500 ? pageStatus(host) : undefined;
147
+ if (status)
148
+ response = new Response(response.body, { status, headers: response.headers });
117
149
  return onBodyEnd(response, () => {
118
- if (!host.runs.length || !binding)
150
+ const { platform } = host;
151
+ if (!host.runs.length || !platform)
119
152
  return;
120
- const flushed = binding.runs({ requestId: host.requestId, runs: host.runs }).catch((error) => {
153
+ const flushed = platform.runs({ runs: host.runs }).catch((error) => {
121
154
  console.error("[zite] couldn't record runs", error);
122
155
  });
123
- rest[1]?.waitUntil(flushed);
156
+ ctx?.waitUntil(flushed);
124
157
  });
125
158
  };
126
159
  }
@@ -18,15 +18,64 @@ export type RunRecord = {
18
18
  message: string;
19
19
  };
20
20
  };
21
+ export type AccessKind = "signedIn" | "anyone" | "webhook" | "workflow";
22
+ /** One data, integration or email call. Who is calling is the platform's to know, never the app's to say. */
23
+ export type PlatformCallInput = {
24
+ integrationId: string;
25
+ className: string;
26
+ methodName: string;
27
+ params: unknown;
28
+ /** The access decision the call ran under; the platform may narrow on it but never widens. */
29
+ access: AccessKind;
30
+ };
31
+ /**
32
+ * A call's answer. Errors come back as values: Workers RPC keeps a thrown
33
+ * error's message but drops its code.
34
+ */
35
+ export type PlatformCallResult = {
36
+ value: unknown;
37
+ } | {
38
+ error: {
39
+ code: string;
40
+ message: string;
41
+ };
42
+ };
43
+ export type PlatformSession = {
44
+ call(call: PlatformCallInput): Promise<PlatformCallResult>;
45
+ };
46
+ /** What the host hands each request: its platform, already scoped to the app, build, request and user. */
47
+ export type PlatformBinding = {
48
+ /** Every platform call of one request goes through one session. */
49
+ session(): PlatformSession | Promise<PlatformSession>;
50
+ /** One batch per request, after its response has ended. */
51
+ runs(batch: {
52
+ runs: RunRecord[];
53
+ }): Promise<void>;
54
+ };
55
+ export type HostMode = "live" | "preview" | "validate";
56
+ export type HostInvoke = {
57
+ kind: "webhook" | "describe" | "workflow" | "warm";
58
+ name: string;
59
+ };
60
+ /** The per-request props the host dispatches with. Only the host can set them. */
61
+ export type HostProps = {
62
+ platform: PlatformBinding;
63
+ requestId: string;
64
+ user: User | null;
65
+ mode: HostMode;
66
+ invoke: HostInvoke | null;
67
+ };
21
68
  /** What the host resolved for this request. Set by the generated server entry, never by the browser. */
22
69
  export type ZiteRequestContext = {
23
70
  requestId: string;
24
71
  url: string;
25
72
  user: User | null;
26
- invoke: {
27
- kind: "webhook" | "describe" | "workflow";
28
- name: string;
29
- } | null;
73
+ mode: HostMode;
74
+ invoke: HostInvoke | null;
30
75
  /** Filled by run capture, flushed to the platform once the response ends. */
31
76
  runs: RunRecord[];
77
+ platform?: PlatformBinding;
78
+ session?: Promise<PlatformSession>;
79
+ /** This request's router, so a page's status can follow the error it rendered. */
80
+ router?: unknown;
32
81
  };
@@ -66,6 +66,9 @@ export function zite(options = {}) {
66
66
  },
67
67
  };
68
68
  },
69
+ // Each new isolate loads the whole server bundle, so its size is cold-start
70
+ // time; Vite leaves SSR output unminified.
71
+ configEnvironment: (name, config) => name === "ssr" && options.cloudflare !== false && config.build?.minify === undefined ? { build: { minify: true } } : undefined,
69
72
  };
70
73
  // Each server function's `description` option, by name, for the editor.
71
74
  const descriptions = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zitejs",
3
- "version": "0.10.0-start.2",
3
+ "version": "0.10.0-start.3",
4
4
  "description": "The Zite framework — build apps on Zite Database",
5
5
  "type": "module",
6
6
  "sideEffects": false,