@grest-ts/file 0.0.5

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/GGFile.ts ADDED
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Unified file abstraction for Grest framework.
3
+ * Works across HTTP (multipart), WebSocket (base64), and tests (in-memory).
4
+ *
5
+ * Single consumption: buffer()/stream()/text() can only be called once.
6
+ * Use clone() if multiple reads are needed.
7
+ */
8
+ export abstract class GGFile {
9
+ abstract readonly name: string;
10
+ abstract readonly mimeType: string;
11
+ abstract readonly size: number;
12
+
13
+ /**
14
+ * Whether the file content has been consumed.
15
+ * Once consumed, buffer()/stream()/text() will throw.
16
+ */
17
+ abstract readonly consumed: boolean;
18
+
19
+ /**
20
+ * Get the file content as a ReadableStream.
21
+ * Can only be called once per file instance.
22
+ * @throws Error if already consumed
23
+ */
24
+ abstract stream(): ReadableStream<Uint8Array>;
25
+
26
+ /**
27
+ * Get the file content as a Uint8Array.
28
+ * Can only be called once per file instance.
29
+ * @throws Error if already consumed
30
+ */
31
+ abstract buffer(): Promise<Uint8Array>;
32
+
33
+ /**
34
+ * Get the file content as a UTF-8 string.
35
+ * Can only be called once per file instance.
36
+ * @throws Error if already consumed
37
+ */
38
+ abstract text(): Promise<string>;
39
+
40
+ /**
41
+ * Create a copy of this file that can be consumed independently.
42
+ * Only works if the file hasn't been consumed yet.
43
+ * @throws Error if already consumed
44
+ */
45
+ abstract clone(): GGFile;
46
+
47
+ // ==================== Static Factory Methods ====================
48
+
49
+ /**
50
+ * Create a GGFile from a Uint8Array buffer.
51
+ */
52
+ static fromBuffer(data: Uint8Array, name: string, mimeType: string = 'application/octet-stream'): GGFile {
53
+ return new BufferGGFile(data, name, mimeType);
54
+ }
55
+
56
+ /**
57
+ * Create a GGFile from a string (encoded as UTF-8).
58
+ */
59
+ static fromString(content: string, name: string, mimeType: string = 'text/plain'): GGFile {
60
+ const encoder = new TextEncoder();
61
+ return new BufferGGFile(encoder.encode(content), name, mimeType);
62
+ }
63
+
64
+ /**
65
+ * Create a GGFile from a base64-encoded string.
66
+ * Used for WebSocket transport where files are sent as base64.
67
+ */
68
+ static fromBase64(base64: string, name: string, mimeType: string = 'application/octet-stream'): GGFile {
69
+ const binaryString = atob(base64);
70
+ const bytes = new Uint8Array(binaryString.length);
71
+ for (let i = 0; i < binaryString.length; i++) {
72
+ bytes[i] = binaryString.charCodeAt(i);
73
+ }
74
+ return new BufferGGFile(bytes, name, mimeType);
75
+ }
76
+
77
+ /**
78
+ * Convert a GGFile to base64 string.
79
+ * Consumes the file.
80
+ */
81
+ static async toBase64(file: GGFile): Promise<string> {
82
+ const buffer = await file.buffer();
83
+ let binary = '';
84
+ for (let i = 0; i < buffer.length; i++) {
85
+ binary += String.fromCharCode(buffer[i]);
86
+ }
87
+ return btoa(binary);
88
+ }
89
+
90
+ /**
91
+ * Serialize a GGFile to JSON format for WebSocket transport.
92
+ * Consumes the file.
93
+ */
94
+ static async toJSON(file: GGFile): Promise<GGFileJSON> {
95
+ return {
96
+ __ggfile: true,
97
+ name: file.name,
98
+ mimeType: file.mimeType,
99
+ size: file.size,
100
+ data: await GGFile.toBase64(file),
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Deserialize a GGFile from JSON format.
106
+ */
107
+ static fromJSON(json: GGFileJSON): GGFile {
108
+ if (!json.__ggfile) {
109
+ throw new Error('Invalid GGFile JSON: missing __ggfile marker');
110
+ }
111
+ return GGFile.fromBase64(json.data, json.name, json.mimeType);
112
+ }
113
+
114
+ /**
115
+ * Check if a value is a serialized GGFile JSON.
116
+ */
117
+ static isJSON(value: unknown): value is GGFileJSON {
118
+ return typeof value === 'object'
119
+ && value !== null
120
+ && '__ggfile' in value
121
+ && (value as GGFileJSON).__ggfile === true
122
+ && 'name' in value
123
+ && 'mimeType' in value
124
+ && 'size' in value
125
+ && 'data' in value;
126
+ }
127
+
128
+ /**
129
+ * Create a GGFile from a native browser File.
130
+ * Lazily reads the file content only when buffer()/stream()/text() is called.
131
+ */
132
+ static fromBrowserFile(file: File & { readonly name: string }): GGFile {
133
+ return new BrowserGGFile(file);
134
+ }
135
+ }
136
+
137
+ /**
138
+ * JSON serialization format for GGFile (used in WebSocket transport).
139
+ */
140
+ export interface GGFileJSON {
141
+ __ggfile: true;
142
+ name: string;
143
+ mimeType: string;
144
+ size: number;
145
+ data: string; // base64 encoded
146
+ }
147
+
148
+ /**
149
+ * In-memory implementation of GGFile.
150
+ * Used for tests and when file content is already fully buffered.
151
+ */
152
+ export class BufferGGFile extends GGFile {
153
+ private _consumed = false;
154
+ private readonly data: Uint8Array;
155
+ readonly name: string;
156
+ readonly mimeType: string;
157
+
158
+ constructor(data: Uint8Array, name: string, mimeType: string = 'application/octet-stream') {
159
+ super();
160
+ this.data = data;
161
+ this.name = name;
162
+ this.mimeType = mimeType;
163
+ }
164
+
165
+ get size(): number {
166
+ return this.data.length;
167
+ }
168
+
169
+ get consumed(): boolean {
170
+ return this._consumed;
171
+ }
172
+
173
+ protected assertNotConsumed(): void {
174
+ if (this._consumed) {
175
+ throw new Error(`GGFile "${this.name}" has already been consumed. Use clone() if multiple reads are needed.`);
176
+ }
177
+ }
178
+
179
+ stream(): ReadableStream<Uint8Array> {
180
+ this.assertNotConsumed();
181
+ this._consumed = true;
182
+
183
+ const data = this.data;
184
+ return new ReadableStream<Uint8Array>({
185
+ start(controller) {
186
+ controller.enqueue(data);
187
+ controller.close();
188
+ }
189
+ });
190
+ }
191
+
192
+ async buffer(): Promise<Uint8Array> {
193
+ this.assertNotConsumed();
194
+ this._consumed = true;
195
+ return this.data;
196
+ }
197
+
198
+ async text(): Promise<string> {
199
+ this.assertNotConsumed();
200
+ this._consumed = true;
201
+ return new TextDecoder().decode(this.data);
202
+ }
203
+
204
+ clone(): GGFile {
205
+ this.assertNotConsumed();
206
+ return new BufferGGFile(new Uint8Array(this.data), this.name, this.mimeType);
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Browser implementation of GGFile that wraps a native File/Blob.
212
+ * Lazily reads file content only when buffer()/stream()/text() is called.
213
+ */
214
+ export class BrowserGGFile extends GGFile {
215
+ private _consumed = false;
216
+ readonly name: string;
217
+ readonly mimeType: string;
218
+ readonly size: number;
219
+
220
+ constructor(private readonly nativeFile: File & { readonly name: string }) {
221
+ super();
222
+ this.name = nativeFile.name;
223
+ this.mimeType = nativeFile.type || 'application/octet-stream';
224
+ this.size = nativeFile.size;
225
+ }
226
+
227
+ get consumed(): boolean {
228
+ return this._consumed;
229
+ }
230
+
231
+ private assertNotConsumed(): void {
232
+ if (this._consumed) {
233
+ throw new Error(`GGFile "${this.name}" has already been consumed. Use clone() if multiple reads are needed.`);
234
+ }
235
+ }
236
+
237
+ stream(): ReadableStream<Uint8Array> {
238
+ this.assertNotConsumed();
239
+ this._consumed = true;
240
+ return this.nativeFile.stream() as ReadableStream<Uint8Array>;
241
+ }
242
+
243
+ async buffer(): Promise<Uint8Array> {
244
+ this.assertNotConsumed();
245
+ this._consumed = true;
246
+ return new Uint8Array(await this.nativeFile.arrayBuffer());
247
+ }
248
+
249
+ async text(): Promise<string> {
250
+ this.assertNotConsumed();
251
+ this._consumed = true;
252
+ return this.nativeFile.text();
253
+ }
254
+
255
+ clone(): GGFile {
256
+ this.assertNotConsumed();
257
+ return new BrowserGGFile(this.nativeFile);
258
+ }
259
+ }
package/src/IsFile.ts ADDED
@@ -0,0 +1,153 @@
1
+ import {GGSchema, GGSchemaNonJsonDefinition, GGSchemaBinaryData, GGIssueInvalid, Opt} from "@grest-ts/schema";
2
+ import type {GGIssuesList} from "@grest-ts/schema";
3
+ import {BufferGGFile, GGFile} from "./GGFile";
4
+
5
+ /**
6
+ * Definition for file schema.
7
+ */
8
+ export interface FileDef extends GGSchemaNonJsonDefinition {
9
+ readonly type: 'file';
10
+ readonly accept?: readonly string[];
11
+ readonly maxSize?: number;
12
+ }
13
+
14
+ // ==================== Helper Functions ====================
15
+
16
+ function isGGFileLike(value: unknown): value is GGFile {
17
+ return typeof value === 'object'
18
+ && value !== null
19
+ && 'name' in value
20
+ && 'mimeType' in value
21
+ && 'size' in value
22
+ && typeof (value as any).name === 'string'
23
+ && typeof (value as any).mimeType === 'string'
24
+ && typeof (value as any).size === 'number';
25
+ }
26
+
27
+ function matchesAccept(name: string, mimeType: string, accept?: readonly string[]): boolean {
28
+ if (!accept || accept.length === 0) return true;
29
+ for (const pattern of accept) {
30
+ if (pattern.startsWith('.')) {
31
+ if (name.toLowerCase().endsWith(pattern.toLowerCase())) return true;
32
+ } else if (pattern === '*/*') {
33
+ return true;
34
+ } else if (pattern.endsWith('/*')) {
35
+ if (mimeType.startsWith(pattern.slice(0, -1))) return true;
36
+ } else {
37
+ if (mimeType === pattern) return true;
38
+ }
39
+ }
40
+ return false;
41
+ }
42
+
43
+ // ==================== FileSchema ====================
44
+
45
+ export class FileSchema<T extends GGFile | undefined | null = GGFile> extends GGSchema<T, FileDef> {
46
+
47
+ public static readonly typeError = new GGIssueInvalid("file.type", "Value must be a file");
48
+ public static readonly mimeTypeError = new GGIssueInvalid<{ accept: string }>("file.mimeType", "File type not accepted, expected: {accept}", {accept: "Accepted types"});
49
+ public static readonly maxSizeError = new GGIssueInvalid<{ max: number }>("file.maxSize", "File exceeds maximum size of {max} bytes", {max: "Maximum size"});
50
+
51
+ constructor(def: { type: 'file', accept?: readonly string[], maxSize?: number, optional?: boolean, nullable?: boolean }) {
52
+ const {accept, maxSize} = def;
53
+
54
+ const is = (value: unknown): value is GGFile => {
55
+ if (!isGGFileLike(value)) return false;
56
+ return matchesAccept(value.name, value.mimeType, accept) && (maxSize === undefined || value.size <= maxSize);
57
+ };
58
+
59
+ const isWithErrors = (value: unknown, issues: GGIssuesList, path: string): value is GGFile => {
60
+ if (!isGGFileLike(value)) {
61
+ return FileSchema.typeError.add(value, issues, path);
62
+ }
63
+ let valid = true;
64
+ if (!matchesAccept(value.name, value.mimeType, accept)) {
65
+ FileSchema.mimeTypeError.add(value, issues, path, {accept: accept?.join(', ') ?? '*/*'});
66
+ valid = false;
67
+ }
68
+ if (maxSize !== undefined && value.size > maxSize) {
69
+ FileSchema.maxSizeError.add(value, issues, path, {max: maxSize});
70
+ valid = false;
71
+ }
72
+ return valid;
73
+ };
74
+
75
+ const encodeToRaw = async (value: unknown, path: string): Promise<GGSchemaBinaryData> => {
76
+ const file = value as GGFile;
77
+ const buffer = await file.clone().buffer();
78
+ const blob = new Blob([buffer as unknown as Uint8Array<ArrayBuffer>], {type: file.mimeType});
79
+ return {path, blob, filename: file.name};
80
+ };
81
+
82
+ const decodeFromRaw = async (raw: GGSchemaBinaryData): Promise<GGFile> => {
83
+ const buffer = await raw.blob.arrayBuffer();
84
+ return new BufferGGFile(new Uint8Array(buffer), raw.filename || 'file', raw.blob.type);
85
+ };
86
+
87
+ super({...def, hasNonJsonData: true, is, isWithErrors, encodeToRaw, decodeFromRaw} as FileDef);
88
+ }
89
+
90
+ // ==================== Schema Methods ====================
91
+
92
+ get orUndefined(): FileSchema<T | undefined> & Opt {
93
+ return super.orUndefined as any;
94
+ }
95
+
96
+ get orNull(): FileSchema<T | null> {
97
+ return super.orNull as any;
98
+ }
99
+
100
+ protected derive<NewT extends GGFile | undefined | null = T>(changes: Partial<FileDef>): FileSchema<NewT> {
101
+ if (this.def.maxSize !== undefined && changes.maxSize !== undefined && changes.maxSize > this.def.maxSize) {
102
+ throw new Error(`Cannot raise maxSize from ${this.def.maxSize} to ${changes.maxSize}`);
103
+ }
104
+ return new FileSchema<NewT>({
105
+ type: 'file',
106
+ accept: changes.accept ?? this.def.accept,
107
+ maxSize: changes.maxSize ?? this.def.maxSize,
108
+ optional: changes.optional ?? this.def.optional,
109
+ nullable: changes.nullable ?? this.def.nullable
110
+ });
111
+ }
112
+
113
+ // ==================== File Constraints ====================
114
+
115
+ accept(...types: string[]): FileSchema<T> {
116
+ const combined = this.def.accept ? [...this.def.accept, ...types] : types;
117
+ return this.derive({accept: combined});
118
+ }
119
+
120
+ maxSize(bytes: number): FileSchema<T> {
121
+ return this.derive({maxSize: bytes});
122
+ }
123
+
124
+ // ==================== Static Shortcuts ====================
125
+
126
+ static image(opts?: { maxSize?: number }): FileSchema {
127
+ let s = new FileSchema({type: 'file', accept: ['image/*']});
128
+ return opts?.maxSize ? s.maxSize(opts.maxSize) : s;
129
+ }
130
+
131
+ static pdf(opts?: { maxSize?: number }): FileSchema {
132
+ let s = new FileSchema({type: 'file', accept: ['application/pdf']});
133
+ return opts?.maxSize ? s.maxSize(opts.maxSize) : s;
134
+ }
135
+
136
+ static any(opts?: { maxSize?: number }): FileSchema {
137
+ let s = new FileSchema({type: 'file'});
138
+ return opts?.maxSize ? s.maxSize(opts.maxSize) : s;
139
+ }
140
+
141
+ static video(opts?: { maxSize?: number }): FileSchema {
142
+ let s = new FileSchema({type: 'file', accept: ['video/*']});
143
+ return opts?.maxSize ? s.maxSize(opts.maxSize) : s;
144
+ }
145
+
146
+ static audio(opts?: { maxSize?: number }): FileSchema {
147
+ let s = new FileSchema({type: 'file', accept: ['audio/*']});
148
+ return opts?.maxSize ? s.maxSize(opts.maxSize) : s;
149
+ }
150
+ }
151
+
152
+ export const IsFile = new FileSchema({type: 'file'});
153
+ export type tFile = typeof IsFile.infer;
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./GGFile";
2
+ export * from "./IsFile";
3
+
4
+ export {BufferGGFile, BrowserGGFile} from "./GGFile";
@@ -0,0 +1,17 @@
1
+ {
2
+ "//": "THIS FILE IS GENERATED - DO NOT EDIT",
3
+ "extends": "../../../../tsconfig.base.json",
4
+ "compilerOptions": {
5
+ "rootDir": ".",
6
+ "lib": [
7
+ "ES2022",
8
+ "DOM"
9
+ ],
10
+ "types": [
11
+ "node"
12
+ ]
13
+ },
14
+ "include": [
15
+ "**/*"
16
+ ]
17
+ }