zitejs 0.10.0-start.1 → 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.
Files changed (35) hide show
  1. package/dist/cjs/check/index.js +81 -3
  2. package/dist/cjs/start/entries.d.ts +5 -0
  3. package/dist/cjs/start/entries.js +32 -3
  4. package/dist/cjs/start/problems.d.ts +19 -0
  5. package/dist/cjs/start/problems.js +11 -0
  6. package/dist/esm/check/index.js +48 -3
  7. package/dist/esm/start/describe.d.ts +16 -0
  8. package/dist/esm/start/describe.js +29 -4
  9. package/dist/esm/start/descriptions.d.ts +4 -0
  10. package/dist/esm/start/descriptions.js +47 -0
  11. package/dist/esm/start/entries.d.ts +5 -0
  12. package/dist/esm/start/entries.js +30 -4
  13. package/dist/esm/start/index.d.ts +6 -0
  14. package/dist/esm/start/index.js +32 -3
  15. package/dist/esm/start/instance.d.ts +1 -1
  16. package/dist/esm/start/instance.js +1 -17
  17. package/dist/esm/start/problems.d.ts +19 -0
  18. package/dist/esm/start/problems.js +8 -0
  19. package/dist/esm/start/router.js +3 -0
  20. package/dist/esm/start/runLog.d.ts +5 -0
  21. package/dist/esm/start/runLog.js +18 -0
  22. package/dist/esm/start/scope.d.ts +2 -2
  23. package/dist/esm/start/server.d.ts +21 -31
  24. package/dist/esm/start/server.js +115 -37
  25. package/dist/esm/start/types.d.ts +53 -4
  26. package/dist/esm/start/vite.js +33 -4
  27. package/dist/esm/start/workflow.d.ts +36 -0
  28. package/dist/esm/start/workflow.js +13 -0
  29. package/dist/esm/start/workflow.test-d.d.ts +1 -0
  30. package/dist/esm/start/workflow.test-d.js +24 -0
  31. package/dist/esm/start/workflowModules.d.ts +1 -0
  32. package/dist/esm/start/workflowModules.js +3 -0
  33. package/dist/esm/start/workflowRun.d.ts +80 -0
  34. package/dist/esm/start/workflowRun.js +107 -0
  35. package/package.json +1 -1
@@ -0,0 +1,8 @@
1
+ /** Every problem in a description, one line each, named by its rule. Any of them fails the build. */
2
+ export function descriptionProblems(description) {
3
+ return [
4
+ ...description.functions.flatMap((f) => f.problems.map((p) => `function ${f.id}${f.file ? ` (${f.file})` : ""}: ${p}`)),
5
+ ...description.routes.flatMap((r) => r.problems.map((p) => `route ${r.path}: ${p}`)),
6
+ ...(description.workflows ?? []).flatMap((w) => w.problems.map((p) => `workflow ${w.id} (${w.file}): ${p}`)),
7
+ ];
8
+ }
@@ -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
  }
@@ -0,0 +1,5 @@
1
+ export declare const preview: (value: unknown) => string | undefined;
2
+ export declare const errorOf: (error: unknown) => {
3
+ code: string;
4
+ message: string;
5
+ };
@@ -0,0 +1,18 @@
1
+ import { ZiteError } from "./index.js";
2
+ const PREVIEW_LIMIT = 8_000;
3
+ export const preview = (value) => {
4
+ if (value === undefined)
5
+ return undefined;
6
+ if (typeof value === "object" && value !== null && Symbol.asyncIterator in value)
7
+ return "[stream]";
8
+ try {
9
+ const json = JSON.stringify(value);
10
+ return json && json.length > PREVIEW_LIMIT ? `${json.slice(0, PREVIEW_LIMIT)}…` : json;
11
+ }
12
+ catch {
13
+ return "[unserializable]";
14
+ }
15
+ };
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) };
@@ -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,42 +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[];
22
+ /** Each server function's `description` option, written in by `zite()` at build. */
23
+ export declare const builtFunctionDescriptions: () => Record<string, string>;
34
24
  type Fetch = (request: Request, ...rest: unknown[]) => Promise<Response> | Response;
35
25
  /**
36
- * The fetch handler of a Start app's server entry. Describe calls get the app's
37
- * description; everything else runs TanStack with the host's identity in
38
- * server-only storage. Platform calls go to the `ZITE` binding from the Worker's
39
- * 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.
40
30
  */
