@notionhq/custom-blocks 0.1.36 → 0.1.38

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/README.md CHANGED
@@ -7,6 +7,11 @@ SDK for building Notion custom blocks.
7
7
 
8
8
  A custom block runs as a sandboxed `<iframe>` inside the Notion app that has no direct access to the internet. The only channel between your block and Notion is a local `postMessage` bridge. This library implements the sandbox side of the bridge protocol and wraps it in a framework-neutral TypeScript API (`@notionhq/custom-blocks`) and typed React hooks (`@notionhq/custom-blocks/react`).
9
9
 
10
+ ## Bundle tooling
11
+
12
+ Node build tools can import `build` and `upload` from
13
+ `@notionhq/custom-blocks/bundle`. See [deployment tooling](./docs/deployment.md#apps-sdk-integration).
14
+
10
15
  ## Install
11
16
 
12
17
  Create a worker project with `ntn workers new --template custom` and use the dependencies and
@@ -0,0 +1,23 @@
1
+ export type BuiltBlock = {
2
+ key: string;
3
+ checksumCrc32: string;
4
+ bundleDir: string;
5
+ bundleSizeBytes: number;
6
+ };
7
+ export type BuildOutput = {
8
+ blocks: BuiltBlock[];
9
+ };
10
+ export type BuildArgs = {
11
+ out: string;
12
+ cwd?: string | undefined;
13
+ manifest?: string | undefined;
14
+ };
15
+ /**
16
+ * Build every `_tag: "custom_block"` view declared by the worker.
17
+ *
18
+ * Imports the worker bundle (`<cwd>/dist/index.js`), reads its runtime manifest,
19
+ * and runs the command specified (default `npm run build`)
20
+ */
21
+ export declare function buildCustomBlocks(args: BuildArgs): Promise<BuildOutput>;
22
+ /** Print the build output as the sentinel-wrapped single-line JSON contract. */
23
+ export declare function printBuildOutput(output: BuildOutput): void;
@@ -15,8 +15,10 @@ const WORKER_ENTRY = "dist/index.js";
15
15
  export async function buildCustomBlocks(args) {
16
16
  const projectRoot = args.cwd ?? process.cwd();
17
17
  const entryPath = path.resolve(projectRoot, WORKER_ENTRY);
18
- const outDir = path.resolve(args.out);
19
- const blocks = await readCustomBlocks(entryPath);
18
+ const outDir = path.resolve(projectRoot, args.out);
19
+ const blocks = args.manifest === undefined
20
+ ? await readCustomBlocks(entryPath)
21
+ : readAppBlocks(path.resolve(projectRoot, args.manifest));
20
22
  if (blocks.length === 0) {
21
23
  return { blocks: [] };
22
24
  }
@@ -24,8 +26,13 @@ export async function buildCustomBlocks(args) {
24
26
  for (const block of blocks) {
25
27
  const bundleDir = path.join(outDir, block.key);
26
28
  buildCustomBlock({ block, projectRoot, bundleDir });
27
- const { checksumCrc32 } = packDirToTarGz(bundleDir);
28
- built.push({ key: block.key, checksumCrc32, bundleDir });
29
+ const { checksumCrc32, tarGz } = packDirToTarGz(bundleDir);
30
+ built.push({
31
+ key: block.key,
32
+ checksumCrc32,
33
+ bundleDir,
34
+ bundleSizeBytes: tarGz.length,
35
+ });
29
36
  }
30
37
  return { blocks: built };
31
38
  }
@@ -185,3 +192,18 @@ function runCommand(key, command, cwd) {
185
192
  throw new Error(`custom_block "${key}" build command "${command}" exited with code ${String(result.status)}`);
186
193
  }
187
194
  }
195
+ /** Read Apps SDK capabilities while retaining the worker manifest config contract. */
196
+ function readAppBlocks(manifestPath) {
197
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
198
+ const capabilities = readProp(manifest, "capabilities");
199
+ if (!Array.isArray(capabilities)) {
200
+ throw new Error(`manifest at ${manifestPath} has no capabilities array`);
201
+ }
202
+ return capabilities.flatMap(capability => {
203
+ const block = asCustomBlock({
204
+ ...capability,
205
+ _tag: readProp(capability, "type") ?? readProp(capability, "_tag"),
206
+ });
207
+ return block ? [block] : [];
208
+ });
209
+ }
@@ -0,0 +1,4 @@
1
+ export type { BuildArgs, BuildOutput, BuiltBlock } from "./build.js";
2
+ export { buildCustomBlocks as build, printBuildOutput } from "./build.js";
3
+ export type { UploadOutput, UploadsInput, UploadTarget } from "./upload.js";
4
+ export { parseUploadsInput, uploadViews as upload } from "./upload.js";
@@ -0,0 +1,2 @@
1
+ export { buildCustomBlocks as build, printBuildOutput } from "./build.js";
2
+ export { parseUploadsInput, uploadViews as upload } from "./upload.js";
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Custom block deployment CLI invoked by Notion's worker build sandbox.
4
+ *
5
+ * It ships with the SDK so worker projects expose the executable expected by
6
+ * the deployment pipeline at `node_modules/.bin/notion-custom-blocks`.
7
+ */
8
+ export declare function main(argv: string[]): Promise<number>;
@@ -26,7 +26,8 @@ async function runBuild(args) {
26
26
  process.stderr.write("usage: notion-custom-blocks build --out <dir>\n");
27
27
  return 1;
28
28
  }
29
- const output = await buildCustomBlocks({ out });
29
+ const { manifest, cwd } = flags;
30
+ const output = await buildCustomBlocks({ out, manifest, cwd });
30
31
  printBuildOutput(output);
31
32
  return 0;
32
33
  }
