zitejs 0.10.0-start.10 → 0.10.0-start.12

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.
@@ -187,3 +187,33 @@ function redirectParamOf(href) {
187
187
  (0, vitest_1.expect)(params.get('usageToken')).toBeNull();
188
188
  });
189
189
  });
190
+ // AUTH-03: what the platform's auth routes receive from the client, on the app's own origin.
191
+ (0, vitest_1.describe)("the client's calls", () => {
192
+ const calls = () => {
193
+ const sent = [];
194
+ vitest_1.vi.stubGlobal('fetch', vitest_1.vi.fn(async (input, init) => {
195
+ const request = new Request(new URL(String(input instanceof Request ? input.url : input), 'https://app.zite.so'), init);
196
+ sent.push({
197
+ path: new URL(request.url).pathname,
198
+ method: request.method,
199
+ body: request.method === 'POST' ? await request.json().catch(() => null) : null,
200
+ });
201
+ return Response.json({});
202
+ }));
203
+ return sent;
204
+ };
205
+ (0, vitest_1.it)('sign in by link, sign out and edit the profile on /api/auth', async () => {
206
+ const sent = calls();
207
+ // The client takes its fetch when the module loads.
208
+ vitest_1.vi.resetModules();
209
+ const { signIn, signOut, updateProfile } = await Promise.resolve().then(() => __importStar(require('./index.js')));
210
+ await signIn.magicLink({ email: 'ada@x.co' });
211
+ await signOut();
212
+ await updateProfile({ firstName: 'Ada' });
213
+ (0, vitest_1.expect)(sent).toEqual([
214
+ { path: '/api/auth/sign-in/magic-link', method: 'POST', body: vitest_1.expect.objectContaining({ email: 'ada@x.co' }) },
215
+ { path: '/api/auth/sign-out', method: 'POST', body: vitest_1.expect.anything() },
216
+ { path: '/api/auth/update-user', method: 'POST', body: { firstName: 'Ada' } },
217
+ ]);
218
+ });
219
+ });
@@ -17,15 +17,17 @@ const renderStartServerEntry = (target = "cloudflare") => target === "cloudflare
17
17
  import handler from '@tanstack/react-start/server-entry';
18
18
  import { createZiteWorker } from 'zitejs/start/worker';
19
19
  import { getRouter } from '../src/router';
20
+ import { startInstance } from './start';
20
21
 
21
- export default createZiteWorker({ handler, getRouter });
22
+ export default createZiteWorker({ handler, getRouter, startInstance });
22
23
  `
23
24
  : `// Generated by zitejs for TanStack Start apps. The platform owns this file.
24
25
  import handler, { createServerEntry } from '@tanstack/react-start/server-entry';
25
26
  import { createZiteFetch } from 'zitejs/start/server';
26
27
  import { getRouter } from '../src/router';
28
+ import { startInstance } from './start';
27
29
 
28
- export default createServerEntry({ fetch: createZiteFetch({ handler, getRouter }) });
30
+ export default createServerEntry({ fetch: createZiteFetch({ handler, getRouter, startInstance }) });
29
31
  `;
30
32
  exports.renderStartServerEntry = renderStartServerEntry;
31
33
  /** The start instance: the platform's middleware, then the app's own `src/start.ts` when it has one. */
@@ -152,3 +152,33 @@ describe('loginWithRedirect', () => {
152
152
  expect(params.get('usageToken')).toBeNull();
153
153
  });
154
154
  });
155
+ // AUTH-03: what the platform's auth routes receive from the client, on the app's own origin.
156
+ describe("the client's calls", () => {
157
+ const calls = () => {
158
+ const sent = [];
159
+ vi.stubGlobal('fetch', vi.fn(async (input, init) => {
160
+ const request = new Request(new URL(String(input instanceof Request ? input.url : input), 'https://app.zite.so'), init);
161
+ sent.push({
162
+ path: new URL(request.url).pathname,
163
+ method: request.method,
164
+ body: request.method === 'POST' ? await request.json().catch(() => null) : null,
165
+ });
166
+ return Response.json({});
167
+ }));
168
+ return sent;
169
+ };
170
+ it('sign in by link, sign out and edit the profile on /api/auth', async () => {
171
+ const sent = calls();
172
+ // The client takes its fetch when the module loads.
173
+ vi.resetModules();
174
+ const { signIn, signOut, updateProfile } = await import('./index.js');
175
+ await signIn.magicLink({ email: 'ada@x.co' });
176
+ await signOut();
177
+ await updateProfile({ firstName: 'Ada' });
178
+ expect(sent).toEqual([
179
+ { path: '/api/auth/sign-in/magic-link', method: 'POST', body: expect.objectContaining({ email: 'ada@x.co' }) },
180
+ { path: '/api/auth/sign-out', method: 'POST', body: expect.anything() },
181
+ { path: '/api/auth/update-user', method: 'POST', body: { firstName: 'Ada' } },
182
+ ]);
183
+ });
184
+ });
@@ -12,15 +12,17 @@ export const renderStartServerEntry = (target = "cloudflare") => target === "clo
12
12
  import handler from '@tanstack/react-start/server-entry';
13
13
  import { createZiteWorker } from 'zitejs/start/worker';
14
14
  import { getRouter } from '../src/router';
15
+ import { startInstance } from './start';
15
16
 
16
- export default createZiteWorker({ handler, getRouter });
17
+ export default createZiteWorker({ handler, getRouter, startInstance });
17
18
  `