41
31
  export declare function createZiteFetch(options: {
42
32
  handler: {
@@ -1,30 +1,52 @@
1
+ import { ZiteError, ZITE_ERROR_STATUS } from "./index.js";
1
2
  import { describeApp } from "./describe.js";
3
+ import { preview } from "./runLog.js";
2
4
  import { getHost, requireScope, runWithHost } from "./scope.js";
5
+ import { loadWorkflows, loggedInput, runWorkflow } from "./workflowRun.js";
3
6
  export { runWithHost } from "./scope.js";
4
7
  export { describeApp } from "./describe.js";
5
8
  /**
6
- * Routes every zitejs data, integration and email call through `call`, and only
7
- * inside an access scope. The access kind travels with the call so the platform
8
- * 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.
9
13
  */
10
- export function installPlatformTransport(call) {
11
- const sdkCall = (integrationId, className, methodName, params) => {
14
+ export function installPlatformTransport() {
15
+ const sdkCall = async (integrationId, className, methodName, params) => {
12
16
  const scope = requireScope(`${className}.${methodName}`);
13
- return call({
14
- integrationId,
15
- className,
16
- methodName,
17
- params,
18
- access: scope.kind,
19
- userId: scope.user?.id ?? null,
20
- requestId: getHost()?.requestId ?? "",
21
- });
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);
22
27
  };
23
28
  globalThis.__wrapSdkCall = sdkCall;
24
29
  }
25
30
  const decodeUser = (value) => JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0))));
26
- /** The host's `x-zite-*` headers. Only the host can set them: the front strips them from public traffic. */
27
- 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) {
28
50
  const { headers } = request;
29
51
  const user = headers.get("x-zite-user");
30
52
  const [kind, name = ""] = headers.get("x-zite-invoke")?.split(":") ?? [];
@@ -32,12 +54,24 @@ export function hostContextFromRequest(request) {
32
54
  requestId: headers.get("x-zite-request-id") ?? crypto.randomUUID(),
33
55
  url: request.url,
34
56
  user: user ? decodeUser(user) : null,
35
- invoke: kind === "webhook" || kind === "describe" || kind === "workflow" ? { kind, name } : null,
57
+ mode: "live",
58
+ invoke: INVOKE_KINDS.has(kind) ? { kind: kind, name } : null,
36
59
  runs: [],
60
+ platform,
37
61
  };
38
62
  }
39
63
  /** Every server function TanStack compiled, written in by `zite()` at build. Empty in dev, where TanStack doesn't call its id hook. */
40
64
  export const builtFunctionIds = () => "__ZITE_FN_IDS__".split(",").filter((id) => id && !id.startsWith("__ZITE"));
65
+ const parseDescriptions = (json) => {
66
+ try {
67
+ return JSON.parse(json);
68
+ }
69
+ catch {
70
+ return {};
71
+ }
72
+ };
73
+ /** Each server function's `description` option, written in by `zite()` at build. */
74
+ export const builtFunctionDescriptions = () => parseDescriptions("__ZITE_FN_DESCRIPTIONS__");
41
75
  // Server functions also run while a streamed page renders, so the run log is
42
76
  // complete only once the body has been read to the end.
43
77
  const onBodyEnd = (response, done) => {
@@ -47,35 +81,79 @@ const onBodyEnd = (response, done) => {
47
81
  }
48
82
  return new Response(response.body.pipeThrough(new TransformStream({ flush: done })), response);
49
83
  };
84
+ const INVOKE_PATH = "/_zite/invoke";
85
+ // The host sends `{ input }`; a platform-fired run's input carries the runner's
86
+ // `__cron`, `__poll` or `__webhook` state, exactly as for classic endpoints.
87
+ async function invokeWorkflow(request, host, workflows) {
88
+ if (request.method !== "POST" || new URL(request.url).pathname !== INVOKE_PATH)
89
+ return new Response("Not found", { status: 404 });
90
+ const name = host.invoke?.name;
91
+ const found = workflows.find((w) => w.id === name);
92
+ if (!found)
93
+ return Response.json({ ok: false, error: { code: "NOT_FOUND", message: `No workflow named ${name}` } }, { status: 404 });
94
+ const input = (await request.json().catch(() => ({})))?.input;
95
+ const startedAt = Date.now();
96
+ const result = await runWorkflow(found.workflow, input);
97
+ host.runs.push({
98
+ fn: `workflow:${found.id}`,
99
+ name: found.id,
100
+ nested: false,
101
+ startedAt,
102
+ durationMs: Date.now() - startedAt,
103
+ input: preview(loggedInput(input)),
104
+ ...(result.ok ? { output: preview(result.output) } : { error: result.error }),
105
+ });
106
+ return Response.json(result, { status: result.ok ? 200 : 500 });
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
+ };
50
116
  /**
51
- * The fetch handler of a Start app's server entry. Describe calls get the app's
52
- * description; everything else runs TanStack with the host's identity in
53
- * server-only storage. Platform calls go to the `ZITE` binding from the Worker's
54
- * 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.
55
121
  */
