@notionhq/apps 0.0.1
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/LICENSE.md +21 -0
- package/README.md +30 -0
- package/dist/cli/build.d.ts +13 -0
- package/dist/cli/build.d.ts.map +1 -0
- package/dist/cli/build.js +36 -0
- package/dist/cli/build.test.d.ts +2 -0
- package/dist/cli/build.test.d.ts.map +1 -0
- package/dist/cli/codegen.d.ts +10 -0
- package/dist/cli/codegen.d.ts.map +1 -0
- package/dist/cli/codegen.js +41 -0
- package/dist/cli/codegen.test.d.ts +2 -0
- package/dist/cli/codegen.test.d.ts.map +1 -0
- package/dist/cli/discover.d.ts +32 -0
- package/dist/cli/discover.d.ts.map +1 -0
- package/dist/cli/discover.js +37 -0
- package/dist/cli/discover.test.d.ts +2 -0
- package/dist/cli/discover.test.d.ts.map +1 -0
- package/dist/cli/emit-manifest.d.ts +10 -0
- package/dist/cli/emit-manifest.d.ts.map +1 -0
- package/dist/cli/emit-manifest.js +131 -0
- package/dist/cli/emit-manifest.test.d.ts +2 -0
- package/dist/cli/emit-manifest.test.d.ts.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +36 -0
- package/dist/context.d.ts +7 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +30 -0
- package/dist/error.d.ts +8 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +11 -0
- package/dist/manifest.d.ts +25 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +0 -0
- package/dist/output.d.ts +7 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +8 -0
- package/dist/runtime-metadata.d.ts +9 -0
- package/dist/runtime-metadata.d.ts.map +1 -0
- package/dist/runtime-metadata.js +33 -0
- package/dist/runtime-metadata.test.d.ts +2 -0
- package/dist/runtime-metadata.test.d.ts.map +1 -0
- package/dist/triggers.d.ts +365 -0
- package/dist/triggers.d.ts.map +1 -0
- package/dist/triggers.generated.d.ts +246 -0
- package/dist/triggers.generated.d.ts.map +1 -0
- package/dist/triggers.generated.js +239 -0
- package/dist/triggers.js +0 -0
- package/dist/workflow.d.ts +125 -0
- package/dist/workflow.d.ts.map +1 -0
- package/dist/workflow.js +210 -0
- package/dist/workflow.test.d.ts +2 -0
- package/dist/workflow.test.d.ts.map +1 -0
- package/docs/BUILD.md +66 -0
- package/package.json +74 -0
- package/src/cli/build.test.ts +73 -0
- package/src/cli/build.ts +57 -0
- package/src/cli/codegen.test.ts +21 -0
- package/src/cli/codegen.ts +51 -0
- package/src/cli/discover.test.ts +26 -0
- package/src/cli/discover.ts +73 -0
- package/src/cli/emit-manifest.test.ts +45 -0
- package/src/cli/emit-manifest.ts +190 -0
- package/src/cli/index.ts +41 -0
- package/src/context.ts +49 -0
- package/src/error.ts +12 -0
- package/src/manifest.ts +26 -0
- package/src/output.ts +8 -0
- package/src/runtime-metadata.test.ts +54 -0
- package/src/runtime-metadata.ts +46 -0
- package/src/triggers.generated.ts +489 -0
- package/src/triggers.ts +500 -0
- package/src/workflow.test.ts +788 -0
- package/src/workflow.ts +418 -0
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@notionhq/apps",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "An SDK for building workflow apps for Notion",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"notion-apps": "./dist/cli/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/",
|
|
11
|
+
"src/",
|
|
12
|
+
"docs/",
|
|
13
|
+
"LICENSE.md",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"exports": {
|
|
18
|
+
"./context": {
|
|
19
|
+
"types": "./dist/context.d.ts",
|
|
20
|
+
"default": "./dist/context.js"
|
|
21
|
+
},
|
|
22
|
+
"./error": {
|
|
23
|
+
"types": "./dist/error.d.ts",
|
|
24
|
+
"default": "./dist/error.js"
|
|
25
|
+
},
|
|
26
|
+
"./manifest": {
|
|
27
|
+
"types": "./dist/manifest.d.ts",
|
|
28
|
+
"default": "./dist/manifest.js"
|
|
29
|
+
},
|
|
30
|
+
"./triggers": {
|
|
31
|
+
"types": "./dist/triggers.generated.d.ts",
|
|
32
|
+
"default": "./dist/triggers.generated.js"
|
|
33
|
+
},
|
|
34
|
+
"./workflow": {
|
|
35
|
+
"types": "./dist/workflow.d.ts",
|
|
36
|
+
"default": "./dist/workflow.js"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"ajv": "^8.17.1",
|
|
44
|
+
"rolldown": "^1.1.5"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^26.0.0",
|
|
48
|
+
"concurrently": "^9.2.1",
|
|
49
|
+
"esbuild": "^0.27.3",
|
|
50
|
+
"tsx": "^4.20.6",
|
|
51
|
+
"typescript": "^5.9.3",
|
|
52
|
+
"vitest": "^4.0.8"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@notionhq/client": ">=2.2.15"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=26.0.0",
|
|
59
|
+
"pnpm": ">=11.24.0"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "rm -rf dist && pnpm run build:types && pnpm run build:js",
|
|
63
|
+
"build:types": "tsc",
|
|
64
|
+
"build:js": "tsx scripts/build.ts",
|
|
65
|
+
"build:watch": "concurrently --group \"pnpm run build:watch:types\" \"pnpm run build:watch:js\"",
|
|
66
|
+
"build:watch:types": "tsc --watch",
|
|
67
|
+
"build:watch:js": "esbuild --watch --format=esm --outdir=dist --target=node26 --packages=external ./src/**.ts ./src/**/*.ts",
|
|
68
|
+
"generate": "tsx scripts/generate-triggers.ts",
|
|
69
|
+
"generate:check": "tsx scripts/generate-triggers.ts --check",
|
|
70
|
+
"sync:triggers": "tsx scripts/sync-triggers.ts",
|
|
71
|
+
"test": "vitest run",
|
|
72
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { buildApp } from "./build.js";
|
|
8
|
+
|
|
9
|
+
const FIXTURES = fileURLToPath(new URL("../../test/fixtures/", import.meta.url));
|
|
10
|
+
const EXAMPLES = fileURLToPath(new URL("../../examples/", import.meta.url));
|
|
11
|
+
const builtDirectories: string[] = [];
|
|
12
|
+
|
|
13
|
+
function fixture(name: string): string {
|
|
14
|
+
const root = path.join(FIXTURES, name);
|
|
15
|
+
builtDirectories.push(root);
|
|
16
|
+
return root;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function example(name: string): string {
|
|
20
|
+
const root = path.join(EXAMPLES, name);
|
|
21
|
+
builtDirectories.push(root);
|
|
22
|
+
return root;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
for (const root of builtDirectories.splice(0)) {
|
|
27
|
+
for (const artifact of [".notion", "dist"]) {
|
|
28
|
+
await fs.promises.rm(path.join(root, artifact), { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("buildApp", () => {
|
|
34
|
+
it("builds a workflow bundle and manifest", async () => {
|
|
35
|
+
const { manifest, bundlePath, manifestPath } = await buildApp(example("workflow"));
|
|
36
|
+
|
|
37
|
+
expect(manifest).toEqual({
|
|
38
|
+
sdkVersion: "0.0.1",
|
|
39
|
+
databases: [],
|
|
40
|
+
xldbs: [],
|
|
41
|
+
pacers: [],
|
|
42
|
+
capabilities: [
|
|
43
|
+
{
|
|
44
|
+
type: "workflow",
|
|
45
|
+
key: "onPageCreated",
|
|
46
|
+
config: {
|
|
47
|
+
name: "Log New Pages",
|
|
48
|
+
description: "Logs every page created in the workspace",
|
|
49
|
+
triggers: [{ type: "notion.page.created" }],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
expect(path.basename(bundlePath)).toBe("worker.js");
|
|
55
|
+
expect(JSON.parse(await fs.promises.readFile(manifestPath, "utf8"))).toEqual(manifest);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("rejects a workflow file whose default export is not a workflow", async () => {
|
|
59
|
+
await expect(buildApp(fixture("wrong-tag"))).rejects.toThrow(
|
|
60
|
+
/notWorkflow\.ts.*default export is not a workflow/,
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("rejects a workflow file with no default export", async () => {
|
|
65
|
+
await expect(buildApp(fixture("no-default-export"))).rejects.toThrow(
|
|
66
|
+
/"default" is not exported by .*nothing\.ts/,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("rejects a project with no workflows", async () => {
|
|
71
|
+
await expect(buildApp(fixture("empty"))).rejects.toThrow(/No capabilities found/);
|
|
72
|
+
});
|
|
73
|
+
});
|
package/src/cli/build.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { rolldown } from "rolldown";
|
|
5
|
+
|
|
6
|
+
import type { AppManifest } from "../manifest.js";
|
|
7
|
+
import { generateEntry } from "./codegen.js";
|
|
8
|
+
import { discoverCapabilities } from "./discover.js";
|
|
9
|
+
import { extractManifest } from "./emit-manifest.js";
|
|
10
|
+
|
|
11
|
+
export type BuildResult = {
|
|
12
|
+
manifest: AppManifest;
|
|
13
|
+
bundlePath: string;
|
|
14
|
+
manifestPath: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build an app project: discover capability files by convention, generate
|
|
19
|
+
* the entry module, bundle it into a single `dist/worker.js`, and emit
|
|
20
|
+
* `dist/manifest.json` describing every capability.
|
|
21
|
+
*/
|
|
22
|
+
export async function buildApp(projectRoot: string): Promise<BuildResult> {
|
|
23
|
+
const capabilities = await discoverCapabilities(projectRoot);
|
|
24
|
+
|
|
25
|
+
if (capabilities.length === 0) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`No capabilities found in ${projectRoot}. ` +
|
|
28
|
+
`Add a workflow file under src/workflows/<name>.ts that default-exports createWorkflow(...).`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const entryPath = path.join(projectRoot, ".notion", "entry.ts");
|
|
33
|
+
await fs.promises.mkdir(path.dirname(entryPath), { recursive: true });
|
|
34
|
+
await fs.promises.writeFile(entryPath, generateEntry(capabilities));
|
|
35
|
+
|
|
36
|
+
const bundlePath = path.join(projectRoot, "dist", "worker.js");
|
|
37
|
+
// Bundle only the app's own code. Bare imports (npm packages) stay
|
|
38
|
+
// external and resolve from node_modules at runtime, so packages behave
|
|
39
|
+
// normally — including native addons, which cannot be inlined.
|
|
40
|
+
const bundle = await rolldown({
|
|
41
|
+
input: entryPath,
|
|
42
|
+
platform: "node",
|
|
43
|
+
external: (id) => !id.startsWith(".") && !path.isAbsolute(id),
|
|
44
|
+
});
|
|
45
|
+
try {
|
|
46
|
+
await bundle.write({ format: "esm", file: bundlePath });
|
|
47
|
+
} finally {
|
|
48
|
+
await bundle.close();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const manifest = await extractManifest(bundlePath, capabilities);
|
|
52
|
+
|
|
53
|
+
const manifestPath = path.join(projectRoot, "dist", "manifest.json");
|
|
54
|
+
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, "\t")}\n`);
|
|
55
|
+
|
|
56
|
+
return { manifest, bundlePath, manifestPath };
|
|
57
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { generateEntry } from "./codegen.js";
|
|
4
|
+
|
|
5
|
+
describe("generateEntry", () => {
|
|
6
|
+
it("statically imports and registers workflows by key", () => {
|
|
7
|
+
const entry = generateEntry([
|
|
8
|
+
{
|
|
9
|
+
type: "workflow",
|
|
10
|
+
tag: "workflow",
|
|
11
|
+
key: "onPageCreated",
|
|
12
|
+
sourcePath: "src/workflows/onPageCreated.ts",
|
|
13
|
+
},
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
expect(entry).toContain(`import capability_0 from "../src/workflows/onPageCreated.ts";`);
|
|
17
|
+
expect(entry).toContain(`"workflow": {`);
|
|
18
|
+
expect(entry).toContain(`"onPageCreated": capability_0,`);
|
|
19
|
+
expect(entry).toContain("export async function run(");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { DiscoveredCapability } from "./discover.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate the app entry module for a set of discovered capabilities.
|
|
5
|
+
*
|
|
6
|
+
* The module uses only static imports so that the bundler can bundle the
|
|
7
|
+
* whole app into a single file. It is written to `.notion/entry.ts` in
|
|
8
|
+
* the app project, so imports are relative to that location.
|
|
9
|
+
*/
|
|
10
|
+
export function generateEntry(capabilities: readonly DiscoveredCapability[]): string {
|
|
11
|
+
const imports: string[] = [];
|
|
12
|
+
const registryEntries = new Map<string, string[]>();
|
|
13
|
+
|
|
14
|
+
capabilities.forEach((capability, index) => {
|
|
15
|
+
const binding = `capability_${index}`;
|
|
16
|
+
imports.push(`import ${binding} from ${JSON.stringify(`../${capability.sourcePath}`)};`);
|
|
17
|
+
|
|
18
|
+
const entries = registryEntries.get(capability.type) ?? [];
|
|
19
|
+
entries.push(`\t\t${JSON.stringify(capability.key)}: ${binding},`);
|
|
20
|
+
registryEntries.set(capability.type, entries);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const registry = Array.from(registryEntries.entries())
|
|
24
|
+
.map(([type, entries]) => `\t${JSON.stringify(type)}: {\n${entries.join("\n")}\n\t},`)
|
|
25
|
+
.join("\n");
|
|
26
|
+
|
|
27
|
+
return `// Generated by \`notion-apps build\`. Do not edit.
|
|
28
|
+
${imports.join("\n")}
|
|
29
|
+
|
|
30
|
+
type Handler = (context?: unknown, options?: { concreteOutput?: true }) => Promise<unknown>;
|
|
31
|
+
|
|
32
|
+
export const capabilities: Record<string, Record<string, { handler: Handler }>> = {
|
|
33
|
+
${registry}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export async function run(
|
|
37
|
+
type: string,
|
|
38
|
+
key: string,
|
|
39
|
+
context?: unknown,
|
|
40
|
+
options?: { concreteOutput?: true },
|
|
41
|
+
): Promise<unknown> {
|
|
42
|
+
const capability = capabilities[type]?.[key];
|
|
43
|
+
|
|
44
|
+
if (!capability) {
|
|
45
|
+
throw new Error(\`Capability "\${type}/\${key}" not found\`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return capability.handler(context, options);
|
|
49
|
+
}
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { discoverCapabilities } from "./discover.js";
|
|
7
|
+
|
|
8
|
+
const EXAMPLES = fileURLToPath(new URL("../../examples/", import.meta.url));
|
|
9
|
+
const FIXTURES = fileURLToPath(new URL("../../test/fixtures/", import.meta.url));
|
|
10
|
+
|
|
11
|
+
describe("discoverCapabilities", () => {
|
|
12
|
+
it("discovers only workflow files", async () => {
|
|
13
|
+
await expect(discoverCapabilities(path.join(EXAMPLES, "workflow"))).resolves.toEqual([
|
|
14
|
+
{
|
|
15
|
+
type: "workflow",
|
|
16
|
+
tag: "workflow",
|
|
17
|
+
key: "onPageCreated",
|
|
18
|
+
sourcePath: path.join("src", "workflows", "onPageCreated.ts"),
|
|
19
|
+
},
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns nothing when no workflow directory exists", async () => {
|
|
24
|
+
expect(await discoverCapabilities(path.join(FIXTURES, "empty"))).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { CapabilityType } from "../manifest.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A capability type the build tool knows how to discover: which directory
|
|
8
|
+
* under `src/` its files live in, and the `_tag` its default exports must
|
|
9
|
+
* carry. This SDK intentionally registers workflows only.
|
|
10
|
+
*/
|
|
11
|
+
export type CapabilityTypeDefinition = {
|
|
12
|
+
type: CapabilityType;
|
|
13
|
+
directory: string;
|
|
14
|
+
tag: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const CAPABILITY_TYPES: readonly CapabilityTypeDefinition[] = [
|
|
18
|
+
{ type: "workflow", directory: "workflows", tag: "workflow" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A capability file found during discovery. The key is the file's basename;
|
|
23
|
+
* combined with the type it forms the capability's identity (`workflow/onPageCreated`).
|
|
24
|
+
*/
|
|
25
|
+
export type DiscoveredCapability = {
|
|
26
|
+
type: CapabilityType;
|
|
27
|
+
tag: string;
|
|
28
|
+
key: string;
|
|
29
|
+
/** Path to the source file, relative to the project root. */
|
|
30
|
+
sourcePath: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Discover capability files by convention: every `.ts` file directly under
|
|
35
|
+
* `src/<directory>/` for each registered capability type. Subdirectories are
|
|
36
|
+
* not searched — they hold plain modules, not capabilities.
|
|
37
|
+
*
|
|
38
|
+
* Only workflow files directly under `src/workflows/` are discovered.
|
|
39
|
+
*/
|
|
40
|
+
export async function discoverCapabilities(projectRoot: string): Promise<DiscoveredCapability[]> {
|
|
41
|
+
const discovered: DiscoveredCapability[] = [];
|
|
42
|
+
|
|
43
|
+
for (const definition of CAPABILITY_TYPES) {
|
|
44
|
+
const directory = path.join(projectRoot, "src", definition.directory);
|
|
45
|
+
|
|
46
|
+
let entries: fs.Dirent[];
|
|
47
|
+
try {
|
|
48
|
+
entries = await fs.promises.readdir(directory, { withFileTypes: true });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
57
|
+
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
if (!entry.isFile() || !entry.name.endsWith(".ts") || entry.name.endsWith(".d.ts")) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
discovered.push({
|
|
64
|
+
type: definition.type,
|
|
65
|
+
tag: definition.tag,
|
|
66
|
+
key: entry.name.slice(0, -".ts".length),
|
|
67
|
+
sourcePath: path.join("src", definition.directory, entry.name),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return discovered;
|
|
73
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { assertJsonSafe } from "./emit-manifest.js";
|
|
4
|
+
|
|
5
|
+
describe("assertJsonSafe", () => {
|
|
6
|
+
it("accepts plain JSON values, including nested objects and arrays", () => {
|
|
7
|
+
expect(() =>
|
|
8
|
+
assertJsonSafe(
|
|
9
|
+
{
|
|
10
|
+
name: "workflow",
|
|
11
|
+
count: 3,
|
|
12
|
+
enabled: true,
|
|
13
|
+
missing: null,
|
|
14
|
+
tags: ["a", "b"],
|
|
15
|
+
nested: { list: [1, 2, { deep: "value" }] },
|
|
16
|
+
},
|
|
17
|
+
"config",
|
|
18
|
+
),
|
|
19
|
+
).not.toThrow();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("omits undefined-valued object properties without throwing", () => {
|
|
23
|
+
expect(() => assertJsonSafe({ a: undefined, b: 1 }, "config")).not.toThrow();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it.each([
|
|
27
|
+
["function", { a: () => {} }],
|
|
28
|
+
["symbol", { a: Symbol("x") }],
|
|
29
|
+
["bigint", { a: 1n }],
|
|
30
|
+
["NaN", { a: Number.NaN }],
|
|
31
|
+
["Infinity", { a: Number.POSITIVE_INFINITY }],
|
|
32
|
+
["Map", { a: new Map() }],
|
|
33
|
+
["Set", { a: new Set() }],
|
|
34
|
+
["Date", { a: new Date(0) }],
|
|
35
|
+
["undefined array element", { a: [undefined] }],
|
|
36
|
+
])("throws for non-serializable %s", (_label, value) => {
|
|
37
|
+
expect(() => assertJsonSafe(value, "config")).toThrow(/not JSON-serializable/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("includes the path to the offending value in the error", () => {
|
|
41
|
+
expect(() =>
|
|
42
|
+
assertJsonSafe({ outer: { inner: () => {} } }, "src/workflows/foo.ts config"),
|
|
43
|
+
).toThrow("src/workflows/foo.ts config.outer.inner");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
|
|
4
|
+
import { Ajv } from "ajv";
|
|
5
|
+
|
|
6
|
+
import type { AppManifest } from "../manifest.js";
|
|
7
|
+
import type { DiscoveredCapability } from "./discover.js";
|
|
8
|
+
|
|
9
|
+
const SDK_VERSION = (() => {
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const packageJson: unknown = require("../../package.json");
|
|
12
|
+
|
|
13
|
+
if (
|
|
14
|
+
typeof packageJson !== "object" ||
|
|
15
|
+
packageJson === null ||
|
|
16
|
+
!("version" in packageJson) ||
|
|
17
|
+
typeof packageJson.version !== "string"
|
|
18
|
+
) {
|
|
19
|
+
throw new Error("Failed to read SDK version from package.json");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return packageJson.version;
|
|
23
|
+
})();
|
|
24
|
+
|
|
25
|
+
const MANIFEST_SCHEMA = {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
sdkVersion: { type: "string" },
|
|
29
|
+
databases: { type: "array", maxItems: 0 },
|
|
30
|
+
xldbs: { type: "array", maxItems: 0 },
|
|
31
|
+
pacers: { type: "array", maxItems: 0 },
|
|
32
|
+
capabilities: {
|
|
33
|
+
type: "array",
|
|
34
|
+
items: {
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: {
|
|
37
|
+
type: { type: "string", const: "workflow" },
|
|
38
|
+
key: { type: "string" },
|
|
39
|
+
config: {},
|
|
40
|
+
},
|
|
41
|
+
required: ["type", "key", "config"],
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
required: ["sdkVersion", "databases", "xldbs", "pacers", "capabilities"],
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
type TaggedWorkflow = {
|
|
51
|
+
_tag: "workflow";
|
|
52
|
+
config: Record<string, unknown>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Extract the workflow-only app manifest from a built bundle. */
|
|
56
|
+
export async function extractManifest(
|
|
57
|
+
bundlePath: string,
|
|
58
|
+
capabilities: readonly DiscoveredCapability[],
|
|
59
|
+
): Promise<AppManifest> {
|
|
60
|
+
const bundle = await importBundle(bundlePath);
|
|
61
|
+
const capabilityRegistry = getCapabilityRegistry(bundle);
|
|
62
|
+
|
|
63
|
+
const capabilityEntries = capabilities.map((capability) => {
|
|
64
|
+
const value = capabilityRegistry.workflow?.[capability.key];
|
|
65
|
+
const workflow = asWorkflow(value);
|
|
66
|
+
|
|
67
|
+
if (!workflow) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`${capability.sourcePath}: default export is not a workflow — ` +
|
|
70
|
+
`files under src/workflows/ must default-export createWorkflow(...)`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
assertJsonSafe(workflow.config, `${capability.sourcePath} config`);
|
|
75
|
+
return { type: "workflow" as const, key: capability.key, config: workflow.config };
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const manifest: unknown = JSON.parse(
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
sdkVersion: SDK_VERSION,
|
|
81
|
+
databases: [],
|
|
82
|
+
xldbs: [],
|
|
83
|
+
pacers: [],
|
|
84
|
+
capabilities: capabilityEntries,
|
|
85
|
+
} satisfies AppManifest),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const ajv = new Ajv();
|
|
89
|
+
if (!ajv.validate(MANIFEST_SCHEMA, manifest)) {
|
|
90
|
+
throw new Error(`Generated manifest is invalid: ${ajv.errorsText(ajv.errors)}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return manifest as AppManifest;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let importNonce = 0;
|
|
97
|
+
|
|
98
|
+
async function importBundle(bundlePath: string): Promise<unknown> {
|
|
99
|
+
try {
|
|
100
|
+
return await import(`${pathToFileURL(bundlePath).href}?import=${importNonce++}`);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Failed to import built app bundle. Workflow modules must be importable ` +
|
|
104
|
+
`without secrets or network access — check for top-level code that throws.\n` +
|
|
105
|
+
`Caused by: ${error instanceof Error ? error.message : String(error)}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getCapabilityRegistry(
|
|
111
|
+
bundle: unknown,
|
|
112
|
+
): Record<string, Record<string, unknown> | undefined> {
|
|
113
|
+
if (
|
|
114
|
+
typeof bundle !== "object" ||
|
|
115
|
+
bundle === null ||
|
|
116
|
+
!("capabilities" in bundle) ||
|
|
117
|
+
typeof bundle.capabilities !== "object" ||
|
|
118
|
+
bundle.capabilities === null
|
|
119
|
+
) {
|
|
120
|
+
throw new Error("Built app bundle does not export a capability registry");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return bundle.capabilities as Record<string, Record<string, unknown> | undefined>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function asWorkflow(value: unknown): TaggedWorkflow | undefined {
|
|
127
|
+
if (
|
|
128
|
+
typeof value === "object" &&
|
|
129
|
+
value !== null &&
|
|
130
|
+
"_tag" in value &&
|
|
131
|
+
value._tag === "workflow" &&
|
|
132
|
+
"config" in value &&
|
|
133
|
+
typeof value.config === "object" &&
|
|
134
|
+
value.config !== null
|
|
135
|
+
) {
|
|
136
|
+
return value as TaggedWorkflow;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Throw if `value` contains anything `JSON.stringify` would silently drop or
|
|
144
|
+
* rewrite, so invalid workflow configuration fails during the build.
|
|
145
|
+
*/
|
|
146
|
+
export function assertJsonSafe(value: unknown, path: string): void {
|
|
147
|
+
const reject = (reason: string): never => {
|
|
148
|
+
throw new Error(`${path}: not JSON-serializable (${reason})`);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
switch (typeof value) {
|
|
152
|
+
case "function":
|
|
153
|
+
reject("function");
|
|
154
|
+
break;
|
|
155
|
+
case "symbol":
|
|
156
|
+
reject("symbol");
|
|
157
|
+
break;
|
|
158
|
+
case "bigint":
|
|
159
|
+
reject("bigint");
|
|
160
|
+
break;
|
|
161
|
+
case "number":
|
|
162
|
+
if (!Number.isFinite(value)) reject(String(value));
|
|
163
|
+
return;
|
|
164
|
+
case "string":
|
|
165
|
+
case "boolean":
|
|
166
|
+
case "undefined":
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (value === null) return;
|
|
171
|
+
|
|
172
|
+
if (Array.isArray(value)) {
|
|
173
|
+
value.forEach((element, index) => {
|
|
174
|
+
if (element === undefined) reject(`undefined at [${index}]`);
|
|
175
|
+
assertJsonSafe(element, `${path}[${index}]`);
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const object = value as { constructor?: { name?: string } };
|
|
181
|
+
const prototype = Object.getPrototypeOf(object);
|
|
182
|
+
if (prototype !== null && prototype !== Object.prototype) {
|
|
183
|
+
reject(object.constructor?.name ?? "non-plain object");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const [key, property] of Object.entries(object as Record<string, unknown>)) {
|
|
187
|
+
if (property === undefined) continue;
|
|
188
|
+
assertJsonSafe(property, `${path}.${key}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { buildApp } from "./build.js";
|
|
5
|
+
|
|
6
|
+
const USAGE = `Usage: notion-apps <command>
|
|
7
|
+
|
|
8
|
+
Commands:
|
|
9
|
+
build Build the app in the current directory
|
|
10
|
+
`;
|
|
11
|
+
|
|
12
|
+
async function main(): Promise<void> {
|
|
13
|
+
const command = process.argv[2];
|
|
14
|
+
|
|
15
|
+
switch (command) {
|
|
16
|
+
case "build": {
|
|
17
|
+
const projectRoot = process.cwd();
|
|
18
|
+
const { manifest, bundlePath, manifestPath } = await buildApp(projectRoot);
|
|
19
|
+
const count = manifest.capabilities.length;
|
|
20
|
+
console.log(
|
|
21
|
+
`Built ${path.relative(projectRoot, bundlePath)} and ` +
|
|
22
|
+
`${path.relative(projectRoot, manifestPath)} ` +
|
|
23
|
+
`(${count} ${count === 1 ? "capability" : "capabilities"})`,
|
|
24
|
+
);
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
case undefined:
|
|
28
|
+
case "help":
|
|
29
|
+
case "--help":
|
|
30
|
+
console.log(USAGE);
|
|
31
|
+
break;
|
|
32
|
+
default:
|
|
33
|
+
console.error(`Unknown command: ${command}\n\n${USAGE}`);
|
|
34
|
+
process.exitCode = 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
main().catch((error: unknown) => {
|
|
39
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
});
|