18
19
  : `// Generated by zitejs for TanStack Start apps. The platform owns this file.
19
20
  import handler, { createServerEntry } from '@tanstack/react-start/server-entry';
20
21
  import { createZiteFetch } from 'zitejs/start/server';
21
22
  import { getRouter } from '../src/router';
23
+ import { startInstance } from './start';
22
24
 
23
- export default createServerEntry({ fetch: createZiteFetch({ handler, getRouter }) });
25
+ export default createServerEntry({ fetch: createZiteFetch({ handler, getRouter, startInstance }) });
24
26
  `;
25
27
  /** The start instance: the platform's middleware, then the app's own `src/start.ts` when it has one. */
26
28
  export const renderStartInstance = (hasAppStart) => hasAppStart
@@ -6,6 +6,7 @@ import type { AnyFunctionMiddleware, AnyRequestMiddleware, StartInstance } from
6
6
  * adapters first, then whatever the app's own `src/start.ts` sets.
7
7
  */
8
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, {
9
+ stack?: string | undefined;
9
10
  code: "BAD_REQUEST" | "UNAUTHORIZED" | "FORBIDDEN" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMITED" | "INTERNAL_ERROR" | "INVALID_INPUT";
10
11
  message: string;
11
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]>;
@@ -5,6 +5,7 @@ import { createSerializationAdapter, redirect } from "@tanstack/react-router";
5
5
  import { toClientError, ZiteError } from "./errors.js";
6
6
  import { isSignInRequired, loginHref } from "./index.js";
7
7
  import { errorOf, preview } from "./runLog.js";
8
+ import { currentHost } from "./host.js";
8
9
  import { getHost, inScope } from "./scope.js";
9
10
  // TanStack drops its default CSRF check once an app has a start instance.
10
11
  const csrf = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" });
