Skip to content

Commit b87ac39

Browse files
authored
feat(storage-objects): Adds a helper method to create an Object from a local directory (#656)
1 parent 58ac02b commit b87ac39

6 files changed

Lines changed: 334 additions & 10 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"form-data-encoder": "1.7.2",
4343
"formdata-node": "^4.3.2",
4444
"node-fetch": "^2.6.7",
45+
"tar": "^7.5.2",
4546
"uuidv7": "^1.0.2",
4647
"zod": "^3.24.1"
4748
},

src/sdk.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,38 @@ export class StorageObjectOps {
488488
): Promise<StorageObject> {
489489
return StorageObject.uploadFromBuffer(this.client, buffer, name, contentType, options);
490490
}
491+
492+
/**
493+
* Upload a local directory as a gzipped tarball (Node.js only).
494+
* This method creates a tar archive of the directory contents, gzips it, and uploads it.
495+
*
496+
* @example
497+
* ```typescript
498+
* const runloop = new RunloopSDK();
499+
*
500+
* const object = await runloop.storageObject.uploadFromDir(
501+
* './my-project',
502+
* {
503+
* name: 'my-project.tar.gz',
504+
* ttl_ms: 3600000, // 1 hour
505+
* metadata: { project: 'demo' }
506+
* }
507+
* );
508+
* console.log(`Uploaded directory as ${object.id}`);
509+
* ```
510+
*
511+
* @param {string} dirPath - The path to the directory to upload.
512+
* @param {Omit<ObjectCreateParams, 'content_type'} params - Parameters for creating the object.
513+
* @param {Core.RequestOptions} options - Request options.
514+
* @returns {Promise<StorageObject>} A {@link StorageObject} instance.
515+
*/
516+
async uploadFromDir(
517+
dirPath: string,
518+
params: Omit<ObjectCreateParams, 'content_type'>,
519+
options?: Core.RequestOptions,
520+
): Promise<StorageObject> {
521+
return StorageObject.uploadFromDir(this.client, dirPath, params, options);
522+
}
491523
}
492524

493525
// @deprecated Use {@link RunloopSDK} instead.

src/sdk/storage-object.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
} from '../resources/objects';
99
import * as fs from 'node:fs/promises';
1010
import * as path from 'node:path';
11+
import * as tar from 'tar';
1112

1213
// Extract the content type from the API types
1314
type ContentType = ObjectCreateParams['content_type'];
@@ -488,6 +489,75 @@ export class StorageObject {
488489
return storageObject;
489490
}
490491

492+
/**
493+
* @hidden
494+
*/
495+
static async uploadFromDir(
496+
client: Runloop,
497+
dirPath: string,
498+
params: Omit<ObjectCreateParams, 'content_type'>,
499+
options?: Core.RequestOptions,
500+
): Promise<StorageObject> {
501+
assertNodeEnvironment();
502+
503+
// Verify directory exists and is actually a directory
504+
try {
505+
const stats = await fs.stat(dirPath);
506+
if (!stats.isDirectory()) {
507+
throw new Error(`Path is not a directory: ${dirPath}`);
508+
}
509+
} catch (error) {
510+
throw new Error(
511+
`Failed to access directory ${dirPath}: ${error instanceof Error ? error.message : 'Unknown error'}`,
512+
);
513+
}
514+
515+
// Create the tarball in-memory.
516+
let buffer;
517+
try {
518+
const tarStream = tar.create({ gzip: true, cwd: dirPath }, ['.']);
519+
const chunks = [];
520+
for await (const chunk of tarStream) {
521+
chunks.push(chunk);
522+
}
523+
buffer = Buffer.concat(chunks);
524+
} catch (error) {
525+
throw new Error(
526+
`Failed to create tarball from directory ${dirPath}: ${error instanceof Error ? error.message : 'Unknown error'}`,
527+
);
528+
}
529+
530+
// Create the object.
531+
const createParams: ObjectCreateParams = { ...params, content_type: 'tgz' };
532+
const objectData = await client.objects.create(createParams, options);
533+
const storageObject = new StorageObject(client, objectData.id, objectData.upload_url);
534+
535+
const uploadUrl = objectData.upload_url;
536+
if (!uploadUrl) {
537+
throw new Error('No upload URL available. Object may already be completed or deleted.');
538+
}
539+
540+
// Write the tarball to the upload URL.
541+
try {
542+
const response = await fetch(uploadUrl, {
543+
method: 'PUT',
544+
body: buffer,
545+
});
546+
547+
if (!response.ok) {
548+
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
549+
}
550+
} catch (error) {
551+
throw new Error(
552+
`Failed to upload tarball: ${error instanceof Error ? error.message : 'Unknown error'}`,
553+
);
554+
}
555+
556+
await storageObject.complete();
557+
558+
return storageObject;
559+
}
560+
491561
/**
492562
* Get the object ID.
493563
*/

