notion-multipart-uploader 1.0.1 → 2.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +45 -29
  2. package/index.d.ts +14 -2
  3. package/index.js +128 -25
  4. package/package.json +3 -2
package/README.md CHANGED
@@ -1,14 +1,16 @@
1
1
  # Notion Multipart Uploader
2
2
 
3
- A zero-dependency, native `fetch` helper to upload files (audio, images, PDFs, etc.) directly to Notion's servers via their `v1/file_uploads` multipart API.
4
-
5
- Because the official `@notionhq/client` SDK does not abstract the binary `multipart/form-data` upload step, this package handles the entire 2-step direct upload flow for you in one single, AI-optimized function call.
3
+ A zero-dependency convenience wrapper around the Notion File Upload APIs.
4
+ Because the official `@notionhq/client` SDK requires manually orchestrating a multi-step fetch process (create, send, complete) with chunking for large files, this package handles the entire direct upload flow for you in one single, AI-optimized function call.
6
5
 
7
6
  ## Features
7
+ - **Smart Multi-Part Chunking (V2):** Automatically slices files larger than 20MB into chunks and uploads them using Notion's `multi_part` API.
8
+ - **Enterprise Resilience (V2):** Built-in exponential backoff. If a chunk fails due to a network blip, it automatically pauses and retries.
9
+ - **Cancel & Timeout Support (V2):** Pass an `AbortSignal` or `timeoutMs` to instantly kill hung uploads.
10
+ - **Workspace Limit Safety (V2):** Instantly catches and throws clear `400` errors if your Notion Workspace is out of storage.
8
11
  - **Zero Dependencies:** Uses native Node `fetch` and `FormData`.
9
12
  - **Any File Type & Media:** Works flawlessly with Videos (`.mp4`), Phone Gallery exports, Audio (`.m4a`, `.mp3`), Images (`.png`, `.jpg`), PDFs, and any raw binary buffers.
10
- - **Strict 20MB Limit Safety:** Automatically detects and safely rejects files over 20MB before initializing the API call to prevent Notion boundary errors.
11
- - **Tiny Size:** < 100 lines of code. Doesn't bloat your node_modules.
13
+ - **Tiny Size:** Doesn't bloat your node_modules.
12
14
  - **TypeScript Support:** Full autocomplete out of the box with JSDoc types.
13
15
 
14
16
  ## Installation
@@ -17,42 +19,56 @@ Because the official `@notionhq/client` SDK does not abstract the binary `multip
17
19
  npm install notion-multipart-uploader
18
20
  ```
19
21
 
