zitejs 0.10.0-start.0 → 0.10.0-start.2
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.
- package/dist/cjs/check/index.js +81 -3
- package/dist/cjs/start/describeBuilt.js +5 -3
- package/dist/cjs/start/entries.d.ts +5 -0
- package/dist/cjs/start/entries.js +32 -3
- package/dist/cjs/start/problems.d.ts +19 -0
- package/dist/cjs/start/problems.js +11 -0
- package/dist/esm/check/index.js +48 -3
- package/dist/esm/start/describe.d.ts +16 -0
- package/dist/esm/start/describe.js +29 -4
- package/dist/esm/start/describeBuilt.js +5 -3
- package/dist/esm/start/descriptions.d.ts +4 -0
- package/dist/esm/start/descriptions.js +47 -0
- package/dist/esm/start/entries.d.ts +5 -0
- package/dist/esm/start/entries.js +30 -4
- package/dist/esm/start/index.d.ts +3 -0
- package/dist/esm/start/index.js +1 -0
- package/dist/esm/start/instance.js +1 -17
- package/dist/esm/start/problems.d.ts +19 -0
- package/dist/esm/start/problems.js +8 -0
- package/dist/esm/start/runLog.d.ts +5 -0
- package/dist/esm/start/runLog.js +18 -0
- package/dist/esm/start/server.d.ts +2 -0
- package/dist/esm/start/server.js +47 -2
- package/dist/esm/start/vite.js +30 -4
- package/dist/esm/start/workflow.d.ts +36 -0
- package/dist/esm/start/workflow.js +13 -0
- package/dist/esm/start/workflow.test-d.d.ts +1 -0
- package/dist/esm/start/workflow.test-d.js +24 -0
- package/dist/esm/start/workflowModules.d.ts +1 -0
- package/dist/esm/start/workflowModules.js +3 -0
- package/dist/esm/start/workflowRun.d.ts +80 -0
- package/dist/esm/start/workflowRun.js +107 -0
- package/package.json +1 -1
package/dist/cjs/check/index.js
CHANGED
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.runCheck = runCheck;
|
|
4
37
|
const child_process_1 = require("child_process");
|
|
@@ -58,15 +91,60 @@ function bundleFailures(output) {
|
|
|
58
91
|
}
|
|
59
92
|
return failures;
|
|
60
93
|
}
|
|
94
|
+
const indent = (output) => output.trim().split('\n').map(l => ` ${l}`).join('\n');
|
|
95
|
+
/**
|
|
96
|
+
* A Start app: `vite build` first, since it regenerates the route tree tsc
|
|
97
|
+
* reads, then tsc, then describe, which reports what the platform's build
|
|
98
|
+
* refuses, read the same way.
|
|
99
|
+
*/
|
|
100
|
+
async function checkStartApp(appPath) {
|
|
101
|
+
process.stdout.write(' vite build ... ');
|
|
102
|
+
const vite = run('npx', ['vite', 'build'], appPath);
|
|
103
|
+
console.log(vite.ok ? '✓' : '✗');
|
|
104
|
+
if (!vite.ok) {
|
|
105
|
+
console.log(indent(vite.output));
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
process.stdout.write(' tsc --noEmit ... ');
|
|
109
|
+
// --pretty for `file:line:col - error` lines, the shape the platform re-roots.
|
|
110
|
+
const tsc = run('npx', ['tsc', '--noEmit', '--pretty', '-p', 'tsconfig.app.json'], appPath);
|
|
111
|
+
console.log(tsc.ok ? '✓' : '✗');
|
|
112
|
+
if (!tsc.ok)
|
|
113
|
+
console.log(indent(tsc.output));
|
|
114
|
+
process.stdout.write(' describe ... ');
|
|
115
|
+
try {
|
|
116
|
+
const { describeBuiltApp } = await Promise.resolve().then(() => __importStar(require('../start/describeBuilt.js')));
|
|
117
|
+
const { descriptionProblems } = await Promise.resolve().then(() => __importStar(require('../start/problems.js')));
|
|
118
|
+
const problems = descriptionProblems((await describeBuiltApp(appPath)));
|
|
119
|
+
console.log(problems.length ? '✗' : '✓');
|
|
120
|
+
if (problems.length)
|
|
121
|
+
console.log(problems.map(p => ` ${p}`).join('\n'));
|
|
122
|
+
return tsc.ok && problems.length === 0;
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
console.log('✗');
|
|
126
|
+
console.log(` ${err instanceof Error ? err.message : String(err)}`);
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
61
130
|
async function runCheck() {
|
|
62
|
-
const
|
|
63
|
-
|
|
131
|
+
const { isStartAppDir } = await Promise.resolve().then(() => __importStar(require('../start/entries.js')));
|
|
132
|
+
// Run inside a Start app, its own zitejs checks just that app: the workspace
|
|
133
|
+
// root's is a classic release that doesn't know Start.
|
|
134
|
+
const inStartApp = isStartAppDir('.');
|
|
135
|
+
const appDirs = inStartApp ? [] : (0, sourceRoots_js_1.listSourceDirs)();
|
|
136
|
+
if (!inStartApp && appDirs.length === 0) {
|
|
64
137
|
console.error('No apps or automations found under apps/ or automations/.');
|
|
65
138
|
process.exit(1);
|
|
66
139
|
}
|
|
67
|
-
let allPassed = true;
|
|
140
|
+
let allPassed = inStartApp ? await checkStartApp('.') : true;
|
|
68
141
|
for (const { root, dir: app, path: appPath } of appDirs) {
|
|
69
142
|
console.log(`\n── ${appPath} ──`);
|
|
143
|
+
if (isStartAppDir(appPath)) {
|
|
144
|
+
if (!(await checkStartApp(appPath)))
|
|
145
|
+
allPassed = false;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
70
148
|
// Only tsconfig.app.json compiles anything. The app's tsconfig.json is a
|
|
71
149
|
// solution-style config — `"files": []` plus a reference — so
|
|
72
150
|
// `tsc --noEmit -p tsconfig.json` type-checks zero files and exits 0. It
|
|
@@ -45,10 +45,12 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
45
45
|
const node_url_1 = require("node:url");
|
|
46
46
|
// Only the host can set x-zite-* in production: the front strips them from public traffic.
|
|
47
47
|
const DESCRIBE_HEADERS = { "x-zite-invoke": "describe" };
|
|
48
|
+
// Not "localhost": it can resolve to ::1 for vite's listen and to 127.0.0.1 for fetch.
|
|
49
|
+
const HOST = "127.0.0.1";
|
|
48
50
|
const freePort = () => new Promise((resolve, reject) => {
|
|
49
51
|
const server = (0, node_net_1.createServer)();
|
|
50
52
|
server.once("error", reject);
|
|
51
|
-
server.listen(0, () => {
|
|
53
|
+
server.listen(0, HOST, () => {
|
|
52
54
|
const { port } = server.address();
|
|
53
55
|
server.close(() => resolve(port));
|
|
54
56
|
});
|
|
@@ -58,7 +60,7 @@ const freePort = () => new Promise((resolve, reject) => {
|
|
|
58
60
|
async function describeInWorkerd(appDir) {
|
|
59
61
|
const port = await freePort();
|
|
60
62
|
// Its own process group, so stopping it also stops vite and workerd under npx.
|
|
61
|
-
const preview = (0, node_child_process_1.spawn)("npx", ["--no-install", "vite", "preview", "--port", String(port), "--strictPort"], {
|
|
63
|
+
const preview = (0, node_child_process_1.spawn)("npx", ["--no-install", "vite", "preview", "--host", HOST, "--port", String(port), "--strictPort"], {
|
|
62
64
|
cwd: appDir,
|
|
63
65
|
stdio: ["ignore", "ignore", "pipe"],
|
|
64
66
|
detached: true,
|
|
@@ -69,7 +71,7 @@ async function describeInWorkerd(appDir) {
|
|
|
69
71
|
const deadline = Date.now() + 60_000;
|
|
70
72
|
while (true) {
|
|
71
73
|
try {
|
|
72
|
-
const response = await fetch(`http
|
|
74
|
+
const response = await fetch(`http://${HOST}:${port}/`, { headers: DESCRIBE_HEADERS });
|
|
73
75
|
// Right after start, preview can answer before the Worker does; only the
|
|
74
76
|
// app's own describe answer is JSON.
|
|
75
77
|
if (response.ok && response.headers.get("content-type")?.includes("application/json")) {
|
|
@@ -8,4 +8,9 @@ export declare const renderStartInstance: (hasAppStart: boolean) => string;
|
|
|
8
8
|
export declare const hasAppStart: (appDir: string) => boolean;
|
|
9
9
|
/** Writes `file` under `appDir` when its content differs. Returns whether it wrote. */
|
|
10
10
|
export declare const writeIfChanged: (appDir: string, file: string, content: string) => boolean;
|
|
11
|
+
export declare const isStartAppDir: (appPath: string) => boolean;
|
|
11
12
|
export declare function regenerateStartEntries(app: SourceDir): void;
|
|
13
|
+
/** App modules that mention createWorkflow: what the workflow registry imports. Describe decides what each one really exports. */
|
|
14
|
+
export declare function workflowFiles(appDir: string): string[];
|
|
15
|
+
/** The registry zite() builds in place of zitejs's empty one. */
|
|
16
|
+
export declare const renderWorkflowModules: (appDir: string, files: string[]) => string;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.writeIfChanged = exports.hasAppStart = exports.renderStartInstance = exports.renderStartServerEntry = exports.START_INSTANCE_ENTRY = exports.START_SERVER_ENTRY = void 0;
|
|
3
|
+
exports.renderWorkflowModules = exports.isStartAppDir = exports.writeIfChanged = exports.hasAppStart = exports.renderStartInstance = exports.renderStartServerEntry = exports.START_INSTANCE_ENTRY = exports.START_SERVER_ENTRY = void 0;
|
|
4
4
|
exports.regenerateStartEntries = regenerateStartEntries;
|
|
5
|
+
exports.workflowFiles = workflowFiles;
|
|
5
6
|
const node_fs_1 = require("node:fs");
|
|
6
7
|
const node_path_1 = require("node:path");
|
|
7
8
|
exports.START_SERVER_ENTRY = ".zite/server.ts";
|
|
@@ -42,14 +43,16 @@ const writeIfChanged = (appDir, file, content) => {
|
|
|
42
43
|
return true;
|
|
43
44
|
};
|
|
44
45
|
exports.writeIfChanged = writeIfChanged;
|
|
45
|
-
const
|
|
46
|
+
const isStartAppDir = (appPath) => {
|
|
46
47
|
try {
|
|
47
|
-
return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(
|
|
48
|
+
return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(appPath, "zite.config.json"), "utf-8"))?.runtime === "start";
|
|
48
49
|
}
|
|
49
50
|
catch {
|
|
50
51
|
return false;
|
|
51
52
|
}
|
|
52
53
|
};
|
|
54
|
+
exports.isStartAppDir = isStartAppDir;
|
|
55
|
+
const isStartApp = (app) => (0, exports.isStartAppDir)(app.path);
|
|
53
56
|
function regenerateStartEntries(app) {
|
|
54
57
|
if (!isStartApp(app))
|
|
55
58
|
return;
|
|
@@ -62,3 +65,29 @@ function regenerateStartEntries(app) {
|
|
|
62
65
|
console.log(`Wrote ${(0, node_path_1.join)(app.path, file)}`);
|
|
63
66
|
}
|
|
64
67
|
}
|
|
68
|
+
const SOURCE_FILE = /\.[mc]?[jt]sx?$/;
|
|
69
|
+
const NOT_APP_CODE = /(\.(test|spec)\.[^.]+|\.d\.ts|routeTree\.gen\.ts)$/;
|
|
70
|
+
/** App modules that mention createWorkflow: what the workflow registry imports. Describe decides what each one really exports. */
|
|
71
|
+
function workflowFiles(appDir) {
|
|
72
|
+
const found = [];
|
|
73
|
+
const walk = (dir) => {
|
|
74
|
+
for (const entry of (0, node_fs_1.readdirSync)(dir, { withFileTypes: true })) {
|
|
75
|
+
const full = (0, node_path_1.join)(dir, entry.name);
|
|
76
|
+
if (entry.isDirectory()) {
|
|
77
|
+
if (entry.name !== "node_modules")
|
|
78
|
+
walk(full);
|
|
79
|
+
}
|
|
80
|
+
else if (SOURCE_FILE.test(entry.name) && !NOT_APP_CODE.test(entry.name) && (0, node_fs_1.readFileSync)(full, "utf-8").includes("createWorkflow")) {
|
|
81
|
+
found.push((0, node_path_1.relative)(appDir, full).split("\\").join("/"));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
if ((0, node_fs_1.existsSync)((0, node_path_1.join)(appDir, "src")))
|
|
86
|
+
walk((0, node_path_1.join)(appDir, "src"));
|
|
87
|
+
return found.sort();
|
|
88
|
+
}
|
|
89
|
+
/** The registry zite() builds in place of zitejs's empty one. */
|
|
90
|
+
const renderWorkflowModules = (appDir, files) => `export const workflowModules = {\n${files
|
|
91
|
+
.map((file) => ` ${JSON.stringify(file)}: () => import(${JSON.stringify((0, node_path_1.join)(appDir, file))}),`)
|
|
92
|
+
.join("\n")}\n};\n`;
|
|
93
|
+
exports.renderWorkflowModules = renderWorkflowModules;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
type WithProblems = {
|
|
2
|
+
problems: string[];
|
|
3
|
+
};
|
|
4
|
+
type Described = {
|
|
5
|
+
functions: Array<WithProblems & {
|
|
6
|
+
id: string;
|
|
7
|
+
file?: string;
|
|
8
|
+
}>;
|
|
9
|
+
routes: Array<WithProblems & {
|
|
10
|
+
path: string;
|
|
11
|
+
}>;
|
|
12
|
+
workflows?: Array<WithProblems & {
|
|
13
|
+
id: string;
|
|
14
|
+
file: string;
|
|
15
|
+
}>;
|
|
16
|
+
};
|
|
17
|
+
/** Every problem in a description, one line each, named by its rule. Any of them fails the build. */
|
|
18
|
+
export declare function descriptionProblems(description: Described): string[];
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.descriptionProblems = descriptionProblems;
|
|
4
|
+
/** Every problem in a description, one line each, named by its rule. Any of them fails the build. */
|
|
5
|
+
function descriptionProblems(description) {
|
|
6
|
+
return [
|
|
7
|
+
...description.functions.flatMap((f) => f.problems.map((p) => `function ${f.id}${f.file ? ` (${f.file})` : ""}: ${p}`)),
|
|
8
|
+
...description.routes.flatMap((r) => r.problems.map((p) => `route ${r.path}: ${p}`)),
|
|
9
|
+
...(description.workflows ?? []).flatMap((w) => w.problems.map((p) => `workflow ${w.id} (${w.file}): ${p}`)),
|
|
10
|
+
];
|
|
11
|
+
}
|
package/dist/esm/check/index.js
CHANGED
|
@@ -55,15 +55,60 @@ function bundleFailures(output) {
|
|
|
55
55
|
}
|
|
56
56
|
return failures;
|
|
57
57
|
}
|
|
58
|
+
const indent = (output) => output.trim().split('\n').map(l => ` ${l}`).join('\n');
|
|
59
|
+
/**
|
|
60
|
+
* A Start app: `vite build` first, since it regenerates the route tree tsc
|
|
61
|
+
* reads, then tsc, then describe, which reports what the platform's build
|
|
62
|
+
* refuses, read the same way.
|
|
63
|
+
*/
|
|
64
|
+
async function checkStartApp(appPath) {
|
|
65
|
+
process.stdout.write(' vite build ... ');
|
|
66
|
+
const vite = run('npx', ['vite', 'build'], appPath);
|
|
67
|
+
console.log(vite.ok ? '✓' : '✗');
|
|
68
|
+
if (!vite.ok) {
|
|
69
|
+
console.log(indent(vite.output));
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
process.stdout.write(' tsc --noEmit ... ');
|
|
73
|
+
// --pretty for `file:line:col - error` lines, the shape the platform re-roots.
|
|
74
|
+
const tsc = run('npx', ['tsc', '--noEmit', '--pretty', '-p', 'tsconfig.app.json'], appPath);
|
|
75
|
+
console.log(tsc.ok ? '✓' : '✗');
|
|
76
|
+
if (!tsc.ok)
|
|
77
|
+
console.log(indent(tsc.output));
|
|
78
|
+
process.stdout.write(' describe ... ');
|
|
79
|
+
try {
|
|
80
|
+
const { describeBuiltApp } = await import('../start/describeBuilt.js');
|
|
81
|
+
const { descriptionProblems } = await import('../start/problems.js');
|
|
82
|
+
const problems = descriptionProblems((await describeBuiltApp(appPath)));
|
|
83
|
+
console.log(problems.length ? '✗' : '✓');
|
|
84
|
+
if (problems.length)
|
|
85
|
+
console.log(problems.map(p => ` ${p}`).join('\n'));
|
|
86
|
+
return tsc.ok && problems.length === 0;
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
console.log('✗');
|
|
90
|
+
console.log(` ${err instanceof Error ? err.message : String(err)}`);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
58
94
|
export async function runCheck() {
|
|
59
|
-
const
|
|
60
|
-
|
|
95
|
+
const { isStartAppDir } = await import('../start/entries.js');
|
|
96
|
+
// Run inside a Start app, its own zitejs checks just that app: the workspace
|
|
97
|
+
// root's is a classic release that doesn't know Start.
|
|
98
|
+
const inStartApp = isStartAppDir('.');
|
|
99
|
+
const appDirs = inStartApp ? [] : listSourceDirs();
|
|
100
|
+
if (!inStartApp && appDirs.length === 0) {
|
|
61
101
|
console.error('No apps or automations found under apps/ or automations/.');
|
|
62
102
|
process.exit(1);
|
|
63
103
|
}
|
|
64
|
-
let allPassed = true;
|
|
104
|
+
let allPassed = inStartApp ? await checkStartApp('.') : true;
|
|
65
105
|
for (const { root, dir: app, path: appPath } of appDirs) {
|
|
66
106
|
console.log(`\n── ${appPath} ──`);
|
|
107
|
+
if (isStartAppDir(appPath)) {
|
|
108
|
+
if (!(await checkStartApp(appPath)))
|
|
109
|
+
allPassed = false;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
67
112
|
// Only tsconfig.app.json compiles anything. The app's tsconfig.json is a
|
|
68
113
|
// solution-style config — `"files": []` plus a reference — so
|
|
69
114
|
// `tsc --noEmit -p tsconfig.json` type-checks zero files and exits 0. It
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { LoadedWorkflow } from "./workflowRun.js";
|
|
2
|
+
import type { WorkflowConfig } from "./workflow.js";
|
|
1
3
|
export type Access = {
|
|
2
4
|
kind: "signedIn" | "anyone" | "webhook" | "none";
|
|
3
5
|
roles: string[];
|
|
@@ -7,6 +9,8 @@ export type DescribedFunction = {
|
|
|
7
9
|
name?: string;
|
|
8
10
|
file?: string;
|
|
9
11
|
method?: string;
|
|
12
|
+
/** From the factory's `description` option; display-only. */
|
|
13
|
+
description?: string;
|
|
10
14
|
access: Access;
|
|
11
15
|
input?: unknown;
|
|
12
16
|
problems: string[];
|
|
@@ -16,9 +20,18 @@ export type DescribedRoute = {
|
|
|
16
20
|
methods: Record<string, Access>;
|
|
17
21
|
problems: string[];
|
|
18
22
|
};
|
|
23
|
+
export type DescribedWorkflow = {
|
|
24
|
+
id: string;
|
|
25
|
+
file: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
schedule?: WorkflowConfig["schedule"];
|
|
28
|
+
trigger?: WorkflowConfig["trigger"];
|
|
29
|
+
problems: string[];
|
|
30
|
+
};
|
|
19
31
|
export type Description = {
|
|
20
32
|
functions: DescribedFunction[];
|
|
21
33
|
routes: DescribedRoute[];
|
|
34
|
+
workflows: DescribedWorkflow[];
|
|
22
35
|
};
|
|
23
36
|
export declare function describeFunction(id: string, request: Request): Promise<DescribedFunction>;
|
|
24
37
|
type RouteLike = {
|
|
@@ -32,9 +45,12 @@ type RouteLike = {
|
|
|
32
45
|
};
|
|
33
46
|
};
|
|
34
47
|
export declare function describeRoutes(routesById: Record<string, RouteLike>): DescribedRoute[];
|
|
48
|
+
export declare function describeWorkflows(workflows: LoadedWorkflow[]): DescribedWorkflow[];
|
|
35
49
|
export declare function describeApp(opts: {
|
|
36
50
|
fnIds: string[];
|
|
51
|
+
descriptions?: Record<string, string>;
|
|
37
52
|
getRouter: () => unknown;
|
|
38
53
|
request: Request;
|
|
54
|
+
workflows?: LoadedWorkflow[];
|
|
39
55
|
}): Promise<Description>;
|
|
40
56
|
export {};
|
|
@@ -38,6 +38,8 @@ const describeProbe = createMiddleware({ type: "function" }).server(({ serverFnM
|
|
|
38
38
|
Object.assign(probe, { meta: serverFnMeta, method, reached: true });
|
|
39
39
|
return "zite:describe";
|
|
40
40
|
});
|
|
41
|
+
const SERVER_ONLY = "server-only: nothing in the browser calls it, and a server function that only other server functions call can't run. " +
|
|
42
|
+
"Move the shared logic into a *.server.ts helper and call that.";
|
|
41
43
|
export async function describeFunction(id, request) {
|
|
42
44
|
const problems = [];
|
|
43
45
|
const seen = [];
|
|
@@ -64,7 +66,14 @@ export async function describeFunction(id, request) {
|
|
|
64
66
|
await runWithStartContext(startContext, () => createSsrRpc(id)({ data: undefined, context: {} }));
|
|
65
67
|
}
|
|
66
68
|
catch (error) {
|
|
67
|
-
|
|
69
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
70
|
+
// TanStack registers only functions something can call through it. One that
|
|
71
|
+
// only other server functions call isn't registered, and calling it fails.
|
|
72
|
+
if (message === `Server function info not found for ${id}`) {
|
|
73
|
+
probe = null;
|
|
74
|
+
return { id, access: { kind: "none", roles: [] }, problems: [SERVER_ONLY] };
|
|
75
|
+
}
|
|
76
|
+
problems.push(`probe-failed: ${message}`);
|
|
68
77
|
}
|
|
69
78
|
const state = probe;
|
|
70
79
|
probe = null;
|
|
@@ -113,10 +122,26 @@ export function describeRoutes(routesById) {
|
|
|
113
122
|
}
|
|
114
123
|
return out;
|
|
115
124
|
}
|
|
125
|
+
// A workflow's id is its export name: what the host invokes it by and the editor shows.
|
|
126
|
+
export function describeWorkflows(workflows) {
|
|
127
|
+
return workflows.map(({ id, file, workflow }) => {
|
|
128
|
+
const problems = [];
|
|
129
|
+
if (id === "default")
|
|
130
|
+
problems.push("workflow-default-export: export it by name, which becomes its id");
|
|
131
|
+
const elsewhere = workflows.find((other) => other.id === id && other.file !== file);
|
|
132
|
+
if (elsewhere)
|
|
133
|
+
problems.push(`duplicate-workflow: ${id} is also exported from ${elsewhere.file}`);
|
|
134
|
+
const { description, schedule, trigger } = workflow.config;
|
|
135
|
+
return { id, file, description, ...(schedule ? { schedule } : { trigger }), problems };
|
|
136
|
+
});
|
|
137
|
+
}
|
|
116
138
|
export async function describeApp(opts) {
|
|
117
139
|
const functions = [];
|
|
118
|
-
for (const id of opts.fnIds)
|
|
119
|
-
|
|
140
|
+
for (const id of opts.fnIds) {
|
|
141
|
+
const described = await describeFunction(id, opts.request);
|
|
142
|
+
const description = opts.descriptions?.[id];
|
|
143
|
+
functions.push(description ? { ...described, description } : described);
|
|
144
|
+
}
|
|
120
145
|
const router = (await opts.getRouter());
|
|
121
|
-
return { functions, routes: describeRoutes(router.routesById) };
|
|
146
|
+
return { functions, routes: describeRoutes(router.routesById), workflows: describeWorkflows(opts.workflows ?? []) };
|
|
122
147
|
}
|
|
@@ -5,10 +5,12 @@ import path from "node:path";
|
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
// Only the host can set x-zite-* in production: the front strips them from public traffic.
|
|
7
7
|
const DESCRIBE_HEADERS = { "x-zite-invoke": "describe" };
|
|
8
|
+
// Not "localhost": it can resolve to ::1 for vite's listen and to 127.0.0.1 for fetch.
|
|
9
|
+
const HOST = "127.0.0.1";
|
|
8
10
|
const freePort = () => new Promise((resolve, reject) => {
|
|
9
11
|
const server = createServer();
|
|
10
12
|
server.once("error", reject);
|
|
11
|
-
server.listen(0, () => {
|
|
13
|
+
server.listen(0, HOST, () => {
|
|
12
14
|
const { port } = server.address();
|
|
13
15
|
server.close(() => resolve(port));
|
|
14
16
|
});
|
|
@@ -18,7 +20,7 @@ const freePort = () => new Promise((resolve, reject) => {
|
|
|
18
20
|
async function describeInWorkerd(appDir) {
|
|
19
21
|
const port = await freePort();
|
|
20
22
|
// Its own process group, so stopping it also stops vite and workerd under npx.
|
|
21
|
-
const preview = spawn("npx", ["--no-install", "vite", "preview", "--port", String(port), "--strictPort"], {
|
|
23
|
+
const preview = spawn("npx", ["--no-install", "vite", "preview", "--host", HOST, "--port", String(port), "--strictPort"], {
|
|
22
24
|
cwd: appDir,
|
|
23
25
|
stdio: ["ignore", "ignore", "pipe"],
|
|
24
26
|
detached: true,
|
|
@@ -29,7 +31,7 @@ async function describeInWorkerd(appDir) {
|
|
|
29
31
|
const deadline = Date.now() + 60_000;
|
|
30
32
|
while (true) {
|
|
31
33
|
try {
|
|
32
|
-
const response = await fetch(`http
|
|
34
|
+
const response = await fetch(`http://${HOST}:${port}/`, { headers: DESCRIBE_HEADERS });
|
|
33
35
|
// Right after start, preview can answer before the Worker does; only the
|
|
34
36
|
// app's own describe answer is JSON.
|
|
35
37
|
if (response.ok && response.headers.get("content-type")?.includes("application/json")) {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const stringValue = (node) => {
|
|
2
|
+
if (node?.type === "Literal" && typeof node.value === "string")
|
|
3
|
+
return node.value;
|
|
4
|
+
const quasis = node?.quasis;
|
|
5
|
+
if (node?.type === "TemplateLiteral" && node.expressions.length === 0)
|
|
6
|
+
return quasis?.[0]?.value.cooked;
|
|
7
|
+
return undefined;
|
|
8
|
+
};
|
|
9
|
+
const descriptionProperty = (node) => {
|
|
10
|
+
for (const property of node.properties) {
|
|
11
|
+
const key = property.key;
|
|
12
|
+
const named = key?.type === "Identifier" ? key.name : key?.type === "Literal" ? key.value : undefined;
|
|
13
|
+
if (property.type === "Property" && named === "description")
|
|
14
|
+
return stringValue(property.value);
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
17
|
+
};
|
|
18
|
+
// Walks a builder chain, `signedInFn({ description }).validator(…).handler(…)`, to an object option.
|
|
19
|
+
const descriptionIn = (node) => {
|
|
20
|
+
if (node?.type === "CallExpression") {
|
|
21
|
+
for (const arg of node.arguments) {
|
|
22
|
+
const found = arg.type === "ObjectExpression" ? descriptionProperty(arg) : undefined;
|
|
23
|
+
if (found !== undefined)
|
|
24
|
+
return found;
|
|
25
|
+
}
|
|
26
|
+
return descriptionIn(node.callee);
|
|
27
|
+
}
|
|
28
|
+
if (node?.type === "MemberExpression")
|
|
29
|
+
return descriptionIn(node.object);
|
|
30
|
+
return undefined;
|
|
31
|
+
};
|
|
32
|
+
/** `name -> description` for each top-level const built with a description option. */
|
|
33
|
+
export function factoryDescriptions(program) {
|
|
34
|
+
const found = new Map();
|
|
35
|
+
for (const statement of program.body) {
|
|
36
|
+
const declaration = (statement.type === "ExportNamedDeclaration" ? statement.declaration : statement);
|
|
37
|
+
if (declaration?.type !== "VariableDeclaration")
|
|
38
|
+
continue;
|
|
39
|
+
for (const declarator of declaration.declarations) {
|
|
40
|
+
const id = declarator.id;
|
|
41
|
+
const description = descriptionIn(declarator.init);
|
|
42
|
+
if (id.type === "Identifier" && description)
|
|
43
|
+
found.set(id.name, description);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return found;
|
|
47
|
+
}
|
|
@@ -8,4 +8,9 @@ export declare const renderStartInstance: (hasAppStart: boolean) => string;
|
|
|
8
8
|
export declare const hasAppStart: (appDir: string) => boolean;
|
|
9
9
|
/** Writes `file` under `appDir` when its content differs. Returns whether it wrote. */
|
|
10
10
|
export declare const writeIfChanged: (appDir: string, file: string, content: string) => boolean;
|
|
11
|
+
export declare const isStartAppDir: (appPath: string) => boolean;
|
|
11
12
|
export declare function regenerateStartEntries(app: SourceDir): void;
|
|
13
|
+
/** App modules that mention createWorkflow: what the workflow registry imports. Describe decides what each one really exports. */
|
|
14
|
+
export declare function workflowFiles(appDir: string): string[];
|
|
15
|
+
/** The registry zite() builds in place of zitejs's empty one. */
|
|
16
|
+
export declare const renderWorkflowModules: (appDir: string, files: string[]) => string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative } from "node:path";
|
|
3
3
|
export const START_SERVER_ENTRY = ".zite/server.ts";
|
|
4
4
|
export const START_INSTANCE_ENTRY = ".zite/start.ts";
|
|
5
5
|
const APP_START_FILES = ["src/start.ts", "src/start.tsx"];
|
|
@@ -34,14 +34,15 @@ export const writeIfChanged = (appDir, file, content) => {
|
|
|
34
34
|
writeFileSync(target, content);
|
|
35
35
|
return true;
|
|
36
36
|
};
|
|
37
|
-
const
|
|
37
|
+
export const isStartAppDir = (appPath) => {
|
|
38
38
|
try {
|
|
39
|
-
return JSON.parse(readFileSync(join(
|
|
39
|
+
return JSON.parse(readFileSync(join(appPath, "zite.config.json"), "utf-8"))?.runtime === "start";
|
|
40
40
|
}
|
|
41
41
|
catch {
|
|
42
42
|
return false;
|
|
43
43
|
}
|
|
44
44
|
};
|
|
45
|
+
const isStartApp = (app) => isStartAppDir(app.path);
|
|
45
46
|
export function regenerateStartEntries(app) {
|
|
46
47
|
if (!isStartApp(app))
|
|
47
48
|
return;
|
|
@@ -54,3 +55,28 @@ export function regenerateStartEntries(app) {
|
|
|
54
55
|
console.log(`Wrote ${join(app.path, file)}`);
|
|
55
56
|
}
|
|
56
57
|
}
|
|
58
|
+
const SOURCE_FILE = /\.[mc]?[jt]sx?$/;
|
|
59
|
+
const NOT_APP_CODE = /(\.(test|spec)\.[^.]+|\.d\.ts|routeTree\.gen\.ts)$/;
|
|
60
|
+
/** App modules that mention createWorkflow: what the workflow registry imports. Describe decides what each one really exports. */
|
|
61
|
+
export function workflowFiles(appDir) {
|
|
62
|
+
const found = [];
|
|
63
|
+
const walk = (dir) => {
|
|
64
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
65
|
+
const full = join(dir, entry.name);
|
|
66
|
+
if (entry.isDirectory()) {
|
|
67
|
+
if (entry.name !== "node_modules")
|
|
68
|
+
walk(full);
|
|
69
|
+
}
|
|
70
|
+
else if (SOURCE_FILE.test(entry.name) && !NOT_APP_CODE.test(entry.name) && readFileSync(full, "utf-8").includes("createWorkflow")) {
|
|
71
|
+
found.push(relative(appDir, full).split("\\").join("/"));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
if (existsSync(join(appDir, "src")))
|
|
76
|
+
walk(join(appDir, "src"));
|
|
77
|
+
return found.sort();
|
|
78
|
+
}
|
|
79
|
+
/** The registry zite() builds in place of zitejs's empty one. */
|
|
80
|
+
export const renderWorkflowModules = (appDir, files) => `export const workflowModules = {\n${files
|
|
81
|
+
.map((file) => ` ${JSON.stringify(file)}: () => import(${JSON.stringify(join(appDir, file))}),`)
|
|
82
|
+
.join("\n")}\n};\n`;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { User } from "./types.js";
|
|
2
2
|
export type { User, ZiteRequestContext } from "./types.js";
|
|
3
3
|
export { createZiteRouter, type ZiteRouterContext } from "./router.js";
|
|
4
|
+
export { createWorkflow, type Workflow, type WorkflowArgs, type WorkflowConfig } from "./workflow.js";
|
|
5
|
+
export type { ZiteSchedule } from "../schedules/index.js";
|
|
6
|
+
export type { ZiteTrigger } from "../backend/index.js";
|
|
4
7
|
declare module "@tanstack/start-client-core" {
|
|
5
8
|
interface ServerFnOptions {
|
|
6
9
|
/** What this function does, in a sentence. Shown in the editor. */
|
package/dist/esm/start/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { USAGE_TOKEN_QUERY_PARAM } from "../auth/constants.js";
|
|
|
4
4
|
import { currentHost } from "./host.js";
|
|
5
5
|
import { getHost, inScope, runInScope } from "./scope.js";
|
|
6
6
|
export { createZiteRouter } from "./router.js";
|
|
7
|
+
export { createWorkflow } from "./workflow.js";
|
|
7
8
|
const STATUS = {
|
|
8
9
|
BAD_REQUEST: 400,
|
|
9
10
|
UNAUTHORIZED: 401,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
|
|
4
4
|
import { createSerializationAdapter, redirect } from "@tanstack/react-router";
|
|
5
5
|
import { isSignInRequired, loginHref, ZiteError } from "./index.js";
|
|
6
|
+
import { errorOf, preview } from "./runLog.js";
|
|
6
7
|
import { getHost, inScope } from "./scope.js";
|
|
7
8
|
// TanStack drops its default CSRF check once an app has a start instance.
|
|
8
9
|
const csrf = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" });
|
|
@@ -21,23 +22,6 @@ const signInOn401 = createMiddleware({ type: "function" }).client(async ({ next
|
|
|
21
22
|
throw redirect({ href, reloadDocument: true });
|
|
22
23
|
}
|
|
23
24
|
});
|
|
24
|
-
const PREVIEW_LIMIT = 8_000;
|
|
25
|
-
const preview = (value) => {
|
|
26
|
-
if (value === undefined)
|
|
27
|
-
return undefined;
|
|
28
|
-
if (typeof value === "object" && value !== null && Symbol.asyncIterator in value)
|
|
29
|
-
return "[stream]";
|
|
30
|
-
try {
|
|
31
|
-
const json = JSON.stringify(value);
|
|
32
|
-
return json && json.length > PREVIEW_LIMIT ? `${json.slice(0, PREVIEW_LIMIT)}…` : json;
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
return "[unserializable]";
|
|
36
|
-
}
|
|
37
|
-
};
|
|
38
|
-
const errorOf = (error) => error instanceof ZiteError
|
|
39
|
-
? { code: error.code, message: error.message }
|
|
40
|
-
: { code: "INTERNAL_ERROR", message: error instanceof Error ? error.message : String(error) };
|
|
41
25
|
// Records each call for the run log. The host's own record of the request is the
|
|
42
26
|
// floor; this adds what only the app sees: decoded input, output and errors.
|
|
43
27
|
const runCapture = createMiddleware({ type: "function" }).server(async ({ next, data, serverFnMeta }) => {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
type WithProblems = {
|
|
2
|
+
problems: string[];
|
|
3
|
+
};
|
|
4
|
+
type Described = {
|
|
5
|
+
functions: Array<WithProblems & {
|
|
6
|
+
id: string;
|
|
7
|
+
file?: string;
|
|
8
|
+
}>;
|
|
9
|
+
routes: Array<WithProblems & {
|
|
10
|
+
path: string;
|
|
11
|
+
}>;
|
|
12
|
+
workflows?: Array<WithProblems & {
|
|
13
|
+
id: string;
|
|
14
|
+
file: string;
|
|
15
|
+
}>;
|
|
16
|
+
};
|
|
17
|
+
/** Every problem in a description, one line each, named by its rule. Any of them fails the build. */
|
|
18
|
+
export declare function descriptionProblems(description: Described): string[];
|
|
19
|
+
export {};
|
|
@@ -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
|
+
}
|
|
@@ -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) };
|
|
@@ -31,6 +31,8 @@ export declare function installPlatformTransport(call: (input: PlatformCallInput
|
|
|
31
31
|
export declare function hostContextFromRequest(request: Request): ZiteRequestContext;
|
|
32
32
|
/** Every server function TanStack compiled, written in by `zite()` at build. Empty in dev, where TanStack doesn't call its id hook. */
|
|
33
33
|
export declare const builtFunctionIds: () => string[];
|
|
34
|
+
/** Each server function's `description` option, written in by `zite()` at build. */
|
|
35
|
+
export declare const builtFunctionDescriptions: () => Record<string, string>;
|
|
34
36
|
type Fetch = (request: Request, ...rest: unknown[]) => Promise<Response> | Response;
|
|
35
37
|
/**
|
|
36
38
|
* The fetch handler of a Start app's server entry. Describe calls get the app's
|
package/dist/esm/start/server.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describeApp } from "./describe.js";
|
|
2
|
+
import { preview } from "./runLog.js";
|
|
2
3
|
import { getHost, requireScope, runWithHost } from "./scope.js";
|
|
4
|
+
import { loadWorkflows, loggedInput, runWorkflow } from "./workflowRun.js";
|
|
3
5
|
export { runWithHost } from "./scope.js";
|
|
4
6
|
export { describeApp } from "./describe.js";
|
|
5
7
|
/**
|
|
@@ -38,6 +40,16 @@ export function hostContextFromRequest(request) {
|
|
|
38
40
|
}
|
|
39
41
|
/** Every server function TanStack compiled, written in by `zite()` at build. Empty in dev, where TanStack doesn't call its id hook. */
|
|
40
42
|
export const builtFunctionIds = () => "__ZITE_FN_IDS__".split(",").filter((id) => id && !id.startsWith("__ZITE"));
|
|
43
|
+
const parseDescriptions = (json) => {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(json);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
/** Each server function's `description` option, written in by `zite()` at build. */
|
|
52
|
+
export const builtFunctionDescriptions = () => parseDescriptions("__ZITE_FN_DESCRIPTIONS__");
|
|
41
53
|
// Server functions also run while a streamed page renders, so the run log is
|
|
42
54
|
// complete only once the body has been read to the end.
|
|
43
55
|
const onBodyEnd = (response, done) => {
|
|
@@ -47,6 +59,30 @@ const onBodyEnd = (response, done) => {
|
|
|
47
59
|
}
|
|
48
60
|
return new Response(response.body.pipeThrough(new TransformStream({ flush: done })), response);
|
|
49
61
|
};
|
|
62
|
+
const INVOKE_PATH = "/_zite/invoke";
|
|
63
|
+
// The host sends `{ input }`; a platform-fired run's input carries the runner's
|
|
64
|
+
// `__cron`, `__poll` or `__webhook` state, exactly as for classic endpoints.
|
|
65
|
+
async function invokeWorkflow(request, host, workflows) {
|
|
66
|
+
if (request.method !== "POST" || new URL(request.url).pathname !== INVOKE_PATH)
|
|
67
|
+
return new Response("Not found", { status: 404 });
|
|
68
|
+
const name = host.invoke?.name;
|
|
69
|
+
const found = workflows.find((w) => w.id === name);
|
|
70
|
+
if (!found)
|
|
71
|
+
return Response.json({ ok: false, error: { code: "NOT_FOUND", message: `No workflow named ${name}` } }, { status: 404 });
|
|
72
|
+
const input = (await request.json().catch(() => ({})))?.input;
|
|
73
|
+
const startedAt = Date.now();
|
|
74
|
+
const result = await runWorkflow(found.workflow, input);
|
|
75
|
+
host.runs.push({
|
|
76
|
+
fn: `workflow:${found.id}`,
|
|
77
|
+
name: found.id,
|
|
78
|
+
nested: false,
|
|
79
|
+
startedAt,
|
|
80
|
+
durationMs: Date.now() - startedAt,
|
|
81
|
+
input: preview(loggedInput(input)),
|
|
82
|
+
...(result.ok ? { output: preview(result.output) } : { error: result.error }),
|
|
83
|
+
});
|
|
84
|
+
return Response.json(result, { status: result.ok ? 200 : 500 });
|
|
85
|
+
}
|
|
50
86
|
/**
|
|
51
87
|
* The fetch handler of a Start app's server entry. Describe calls get the app's
|
|
52
88
|
* description; everything else runs TanStack with the host's identity in
|
|
@@ -62,13 +98,22 @@ export function createZiteFetch(options) {
|
|
|
62
98
|
return binding;
|
|
63
99
|
};
|
|
64
100
|
installPlatformTransport((call) => platform().call(call));
|
|
101
|
+
// Loaded on first use, so a page request never pays for importing workflow modules.
|
|
102
|
+
let workflows;
|
|
103
|
+
const loadedWorkflows = () => (workflows ??= loadWorkflows());
|
|
65
104
|
return async (request, ...rest) => {
|
|
66
105
|
binding ??= rest[0]?.ZITE;
|
|
67
106
|
if (request.headers.get("x-zite-invoke") === "describe") {
|
|
68
|
-
return Response.json(await describeApp({
|
|
107
|
+
return Response.json(await describeApp({
|
|
108
|
+
fnIds: builtFunctionIds(),
|
|
109
|
+
descriptions: builtFunctionDescriptions(),
|
|
110
|
+
getRouter: options.getRouter,
|
|
111
|
+
request,
|
|
112
|
+
workflows: await loadedWorkflows(),
|
|
113
|
+
}));
|
|
69
114
|
}
|
|
70
115
|
const host = hostContextFromRequest(request);
|
|
71
|
-
const response = await runWithHost(host, () => options.handler.fetch(request));
|
|
116
|
+
const response = await runWithHost(host, async () => host.invoke?.kind === "workflow" ? invokeWorkflow(request, host, await loadedWorkflows()) : options.handler.fetch(request));
|
|
72
117
|
return onBodyEnd(response, () => {
|
|
73
118
|
if (!host.runs.length || !binding)
|
|
74
119
|
return;
|
package/dist/esm/start/vite.js
CHANGED
|
@@ -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 {
|
|
6
|
-
|
|
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";
|
|
@@ -63,17 +67,38 @@ export function zite(options = {}) {
|
|
|
63
67
|
};
|
|
64
68
|
},
|
|
65
69
|
};
|
|
70
|
+
// Each server function's `description` option, by name, for the editor.
|
|
71
|
+
const descriptions = new Map();
|
|
72
|
+
const srcDir = `${path.join(appDir, "src")}${path.sep}`;
|
|
66
73
|
const functionIds = {
|
|
67
74
|
name: "zite:function-ids",
|
|
68
75
|
apply: "build",
|
|
76
|
+
transform(code, id) {
|
|
77
|
+
if (this.environment?.config.consumer !== "server" || !id.startsWith(srcDir) || !code.includes("description"))
|
|
78
|
+
return;
|
|
79
|
+
try {
|
|
80
|
+
for (const [name, text] of factoryDescriptions(this.parse(code)))
|
|
81
|
+
descriptions.set(name, text);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Display-only: an unreadable module just shows no descriptions.
|
|
85
|
+
}
|
|
86
|
+
},
|
|
69
87
|
generateBundle(_, bundle) {
|
|
88
|
+
const described = Object.fromEntries([...descriptions].filter(([name]) => ids.has(name)));
|
|
70
89
|
for (const chunk of Object.values(bundle)) {
|
|
71
|
-
if (chunk.type
|
|
90
|
+
if (chunk.type !== "chunk")
|
|
91
|
+
continue;
|
|
92
|
+
if (chunk.code.includes(FN_IDS_TOKEN))
|
|
72
93
|
chunk.code = chunk.code.replace(FN_IDS_TOKEN, [...ids.keys()].join(","));
|
|
73
|
-
|
|
94
|
+
chunk.code = chunk.code.replace(FN_DESCRIPTIONS_LITERAL, () => JSON.stringify(JSON.stringify(described)));
|
|
74
95
|
}
|
|
75
96
|
},
|
|
76
97
|
};
|
|
98
|
+
const workflowRegistry = {
|
|
99
|
+
name: "zite:workflow-registry",
|
|
100
|
+
load: (id) => (WORKFLOW_REGISTRY.test(id.split("?")[0]) ? renderWorkflowModules(appDir, workflowFiles(appDir)) : undefined),
|
|
101
|
+
};
|
|
77
102
|
return [
|
|
78
103
|
tanstackStart({
|
|
79
104
|
// TanStack resolves these from `src/`.
|
|
@@ -110,5 +135,6 @@ export function zite(options = {}) {
|
|
|
110
135
|
ziteId(),
|
|
111
136
|
compileZitejs,
|
|
112
137
|
functionIds,
|
|
138
|
+
workflowRegistry,
|
|
113
139
|
];
|
|
114
140
|
}
|
|
@@ -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,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;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Server-only: runs a workflow the host invoked. Takes the same platform input
|
|
2
|
+
// as classic triggered endpoints (`__cron`, `__poll`, `__webhook`), so the
|
|
3
|
+
// runner's schedules, polls and provider deliveries work unchanged.
|
|
4
|
+
import { errorOf } from "./runLog.js";
|
|
5
|
+
import { runInScope } from "./scope.js";
|
|
6
|
+
import { isWorkflow } from "./workflow.js";
|
|
7
|
+
import { workflowModules } from "./workflowModules.js";
|
|
8
|
+
const asRecord = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
9
|
+
const idOf = (id) => {
|
|
10
|
+
if (typeof id !== "string" && typeof id !== "number")
|
|
11
|
+
throw new TypeError(`trigger ids must be strings, got ${typeof id}`);
|
|
12
|
+
const value = String(id);
|
|
13
|
+
if (value.length === 0 || value.length > 512)
|
|
14
|
+
throw new TypeError("trigger ids must be 1 to 512 characters");
|
|
15
|
+
return value;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Splits the platform's state out of the input. A poll or a provider delivery
|
|
19
|
+
* with a cursor gets `seen`, `handled` and `advance`. Handled ids are reported
|
|
20
|
+
* whether or not the run throws, so an item acted on is never acted on again;
|
|
21
|
+
* the staged cursor only on success. Only ids the incoming `seen` lacked are
|
|
22
|
+
* reported: billing reads an empty list as a run that found nothing new.
|
|
23
|
+
*/
|
|
24
|
+
export function triggerContext(workflow, rawInput) {
|
|
25
|
+
const raw = asRecord(rawInput);
|
|
26
|
+
const { __cron, __poll, __webhook, ...input } = raw ?? {};
|
|
27
|
+
const declared = workflow.config.trigger;
|
|
28
|
+
if (!declared)
|
|
29
|
+
return { input: raw ? input : rawInput };
|
|
30
|
+
const poll = asRecord(__poll);
|
|
31
|
+
const webhook = asRecord(__webhook);
|
|
32
|
+
const delivery = typeof webhook?.provider === "string" ? webhook : null;
|
|
33
|
+
// A run with no trigger state, such as a manual one, still gets a trigger: an empty one.
|
|
34
|
+
const info = delivery
|
|
35
|
+
? {
|
|
36
|
+
headers: asRecord(delivery.headers) ?? {},
|
|
37
|
+
...(typeof delivery.deliveryId === "string" ? { deliveryId: delivery.deliveryId } : {}),
|
|
38
|
+
provider: delivery.provider,
|
|
39
|
+
}
|
|
40
|
+
: declared.every
|
|
41
|
+
? {}
|
|
42
|
+
: { headers: {} };
|
|
43
|
+
if (!poll && declared.events)
|
|
44
|
+
return { input, trigger: info };
|
|
45
|
+
const state = poll ?? delivery ?? {};
|
|
46
|
+
const known = new Set(Array.isArray(state.seen) ? state.seen.map(String) : []);
|
|
47
|
+
const handled = [];
|
|
48
|
+
let staged;
|
|
49
|
+
const trigger = {
|
|
50
|
+
cursor: typeof state.cursor === "string" ? state.cursor : null,
|
|
51
|
+
seen: (id) => known.has(String(id)),
|
|
52
|
+
handled: (id) => {
|
|
53
|
+
const value = idOf(id);
|
|
54
|
+
if (known.has(value))
|
|
55
|
+
return;
|
|
56
|
+
known.add(value);
|
|
57
|
+
handled.push(value);
|
|
58
|
+
},
|
|
59
|
+
advance: (cursor) => {
|
|
60
|
+
if (typeof cursor !== "string" || cursor.length > 4096) {
|
|
61
|
+
throw new TypeError("trigger.advance takes a string of at most 4096 characters");
|
|
62
|
+
}
|
|
63
|
+
staged = cursor;
|
|
64
|
+
},
|
|
65
|
+
...info,
|
|
66
|
+
};
|
|
67
|
+
const report = (succeeded) => ({
|
|
68
|
+
handled: handled.slice(-2000),
|
|
69
|
+
...(succeeded && staged !== undefined ? { cursor: staged } : {}),
|
|
70
|
+
});
|
|
71
|
+
return { input, trigger, report };
|
|
72
|
+
}
|
|
73
|
+
/** Runs the handler in a workflow access scope: no user, and data calls allowed. */
|
|
74
|
+
export async function runWorkflow(workflow, rawInput) {
|
|
75
|
+
const { input, trigger, report } = triggerContext(workflow, rawInput);
|
|
76
|
+
const args = { input, ...(trigger ? { trigger } : {}) };
|
|
77
|
+
try {
|
|
78
|
+
const output = await runInScope({ kind: "workflow", user: null }, () => workflow.handler(args));
|
|
79
|
+
return { ok: true, output, ...(report ? { trigger: report(true) } : {}) };
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
return { ok: false, error: errorOf(error), ...(report ? { trigger: report(false) } : {}) };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Every exported workflow in the app, by export name. */
|
|
86
|
+
export async function loadWorkflows(modules = workflowModules) {
|
|
87
|
+
const found = [];
|
|
88
|
+
for (const [file, load] of Object.entries(modules)) {
|
|
89
|
+
for (const [id, value] of Object.entries(await load()))
|
|
90
|
+
if (isWorkflow(value))
|
|
91
|
+
found.push({ id, file, workflow: value });
|
|
92
|
+
}
|
|
93
|
+
return found;
|
|
94
|
+
}
|
|
95
|
+
/** The input as the run log keeps it: a trigger's `seen` list can hold thousands of ids. */
|
|
96
|
+
export const loggedInput = (rawInput) => {
|
|
97
|
+
const raw = asRecord(rawInput);
|
|
98
|
+
if (!raw)
|
|
99
|
+
return rawInput;
|
|
100
|
+
const out = { ...raw };
|
|
101
|
+
for (const key of ["__poll", "__webhook"]) {
|
|
102
|
+
const state = asRecord(raw[key]);
|
|
103
|
+
if (state && Array.isArray(state.seen))
|
|
104
|
+
out[key] = { ...state, seen: `[${state.seen.length} ids]` };
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
};
|