@valentinkolb/filegate 0.0.1

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/src/schemas.ts ADDED
@@ -0,0 +1,170 @@
1
+ import { z } from "zod";
2
+
3
+ // ============================================================================
4
+ // Common
5
+ // ============================================================================
6
+
7
+ export const ErrorSchema = z.object({
8
+ error: z.string(),
9
+ });
10
+
11
+ export const FileTypeSchema = z.enum(["file", "directory"]);
12
+
13
+ export const FileInfoSchema = z.object({
14
+ name: z.string(),
15
+ path: z.string(),
16
+ type: FileTypeSchema,
17
+ size: z.number(),
18
+ mtime: z.iso.datetime(),
19
+ isHidden: z.boolean(),
20
+ mimeType: z.string().optional(),
21
+ });
22
+
23
+ export const DirInfoSchema = FileInfoSchema.extend({
24
+ items: z.array(FileInfoSchema),
25
+ total: z.number(),
26
+ });
27
+
28
+ // ============================================================================
29
+ // Query Params
30
+ // ============================================================================
31
+
32
+ export const PathQuerySchema = z.object({
33
+ path: z.string().min(1),
34
+ });
35
+
36
+ export const InfoQuerySchema = z.object({
37
+ path: z.string().min(1),
38
+ showHidden: z
39
+ .string()
40
+ .optional()
41
+ .transform((v) => v === "true"),
42
+ });
43
+
44
+ export const SearchQuerySchema = z.object({
45
+ paths: z.string().min(1),
46
+ pattern: z.string().min(1).max(500),
47
+ showHidden: z
48
+ .string()
49
+ .optional()
50
+ .transform((v) => v === "true"),
51
+ limit: z
52
+ .string()
53
+ .optional()
54
+ .transform((v) => (v ? parseInt(v, 10) : undefined)),
55
+ });
56
+
57
+ /** Count recursive wildcards (**) in a glob pattern */
58
+ export const countRecursiveWildcards = (pattern: string): number => {
59
+ return (pattern.match(/\*\*/g) || []).length;
60
+ };
61
+
62
+ // ============================================================================
63
+ // Request Bodies
64
+ // ============================================================================
65
+
66
+ export const MkdirBodySchema = z.object({
67
+ path: z.string().min(1),
68
+ ownerUid: z.number().int().optional(),
69
+ ownerGid: z.number().int().optional(),
70
+ mode: z
71
+ .string()
72
+ .regex(/^[0-7]{3,4}$/)
73
+ .optional(),
74
+ });
75
+
76
+ export const MoveBodySchema = z.object({
77
+ from: z.string().min(1),
78
+ to: z.string().min(1),
79
+ });
80
+
81
+ export const CopyBodySchema = z.object({
82
+ from: z.string().min(1),
83
+ to: z.string().min(1),
84
+ });
85
+
86
+ export const UploadStartBodySchema = z.object({
87
+ path: z.string().min(1),
88
+ filename: z.string().min(1),
89
+ size: z.number().int().positive(),
90
+ checksum: z.string().regex(/^sha256:[a-f0-9]{64}$/),
91
+ chunkSize: z.number().int().positive(),
92
+ ownerUid: z.number().int().optional(),
93
+ ownerGid: z.number().int().optional(),
94
+ mode: z
95
+ .string()
96
+ .regex(/^[0-7]{3,4}$/)
97
+ .optional(),
98
+ });
99
+
100
+ // ============================================================================
101
+ // Response Schemas
102
+ // ============================================================================
103
+
104
+ export const SearchResultSchema = z.object({
105
+ basePath: z.string(),
106
+ files: z.array(FileInfoSchema),
107
+ total: z.number(),
108
+ hasMore: z.boolean(),
109
+ });
110
+
111
+ export const SearchResponseSchema = z.object({
112
+ results: z.array(SearchResultSchema),
113
+ totalFiles: z.number(),
114
+ });
115
+
116
+ export const UploadStartResponseSchema = z.object({
117
+ uploadId: z.string().regex(/^[a-f0-9]{16}$/),
118
+ totalChunks: z.number(),
119
+ chunkSize: z.number(),
120
+ uploadedChunks: z.array(z.number()),
121
+ completed: z.literal(false),
122
+ });
123
+
124
+ export const UploadChunkProgressSchema = z.object({
125
+ chunkIndex: z.number(),
126
+ uploadedChunks: z.array(z.number()),
127
+ completed: z.literal(false),
128
+ });
129
+
130
+ export const UploadChunkCompleteSchema = z.object({
131
+ completed: z.literal(true),
132
+ file: FileInfoSchema.extend({ checksum: z.string() }),
133
+ });
134
+
135
+ export const UploadChunkResponseSchema = z.union([UploadChunkProgressSchema, UploadChunkCompleteSchema]);
136
+
137
+ // ============================================================================
138
+ // Header Schemas
139
+ // ============================================================================
140
+
141
+ export const UploadFileHeadersSchema = z.object({
142
+ "x-file-path": z.string().min(1),
143
+ "x-file-name": z.string().min(1),
144
+ "x-owner-uid": z.string().regex(/^\d+$/).transform(Number).optional(),
145
+ "x-owner-gid": z.string().regex(/^\d+$/).transform(Number).optional(),
146
+ "x-file-mode": z
147
+ .string()
148
+ .regex(/^[0-7]{3,4}$/)
149
+ .optional(),
150
+ });
151
+
152
+ export const UploadChunkHeadersSchema = z.object({
153
+ "x-upload-id": z.string().regex(/^[a-f0-9]{16}$/),
154
+ "x-chunk-index": z.string().regex(/^\d+$/).transform(Number),
155
+ "x-chunk-checksum": z
156
+ .string()
157
+ .regex(/^sha256:[a-f0-9]{64}$/)
158
+ .optional(),
159
+ });
160
+
161
+ // ============================================================================
162
+ // Types
163
+ // ============================================================================
164
+
165
+ export type FileInfo = z.infer<typeof FileInfoSchema>;
166
+ export type DirInfo = z.infer<typeof DirInfoSchema>;
167
+ export type SearchResult = z.infer<typeof SearchResultSchema>;
168
+ export type UploadStartBody = z.infer<typeof UploadStartBodySchema>;
169
+ export type UploadFileHeaders = z.infer<typeof UploadFileHeadersSchema>;
170
+ export type UploadChunkHeaders = z.infer<typeof UploadChunkHeadersSchema>;
package/src/utils.ts ADDED
@@ -0,0 +1,282 @@
1
+ // ============================================================================
2
+ // Filegate Utils - Browser-compatible utilities for chunked uploads
3
+ // ============================================================================
4
+
5
+ // ============================================================================
6
+ // Type Declarations
7
+ // ============================================================================
8
+
9
+ // Blob.slice() is standard but missing from TypeScript's lib.dom.d.ts
10
+ declare global {
11
+ interface Blob {
12
+ slice(start?: number, end?: number, contentType?: string): Blob;
13
+ }
14
+ }
15
+
16
+ // ============================================================================
17
+ // Types
18
+ // ============================================================================
19
+
20
+ export interface ChunkInfo {
21
+ index: number;
22
+ data: Blob;
23
+ total: number;
24
+ }
25
+
26
+ export interface UploadState {
27
+ uploaded: number;
28
+ total: number;
29
+ percent: number;
30
+ status: "pending" | "uploading" | "completed" | "error";
31
+ }
32
+
33
+ export type StateSubscriber = (state: UploadState) => void;
34
+
35
+ export interface PrepareOptions {
36
+ file: Blob | File;
37
+ chunkSize?: number;
38
+ }
39
+
40
+ export interface SendOptions {
41
+ index: number;
42
+ retries?: number;
43
+ fn: (chunk: { index: number; data: Blob }) => Promise<void>;
44
+ }
45
+
46
+ export interface SendAllOptions {
47
+ skip?: number[];
48
+ retries?: number;
49
+ concurrency?: number;
50
+ fn: (chunk: { index: number; data: Blob }) => Promise<void>;
51
+ }
52
+
53
+ // ============================================================================
54
+ // ChunkedUpload Class
55
+ // ============================================================================
56
+
57
+ export class ChunkedUpload {
58
+ readonly file: Blob | File;
59
+ readonly fileSize: number;
60
+ readonly chunkSize: number;
61
+ readonly totalChunks: number;
62
+ readonly checksum: string;
63
+
64
+ private _state: UploadState;
65
+ private _subscribers: Set<StateSubscriber> = new Set();
66
+ private _completedChunks: Set<number> = new Set();
67
+
68
+ constructor(opts: { file: Blob | File; fileSize: number; chunkSize: number; totalChunks: number; checksum: string }) {
69
+ this.file = opts.file;
70
+ this.fileSize = opts.fileSize;
71
+ this.chunkSize = opts.chunkSize;
72
+ this.totalChunks = opts.totalChunks;
73
+ this.checksum = opts.checksum;
74
+ this._state = {
75
+ uploaded: 0,
76
+ total: opts.totalChunks,
77
+ percent: 0,
78
+ status: "pending",
79
+ };
80
+ }
81
+
82
+ // ==========================================================================
83
+ // State Management
84
+ // ==========================================================================
85
+
86
+ get state(): UploadState {
87
+ return { ...this._state };
88
+ }
89
+
90
+ subscribe(fn: StateSubscriber): () => void {
91
+ this._subscribers.add(fn);
92
+ // Emit current state immediately
93
+ fn(this.state);
94
+ return () => {
95
+ this._subscribers.delete(fn);
96
+ };
97
+ }
98
+
99
+ private _updateState(partial: Partial<UploadState>): void {
100
+ this._state = { ...this._state, ...partial };
101
+ for (const fn of this._subscribers) {
102
+ fn(this.state);
103
+ }
104
+ }
105
+
106
+ complete(opts: { index: number }): void {
107
+ if (this._completedChunks.has(opts.index)) return;
108
+
109
+ this._completedChunks.add(opts.index);
110
+ const uploaded = this._completedChunks.size;
111
+ const percent = Math.round((uploaded / this.totalChunks) * 100);
112
+ const status = uploaded === this.totalChunks ? "completed" : "uploading";
113
+
114
+ this._updateState({ uploaded, percent, status });
115
+ }
116
+
117
+ reset(): void {
118
+ this._completedChunks.clear();
119
+ this._updateState({
120
+ uploaded: 0,
121
+ percent: 0,
122
+ status: "pending",
123
+ });
124
+ }
125
+
126
+ // ==========================================================================
127
+ // Chunk Access
128
+ // ==========================================================================
129
+
130
+ get(opts: { index: number }): Blob {
131
+ const start = opts.index * this.chunkSize;
132
+ const end = Math.min(start + this.chunkSize, this.fileSize);
133
+ // Blob.prototype.slice is standard but TypeScript's lib.dom.d.ts doesn't include it
134
+ // We use a type-safe wrapper that preserves the Blob type
135
+ return (this.file as Blob).slice(start, end);
136
+ }
137
+
138
+ async hash(opts: { data: Blob | ArrayBuffer }): Promise<string> {
139
+ const buffer = opts.data instanceof Blob ? await opts.data.arrayBuffer() : opts.data;
140
+ const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
141
+ const hashArray = new Uint8Array(hashBuffer);
142
+ return `sha256:${Array.from(hashArray)
143
+ .map((b) => b.toString(16).padStart(2, "0"))
144
+ .join("")}`;
145
+ }
146
+
147
+ // ==========================================================================
148
+ // Upload Helpers
149
+ // ==========================================================================
150
+
151
+ async send(opts: SendOptions): Promise<void> {
152
+ const { index, retries = 0, fn } = opts;
153
+ const data = this.get({ index });
154
+
155
+ let lastError: Error | null = null;
156
+ for (let attempt = 0; attempt <= retries; attempt++) {
157
+ try {
158
+ await fn({ index, data });
159
+ this.complete({ index });
160
+ return;
161
+ } catch (err) {
162
+ lastError = err instanceof Error ? err : new Error(String(err));
163
+ if (attempt < retries) {
164
+ // Exponential backoff: 100ms, 200ms, 400ms, ...
165
+ await new Promise((r) => setTimeout(r, 100 * Math.pow(2, attempt)));
166
+ }
167
+ }
168
+ }
169
+
170
+ this._updateState({ status: "error" });
171
+ throw lastError;
172
+ }
173
+
174
+ async sendAll(opts: SendAllOptions): Promise<void> {
175
+ const { skip = [], retries = 0, concurrency = 1, fn } = opts;
176
+ const skipSet = new Set(skip);
177
+
178
+ // Mark skipped chunks as completed
179
+ for (const index of skip) {
180
+ this.complete({ index });
181
+ }
182
+
183
+ this._updateState({ status: "uploading" });
184
+
185
+ // Get indices that need to be uploaded
186
+ const toUpload: number[] = [];
187
+ for (let i = 0; i < this.totalChunks; i++) {
188
+ if (!skipSet.has(i) && !this._completedChunks.has(i)) {
189
+ toUpload.push(i);
190
+ }
191
+ }
192
+
193
+ if (concurrency === 1) {
194
+ // Sequential upload
195
+ for (const index of toUpload) {
196
+ await this.send({ index, retries, fn });
197
+ }
198
+ } else {
199
+ // Concurrent upload with limited parallelism
200
+ const queue = [...toUpload];
201
+ const inFlight: Promise<void>[] = [];
202
+
203
+ while (queue.length > 0 || inFlight.length > 0) {
204
+ // Start new uploads up to concurrency limit
205
+ while (queue.length > 0 && inFlight.length < concurrency) {
206
+ const index = queue.shift()!;
207
+ const promise = this.send({ index, retries, fn }).then(() => {
208
+ inFlight.splice(inFlight.indexOf(promise), 1);
209
+ });
210
+ inFlight.push(promise);
211
+ }
212
+
213
+ // Wait for at least one to complete
214
+ if (inFlight.length > 0) {
215
+ await Promise.race(inFlight);
216
+ }
217
+ }
218
+ }
219
+
220
+ this._updateState({ status: "completed" });
221
+ }
222
+
223
+ // ==========================================================================
224
+ // Async Iterator
225
+ // ==========================================================================
226
+
227
+ async *[Symbol.asyncIterator](): AsyncGenerator<ChunkInfo> {
228
+ for (let index = 0; index < this.totalChunks; index++) {
229
+ yield {
230
+ index,
231
+ data: this.get({ index }),
232
+ total: this.totalChunks,
233
+ };
234
+ }
235
+ }
236
+ }
237
+
238
+ // ============================================================================
239
+ // chunks namespace
240
+ // ============================================================================
241
+
242
+ async function prepare(opts: PrepareOptions): Promise<ChunkedUpload> {
243
+ const { file, chunkSize = 5 * 1024 * 1024 } = opts;
244
+ const fileSize = file.size;
245
+ const totalChunks = Math.ceil(fileSize / chunkSize);
246
+
247
+ // Calculate checksum using WebCrypto
248
+ const buffer = await file.arrayBuffer();
249
+ const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
250
+ const hashArray = new Uint8Array(hashBuffer);
251
+ const checksum = `sha256:${Array.from(hashArray)
252
+ .map((b) => b.toString(16).padStart(2, "0"))
253
+ .join("")}`;
254
+
255
+ return new ChunkedUpload({
256
+ file,
257
+ fileSize,
258
+ chunkSize,
259
+ totalChunks,
260
+ checksum,
261
+ });
262
+ }
263
+
264
+ export const chunks = {
265
+ prepare,
266
+ };
267
+
268
+ // ============================================================================
269
+ // formatBytes
270
+ // ============================================================================
271
+
272
+ export function formatBytes(opts: { bytes: number; decimals?: number }): string {
273
+ const { bytes, decimals = 2 } = opts;
274
+
275
+ if (bytes === 0) return "0 Bytes";
276
+
277
+ const k = 1024;
278
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB"];
279
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
280
+
281
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
282
+ }