@syncular/server 0.0.4-26 → 0.0.4-33

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.
@@ -1,219 +0,0 @@
1
- /**
2
- * S3-compatible blob storage adapter.
3
- *
4
- * Works with AWS S3, Cloudflare R2, MinIO, and other S3-compatible services.
5
- * Requires @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner as peer dependencies.
6
- */
7
- /**
8
- * Create an S3-compatible blob storage adapter.
9
- *
10
- * @example
11
- * ```typescript
12
- * import { S3Client, PutObjectCommand, GetObjectCommand, HeadObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
13
- * import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
14
- *
15
- * const adapter = createS3BlobStorageAdapter({
16
- * client: new S3Client({ region: 'us-east-1' }),
17
- * bucket: 'my-bucket',
18
- * keyPrefix: 'blobs/',
19
- * commands: { PutObjectCommand, GetObjectCommand, HeadObjectCommand, DeleteObjectCommand },
20
- * getSignedUrl,
21
- * });
22
- * ```
23
- */
24
- export function createS3BlobStorageAdapter(options) {
25
- const { client, bucket, keyPrefix = '', commands, getSignedUrl, requireChecksum = true, } = options;
26
- function getKey(hash) {
27
- // Remove "sha256:" prefix and use hex as key
28
- const hex = hash.startsWith('sha256:') ? hash.slice(7) : hash;
29
- return `${keyPrefix}${hex}`;
30
- }
31
- return {
32
- name: 's3',
33
- async signUpload(opts) {
34
- const key = getKey(opts.hash);
35
- // Extract hex hash for checksum (S3 expects base64-encoded SHA-256)
36
- const hexHash = opts.hash.startsWith('sha256:')
37
- ? opts.hash.slice(7)
38
- : opts.hash;
39
- // Convert hex to base64 for S3 checksum header
40
- const checksumBase64 = hexToBase64(hexHash);
41
- const commandInput = {
42
- Bucket: bucket,
43
- Key: key,
44
- ContentLength: opts.size,
45
- ContentType: opts.mimeType,
46
- };
47
- if (requireChecksum) {
48
- commandInput.ChecksumSHA256 = checksumBase64;
49
- }
50
- const command = new commands.PutObjectCommand(commandInput);
51
- const url = await getSignedUrl(client, command, {
52
- expiresIn: opts.expiresIn,
53
- });
54
- const headers = {
55
- 'Content-Type': opts.mimeType,
56
- 'Content-Length': String(opts.size),
57
- };
58
- if (requireChecksum) {
59
- headers['x-amz-checksum-sha256'] = checksumBase64;
60
- }
61
- return {
62
- url,
63
- method: 'PUT',
64
- headers,
65
- };
66
- },
67
- async signDownload(opts) {
68
- const key = getKey(opts.hash);
69
- const command = new commands.GetObjectCommand({
70
- Bucket: bucket,
71
- Key: key,
72
- });
73
- return getSignedUrl(client, command, { expiresIn: opts.expiresIn });
74
- },
75
- async exists(hash) {
76
- const key = getKey(hash);
77
- try {
78
- const command = new commands.HeadObjectCommand({
79
- Bucket: bucket,
80
- Key: key,
81
- });
82
- await client.send(command);
83
- return true;
84
- }
85
- catch (err) {
86
- // Check for NotFound error
87
- if (isNotFoundError(err)) {
88
- return false;
89
- }
90
- throw err;
91
- }
92
- },
93
- async delete(hash) {
94
- const key = getKey(hash);
95
- const command = new commands.DeleteObjectCommand({
96
- Bucket: bucket,
97
- Key: key,
98
- });
99
- await client.send(command);
100
- },
101
- async getMetadata(hash) {
102
- const key = getKey(hash);
103
- try {
104
- const command = new commands.HeadObjectCommand({
105
- Bucket: bucket,
106
- Key: key,
107
- });
108
- const response = (await client.send(command));
109
- return {
110
- size: response.ContentLength ?? 0,
111
- mimeType: response.ContentType,
112
- };
113
- }
114
- catch (err) {
115
- if (isNotFoundError(err)) {
116
- return null;
117
- }
118
- throw err;
119
- }
120
- },
121
- async put(hash, data) {
122
- const key = getKey(hash);
123
- const command = new commands.PutObjectCommand({
124
- Bucket: bucket,
125
- Key: key,
126
- Body: data,
127
- ContentLength: data.length,
128
- ContentType: 'application/octet-stream',
129
- });
130
- await client.send(command);
131
- },
132
- async get(hash) {
133
- const key = getKey(hash);
134
- try {
135
- const command = new commands.GetObjectCommand({
136
- Bucket: bucket,
137
- Key: key,
138
- });
139
- const response = (await client.send(command));
140
- if (!response.Body)
141
- return null;
142
- return response.Body.transformToByteArray();
143
- }
144
- catch (err) {
145
- if (isNotFoundError(err)) {
146
- return null;
147
- }
148
- throw err;
149
- }
150
- },
151
- async getStream(hash) {
152
- const key = getKey(hash);
153
- try {
154
- const command = new commands.GetObjectCommand({
155
- Bucket: bucket,
156
- Key: key,
157
- });
158
- const response = (await client.send(command));
159
- if (!response.Body)
160
- return null;
161
- return response.Body.transformToWebStream();
162
- }
163
- catch (err) {
164
- if (isNotFoundError(err)) {
165
- return null;
166
- }
167
- throw err;
168
- }
169
- },
170
- };
171
- }
172
- function isNotFoundError(err) {
173
- if (typeof err !== 'object' || err === null)
174
- return false;
175
- const e = err;
176
- return (e.name === 'NotFound' ||
177
- e.name === 'NoSuchKey' ||
178
- e.$metadata?.httpStatusCode === 404);
179
- }
180
- function hexToBase64(hex) {
181
- const bytes = new Uint8Array(hex.length / 2);
182
- for (let i = 0; i < bytes.length; i++) {
183
- bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
184
- }
185
- // Use Buffer if available (Node/Bun), otherwise manual base64
186
- if (typeof Buffer !== 'undefined') {
187
- return Buffer.from(bytes).toString('base64');
188
- }
189
- // Manual base64 encoding
190
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
191
- let result = '';
192
- const len = bytes.length;
193
- const remainder = len % 3;
194
- for (let i = 0; i < len - remainder; i += 3) {
195
- const a = bytes[i];
196
- const b = bytes[i + 1];
197
- const c = bytes[i + 2];
198
- result +=
199
- chars.charAt((a >> 2) & 0x3f) +
200
- chars.charAt(((a << 4) | (b >> 4)) & 0x3f) +
201
- chars.charAt(((b << 2) | (c >> 6)) & 0x3f) +
202
- chars.charAt(c & 0x3f);
203
- }
204
- if (remainder === 1) {
205
- const a = bytes[len - 1];
206
- result += `${chars.charAt((a >> 2) & 0x3f) + chars.charAt((a << 4) & 0x3f)}==`;
207
- }
208
- else if (remainder === 2) {
209
- const a = bytes[len - 2];
210
- const b = bytes[len - 1];
211
- result +=
212
- chars.charAt((a >> 2) & 0x3f) +
213
- chars.charAt(((a << 4) | (b >> 4)) & 0x3f) +
214
- chars.charAt((b << 2) & 0x3f) +
215
- '=';
216
- }
217
- return result;
218
- }
219
- //# sourceMappingURL=s3.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"s3.js","sourceRoot":"","sources":["../../../src/blobs/adapters/s3.ts"],"names":[],"mappings":"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"}
@@ -1,132 +0,0 @@
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 './database';
7
- import { createFilesystemBlobStorageAdapter } from './filesystem';
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
- });
@@ -1,189 +0,0 @@
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
- import type { BlobTokenSigner } from './database';
26
-
27
- export interface FilesystemBlobStorageAdapterOptions {
28
- /** Directory root for blob files */
29
- basePath: string;
30
- /** Server base URL for upload/download routes (e.g. "/api/sync") */
31
- baseUrl: string;
32
- /** Token signer for authorization */
33
- tokenSigner: BlobTokenSigner;
34
- }
35
-
36
- /**
37
- * Resolve hash to a 2-level subdirectory path:
38
- * `{basePath}/{hex[0..2]}/{hex[2..4]}/{hex}`
39
- */
40
- function hashToFilePath(basePath: string, hash: string): string {
41
- const hex = hash.startsWith('sha256:') ? hash.slice(7) : hash;
42
- return join(basePath, hex.slice(0, 2), hex.slice(2, 4), hex);
43
- }
44
-
45
- function tmpPath(filePath: string): string {
46
- return `${filePath}.${Date.now()}.tmp`;
47
- }
48
-
49
- /**
50
- * Create a filesystem blob storage adapter.
51
- *
52
- * @example
53
- * ```typescript
54
- * const adapter = createFilesystemBlobStorageAdapter({
55
- * basePath: '/data/blobs',
56
- * baseUrl: 'https://api.example.com/api/sync',
57
- * tokenSigner: createHmacTokenSigner(process.env.BLOB_SECRET!),
58
- * });
59
- * ```
60
- */
61
- export function createFilesystemBlobStorageAdapter(
62
- options: FilesystemBlobStorageAdapterOptions
63
- ): BlobStorageAdapter {
64
- const { basePath, tokenSigner } = options;
65
- const normalizedBaseUrl = options.baseUrl.replace(/\/$/, '');
66
-
67
- return {
68
- name: 'filesystem',
69
-
70
- async signUpload(opts: BlobSignUploadOptions): Promise<BlobSignedUpload> {
71
- const expiresAt = Date.now() + opts.expiresIn * 1000;
72
- const token = await tokenSigner.sign(
73
- { hash: opts.hash, action: 'upload', expiresAt },
74
- opts.expiresIn
75
- );
76
-
77
- const url = `${normalizedBaseUrl}/blobs/${encodeURIComponent(opts.hash)}/upload?token=${encodeURIComponent(token)}`;
78
-
79
- return {
80
- url,
81
- method: 'PUT',
82
- headers: {
83
- 'Content-Type': opts.mimeType,
84
- 'Content-Length': String(opts.size),
85
- },
86
- };
87
- },
88
-
89
- async signDownload(opts: BlobSignDownloadOptions): Promise<string> {
90
- const expiresAt = Date.now() + opts.expiresIn * 1000;
91
- const token = await tokenSigner.sign(
92
- { hash: opts.hash, action: 'download', expiresAt },
93
- opts.expiresIn
94
- );
95
-
96
- return `${normalizedBaseUrl}/blobs/${encodeURIComponent(opts.hash)}/download?token=${encodeURIComponent(token)}`;
97
- },
98
-
99
- async exists(hash: string): Promise<boolean> {
100
- try {
101
- await stat(hashToFilePath(basePath, hash));
102
- return true;
103
- } catch {
104
- return false;
105
- }
106
- },
107
-
108
- async delete(hash: string): Promise<void> {
109
- try {
110
- await unlink(hashToFilePath(basePath, hash));
111
- } catch (err) {
112
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
113
- }
114
- },
115
-
116
- async getMetadata(
117
- hash: string
118
- ): Promise<{ size: number; mimeType?: string } | null> {
119
- try {
120
- const s = await stat(hashToFilePath(basePath, hash));
121
- return { size: s.size };
122
- } catch {
123
- return null;
124
- }
125
- },
126
-
127
- async put(hash: string, data: Uint8Array): Promise<void> {
128
- const filePath = hashToFilePath(basePath, hash);
129
- const tmp = tmpPath(filePath);
130
- await mkdir(dirname(filePath), { recursive: true });
131
- await writeFile(tmp, data);
132
- await rename(tmp, filePath);
133
- },
134
-
135
- async putStream(
136
- hash: string,
137
- stream: ReadableStream<Uint8Array>
138
- ): Promise<void> {
139
- const filePath = hashToFilePath(basePath, hash);
140
- const tmp = tmpPath(filePath);
141
- await mkdir(dirname(filePath), { recursive: true });
142
-
143
- const fh = await open(tmp, 'w');
144
- try {
145
- const reader = stream.getReader();
146
- while (true) {
147
- const { done, value } = await reader.read();
148
- if (done) break;
149
- await fh.write(value);
150
- }
151
- } finally {
152
- await fh.close();
153
- }
154
-
155
- await rename(tmp, filePath);
156
- },
157
-
158
- async get(hash: string): Promise<Uint8Array | null> {
159
- try {
160
- const buf = await readFile(hashToFilePath(basePath, hash));
161
- return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
162
- } catch (err) {
163
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
164
- throw err;
165
- }
166
- },
167
-
168
- async getStream(hash: string): Promise<ReadableStream<Uint8Array> | null> {
169
- let data: Buffer;
170
- try {
171
- data = await readFile(hashToFilePath(basePath, hash));
172
- } catch (err) {
173
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
174
- throw err;
175
- }
176
- const bytes = new Uint8Array(
177
- data.buffer,
178
- data.byteOffset,
179
- data.byteLength
180
- );
181
- return new ReadableStream<Uint8Array>({
182
- start(controller) {
183
- controller.enqueue(bytes);
184
- controller.close();
185
- },
186
- });
187
- },
188
- };
189
- }