@@ -0,0 +1,11 @@
1
+ type PackedTarGz = {
2
+ tarGz: Buffer;
3
+ checksumCrc32: string;
4
+ };
5
+ /**
6
+ * Pack a directory into a deterministic gzipped tarball and return both the
7
+ * bytes and their base64 big-endian crc32 checksum (the form
8
+ * `CreateCustomBlockDeploy` signs into the presigned PUT URL).
9
+ */
10
+ export declare function packDirToTarGz(dir: string): PackedTarGz;
11
+ export {};
@@ -0,0 +1,14 @@
1
+ export type UploadTarget = {
2
+ uploadUrl: string;
3
+ bundleDir: string;
4
+ headers?: Record<string, string>;
5
+ };
6
+ export type UploadsInput = Record<string, UploadTarget>;
7
+ export type UploadOutput = {
8
+ uploaded: Record<string, true>;
9
+ };
10
+ export type UploadDeps = {
11
+ fetch: typeof fetch;
12
+ };
13
+ export declare function uploadViews(uploads: UploadsInput, deps?: UploadDeps): Promise<UploadOutput>;
14
+ export declare function parseUploadsInput(raw: string): UploadsInput;
@@ -7,6 +7,7 @@ export async function uploadViews(uploads, deps = DEFAULT_DEPS) {
7
7
  const response = await deps.fetch(target.uploadUrl, {
8
8
  method: "PUT",
9
9
  headers: {
10
+ ...target.headers,
10
11
  "Content-Type": "application/gzip",
11
12
  "x-amz-checksum-crc32": checksumCrc32,
12
13
  },
@@ -46,10 +47,23 @@ export function parseUploadsInput(raw) {
46
47
  result[key] = {
47
48
  uploadUrl: record.uploadUrl,
48
49
  bundleDir: record.bundleDir,
50
+ headers: parseHeaders(record.headers, key),
49
51
  };
50
52
  }
51
53
  return result;
52
54
  }
55
+ function parseHeaders(value, key) {
56
+ if (value === undefined) {
57
+ return undefined;
58
+ }
59
+ if (value === null ||
60
+ typeof value !== "object" ||
61
+ Array.isArray(value) ||
62
+ Object.values(value).some(header => typeof header !== "string")) {
63
+ throw new Error(`--uploads["${key}"].headers must be an object of string values`);
64
+ }
65
+ return value;
66
+ }
53
67
  async function safeReadText(response) {
54
68
  try {
55
69
  const text = await response.text();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ // @vitest-environment node
2
+ import { describe, expect, it } from "vitest";
3
+ import { parseUploadsInput } from "./upload.js";
4
+ describe("parseUploadsInput", () => {
5
+ it("preserves signed headers and supports targets without headers", () => {
6
+ const targets = {
7
+ hello: {
8
+ uploadUrl: "https://example.com/bundle?signature=a%2Bb",
9
+ bundleDir: "/tmp/block",
10
+ headers: { "Content-Length": "42", "x-amz-tagging": "source=cli" },
11
+ },
12
+ legacy: {
13
+ uploadUrl: "https://example.com/legacy",
14
+ bundleDir: "/tmp/legacy",
15
+ },
16
+ };
17
+ expect(parseUploadsInput(JSON.stringify(targets))).toEqual(targets);
18
+ });
19
+ it.each([
20
+ "broken",
21
+ "null",
22
+ "[]",
23
+ "42",
24
+ '{"hello":null}',
25
+ '{"hello":{"bundleDir":"x"}}',
26
+ '{"hello":{"uploadUrl":"x"}}',
27
+ ])("rejects malformed target input %s", raw => {
28
+ expect(() => parseUploadsInput(raw)).toThrow();
29
+ });
30
+ it.each([
31
+ null,
32
+ [],
33
+ "bad",
34
+ { "Content-Length": 42 },
35
+ ])("rejects invalid signed headers %j", headers => {
36
+ expect(() => parseUploadsInput(JSON.stringify({ hello: { uploadUrl: "x", bundleDir: "x", headers } }))).toThrow("headers must be an object of string values");
37
+ });
38
+ });
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@
4
4
  *
5
5
  * WARNING: Generated during SDK publish. Do not edit in the published package.
6
6
  */
7
- export const CUSTOM_BLOCKS_SDK_VERSION = "0.1.36"
7
+ export const CUSTOM_BLOCKS_SDK_VERSION = "0.1.38"
@@ -19,3 +19,29 @@ At startup, the SDK fetches the page-relative `manifest` path only when the bund
19
19
  - `ManifestDataSource` — a single entry in `dataSources`.
20
20
  - `ManifestProperty` — a single property declaration inside a `ManifestDataSource`.
21
21
  - `ManifestIcon` — the icon variant accepted on a `ManifestDataSource`.
22
+
23
+ ## Apps SDK integration
24
+
25
+ `notion-custom-blocks build --manifest dist/manifest.json --out <dir>` reads
26
+ static Apps SDK capabilities. Paths in the manifest stay relative to the project
27
+ root (the current directory, or `--cwd`). Without `--manifest`, the command
28
+ continues to import the Workers SDK bundle at `dist/index.js`.
29
+
30
+ Node build tools use one entrypoint:
31
+
32
+ ```ts
33
+ import { build, upload } from "@notionhq/custom-blocks/bundle"
34
+ ```
35
+
36
+ `build` accepts `BuildArgs` and returns `BuildOutput` containing `BuiltBlock`
37
+ results. `upload` accepts `UploadsInput`, a map of block keys to `UploadTarget`,
38
+ and returns `UploadOutput`. `printBuildOutput` writes the sentinel-wrapped build
39
+ result; `parseUploadsInput` validates JSON upload arguments. The Apps CLI calls
40
+ these functions from `build-blocks` and `upload-blocks`.
41
+
42
+ Relative `--out` paths also resolve from `--cwd`. Build results include
43
+ `bundleSizeBytes`, the exact compressed tarball length, for size-bound uploads.
44
+
45
+ Upload targets may include a `headers` map containing the headers returned by
46
+ the server presign, including `Content-Length` and object tags. The uploader
47
+ forwards these alongside the tarball checksum and content type.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notionhq/custom-blocks",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -25,6 +25,10 @@
25
25
  "./vite": {
26
26
  "import": "./vite-plugin/index.js",
27
27
  "types": "./vite-plugin/index.d.ts"
28
+ },
29
+ "./bundle": {
30
+ "types": "./bin/notion-custom-blocks/bundle.d.ts",
31
+ "import": "./bin/notion-custom-blocks/bundle.js"
28
32
  }
29
33
  },
30
34
  "bin": {