@syncular/server-storage-filesystem 0.0.0
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/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +145 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/index.test.ts +132 -0
- package/src/index.ts +204 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem blob storage adapter.
|
|
3
|
+
*
|
|
4
|
+
* Stores blobs as files on disk with 2-level hash-based subdirectories.
|
|
5
|
+
* Uploads/downloads go through the server's blob routes using signed tokens
|
|
6
|
+
* (same pattern as the database adapter).
|
|
7
|
+
*/
|
|
8
|
+
import type { BlobStorageAdapter } from '@syncular/core';
|
|
9
|
+
export interface BlobTokenSigner {
|
|
10
|
+
sign(payload: {
|
|
11
|
+
hash: string;
|
|
12
|
+
action: 'upload' | 'download';
|
|
13
|
+
expiresAt: number;
|
|
14
|
+
}, expiresInSeconds: number): Promise<string>;
|
|
15
|
+
}
|
|
16
|
+
export interface FilesystemBlobStorageAdapterOptions {
|
|
17
|
+
/** Directory root for blob files */
|
|
18
|
+
basePath: string;
|
|
19
|
+
/** Server base URL for upload/download routes (e.g. "/api/sync") */
|
|
20
|
+
baseUrl: string;
|
|
21
|
+
/** Token signer for authorization */
|
|
22
|
+
tokenSigner: BlobTokenSigner;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Create a filesystem blob storage adapter.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* const adapter = createFilesystemBlobStorageAdapter({
|
|
30
|
+
* basePath: '/data/blobs',
|
|
31
|
+
* baseUrl: 'https://api.example.com/api/sync',
|
|
32
|
+
* tokenSigner: createHmacTokenSigner(process.env.BLOB_SECRET!),
|
|
33
|
+
* });
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export declare function createFilesystemBlobStorageAdapter(options: FilesystemBlobStorageAdapterOptions): BlobStorageAdapter;
|
|
37
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAYH,OAAO,KAAK,EAIV,kBAAkB,EACnB,MAAM,gBAAgB,CAAC;AAExB,MAAM,WAAW,eAAe;IAC9B,IAAI,CACF,OAAO,EAAE;QACP,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,QAAQ,GAAG,UAAU,CAAC;QAC9B,SAAS,EAAE,MAAM,CAAC;KACnB,EACD,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,MAAM,CAAC,CAAC;CACpB;AAOD,MAAM,WAAW,mCAAmC;IAClD,oCAAoC;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,oEAAoE;IACpE,OAAO,EAAE,MAAM,CAAC;IAChB,qCAAqC;IACrC,WAAW,EAAE,eAAe,CAAC;CAC9B;AAeD;;;;;;;;;;;GAWG;AACH,wBAAgB,kCAAkC,CAChD,OAAO,EAAE,mCAAmC,GAC3C,kBAAkB,CA8HpB"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem blob storage adapter.
|
|
3
|
+
*
|
|
4
|
+
* Stores blobs as files on disk with 2-level hash-based subdirectories.
|
|
5
|
+
* Uploads/downloads go through the server's blob routes using signed tokens
|
|
6
|
+
* (same pattern as the database adapter).
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir, open, readFile, rename, stat, unlink, writeFile, } from 'node:fs/promises';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
function hasErrnoCode(error, code) {
|
|
11
|
+
if (typeof error !== 'object' || error === null)
|
|
12
|
+
return false;
|
|
13
|
+
return error.code === code;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolve hash to a 2-level subdirectory path:
|
|
17
|
+
* `{basePath}/{hex[0..2]}/{hex[2..4]}/{hex}`
|
|
18
|
+
*/
|
|
19
|
+
function hashToFilePath(basePath, hash) {
|
|
20
|
+
const hex = hash.startsWith('sha256:') ? hash.slice(7) : hash;
|
|
21
|
+
return join(basePath, hex.slice(0, 2), hex.slice(2, 4), hex);
|
|
22
|
+
}
|
|
23
|
+
function tmpPath(filePath) {
|
|
24
|
+
return `${filePath}.${Date.now()}.tmp`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Create a filesystem blob storage adapter.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```typescript
|
|
31
|
+
* const adapter = createFilesystemBlobStorageAdapter({
|
|
32
|
+
* basePath: '/data/blobs',
|
|
33
|
+
* baseUrl: 'https://api.example.com/api/sync',
|
|
34
|
+
* tokenSigner: createHmacTokenSigner(process.env.BLOB_SECRET!),
|
|
35
|
+
* });
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function createFilesystemBlobStorageAdapter(options) {
|
|
39
|
+
const { basePath, tokenSigner } = options;
|
|
40
|
+
const normalizedBaseUrl = options.baseUrl.replace(/\/$/, '');
|
|
41
|
+
return {
|
|
42
|
+
name: 'filesystem',
|
|
43
|
+
async signUpload(opts) {
|
|
44
|
+
const expiresAt = Date.now() + opts.expiresIn * 1000;
|
|
45
|
+
const token = await tokenSigner.sign({ hash: opts.hash, action: 'upload', expiresAt }, opts.expiresIn);
|
|
46
|
+
const url = `${normalizedBaseUrl}/blobs/${encodeURIComponent(opts.hash)}/upload?token=${encodeURIComponent(token)}`;
|
|
47
|
+
return {
|
|
48
|
+
url,
|
|
49
|
+
method: 'PUT',
|
|
50
|
+
headers: {
|
|
51
|
+
'Content-Type': opts.mimeType,
|
|
52
|
+
'Content-Length': String(opts.size),
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
async signDownload(opts) {
|
|
57
|
+
const expiresAt = Date.now() + opts.expiresIn * 1000;
|
|
58
|
+
const token = await tokenSigner.sign({ hash: opts.hash, action: 'download', expiresAt }, opts.expiresIn);
|
|
59
|
+
return `${normalizedBaseUrl}/blobs/${encodeURIComponent(opts.hash)}/download?token=${encodeURIComponent(token)}`;
|
|
60
|
+
},
|
|
61
|
+
async exists(hash) {
|
|
62
|
+
try {
|
|
63
|
+
await stat(hashToFilePath(basePath, hash));
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
async delete(hash) {
|
|
71
|
+
try {
|
|
72
|
+
await unlink(hashToFilePath(basePath, hash));
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
if (!hasErrnoCode(err, 'ENOENT'))
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
async getMetadata(hash) {
|
|
80
|
+
try {
|
|
81
|
+
const fileStats = await stat(hashToFilePath(basePath, hash));
|
|
82
|
+
return { size: fileStats.size };
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
async put(hash, data) {
|
|
89
|
+
const filePath = hashToFilePath(basePath, hash);
|
|
90
|
+
const tmp = tmpPath(filePath);
|
|
91
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
92
|
+
await writeFile(tmp, data);
|
|
93
|
+
await rename(tmp, filePath);
|
|
94
|
+
},
|
|
95
|
+
async putStream(hash, stream) {
|
|
96
|
+
const filePath = hashToFilePath(basePath, hash);
|
|
97
|
+
const tmp = tmpPath(filePath);
|
|
98
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
99
|
+
const fh = await open(tmp, 'w');
|
|
100
|
+
try {
|
|
101
|
+
const reader = stream.getReader();
|
|
102
|
+
while (true) {
|
|
103
|
+
const { done, value } = await reader.read();
|
|
104
|
+
if (done)
|
|
105
|
+
break;
|
|
106
|
+
await fh.write(value);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await fh.close();
|
|
111
|
+
}
|
|
112
|
+
await rename(tmp, filePath);
|
|
113
|
+
},
|
|
114
|
+
async get(hash) {
|
|
115
|
+
try {
|
|
116
|
+
const data = await readFile(hashToFilePath(basePath, hash));
|
|
117
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
if (hasErrnoCode(err, 'ENOENT'))
|
|
121
|
+
return null;
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
async getStream(hash) {
|
|
126
|
+
let data;
|
|
127
|
+
try {
|
|
128
|
+
data = await readFile(hashToFilePath(basePath, hash));
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
if (hasErrnoCode(err, 'ENOENT'))
|
|
132
|
+
return null;
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
136
|
+
return new ReadableStream({
|
|
137
|
+
start(controller) {
|
|
138
|
+
controller.enqueue(bytes);
|
|
139
|
+
controller.close();
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@syncular/server-storage-filesystem",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Filesystem blob storage adapter for Syncular server",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Benjamin Kniffler",
|
|
7
|
+
"homepage": "https://syncular.dev",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/syncular/syncular.git",
|
|
11
|
+
"directory": "packages/server-storage-filesystem"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/syncular/syncular/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"sync",
|
|
18
|
+
"offline-first",
|
|
19
|
+
"blob",
|
|
20
|
+
"storage",
|
|
21
|
+
"filesystem"
|
|
22
|
+
],
|
|
23
|
+
"private": false,
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"type": "module",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"bun": "./src/index.ts",
|
|
31
|
+
"import": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "bun test --pass-with-no-tests",
|
|
39
|
+
"tsgo": "tsgo --noEmit",
|
|
40
|
+
"build": "tsgo",
|
|
41
|
+
"release": "bunx syncular-publish"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@syncular/core": "0.0.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@syncular/config": "0.0.0",
|
|
48
|
+
"@syncular/server": "0.0.0"
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"dist",
|
|
52
|
+
"src"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtemp, readdir, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type { BlobStorageAdapter } from '@syncular/core';
|
|
6
|
+
import { createHmacTokenSigner } from '@syncular/server';
|
|
7
|
+
import { createFilesystemBlobStorageAdapter } from './index';
|
|
8
|
+
|
|
9
|
+
let basePath: string;
|
|
10
|
+
let adapter: BlobStorageAdapter;
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
basePath = await mkdtemp(join(tmpdir(), 'syncular-blob-test-'));
|
|
14
|
+
adapter = createFilesystemBlobStorageAdapter({
|
|
15
|
+
basePath,
|
|
16
|
+
baseUrl: 'https://example.com/api/sync',
|
|
17
|
+
tokenSigner: createHmacTokenSigner('test-secret'),
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await rm(basePath, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const testHash =
|
|
26
|
+
'sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890';
|
|
27
|
+
const testData = new TextEncoder().encode('hello world');
|
|
28
|
+
|
|
29
|
+
describe('createFilesystemBlobStorageAdapter', () => {
|
|
30
|
+
test('put + get round-trip', async () => {
|
|
31
|
+
await adapter.put!(testHash, testData);
|
|
32
|
+
const result = await adapter.get!(testHash);
|
|
33
|
+
expect(result).toEqual(testData);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('putStream + getStream round-trip', async () => {
|
|
37
|
+
const inputStream = new ReadableStream<Uint8Array>({
|
|
38
|
+
start(controller) {
|
|
39
|
+
controller.enqueue(testData);
|
|
40
|
+
controller.close();
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await adapter.putStream!(testHash, inputStream);
|
|
45
|
+
|
|
46
|
+
const outputStream = await adapter.getStream!(testHash);
|
|
47
|
+
expect(outputStream).not.toBeNull();
|
|
48
|
+
|
|
49
|
+
const reader = outputStream!.getReader();
|
|
50
|
+
const chunks: Uint8Array[] = [];
|
|
51
|
+
while (true) {
|
|
52
|
+
const { done, value } = await reader.read();
|
|
53
|
+
if (done) break;
|
|
54
|
+
chunks.push(value);
|
|
55
|
+
}
|
|
56
|
+
const result = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0));
|
|
57
|
+
let offset = 0;
|
|
58
|
+
for (const chunk of chunks) {
|
|
59
|
+
result.set(chunk, offset);
|
|
60
|
+
offset += chunk.length;
|
|
61
|
+
}
|
|
62
|
+
expect(result).toEqual(testData);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('get returns null for missing blob', async () => {
|
|
66
|
+
const result = await adapter.get!(testHash);
|
|
67
|
+
expect(result).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('getStream returns null for missing blob', async () => {
|
|
71
|
+
const result = await adapter.getStream!(testHash);
|
|
72
|
+
expect(result).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('exists returns true after put', async () => {
|
|
76
|
+
expect(await adapter.exists(testHash)).toBe(false);
|
|
77
|
+
await adapter.put!(testHash, testData);
|
|
78
|
+
expect(await adapter.exists(testHash)).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('delete removes the blob', async () => {
|
|
82
|
+
await adapter.put!(testHash, testData);
|
|
83
|
+
expect(await adapter.exists(testHash)).toBe(true);
|
|
84
|
+
await adapter.delete(testHash);
|
|
85
|
+
expect(await adapter.exists(testHash)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('delete is idempotent for missing blob', async () => {
|
|
89
|
+
await adapter.delete(testHash);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('getMetadata returns size', async () => {
|
|
93
|
+
await adapter.put!(testHash, testData);
|
|
94
|
+
const meta = await adapter.getMetadata!(testHash);
|
|
95
|
+
expect(meta).toEqual({ size: testData.length });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('getMetadata returns null for missing blob', async () => {
|
|
99
|
+
const meta = await adapter.getMetadata!(testHash);
|
|
100
|
+
expect(meta).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('creates hash-based subdirectories', async () => {
|
|
104
|
+
await adapter.put!(testHash, testData);
|
|
105
|
+
// hex = abcdef..., so subdirs should be "ab/cd"
|
|
106
|
+
const firstLevel = await readdir(basePath);
|
|
107
|
+
expect(firstLevel).toContain('ab');
|
|
108
|
+
const secondLevel = await readdir(join(basePath, 'ab'));
|
|
109
|
+
expect(secondLevel).toContain('cd');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('signUpload returns a token URL', async () => {
|
|
113
|
+
const result = await adapter.signUpload({
|
|
114
|
+
hash: testHash,
|
|
115
|
+
size: 100,
|
|
116
|
+
mimeType: 'application/octet-stream',
|
|
117
|
+
expiresIn: 60,
|
|
118
|
+
});
|
|
119
|
+
expect(result.url).toContain('/blobs/');
|
|
120
|
+
expect(result.url).toContain('token=');
|
|
121
|
+
expect(result.method).toBe('PUT');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('signDownload returns a token URL', async () => {
|
|
125
|
+
const url = await adapter.signDownload({
|
|
126
|
+
hash: testHash,
|
|
127
|
+
expiresIn: 60,
|
|
128
|
+
});
|
|
129
|
+
expect(url).toContain('/blobs/');
|
|
130
|
+
expect(url).toContain('token=');
|
|
131
|
+
});
|
|
132
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem blob storage adapter.
|
|
3
|
+
*
|
|
4
|
+
* Stores blobs as files on disk with 2-level hash-based subdirectories.
|
|
5
|
+
* Uploads/downloads go through the server's blob routes using signed tokens
|
|
6
|
+
* (same pattern as the database adapter).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
mkdir,
|
|
11
|
+
open,
|
|
12
|
+
readFile,
|
|
13
|
+
rename,
|
|
14
|
+
stat,
|
|
15
|
+
unlink,
|
|
16
|
+
writeFile,
|
|
17
|
+
} from 'node:fs/promises';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import type {
|
|
20
|
+
BlobSignDownloadOptions,
|
|
21
|
+
BlobSignedUpload,
|
|
22
|
+
BlobSignUploadOptions,
|
|
23
|
+
BlobStorageAdapter,
|
|
24
|
+
} from '@syncular/core';
|
|
25
|
+
|
|
26
|
+
export interface BlobTokenSigner {
|
|
27
|
+
sign(
|
|
28
|
+
payload: {
|
|
29
|
+
hash: string;
|
|
30
|
+
action: 'upload' | 'download';
|
|
31
|
+
expiresAt: number;
|
|
32
|
+
},
|
|
33
|
+
expiresInSeconds: number
|
|
34
|
+
): Promise<string>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hasErrnoCode(error: unknown, code: string): boolean {
|
|
38
|
+
if (typeof error !== 'object' || error === null) return false;
|
|
39
|
+
return (error as { code?: unknown }).code === code;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FilesystemBlobStorageAdapterOptions {
|
|
43
|
+
/** Directory root for blob files */
|
|
44
|
+
basePath: string;
|
|
45
|
+
/** Server base URL for upload/download routes (e.g. "/api/sync") */
|
|
46
|
+
baseUrl: string;
|
|
47
|
+
/** Token signer for authorization */
|
|
48
|
+
tokenSigner: BlobTokenSigner;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve hash to a 2-level subdirectory path:
|
|
53
|
+
* `{basePath}/{hex[0..2]}/{hex[2..4]}/{hex}`
|
|
54
|
+
*/
|
|
55
|
+
function hashToFilePath(basePath: string, hash: string): string {
|
|
56
|
+
const hex = hash.startsWith('sha256:') ? hash.slice(7) : hash;
|
|
57
|
+
return join(basePath, hex.slice(0, 2), hex.slice(2, 4), hex);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function tmpPath(filePath: string): string {
|
|
61
|
+
return `${filePath}.${Date.now()}.tmp`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create a filesystem blob storage adapter.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```typescript
|
|
69
|
+
* const adapter = createFilesystemBlobStorageAdapter({
|
|
70
|
+
* basePath: '/data/blobs',
|
|
71
|
+
* baseUrl: 'https://api.example.com/api/sync',
|
|
72
|
+
* tokenSigner: createHmacTokenSigner(process.env.BLOB_SECRET!),
|
|
73
|
+
* });
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export function createFilesystemBlobStorageAdapter(
|
|
77
|
+
options: FilesystemBlobStorageAdapterOptions
|
|
78
|
+
): BlobStorageAdapter {
|
|
79
|
+
const { basePath, tokenSigner } = options;
|
|
80
|
+
const normalizedBaseUrl = options.baseUrl.replace(/\/$/, '');
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
name: 'filesystem',
|
|
84
|
+
|
|
85
|
+
async signUpload(opts: BlobSignUploadOptions): Promise<BlobSignedUpload> {
|
|
86
|
+
const expiresAt = Date.now() + opts.expiresIn * 1000;
|
|
87
|
+
const token = await tokenSigner.sign(
|
|
88
|
+
{ hash: opts.hash, action: 'upload', expiresAt },
|
|
89
|
+
opts.expiresIn
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const url = `${normalizedBaseUrl}/blobs/${encodeURIComponent(opts.hash)}/upload?token=${encodeURIComponent(token)}`;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
url,
|
|
96
|
+
method: 'PUT',
|
|
97
|
+
headers: {
|
|
98
|
+
'Content-Type': opts.mimeType,
|
|
99
|
+
'Content-Length': String(opts.size),
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async signDownload(opts: BlobSignDownloadOptions): Promise<string> {
|
|
105
|
+
const expiresAt = Date.now() + opts.expiresIn * 1000;
|
|
106
|
+
const token = await tokenSigner.sign(
|
|
107
|
+
{ hash: opts.hash, action: 'download', expiresAt },
|
|
108
|
+
opts.expiresIn
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return `${normalizedBaseUrl}/blobs/${encodeURIComponent(opts.hash)}/download?token=${encodeURIComponent(token)}`;
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async exists(hash: string): Promise<boolean> {
|
|
115
|
+
try {
|
|
116
|
+
await stat(hashToFilePath(basePath, hash));
|
|
117
|
+
return true;
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
async delete(hash: string): Promise<void> {
|
|
124
|
+
try {
|
|
125
|
+
await unlink(hashToFilePath(basePath, hash));
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (!hasErrnoCode(err, 'ENOENT')) throw err;
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async getMetadata(
|
|
132
|
+
hash: string
|
|
133
|
+
): Promise<{ size: number; mimeType?: string } | null> {
|
|
134
|
+
try {
|
|
135
|
+
const fileStats = await stat(hashToFilePath(basePath, hash));
|
|
136
|
+
return { size: fileStats.size };
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
async put(hash: string, data: Uint8Array): Promise<void> {
|
|
143
|
+
const filePath = hashToFilePath(basePath, hash);
|
|
144
|
+
const tmp = tmpPath(filePath);
|
|
145
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
146
|
+
await writeFile(tmp, data);
|
|
147
|
+
await rename(tmp, filePath);
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
async putStream(
|
|
151
|
+
hash: string,
|
|
152
|
+
stream: ReadableStream<Uint8Array>
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const filePath = hashToFilePath(basePath, hash);
|
|
155
|
+
const tmp = tmpPath(filePath);
|
|
156
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
157
|
+
|
|
158
|
+
const fh = await open(tmp, 'w');
|
|
159
|
+
try {
|
|
160
|
+
const reader = stream.getReader();
|
|
161
|
+
while (true) {
|
|
162
|
+
const { done, value } = await reader.read();
|
|
163
|
+
if (done) break;
|
|
164
|
+
await fh.write(value);
|
|
165
|
+
}
|
|
166
|
+
} finally {
|
|
167
|
+
await fh.close();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await rename(tmp, filePath);
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
async get(hash: string): Promise<Uint8Array | null> {
|
|
174
|
+
try {
|
|
175
|
+
const data = await readFile(hashToFilePath(basePath, hash));
|
|
176
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (hasErrnoCode(err, 'ENOENT')) return null;
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async getStream(hash: string): Promise<ReadableStream<Uint8Array> | null> {
|
|
184
|
+
let data: Uint8Array;
|
|
185
|
+
try {
|
|
186
|
+
data = await readFile(hashToFilePath(basePath, hash));
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (hasErrnoCode(err, 'ENOENT')) return null;
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
const bytes = new Uint8Array(
|
|
192
|
+
data.buffer,
|
|
193
|
+
data.byteOffset,
|
|
194
|
+
data.byteLength
|
|
195
|
+
);
|
|
196
|
+
return new ReadableStream<Uint8Array>({
|
|
197
|
+
start(controller) {
|
|
198
|
+
controller.enqueue(bytes);
|
|
199
|
+
controller.close();
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|