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