@howells/stow-server 0.1.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/README.md +161 -0
- package/dist/index.d.mts +302 -0
- package/dist/index.d.ts +302 -0
- package/dist/index.js +477 -0
- package/dist/index.mjs +451 -0
- package/package.json +49 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
var StowError = class extends Error {
|
|
4
|
+
status;
|
|
5
|
+
code;
|
|
6
|
+
constructor(message, status, code) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "StowError";
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
var uploadResultSchema = z.object({
|
|
14
|
+
key: z.string(),
|
|
15
|
+
url: z.string().nullable(),
|
|
16
|
+
size: z.number(),
|
|
17
|
+
contentType: z.string().optional(),
|
|
18
|
+
metadata: z.record(z.string(), z.string()).optional()
|
|
19
|
+
});
|
|
20
|
+
var listFilesSchema = z.object({
|
|
21
|
+
files: z.array(
|
|
22
|
+
z.object({
|
|
23
|
+
key: z.string(),
|
|
24
|
+
size: z.number(),
|
|
25
|
+
lastModified: z.string(),
|
|
26
|
+
url: z.string().nullable(),
|
|
27
|
+
metadata: z.record(z.string(), z.string()).optional()
|
|
28
|
+
})
|
|
29
|
+
),
|
|
30
|
+
nextCursor: z.string().nullable()
|
|
31
|
+
});
|
|
32
|
+
var errorSchema = z.object({
|
|
33
|
+
error: z.string(),
|
|
34
|
+
code: z.string().optional()
|
|
35
|
+
});
|
|
36
|
+
var dropResultSchema = z.object({
|
|
37
|
+
shortId: z.string(),
|
|
38
|
+
url: z.string(),
|
|
39
|
+
filename: z.string(),
|
|
40
|
+
size: z.number(),
|
|
41
|
+
contentType: z.string()
|
|
42
|
+
});
|
|
43
|
+
var dropSchema = z.object({
|
|
44
|
+
id: z.string(),
|
|
45
|
+
shortId: z.string(),
|
|
46
|
+
url: z.string(),
|
|
47
|
+
filename: z.string(),
|
|
48
|
+
size: z.number(),
|
|
49
|
+
contentType: z.string(),
|
|
50
|
+
createdAt: z.string()
|
|
51
|
+
});
|
|
52
|
+
var listDropsSchema = z.object({
|
|
53
|
+
drops: z.array(dropSchema),
|
|
54
|
+
usage: z.object({ bytes: z.number(), limit: z.number() })
|
|
55
|
+
});
|
|
56
|
+
var presignResultSchema = z.object({
|
|
57
|
+
fileKey: z.string(),
|
|
58
|
+
uploadUrl: z.string(),
|
|
59
|
+
r2Key: z.string(),
|
|
60
|
+
confirmUrl: z.string()
|
|
61
|
+
});
|
|
62
|
+
var confirmResultSchema = z.object({
|
|
63
|
+
key: z.string(),
|
|
64
|
+
url: z.string().nullable(),
|
|
65
|
+
size: z.number(),
|
|
66
|
+
contentType: z.string(),
|
|
67
|
+
metadata: z.record(z.string(), z.string()).optional()
|
|
68
|
+
});
|
|
69
|
+
var StowServer = class {
|
|
70
|
+
apiKey;
|
|
71
|
+
baseUrl;
|
|
72
|
+
bucket;
|
|
73
|
+
constructor(config) {
|
|
74
|
+
if (typeof config === "string") {
|
|
75
|
+
this.apiKey = config;
|
|
76
|
+
this.baseUrl = "https://stow.sh";
|
|
77
|
+
} else {
|
|
78
|
+
this.apiKey = config.apiKey;
|
|
79
|
+
this.baseUrl = config.baseUrl || "https://stow.sh";
|
|
80
|
+
this.bucket = config.bucket;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Get the base URL for this instance (used by client SDK)
|
|
85
|
+
*/
|
|
86
|
+
getBaseUrl() {
|
|
87
|
+
return this.baseUrl;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the effective bucket for this request.
|
|
91
|
+
* Per-call override > constructor default.
|
|
92
|
+
*/
|
|
93
|
+
resolveBucket(override) {
|
|
94
|
+
return override ?? this.bucket;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Append bucket query param to a URL path if set.
|
|
98
|
+
*/
|
|
99
|
+
withBucket(path, bucket) {
|
|
100
|
+
const b = this.resolveBucket(bucket);
|
|
101
|
+
if (!b) {
|
|
102
|
+
return path;
|
|
103
|
+
}
|
|
104
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
105
|
+
return `${path}${sep}bucket=${encodeURIComponent(b)}`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Make an API request with proper error handling
|
|
109
|
+
*/
|
|
110
|
+
async request(path, options, schema) {
|
|
111
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
112
|
+
...options,
|
|
113
|
+
headers: {
|
|
114
|
+
...options.headers,
|
|
115
|
+
"x-api-key": this.apiKey
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
const data = await response.json();
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
const error = errorSchema.safeParse(data);
|
|
121
|
+
const message = error.success ? error.data.error : "Request failed";
|
|
122
|
+
const code = error.success ? error.data.code : void 0;
|
|
123
|
+
throw new StowError(message, response.status, code);
|
|
124
|
+
}
|
|
125
|
+
return schema ? schema.parse(data) : data;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Upload a file directly from the server
|
|
129
|
+
*/
|
|
130
|
+
async uploadFile(file, options) {
|
|
131
|
+
const formData = new FormData();
|
|
132
|
+
const blob = Buffer.isBuffer(file) ? new Blob([new Uint8Array(file)], {
|
|
133
|
+
type: options?.contentType || "application/octet-stream"
|
|
134
|
+
}) : file;
|
|
135
|
+
formData.append("file", blob, options?.filename || "file");
|
|
136
|
+
if (options?.route) {
|
|
137
|
+
formData.append("route", options.route);
|
|
138
|
+
}
|
|
139
|
+
if (options?.metadata) {
|
|
140
|
+
formData.append("metadata", JSON.stringify(options.metadata));
|
|
141
|
+
}
|
|
142
|
+
const result = await this.request(
|
|
143
|
+
this.withBucket("/api/upload", options?.bucket),
|
|
144
|
+
{
|
|
145
|
+
method: "POST",
|
|
146
|
+
body: formData
|
|
147
|
+
},
|
|
148
|
+
uploadResultSchema
|
|
149
|
+
);
|
|
150
|
+
return {
|
|
151
|
+
key: result.key,
|
|
152
|
+
url: result.url,
|
|
153
|
+
size: result.size,
|
|
154
|
+
contentType: result.contentType || options?.contentType || "application/octet-stream",
|
|
155
|
+
...result.metadata ? { metadata: result.metadata } : {}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Upload a file from a URL (server-side fetch + upload)
|
|
160
|
+
*/
|
|
161
|
+
async uploadFromUrl(url, filename, options) {
|
|
162
|
+
const result = await this.request(
|
|
163
|
+
this.withBucket("/api/upload", options?.bucket),
|
|
164
|
+
{
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: { "Content-Type": "application/json" },
|
|
167
|
+
body: JSON.stringify({
|
|
168
|
+
url,
|
|
169
|
+
filename,
|
|
170
|
+
...options?.metadata ? { metadata: options.metadata } : {}
|
|
171
|
+
})
|
|
172
|
+
},
|
|
173
|
+
uploadResultSchema
|
|
174
|
+
);
|
|
175
|
+
return {
|
|
176
|
+
key: result.key,
|
|
177
|
+
url: result.url,
|
|
178
|
+
size: result.size,
|
|
179
|
+
contentType: result.contentType || "application/octet-stream",
|
|
180
|
+
...result.metadata ? { metadata: result.metadata } : {}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Get a presigned URL for direct client-side upload.
|
|
185
|
+
*
|
|
186
|
+
* This enables uploads that bypass your server entirely:
|
|
187
|
+
* 1. Client calls your endpoint
|
|
188
|
+
* 2. Your endpoint calls this method
|
|
189
|
+
* 3. Client PUTs directly to the returned uploadUrl
|
|
190
|
+
* 4. Client calls confirmUpload to finalize
|
|
191
|
+
*/
|
|
192
|
+
getPresignedUrl(request) {
|
|
193
|
+
const { filename, contentType, size, route, bucket, metadata } = request;
|
|
194
|
+
return this.request(
|
|
195
|
+
this.withBucket("/api/presign", bucket),
|
|
196
|
+
{
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: { "Content-Type": "application/json" },
|
|
199
|
+
body: JSON.stringify({
|
|
200
|
+
filename,
|
|
201
|
+
contentType,
|
|
202
|
+
size,
|
|
203
|
+
route,
|
|
204
|
+
...metadata ? { metadata } : {}
|
|
205
|
+
})
|
|
206
|
+
},
|
|
207
|
+
presignResultSchema
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Confirm a presigned upload after the client has uploaded to R2.
|
|
212
|
+
* This creates the file record in the database.
|
|
213
|
+
*/
|
|
214
|
+
confirmUpload(request) {
|
|
215
|
+
const { fileKey, size, contentType, bucket, metadata } = request;
|
|
216
|
+
return this.request(
|
|
217
|
+
this.withBucket("/api/presign/confirm", bucket),
|
|
218
|
+
{
|
|
219
|
+
method: "POST",
|
|
220
|
+
headers: { "Content-Type": "application/json" },
|
|
221
|
+
body: JSON.stringify({
|
|
222
|
+
fileKey,
|
|
223
|
+
size,
|
|
224
|
+
contentType,
|
|
225
|
+
...metadata ? { metadata } : {}
|
|
226
|
+
})
|
|
227
|
+
},
|
|
228
|
+
confirmResultSchema
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* List files in the bucket
|
|
233
|
+
*/
|
|
234
|
+
listFiles(options) {
|
|
235
|
+
const params = new URLSearchParams();
|
|
236
|
+
if (options?.prefix) {
|
|
237
|
+
params.set("prefix", options.prefix);
|
|
238
|
+
}
|
|
239
|
+
if (options?.limit) {
|
|
240
|
+
params.set("limit", options.limit.toString());
|
|
241
|
+
}
|
|
242
|
+
if (options?.cursor) {
|
|
243
|
+
params.set("cursor", options.cursor);
|
|
244
|
+
}
|
|
245
|
+
if (options?.tag) {
|
|
246
|
+
params.set("tag", options.tag);
|
|
247
|
+
}
|
|
248
|
+
const path = `/api/files?${params}`;
|
|
249
|
+
return this.request(
|
|
250
|
+
this.withBucket(path, options?.bucket),
|
|
251
|
+
{ method: "GET" },
|
|
252
|
+
listFilesSchema
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Delete a file by key
|
|
257
|
+
*/
|
|
258
|
+
async deleteFile(key, options) {
|
|
259
|
+
const path = `/api/files/${encodeURIComponent(key)}`;
|
|
260
|
+
await this.request(this.withBucket(path, options?.bucket), {
|
|
261
|
+
method: "DELETE"
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Update metadata on an existing file
|
|
266
|
+
*/
|
|
267
|
+
async updateFileMetadata(key, metadata, options) {
|
|
268
|
+
const path = `/api/files/${encodeURIComponent(key)}`;
|
|
269
|
+
return this.request(this.withBucket(path, options?.bucket), {
|
|
270
|
+
method: "PATCH",
|
|
271
|
+
headers: { "Content-Type": "application/json" },
|
|
272
|
+
body: JSON.stringify({ metadata })
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Get a transform URL for an image.
|
|
277
|
+
*
|
|
278
|
+
* Appends transform query params (?w=, ?h=, ?q=, ?f=) to a file URL.
|
|
279
|
+
* Transforms are applied at the edge by the Cloudflare Worker — no
|
|
280
|
+
* server round-trip needed.
|
|
281
|
+
*
|
|
282
|
+
* @param url - Full file URL (e.g. from upload result's fileUrl)
|
|
283
|
+
* @param options - Transform options (width, height, quality, format)
|
|
284
|
+
*/
|
|
285
|
+
getTransformUrl(url, options) {
|
|
286
|
+
const params = new URLSearchParams();
|
|
287
|
+
if (options?.width) {
|
|
288
|
+
params.set("w", options.width.toString());
|
|
289
|
+
}
|
|
290
|
+
if (options?.height) {
|
|
291
|
+
params.set("h", options.height.toString());
|
|
292
|
+
}
|
|
293
|
+
if (options?.quality) {
|
|
294
|
+
params.set("q", options.quality.toString());
|
|
295
|
+
}
|
|
296
|
+
if (options?.format) {
|
|
297
|
+
params.set("f", options.format);
|
|
298
|
+
}
|
|
299
|
+
const query = params.toString();
|
|
300
|
+
if (!query) {
|
|
301
|
+
return url;
|
|
302
|
+
}
|
|
303
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
304
|
+
return `${url}${separator}${query}`;
|
|
305
|
+
}
|
|
306
|
+
// ============================================================
|
|
307
|
+
// TAGS - Org-scoped labels for file organization
|
|
308
|
+
// ============================================================
|
|
309
|
+
/**
|
|
310
|
+
* Tags namespace for creating, listing, and deleting tags
|
|
311
|
+
*/
|
|
312
|
+
get tags() {
|
|
313
|
+
return {
|
|
314
|
+
list: () => this.listTags(),
|
|
315
|
+
create: (params) => this.createTag(params),
|
|
316
|
+
delete: (id) => this.deleteTag(id)
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
listTags() {
|
|
320
|
+
return this.request("/api/tags", { method: "GET" });
|
|
321
|
+
}
|
|
322
|
+
createTag(params) {
|
|
323
|
+
return this.request("/api/tags", {
|
|
324
|
+
method: "POST",
|
|
325
|
+
headers: { "Content-Type": "application/json" },
|
|
326
|
+
body: JSON.stringify(params)
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
async deleteTag(id) {
|
|
330
|
+
await this.request(`/api/tags/${encodeURIComponent(id)}`, {
|
|
331
|
+
method: "DELETE"
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
// ============================================================
|
|
335
|
+
// FILE TAGGING
|
|
336
|
+
// ============================================================
|
|
337
|
+
/**
|
|
338
|
+
* Add tags to a file
|
|
339
|
+
*/
|
|
340
|
+
async addTags(key, tagIds, options) {
|
|
341
|
+
const path = `/api/files/${encodeURIComponent(key)}/tags`;
|
|
342
|
+
await this.request(this.withBucket(path, options?.bucket), {
|
|
343
|
+
method: "POST",
|
|
344
|
+
headers: { "Content-Type": "application/json" },
|
|
345
|
+
body: JSON.stringify({ tagIds })
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Remove a tag from a file
|
|
350
|
+
*/
|
|
351
|
+
async removeTag(key, tagId, options) {
|
|
352
|
+
const path = `/api/files/${encodeURIComponent(key)}/tags/${encodeURIComponent(tagId)}`;
|
|
353
|
+
await this.request(this.withBucket(path, options?.bucket), {
|
|
354
|
+
method: "DELETE"
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
// ============================================================
|
|
358
|
+
// SEARCH - Vector similarity search
|
|
359
|
+
// ============================================================
|
|
360
|
+
/**
|
|
361
|
+
* Search namespace for vector similarity search
|
|
362
|
+
*/
|
|
363
|
+
get search() {
|
|
364
|
+
return {
|
|
365
|
+
similar: (params) => this.searchSimilar(params)
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
searchSimilar(params) {
|
|
369
|
+
return this.request("/api/search/similar", {
|
|
370
|
+
method: "POST",
|
|
371
|
+
headers: { "Content-Type": "application/json" },
|
|
372
|
+
body: JSON.stringify({
|
|
373
|
+
...params.fileKey ? { fileKey: params.fileKey } : {},
|
|
374
|
+
...params.vector ? { vector: params.vector } : {},
|
|
375
|
+
...params.bucket ? { bucket: params.bucket } : {},
|
|
376
|
+
...params.limit ? { limit: params.limit } : {}
|
|
377
|
+
})
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
// ============================================================
|
|
381
|
+
// DROPS - Quick share without buckets
|
|
382
|
+
// ============================================================
|
|
383
|
+
/**
|
|
384
|
+
* Upload a file as a drop (quick share)
|
|
385
|
+
*
|
|
386
|
+
* Drops are simpler than bucket uploads - just upload and get a URL.
|
|
387
|
+
* No bucket setup required. 1GB storage limit per user.
|
|
388
|
+
*
|
|
389
|
+
* @example
|
|
390
|
+
* ```typescript
|
|
391
|
+
* const drop = await stow.drop(buffer, { filename: "screenshot.png" });
|
|
392
|
+
* console.log(drop.url); // https://d.stow.sh/x7kQ3m
|
|
393
|
+
* ```
|
|
394
|
+
*/
|
|
395
|
+
async drop(file, options) {
|
|
396
|
+
const contentType = options?.contentType || "application/octet-stream";
|
|
397
|
+
const filename = options?.filename || "file";
|
|
398
|
+
const buffer = Buffer.isBuffer(file) ? file : Buffer.from(await file.arrayBuffer());
|
|
399
|
+
const presign = await this.request("/api/drops/presign", {
|
|
400
|
+
method: "POST",
|
|
401
|
+
headers: { "Content-Type": "application/json" },
|
|
402
|
+
body: JSON.stringify({
|
|
403
|
+
filename,
|
|
404
|
+
contentType,
|
|
405
|
+
size: buffer.length
|
|
406
|
+
})
|
|
407
|
+
});
|
|
408
|
+
const putRes = await fetch(presign.uploadUrl, {
|
|
409
|
+
method: "PUT",
|
|
410
|
+
headers: { "Content-Type": contentType },
|
|
411
|
+
body: buffer
|
|
412
|
+
});
|
|
413
|
+
if (!putRes.ok) {
|
|
414
|
+
throw new StowError("Failed to upload to storage", putRes.status);
|
|
415
|
+
}
|
|
416
|
+
return this.request(
|
|
417
|
+
"/api/drops/presign/confirm",
|
|
418
|
+
{
|
|
419
|
+
method: "POST",
|
|
420
|
+
headers: { "Content-Type": "application/json" },
|
|
421
|
+
body: JSON.stringify({
|
|
422
|
+
shortId: presign.shortId,
|
|
423
|
+
r2Key: presign.r2Key,
|
|
424
|
+
size: buffer.length,
|
|
425
|
+
contentType,
|
|
426
|
+
filename,
|
|
427
|
+
...presign.uploadToken ? { uploadToken: presign.uploadToken } : {}
|
|
428
|
+
})
|
|
429
|
+
},
|
|
430
|
+
dropResultSchema
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* List all drops for the authenticated user
|
|
435
|
+
*/
|
|
436
|
+
listDrops() {
|
|
437
|
+
return this.request("/api/drops", { method: "GET" }, listDropsSchema);
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Delete a drop by ID
|
|
441
|
+
*/
|
|
442
|
+
async deleteDrop(id) {
|
|
443
|
+
await this.request(`/api/drops/${encodeURIComponent(id)}`, {
|
|
444
|
+
method: "DELETE"
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
export {
|
|
449
|
+
StowError,
|
|
450
|
+
StowServer
|
|
451
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@howells/stow-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Server-side SDK for Stow file storage",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/howells/stow.git",
|
|
9
|
+
"directory": "packages/stow-server"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://stow.sh",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"stow",
|
|
14
|
+
"file-storage",
|
|
15
|
+
"s3",
|
|
16
|
+
"upload",
|
|
17
|
+
"sdk"
|
|
18
|
+
],
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"module": "./dist/index.mjs",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"import": "./dist/index.mjs",
|
|
26
|
+
"require": "./dist/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
34
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"test:watch": "vitest"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"zod": "^3.0.0 || ^4.0.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@stow/typescript-config": "workspace:*",
|
|
43
|
+
"@types/node": "^25.2.1",
|
|
44
|
+
"tsup": "^8.0.0",
|
|
45
|
+
"typescript": "^5.0.0",
|
|
46
|
+
"vitest": "^4.0.0",
|
|
47
|
+
"zod": "^4.3.6"
|
|
48
|
+
}
|
|
49
|
+
}
|