@@ -50,16 +51,24 @@ const runCapture = createMiddleware({ type: "function" }).server(async ({ next,
50
51
  });
51
52
  /**
52
53
  * 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
+ * message classic would show. Anything else on it, an SDK's request, stays on the server; its stack goes only to the
55
+ * editor's preview, for "Fix it for me".
54
56
  */
55
57
  const ziteErrorAdapter = createSerializationAdapter({
56
58
  key: "zite-error",
57
59
  test: (value) => value instanceof Error,
58
60
  toSerializable: (error) => {
59
61
  const { code, message } = toClientError(error);
60
- return { code, message };
62
+ // `currentHost`, not `getHost`: this adapter ships to the browser, which mustn't bundle the request scope.
63
+ const stack = currentHost()?.mode === "preview" ? error.stack : undefined;
64
+ return { code, message, ...(stack && { stack }) };
65
+ },
66
+ fromSerializable: ({ code, message, stack }) => {
67
+ const error = new ZiteError(code, message);
68
+ if (stack)
69
+ error.stack = stack;
70
+ return error;
61
71
  },
62
- fromSerializable: ({ code, message }) => new ZiteError(code, message),
63
72
  });
64
73
  /**
65
74
  * The start instance every Start app builds with: the platform's middleware and
@@ -0,0 +1,2 @@
1
+ /** Runs `fn` with this request's console dropped; the isolate's other requests log as before. */
2
+ export declare const withoutConsole: <T>(fn: () => T) => T;
@@ -0,0 +1,27 @@
1
+ // Server-only. A trigger registration's console stays out of the platform's
2
+ // logs, as classic's does: the provider can answer it with the signing secret.
3
+ import { AsyncLocalStorage } from "node:async_hooks";
4
+ const LEVELS = ["debug", "dir", "error", "info", "log", "table", "trace", "warn"];
5
+ // Keyed on globalThis so two copies of zitejs in one bundle wrap console once.
6
+ const stores = globalThis;
7
+ const QUIET_KEY = Symbol.for("zitejs:quiet-console");
8
+ const quietStorage = () => {
9
+ const held = stores[QUIET_KEY];
10
+ if (held)
11
+ return held;
12
+ const storage = new AsyncLocalStorage();
13
+ stores[QUIET_KEY] = storage;
14
+ const methods = console;
15
+ for (const level of LEVELS) {
16
+ const original = methods[level];
17
+ if (typeof original !== "function")
18
+ continue;
19
+ methods[level] = (...args) => {
20
+ if (!storage.getStore())
21
+ original.apply(console, args);
22
+ };
23
+ }
24
+ return storage;
25
+ };
26
+ /** Runs `fn` with this request's console dropped; the isolate's other requests log as before. */
27
+ export const withoutConsole = (fn) => quietStorage().run(true, fn);
@@ -11,6 +11,11 @@ type Dehydrated = {
11
11
  user: User | null;
12
12
  };
13
13
  type ZiteRouterOptions<TRouteTree extends AnyRoute> = Omit<RouterConstructorOptions<TRouteTree, "never", false, RouterHistory, Dehydrated>, "context" | "dehydrate" | "hydrate">;
14
+ /**
15
+ * @internal Tells the editor's preview the page crashed, as classic's runtime reports an emptied #root: a Start page
16
+ * has none, and an error a route's boundary catches is the crash. Being signed out isn't one.
17
+ */
18
+ export declare const reportCrash: (error: unknown) => void;
14
19
  /**
15
20
  * The router for `src/router.tsx`. The signed-in user is in `context.user`: on the server from the platform, in the
16
21
  * browser from the page it rendered, so no request asks for it again. `context.queryClient` is new for each request
@@ -9,12 +9,21 @@ import { currentHost } from "./host.js";
9
9
  export const QUERY_STALE_TIME_MS = 30_000;
10
10
  const ziteWindow = () => (typeof window === "undefined" ? undefined : window);
11
11
  const box = { maxWidth: 480, margin: "15vh auto", padding: 24, fontFamily: "system-ui, sans-serif", textAlign: "center" };
12
+ /**
13
+ * @internal Tells the editor's preview the page crashed, as classic's runtime reports an emptied #root: a Start page
14
+ * has none, and an error a route's boundary catches is the crash. Being signed out isn't one.
15
+ */
16
+ export const reportCrash = (error) => {
17
+ if (!isSignInError(error))
18
+ ziteWindow()?._ziteOnCrash?.(error);
19
+ };
12
20
  // As classic's crash screen: the message, "Fix it for me" in the editor, and a reload. The server renders from the
13
21
  // app's own error and the browser from the one that crossed, already mapped, so both show the same text.
14
22
  function ZiteErrorComponent({ error }) {
15
23
  const router = useRouter();
16
24
  const [allowAiFix, setAllowAiFix] = useState(false);
17
25
  useEffect(() => setAllowAiFix(ziteWindow()?.__ziteAllowAiFix === true), []);
26
+ useEffect(() => markReady("error"), []);
18
27
  const message = typeof window === "undefined" ? toClientError(error).message : error instanceof Error ? error.message : String(error);
19
28
  const reload = () => {
20
29
  const onReload = ziteWindow()?._ziteOnReload;
@@ -27,6 +36,20 @@ function ZiteErrorComponent({ error }) {
27
36
  !isSignInError(error) &&
28
37
  h("button", { type: "button", onClick: () => ziteWindow()?._ziteOnFixError?.(error) }, "Fix it for me"), h("button", { type: "button", onClick: reload }, "Try again"));
29
38
  }
39
+ /**
40
+ * `<html data-zite-ready>`: what the platform's screenshots wait for. `1` once
41
+ * the page has rendered with nothing loading, `error` when the error screen did.
42
+ */
43
+ const markReady = (state) => {
44
+ if (typeof document !== "undefined")
45
+ document.documentElement.dataset.ziteReady = state;
46
+ };
47
+ const watchReady = (router) => () => {
48
+ const { status, isLoading, matches } = router.state;
49
+ if (status !== "idle" || isLoading || matches.some((match) => match.status === "pending"))
50
+ return;
51
+ markReady(matches.some((match) => match.status === "error") ? "error" : "1");
52
+ };
30
53
  function ZiteNotFound() {
31
54
  return h("div", { style: box }, h("p", { style: { fontWeight: 600 } }, "Page not found"), h(Link, { to: "/" }, "Go home"));
32
55
  }
@@ -67,6 +90,12 @@ export function createZiteRouter(options) {
67
90
  defaultPendingComponent: ZitePending,
68
91
  ...options,
69
92
  ...(claim && { rewrite: keepPreviewClaim(claim, options.rewrite) }),
93
+ // Whichever screen shows it: a route may have its own.
94
+ defaultOnCatch: (error, info) => {
95
+ markReady("error");
96
+ reportCrash(error);
97
+ options.defaultOnCatch?.(error, info);
98
+ },
70
99
  context,
71
100
  dehydrate: () => ({ user: context.user }),
72
101
  hydrate: (dehydrated) => {
@@ -74,6 +103,9 @@ export function createZiteRouter(options) {
74
103
  },
75
104
  });
76
105
  setupRouterSsrQueryIntegration({ router, queryClient });
106
+ const mark = watchReady(router);
107
+ router.subscribe("onResolved", mark);
108
+ router.subscribe("onRendered", mark);
77
109
  const host = currentHost();
78
110
  if (host)
79
111
  host.router = router;
@@ -35,6 +35,10 @@ export type ZiteServerOptions = {
35
35
  };
36
36
  getRouter: () => unknown;
37
37
  platform?: PlatformBinding;
38
+ /** The app's start instance, which a test run of a server function runs under. Entries before 0.10.0-start.11 don't pass it. */
39
+ startInstance?: {
40
+ getOptions: () => unknown;
41
+ };
38
42
  };
39
43
  type HostedContext = WorkerContext & {
40
44
  props?: Partial<HostProps>;
@@ -1,3 +1,7 @@
1
+ // For the generated server entry only. Apps never import this.
2
+ import { requestHandler } from "@tanstack/react-start/server";
3
+ import { createSsrRpc } from "@tanstack/react-start/ssr-rpc";
4
+ import { runWithStartContext } from "@tanstack/start-storage-context";
1
5
  import { asZiteError, isZiteErrorCode, ZiteError, ZITE_ERROR_STATUS } from "./errors.js";
2
6
  import { describeApp } from "./describe.js";
3
7
  import { runRegistration } from "./registration.js";
@@ -5,6 +9,7 @@ import { isAuthHookName, loadAuthHooks } from "./authHooks.js";
5
9
  import { errorOf, preview } from "./runLog.js";
6
10
  import { DATA_INTEGRATION_ID, dataContextOf, runDataCall } from "./data/index.js";
7
11
  import { airtableSettingsOf, runAirtableCall } from "./airtable/index.js";
12
+ import { withoutConsole } from "./quiet.js";
8
13
  import { getHost, requireScope, runInScope, runWithHost } from "./scope.js";
9
14
  import { ZITEJS_VERSION } from "./version.js";
10
15
  import { loadWorkflows, loggedInput, runWorkflow } from "./workflowRun.js";
@@ -91,7 +96,7 @@ const watchOutboundFetches = () => {
91
96
  };
92
97
  };
93
98
  const decodeUser = (value) => JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0))));
