@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/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
+ }