22
+ > **Looking for the ultra-lightweight Version 1?**
23
+ > If you are absolutely certain your app will only ever upload small files (under 20MB) and you want the absolute leanest possible script without the V2 multi-part chunking logic, you can install the legacy V1:
24
+ > ```bash
25
+ > npm install notion-multipart-uploader@1.0.1
26
+ > ```
27
+
28
+ ## Getting an API Key
29
+ To use this package, you need a Notion Integration Token:
30
+ 1. Go to [Notion's Integration Dashboard](https://www.notion.so/my-integrations).
31
+ 2. Click **New Integration**.
32
+ 3. Copy the **Internal Integration Secret** (`secret_...`).
33
+ 4. **CRITICAL:** Go to the Database page in your Notion app, click the three dots (`...`) in the top right, click **Connect to**, and select your new Integration name so it has permission to write files there!
34
+
20
35
  ## Usage
21
36
 
22
37
  ```javascript
23
- import { Client } from '@notionhq/client';
24
- import { uploadToNotion } from 'notion-multipart-uploader';
38
+ const { uploadToNotion } = require("notion-multipart-uploader");
39
+ const { Client } = require("@notionhq/client");
40
+ const fs = require("fs");
41
+
42
+ async function run() {
43
+ const fileBuffer = fs.readFileSync("./voice-note.m4a");
44
+ const apiKey = "secret_yourNotionApiKeyHere";
25
45
 
26
- const NOTION_KEY = process.env.NOTION_KEY;
27
- const notion = new Client({ auth: NOTION_KEY });
46
+ // V2: You can now pass Advanced Options
47
+ const options = {
48
+ retries: 5, // Auto-retry network failures 5 times
49
+ timeoutMs: 60000 // Kill request if it takes longer than 60s
50
+ };
28
51
 
29
- async function saveFileToNotion(binaryBuffer) {
30
52
  // 1. Upload the binary file directly to Notion
31
- const fileId = await uploadToNotion(
32
- NOTION_KEY,
33
- binaryBuffer,
34
- "audio/mp4", // Or 'image/png', 'application/pdf', etc.
35
- "voice-note.m4a"
36
- );
53
+ const fileId = await uploadToNotion(apiKey, fileBuffer, "audio/mp4", "voice-note.m4a", options);
37
54
 
38
55
  // 2. Attach the uploaded file ID to a new Notion page block
56
+ const notion = new Client({ auth: apiKey });
39
57
  await notion.pages.create({
40
- parent: { database_id: 'YOUR_DATABASE_ID' },
41
- properties: {
42
- "Name": { title: [{ text: { content: "Uploaded File" } }] }
43
- },
44
- children: [
45
- {
46
- object: 'block',
47
- type: 'audio', // Or 'image', 'file', etc.
48
- audio: {
49
- type: 'file_upload',
50
- file_upload: { id: fileId }
51
- }
58
+ parent: { database_id: "your_database_id" },
59
+ properties: { "Title": { title: [{ text: { content: "My Voice Note" } }] } },
60
+ children: [{
61
+ object: "block",
62
+ type: "audio",
63
+ audio: {
64
+ type: "file_upload",
65
+ file_upload: { id: fileId }
52
66
  }
53
- ]
67
+ }]
54
68
  });
69
+ console.log("Success!");
55
70
  }
71
+ run();
56
72
  ```
57
73
 
58
74
  ---
package/index.d.ts CHANGED
@@ -1,6 +1,18 @@
1
+ export interface UploadOptions {
2
+ /** Override the default Notion API version (default: '2026-03-11') */
3
+ notionVersion?: string;
4
+ /** Number of times to retry failed network requests with exponential backoff (default: 3) */
5
+ retries?: number;
6
+ /** Timeout in milliseconds for each network request */
7
+ timeoutMs?: number;
8
+ /** Standard AbortSignal to cancel the upload at any time */
9
+ signal?: AbortSignal;
10
+ }
11
+
1
12
  export declare function uploadToNotion(
2
13
  apiKey: string,
3
- fileBuffer: Buffer | Blob,
14
+ fileBuffer: Uint8Array | Blob | any,
4
15
  mimeType: string,
5
- filename?: string
16
+ filename?: string,
17
+ options?: UploadOptions
6
18
  ): Promise<string>;
package/index.js CHANGED
@@ -1,59 +1,162 @@
1
1
  /**
2
- * Uploads a binary file directly to Notion's servers using a 2-step multipart flow.
2
+ * Uploads a binary file directly to Notion's servers using native fetch.
3
+ * Automatically handles single-part (<= 20MB) and multi-part (> 20MB) chunking.
3
4
  *
4
5
  * @param {string} apiKey - Your Notion Integration Token
5
6
  * @param {Buffer|Blob} fileBuffer - The binary data of the file (audio, image, pdf, etc.)
6
7
  * @param {string} mimeType - e.g., 'audio/mp4', 'image/jpeg', 'application/pdf'
7
8
  * @param {string} filename - The name of the file (e.g., 'voice-note.m4a')
9
+ * @param {object} options - Additional options like retries or Notion API version
8
10
  * @returns {Promise<string>} The Notion File ID to use in your blocks
9
11
  */
10
- async function uploadToNotion(apiKey, fileBuffer, mimeType, filename) {
12
+ async function uploadToNotion(apiKey, fileBuffer, mimeType, filename, options = {}) {
11
13
  if (!apiKey) throw new Error("Notion API Key is required");
12
14
  if (!fileBuffer) throw new Error("File buffer is required");
13
15
 
16
+ const notionVersion = options.notionVersion || "2026-03-11";
17
+ const maxRetries = options.retries || 3;
18
+ const timeoutMs = options.timeoutMs;
19
+ const userSignal = options.signal;
20
+
14
21
  const size = fileBuffer.length || fileBuffer.size || fileBuffer.byteLength;
15
- if (size && size > 20971520) {
16
- throw new Error("File exceeds Notion's 20MB direct upload limit. Multi-part chunking for larger files will be supported in v2.0.");
22
+
23
+ // Notion strictly limits files to 5GB (paid workspaces)
24
+ if (size > 5 * 1024 * 1024 * 1024) {
25
+ throw new Error("File exceeds Notion's absolute maximum size limit of 5GB.");
26
+ }
27
+
28
+ const isMultiPart = size > 20971520; // Notion's 20MB limit
29
+ const mode = isMultiPart ? "multi_part" : "single_part";
30
+
31
+ // Default to 10MB chunks, but dynamically scale up if we hit the 1000 part limit
32
+ let CHUNK_SIZE = 10 * 1024 * 1024;
33
+ let numParts = isMultiPart ? Math.ceil(size / CHUNK_SIZE) : 1;
34
+
35
+ if (numParts > 1000) {
36
+ CHUNK_SIZE = Math.ceil(size / 1000);
37
+ numParts = 1000;
38
+ }
39
+
40
+ // Exponential backoff retry wrapper with Abort/Timeout support
41
+ const fetchWithRetry = async (url, fetchOptions, retries = maxRetries) => {
42
+ for (let i = 0; i < retries; i++) {
43
+ const controller = new AbortController();
44
+ let timeoutId;
45
+
46
+ if (timeoutMs) {
47
+ timeoutId = setTimeout(() => controller.abort(), timeoutMs);
48
+ }
49
+
50
+ let abortHandler;
51
+ if (userSignal) {
52
+ if (userSignal.aborted) throw new Error("Upload aborted by user");
53
+ abortHandler = () => controller.abort();
54
+ userSignal.addEventListener("abort", abortHandler);
55
+ }
56
+
57
+ try {
58
+ const res = await fetch(url, { ...fetchOptions, signal: controller.signal });
59
+ if (timeoutId) clearTimeout(timeoutId);
60
+ if (userSignal && abortHandler) userSignal.removeEventListener("abort", abortHandler);
61
+
62
+ if (res.ok) return res;
63
+
64
+ // Notion Workspace Limit Check or Bad Request: Don't retry 4xx errors except 429 (Rate Limit)
65
+ if (res.status >= 400 && res.status < 500 && res.status !== 429) {
66
+ const error = new Error(`Notion API Error (${res.status}): ${await res.text()}`);
67
+ error.isFatal = true;
68
+ throw error;
69
+ }
70
+
71
+ if (i === retries - 1) throw new Error(`Notion API Failed (${res.status}): ${await res.text()}`);
72
+ } catch (err) {
73
+ if (timeoutId) clearTimeout(timeoutId);
74
+ if (userSignal && abortHandler) userSignal.removeEventListener("abort", abortHandler);
75
+
76
+ if (err.name === 'AbortError') throw new Error(userSignal?.aborted ? "Upload aborted by user" : "Upload timed out");
77
+ if (err.isFatal) throw err;
78
+ if (i === retries - 1) throw err;
79
+ }
80
+ await new Promise(r => setTimeout(r, 1000 * Math.pow(2, i)));
81
+ }
82
+ };
83
+
84
+ const createBody = {
85
+ filename: filename || "uploaded-file",
86
+ content_type: mimeType,
87
+ mode: mode
88
+ };
89
+ if (isMultiPart) {
90
+ createBody.number_of_parts = numParts;
17
91
  }
18
92
 
19
93
  // Step 1: Initialize the upload with Notion
20
- const createRes = await fetch("https://api.notion.com/v1/file_uploads", {
94
+ const createRes = await fetchWithRetry("https://api.notion.com/v1/file_uploads", {
21
95
  method: "POST",
22
96
  headers: {
23
97
  "Authorization": `Bearer ${apiKey}`,
24
98
  "Content-Type": "application/json",
25
- "Notion-Version": "2022-06-28"
99
+ "Notion-Version": notionVersion
26
100
  },
27
- body: JSON.stringify({
28
- filename: filename || "uploaded-file",
29
- content_type: mimeType
30
- })
101
+ body: JSON.stringify(createBody)
31
102
  });
32
103
 
33
- if (!createRes.ok) {
34
- throw new Error(`Notion Init Upload Failed: ${await createRes.text()}`);
104
+ const createData = await createRes.json();
105
+ const { id, upload_url, complete_url } = createData;
106
+
107
+ // Step 2: Upload the binary data
108
+ if (!isMultiPart) {
109
+ // Single-part upload
110
+ const form = new FormData();
111
+ const blob = new Blob([fileBuffer], { type: mimeType });
112
+ form.append("file", blob, filename || "uploaded-file");
113
+
114
+ await fetchWithRetry(upload_url, {
115
+ method: "POST",
116
+ headers: {
117
+ "Authorization": `Bearer ${apiKey}`,
118
+ "Notion-Version": notionVersion
119
+ },
120
+ body: form
121
+ });
122
+ return id;
35
123
  }
36
-
37
- const { id, upload_url } = await createRes.json();
38
124
 
39
- // Step 2: Upload the binary data via Multipart FormData
40
- const form = new FormData();
41
- const blob = new Blob([fileBuffer], { type: mimeType });
42
- form.append("file", blob, filename || "uploaded-file");
125
+ // Multi-part chunked upload
126
+ for (let part = 0; part < numParts; part++) {
127
+ const start = part * CHUNK_SIZE;
128
+ const end = Math.min(start + CHUNK_SIZE, size);
129
+
130
+ // Handle slice for both Buffer and Blob
131
+ const chunk = typeof fileBuffer.slice === 'function' ? fileBuffer.slice(start, end) : fileBuffer.subarray(start, end);
132
+
133
+ const form = new FormData();
134
+ const blob = new Blob([chunk], { type: mimeType });
135
+ form.append("file", blob, filename || "uploaded-file");
136
+ form.append("part_number", (part + 1).toString()); // 1-indexed
43
137
 
44
- const uploadRes = await fetch(upload_url, {
138
+ await fetchWithRetry(upload_url, {
139
+ method: "POST",
140
+ headers: {
141
+ "Authorization": `Bearer ${apiKey}`,
142
+ "Notion-Version": notionVersion
143
+ },
144
+ body: form
145
+ });
146
+ }
147
+
148
+ // Step 3: Complete multi-part upload
149
+ const targetCompleteUrl = complete_url || `https://api.notion.com/v1/file_uploads/${id}/complete`;
150
+ await fetchWithRetry(targetCompleteUrl, {
45
151
  method: "POST",
46
152
  headers: {
47
153
  "Authorization": `Bearer ${apiKey}`,
48
- "Notion-Version": "2022-06-28"
154
+ "Content-Type": "application/json",
155
+ "Notion-Version": notionVersion
49
156
  },
50
- body: form
157
+ body: JSON.stringify({})
51
158
  });
52
159
 
53
- if (!uploadRes.ok) {
54
- throw new Error(`Notion Binary Upload Failed: ${await uploadRes.text()}`);
55
- }
56
-
57
160
  return id;
58
161
  }
59
162
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "notion-multipart-uploader",
3
- "version": "1.0.1",
3
+ "version": "2.1.4",
4
4
  "description": "Native zero-dependency multipart file uploader for Notion API. Direct upload any media type (videos, phone gallery images, audio, PDFs, and binary blobs) to Notion without external hosting.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -14,7 +14,8 @@
14
14
  "node": ">=18"
15
15
  },
16
16
  "scripts": {
17
- "test": "node test.js"
17
+ "test": "node --test test.mock.js",
18
+ "test:integration": "node test.js"
18
19
  },
19
20
  "keywords": [
20
21
  "notion",