94
- const INVOKE_KINDS = new Set(["webhook", "describe", "workflow", "register", "warm", "authHook"]);
99
+ const INVOKE_KINDS = new Set(["webhook", "describe", "workflow", "register", "warm", "authHook", "function"]);
95
100
  // What a request without props may ask for: neither runs app code on anyone's behalf.
96
101
  const UNHOSTED_INVOKE_KINDS = new Set(["warm", "describe"]);
97
102
  /** What the host dispatched this request with. Headers are ignored: props are the only thing it alone can set. */
@@ -206,6 +211,38 @@ async function invokeWorkflow(request, host, workflows) {
206
211
  });
207
212
  return Response.json(result, { status: result.ok ? 200 : 500 });
208
213
  }
214
+ // The host sends `{ input }`, an editor's test run of a server function. It runs
215
+ // as a call from a page would on the server: its own middleware decides access
216
+ // for the user the host dispatched with, validates the input and records the run.
217
+ async function invokeFunction(request, options) {
218
+ if (request.method !== "POST" || new URL(request.url).pathname !== INVOKE_PATH)
219
+ return new Response("Not found", { status: 404 });
220
+ const id = getHost()?.invoke?.name ?? "";
221
+ if (!builtFunctionIds().includes(id)) {
222
+ return Response.json({ ok: false, error: { code: "NOT_FOUND", message: `No server function ${id}` } }, { status: 404 });
223
+ }
224
+ const input = (await request.json().catch(() => ({})))?.input;
225
+ const startContext = {
226
+ request,
227
+ getRouter: options.getRouter,
228
+ startOptions: (await options.startInstance?.getOptions()) ?? {},
229
+ executedRequestMiddlewares: new Set(),
230
+ contextAfterGlobalMiddlewares: {},
231
+ handlerType: "serverFn",
232
+ };
233
+ // In TanStack's request event too, which the function's own status setting reads.
234
+ return requestHandler(async () => {
235
+ try {
236
+ const answer = (await runWithStartContext(startContext, () => createSsrRpc(id)({ data: input, context: {} })));
237
+ if (answer.error !== undefined)
238
+ throw answer.error;
239
+ return Response.json({ ok: true, output: answer.result ?? null });
240
+ }
241
+ catch (error) {
242
+ return Response.json({ ok: false, error: errorOf(error) }, { status: 500 });
243
+ }
244
+ })(request, undefined);
245
+ }
209
246
  // The host sends `{ op, input }`: `subscribe` with the platform's notification