56
122
  export function createZiteFetch(options) {
57
- let binding = options.platform;
58
- // Fails closed: no binding means no platform access, never a fallback.
59
- const platform = () => {
60
- if (!binding)
61
- throw new Error("The Zite platform binding is missing");
62
- return binding;
63
- };
64
- installPlatformTransport((call) => platform().call(call));
123
+ installPlatformTransport();
124
+ // Loaded on first use, so a page request never pays for importing workflow modules.
125
+ let workflows;
126
+ const loadedWorkflows = () => (workflows ??= loadWorkflows());
65
127
  return async (request, ...rest) => {
66
- binding ??= rest[0]?.ZITE;
67
- if (request.headers.get("x-zite-invoke") === "describe") {
68
- return Response.json(await describeApp({ fnIds: builtFunctionIds(), getRouter: options.getRouter, request }));
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") {
137
+ return Response.json(await describeApp({
138
+ fnIds: builtFunctionIds(),
139
+ descriptions: builtFunctionDescriptions(),
140
+ getRouter: options.getRouter,
141
+ request,
142
+ workflows: await loadedWorkflows(),
143
+ }));
69
144
  }
70
- const host = hostContextFromRequest(request);
71
- const response = await runWithHost(host, () => 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 });
72
149
  return onBodyEnd(response, () => {
73
- if (!host.runs.length || !binding)
150
+ const { platform } = host;
151
+ if (!host.runs.length || !platform)
74
152
  return;
75
- const flushed = binding.runs({ requestId: host.requestId, runs: host.runs }).catch((error) => {
153
+ const flushed = platform.runs({ runs: host.runs }).catch((error) => {
76
154
  console.error("[zite] couldn't record runs", error);
77
155
  });
78
- rest[1]?.waitUntil(flushed);
156
+ ctx?.waitUntil(flushed);
79
157
  });
80
158
  };
81
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
  };
@@ -2,9 +2,13 @@ import path from "node:path";
2
2
  import { tanstackStart } from "@tanstack/react-start/plugin/vite";
3
3
  import viteReact from "@vitejs/plugin-react";
4
4
  import { ziteId } from "../vite/index.js";
5
- import { hasAppStart, renderStartInstance, renderStartServerEntry, START_INSTANCE_ENTRY, START_SERVER_ENTRY, writeIfChanged, } from "./entries.js";
6
- // Must match the literal in ./server.ts, which the build rewrites.
5
+ import { factoryDescriptions } from "./descriptions.js";
6
+ import { hasAppStart, renderStartInstance, renderStartServerEntry, renderWorkflowModules, START_INSTANCE_ENTRY, START_SERVER_ENTRY, workflowFiles, writeIfChanged, } from "./entries.js";
7
+ // Must match the literals in ./server.ts, which the build rewrites.
7
8
  const FN_IDS_TOKEN = "__ZITE_FN_IDS__";
9
+ const FN_DESCRIPTIONS_LITERAL = /(["'`])__ZITE_FN_DESCRIPTIONS__\1/;
10
+ // zitejs's empty registry, which the build replaces with the app's.
11
+ const WORKFLOW_REGISTRY = /[\\/]zitejs[\\/]dist[\\/]esm[\\/]start[\\/]workflowModules\.js$/;
8
12
  // Platform-pinned Worker settings. The deploy uses its own fixed template, so
9
13
  // these only have to match it; apps don't carry a wrangler config.
10
14
  const WORKER_COMPATIBILITY_DATE = "2026-09-25";
@@ -62,18 +66,42 @@ export function zite(options = {}) {
62
66
  },
63
67
  };
64
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,
65
72
  };
73
+ // Each server function's `description` option, by name, for the editor.
74
+ const descriptions = new Map();
75
+ const srcDir = `${path.join(appDir, "src")}${path.sep}`;
66
76
  const functionIds = {
67
77
  name: "zite:function-ids",
68
78
  apply: "build",
79
+ transform(code, id) {
80
+ if (this.environment?.config.consumer !== "server" || !id.startsWith(srcDir) || !code.includes("description"))
81
+ return;
82
+ try {
83
+ for (const [name, text] of factoryDescriptions(this.parse(code)))
84
+ descriptions.set(name, text);
85
+ }
86
+ catch {
87
+ // Display-only: an unreadable module just shows no descriptions.
88
+ }
89
+ },
69
90
  generateBundle(_, bundle) {
91
+ const described = Object.fromEntries([...descriptions].filter(([name]) => ids.has(name)));
70
92
  for (const chunk of Object.values(bundle)) {
71
- if (chunk.type === "chunk" && chunk.code.includes(FN_IDS_TOKEN)) {
93
+ if (chunk.type !== "chunk")
94
+ continue;
95
+ if (chunk.code.includes(FN_IDS_TOKEN))
72
96
  chunk.code = chunk.code.replace(FN_IDS_TOKEN, [...ids.keys()].join(","));
73
- }
97
+ chunk.code = chunk.code.replace(FN_DESCRIPTIONS_LITERAL, () => JSON.stringify(JSON.stringify(described)));
74
98
  }
75
99
  },
76
100
  };
101
+ const workflowRegistry = {
102
+ name: "zite:workflow-registry",
103
+ load: (id) => (WORKFLOW_REGISTRY.test(id.split("?")[0]) ? renderWorkflowModules(appDir, workflowFiles(appDir)) : undefined),
104
+ };
77
105
  return [
78
106
  tanstackStart({
79
107
  // TanStack resolves these from `src/`.
@@ -110,5 +138,6 @@ export function zite(options = {}) {
110
138
  ziteId(),
111
139
  compileZitejs,
112
140
  functionIds,
141
+ workflowRegistry,
113
142
  ];
114
143
  }
@@ -0,0 +1,36 @@
1
+ import type { ZiteTrigger, ZiteTriggerContext } from "../backend/index.js";
2
+ import type { ZiteSchedule } from "../schedules/index.js";
3
+ declare const WORKFLOW: unique symbol;
4
+ export type WorkflowConfig = {
5
+ /** What this workflow does, in a sentence. Shown in the editor. */
6
+ description?: string;
7
+ } & ({
8
+ schedule: ZiteSchedule;
9
+ trigger?: never;
10
+ } | {
11
+ trigger: ZiteTrigger;
12
+ schedule?: never;
13
+ });
14
+ /** What a workflow's handler gets. A trigger adds `trigger`, typed by the kind it declares. */
15
+ export type WorkflowArgs<TConfig extends WorkflowConfig> = {
16
+ input: unknown;
17
+ } & (TConfig extends {
18
+ trigger: infer T;
19
+ } ? {
20
+ trigger: ZiteTriggerContext<T>;
21
+ } : unknown);
22
+ export type Workflow<TConfig extends WorkflowConfig = WorkflowConfig> = {
23
+ readonly [WORKFLOW]: true;
24
+ readonly config: TConfig;
25
+ readonly handler: (args: WorkflowArgs<TConfig>) => unknown;
26
+ };
27
+ /**
28
+ * Scheduled or triggered work, in a `*.server.ts` file:
29
+ * `export const nightly = createWorkflow({ schedule }).handler(async () => { ... })`.
30
+ * Only the platform runs it. It has no user and no URL, and reads data like a server function does.
31
+ */
32
+ export declare function createWorkflow<const TConfig extends WorkflowConfig>(config: TConfig): {
33
+ handler: (fn: (args: WorkflowArgs<TConfig>) => unknown) => Workflow<TConfig>;
34
+ };
35
+ export declare const isWorkflow: (value: unknown) => value is Workflow;
36
+ export {};
@@ -0,0 +1,13 @@
1
+ // Shared across copies of zitejs in one bundle, like the access scope.
2
+ const WORKFLOW = Symbol.for("zitejs:workflow");
3
+ /**
4
+ * Scheduled or triggered work, in a `*.server.ts` file:
5
+ * `export const nightly = createWorkflow({ schedule }).handler(async () => { ... })`.
6
+ * Only the platform runs it. It has no user and no URL, and reads data like a server function does.
7
+ */
8
+ export function createWorkflow(config) {
9
+ return {
10
+ handler: (fn) => ({ [WORKFLOW]: true, config, handler: fn }),
11
+ };
12
+ }
13
+ export const isWorkflow = (value) => typeof value === "object" && value !== null && value[WORKFLOW] === true;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import { describe, expectTypeOf, it } from "vitest";
2
+ import { createWorkflow } from "./workflow.js";
3
+ describe("createWorkflow handler args", () => {
4
+ it("gives a schedule no trigger", () => {
5
+ createWorkflow({ schedule: { scheduleType: "oneTime", fireAt: "2026-10-10T03:00:00Z", timezone: "UTC" } }).handler((args) => {
6
+ expectTypeOf(args).toEqualTypeOf();
7
+ });
8
+ });
9
+ it("types the trigger by the kind it declares", () => {
10
+ createWorkflow({ trigger: { every: "5m" } }).handler(({ trigger }) => {
11
+ expectTypeOf(trigger).toEqualTypeOf();
12
+ });
13
+ createWorkflow({ trigger: { integration: "slack", events: ["message.channels"] } }).handler(({ trigger }) => {
14
+ expectTypeOf(trigger).toEqualTypeOf();
15
+ });
16
+ createWorkflow({ trigger: { integration: "asana" } }).handler(({ trigger }) => {
17
+ expectTypeOf(trigger).toEqualTypeOf();
18
+ });
19
+ });
20
+ it("takes a schedule or a trigger, not both", () => {
21
+ // @ts-expect-error a workflow runs on one or the other
22
+ createWorkflow({ schedule: { scheduleType: "oneTime", fireAt: "x", timezone: "UTC" }, trigger: { every: "5m" } });
23
+ });
24
+ });
@@ -0,0 +1 @@
1
+ export declare const workflowModules: Record<string, () => Promise<Record<string, unknown>>>;
@@ -0,0 +1,3 @@
1
+ // Replaced at build by zite() with a lazy import of every app module that
2
+ // mentions createWorkflow. Empty wherever the plugin didn't build the app.
3
+ export const workflowModules = {};
@@ -0,0 +1,80 @@
1
+ import { type Workflow } from "./workflow.js";
2
+ export type TriggerReport = {
3
+ handled: string[];
4
+ cursor?: string;
5
+ };
6
+ export type WorkflowRunResult = {
7
+ ok: true;
8
+ output: unknown;
9
+ trigger?: TriggerReport;
10
+ } | {
11
+ ok: false;
12
+ error: {
13
+ code: string;
14
+ message: string;
15
+ };
16
+ trigger?: TriggerReport;
17
+ };
18
+ /**
19
+ * Splits the platform's state out of the input. A poll or a provider delivery
20
+ * with a cursor gets `seen`, `handled` and `advance`. Handled ids are reported
21
+ * whether or not the run throws, so an item acted on is never acted on again;
22
+ * the staged cursor only on success. Only ids the incoming `seen` lacked are
23
+ * reported: billing reads an empty list as a run that found nothing new.
24
+ */
25
+ export declare function triggerContext(workflow: Workflow, rawInput: unknown): {
26
+ input: unknown;
27
+ trigger?: undefined;
28
+ report?: undefined;
29
+ } | {
30
+ input: {
31
+ [x: string]: unknown;
32
+ };
33
+ trigger: {
34
+ provider: string;
35
+ deliveryId?: string | undefined;
36
+ headers: Record<string, unknown>;
37
+ } | {
38
+ headers?: undefined;
39
+ } | {
40
+ headers: {};
41
+ };
42
+ report?: undefined;
43
+ } | {
44
+ input: {
45
+ [x: string]: unknown;
46
+ };
47
+ trigger: {
48
+ provider: string;
49
+ deliveryId?: string | undefined;
50
+ headers: Record<string, unknown>;
51
+ cursor: string | null;
52
+ seen: (id: string) => boolean;
53
+ handled: (id: string) => void;
54
+ advance: (cursor: string) => void;
55
+ } | {
56
+ headers?: undefined;
57
+ cursor: string | null;
58
+ seen: (id: string) => boolean;
59
+ handled: (id: string) => void;
60
+ advance: (cursor: string) => void;
61
+ } | {
62
+ headers: {};
63
+ cursor: string | null;
64
+ seen: (id: string) => boolean;
65
+ handled: (id: string) => void;
66
+ advance: (cursor: string) => void;
67
+ };
68
+ report: (succeeded: boolean) => TriggerReport;
69
+ };
70
+ /** Runs the handler in a workflow access scope: no user, and data calls allowed. */
71
+ export declare function runWorkflow(workflow: Workflow, rawInput: unknown): Promise<WorkflowRunResult>;
72
+ export type LoadedWorkflow = {
73
+ id: string;
74
+ file: string;
75
+ workflow: Workflow;
76
+ };
77
+ /** Every exported workflow in the app, by export name. */
78
+ export declare function loadWorkflows(modules?: Record<string, () => Promise<Record<string, unknown>>>): Promise<LoadedWorkflow[]>;
79
+ /** The input as the run log keeps it: a trigger's `seen` list can hold thousands of ids. */
80
+ export declare const loggedInput: (rawInput: unknown) => unknown;