tests/objects/storage-object.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ jest.mock('node:path', () => ({
1919
const ext = path.split('.').pop();
2020
return ext ? `.${ext}` : '';
2121
}),
22+
join: jest.fn((...paths) => paths.join('/')),
23+
}));
24+
25+
// Mock tar module
26+
jest.mock('tar', () => ({
27+
create: jest.fn(),
2228
}));
2329

2430
describe('StorageObject (New API)', () => {
@@ -810,6 +816,146 @@ describe('StorageObject (New API)', () => {
810816
});
811817
});
812818

819+
describe('uploadFromDir', () => {
820+
let mockTar: any;
821+
822+
beforeEach(() => {
823+
// Clear all mocks
824+
jest.clearAllMocks();
825+
// Reset global fetch mock
826+
((global as any).fetch as jest.Mock).mockClear();
827+
// Get tar mock
828+
mockTar = require('tar');
829+
});
830+
831+
it('should upload a directory as gzipped tarball', async () => {
832+
// Mock directory exists
833+
mockFs.stat.mockResolvedValue({ isDirectory: () => true });
834+
835+
// Mock tar stream
836+
const mockTarballBuffer = Buffer.from('compressed tarball content');
837+
mockTar.create.mockReturnValue({
838+
[Symbol.asyncIterator]: async function* () {
839+
yield mockTarballBuffer;
840+
},
841+
});
842+
843+
const mockObjectData = { id: 'dir-123', upload_url: 'https://upload.example.com/dir' };
844+
const mockObjectInfo = { ...mockObjectData, name: 'project.tar.gz', state: 'UPLOADING' };
845+
const mockCompletedData = { ...mockObjectInfo, state: 'READ_ONLY' };
846+
847+
mockClient.objects.create.mockResolvedValue(mockObjectData);
848+
mockClient.objects.retrieve.mockResolvedValue(mockObjectInfo);
849+
mockClient.objects.complete.mockResolvedValue(mockCompletedData);
850+
851+
((global as any).fetch as jest.Mock).mockResolvedValue({
852+
ok: true,
853+
status: 200,
854+
statusText: 'OK',
855+
});
856+
857+
const result = await StorageObject.uploadFromDir(mockClient, './my-project', {
858+
name: 'project.tar.gz',
859+
});
860+
861+
expect(mockClient.objects.create).toHaveBeenCalledWith(
862+
{ name: 'project.tar.gz', content_type: 'tgz' },
863+
undefined,
864+
);
865+
expect(mockTar.create).toHaveBeenCalled();
866+
expect(result).toBeInstanceOf(StorageObject);
867+
expect(result.id).toBe('dir-123');
868+
});
869+
870+
it('should upload directory with TTL and metadata', async () => {
871+
// Mock directory exists
872+
mockFs.stat.mockResolvedValue({ isDirectory: () => true });
873+
874+
// Mock tar stream
875+
const mockTarballBuffer = Buffer.from('compressed tarball');
876+
mockTar.create.mockReturnValue({
877+
[Symbol.asyncIterator]: async function* () {
878+
yield mockTarballBuffer;
879+
},
880+
});
881+
882+
const mockObjectData = { id: 'dir-456', upload_url: 'https://upload.example.com/dir' };
883+
const mockCompletedData = { ...mockObjectData, state: 'READ_ONLY' };
884+
885+
mockClient.objects.create.mockResolvedValue(mockObjectData);
886+
mockClient.objects.complete.mockResolvedValue(mockCompletedData);
887+
888+
((global as any).fetch as jest.Mock).mockResolvedValue({
889+
ok: true,
890+
status: 200,
891+
statusText: 'OK',
892+
});
893+
894+
const result = await StorageObject.uploadFromDir(mockClient, './my-project', {
895+
name: 'project.tar.gz',
896+
ttl_ms: 3600000,
897+
metadata: { project: 'demo' },
898+
});
899+
900+
expect(mockClient.objects.create).toHaveBeenCalledWith(
901+
{ name: 'project.tar.gz', content_type: 'tgz', metadata: { project: 'demo' }, ttl_ms: 3600000 },
902+
undefined,
903+
);
904+
expect(result.id).toBe('dir-456');
905+
});
906+
907+
it('should throw error if path is not a directory', async () => {
908+
mockFs.stat.mockResolvedValue({ isDirectory: () => false });
909+
910+
await expect(
911+
StorageObject.uploadFromDir(mockClient, './file.txt', { name: 'archive.tar.gz' }),
912+
).rejects.toThrow('Path is not a directory: ./file.txt');
913+
});
914+
915+
it('should throw error if directory does not exist', async () => {
916+
mockFs.stat.mockRejectedValue(new Error('ENOENT: no such file or directory'));
917+
918+
await expect(
919+
StorageObject.uploadFromDir(mockClient, './nonexistent', { name: 'archive.tar.gz' }),
920+
).rejects.toThrow('Failed to access directory ./nonexistent');
921+
});
922+
923+
it('should throw error in browser environment', async () => {
924+
const originalProcess = global.process;
925+
delete (global as any).process;
926+
927+
await expect(
928+
StorageObject.uploadFromDir(mockClient, './project', { name: 'project.tar.gz' }),
929+
).rejects.toThrow('File upload methods are only available in Node.js environment');
930+
931+
global.process = originalProcess;
932+
});
933+
934+
it('should handle upload failures gracefully', async () => {
935+
mockFs.stat.mockResolvedValue({ isDirectory: () => true });
936+
937+
const mockTarballBuffer = Buffer.from('tarball');
938+
mockTar.create.mockReturnValue({
939+
[Symbol.asyncIterator]: async function* () {
940+
yield mockTarballBuffer;
941+
},
942+
});
943+
944+
const mockObjectData = { id: 'dir-999', upload_url: 'https://upload.example.com/dir' };
945+
mockClient.objects.create.mockResolvedValue(mockObjectData);
946+
947+
((global as any).fetch as jest.Mock).mockResolvedValue({
948+
ok: false,
949+
status: 500,
950+
statusText: 'Internal Server Error',
951+
});
952+
953+
await expect(
954+
StorageObject.uploadFromDir(mockClient, './project', { name: 'project.tar.gz' }),
955+
).rejects.toThrow('Failed to upload tarball: Upload failed: 500 Internal Server Error');
956+
});
957+
});
958+
813959
describe('error handling', () => {
814960
it('should handle create errors', async () => {
815961
const error = new Error('Create failed');

tests/smoketests/object-oriented/storage-object.test.ts

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ReadEntry } from 'tar';
12
import { THIRTY_SECOND_TIMEOUT, uniqueName, makeClientSDK } from '../utils';
23
import { Devbox, StorageObject } from '@runloop/api-client/sdk';
34

@@ -195,13 +196,13 @@ describe('smoketest: object-oriented storage object', () => {
195196
});
196197

197198
test('upload from file', async () => {
198-
const fs = require('fs');
199+
const fs = require('fs/promises');
199200
const path = require('path');
200201
const os = require('os');
201202

202203
// Create a temporary file
203204
const tmpFile = path.join(os.tmpdir(), `test-upload-${Date.now()}.txt`);
204-
fs.writeFileSync(tmpFile, 'Hello from uploadFromFile!');
205+
await fs.writeFile(tmpFile, 'Hello from uploadFromFile!');
205206

206207
try {
207208
const uploaded = await sdk.storageObject.uploadFromFile(tmpFile, uniqueName('sdk-file-upload'), {
@@ -218,13 +219,52 @@ describe('smoketest: object-oriented storage object', () => {
218219
await uploaded.delete();
219220
} finally {
220221
// Clean up temp file
221-
if (fs.existsSync(tmpFile)) {
222-
fs.unlinkSync(tmpFile);
223-
}
222+
await fs.unlink(tmpFile).catch(() => {});
224223
}
225224
});
226225
});
227226

227+
test('upload from dir', async () => {
228+
const fs = require('fs/promises');
229+
const path = require('path');
230+
const os = require('os');
231+
const tar = require('tar');
232+
233+
// Create a temporary directory with a file in it.
234+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dir-to-tar-'));
235+
const contentPath = path.join(tmpDir, 'content');
236+
await fs.writeFile(contentPath, 'Hello from uploadFromDir!');
237+
try {
238+
const uploaded = await sdk.storageObject.uploadFromDir(tmpDir, { name: uniqueName('sdk-dir-upload') });
239+
expect(uploaded).toBeDefined();
240+
expect(uploaded.id).toBeTruthy();
241+
242+
// Verify content type.
243+
const info = await uploaded.getInfo();
244+
expect(info.content_type).toBe('tgz');
245+
246+
// Untar the downloaded object and check for the original
247+
// file.
248+
const data = await uploaded.downloadAsBuffer();
249+
const contentChunks: Buffer[] = [];
250+
const tarStream = tar.list({
251+
onReadEntry: async (entry: ReadEntry) => {
252+
if (entry.path == './content') {
253+
for await (const chunk of entry) {
254+
contentChunks.push(chunk);
255+
}
256+
}
257+
},
258+
});
259+
await tarStream.write(data);
260+
const content = Buffer.concat(contentChunks);
261+
expect(content.toString('utf-8')).toBe('Hello from uploadFromDir!');
262+
} finally {
263+
await fs.unlink(contentPath).catch(() => {});
264+
await fs.unlink(tmpDir).catch(() => {});
265+
}
266+
});
267+
228268
describe('storage object list and retrieval', () => {
229269
test('list storage objects via SDK', async () => {
230270
const objects = await sdk.storageObject.list({ limit: 10 });

0 commit comments

Comments
 (0)