210
247
  // URL and secret, or `unsubscribe` with what `subscribe` returned.
211
248
  async function invokeRegistration(request, host, workflows) {
@@ -283,11 +320,13 @@ export function createZiteHandlers(options) {
283
320
  }
284
321
  let response = await runWithHost(host, async () => host.invoke?.kind === "workflow"
285
322
  ? invokeWorkflow(request, host, await loadedWorkflows())
286
- : host.invoke?.kind === "register"
287
- ? invokeRegistration(request, host, await loadedWorkflows())
288
- : host.invoke?.kind === "authHook"
289
- ? invokeAuthHook(request, host)
290
- : options.handler.fetch(request));
323
+ : host.invoke?.kind === "function"
324
+ ? invokeFunction(request, options)
325
+ : host.invoke?.kind === "register"
326
+ ? withoutConsole(async () => invokeRegistration(request, host, await loadedWorkflows()))
327
+ : host.invoke?.kind === "authHook"
328
+ ? invokeAuthHook(request, host)
329
+ : options.handler.fetch(request));
291
330
  const status = response.status === 500 ? pageStatus(host) : undefined;
292
331
  if (status)
293
332
  response = new Response(response.body, { status, headers: response.headers });
@@ -120,7 +120,7 @@ export type PlatformBinding = {
120
120
  /** A demo is the published build serving a read-only visitor: as live, but every write is refused. */
121
121
  export type HostMode = "live" | "preview" | "demo" | "validate";
122
122
  export type HostInvoke = {
123
- kind: "webhook" | "describe" | "workflow" | "register" | "warm" | "authHook";
123
+ kind: "webhook" | "describe" | "workflow" | "register" | "warm" | "authHook" | "function";
124
124
  name: string;
125
125
  };
126
126
  /** The per-request props the host dispatches with. Only the host can set them. */
@@ -1 +1 @@
1
- export declare const ZITEJS_VERSION = "0.10.0-start.10";
1
+ export declare const ZITEJS_VERSION = "0.10.0-start.12";
@@ -1,2 +1,2 @@
1
1
  // Written by scripts/write-version.mjs from package.json.
2
- export const ZITEJS_VERSION = "0.10.0-start.10";
2
+ export const ZITEJS_VERSION = "0.10.0-start.12";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zitejs",
3
- "version": "0.10.0-start.10",
3
+ "version": "0.10.0-start.12",
4
4
  "description": "The Zite framework — build apps on Zite Database",
5
5
  "type": "module",
6
6
  "sideEffects": false,