@txstate-mws/sveltekit-utils 1.1.6 → 1.2.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/dist/api.d.ts CHANGED
@@ -1,6 +1,18 @@
1
1
  import type { InteractionEvent, ValidatedResponse } from '@txstate-mws/fastify-shared';
2
2
  export type APIBaseQueryPayload = string | Record<string, undefined | string | number | (string | number)[]>;
3
3
  type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
4
+ export interface APIUploadInfo {
5
+ _type: 'APIUploadInfo';
6
+ multipartIndex: number;
7
+ name: string;
8
+ mime: string;
9
+ size: number;
10
+ }
11
+ export type APIBaseProgressFn = (info: {
12
+ loaded: number;
13
+ total: number;
14
+ ratio: number;
15
+ } | undefined) => void;
4
16
  /**
5
17
  * Provided for convenience in case you are not using APIBase but still want to record navigations
6
18
  *
@@ -25,6 +37,7 @@ export declare class APIBase {
25
37
  query?: APIBaseQueryPayload;
26
38
  inlineValidation?: boolean;
27
39
  }): Promise<ReturnType>;
40
+ uploadWithProgress(path: string, formData: FormData, progress: APIBaseProgressFn): Promise<any>;
28
41
  get<ReturnType = any>(path: string, query?: APIBaseQueryPayload): Promise<ReturnType>;
29
42
  /**
30
43
  * Remember to use validatedPost when the user is interacting with a form. That way they
@@ -61,6 +74,30 @@ export declare class APIBase {
61
74
  */
62
75
  delete<ReturnType = any>(path: string, query?: APIBaseQueryPayload, body?: any): Promise<ReturnType>;
63
76
  graphql<ReturnType = any>(query: string, variables?: any, querySignature?: string): Promise<ReturnType>;
77
+ /**
78
+ * This is a special graphql request method that allows you to upload files. It will
79
+ * find all the File objects in the variables and replace them with an APIUploadInfo object.
80
+ *
81
+ * Then it will send a multipart/form-data request instead of a standard JSON body, and all
82
+ * the file data will be included in later parts.
83
+ */
84
+ graphqlWithUploads<ReturnType = any>(query: string, variables: Record<string, any>, options?: {
85
+ /**
86
+ * Generally, set this to true if you are only validating a form. You don't want to
87
+ * be uploading files on every keystroke.
88
+ *
89
+ * In this case, we will send a regular post instead of multipart, and all the File objects
90
+ * in the variables will be replaced by an APIUploadInfo object as normal, including the
91
+ * multipartIndex, even though there are no multipart parts coming.
92
+ */
93
+ omitUploads?: boolean;
94
+ querySignature?: string;
95
+ /**
96
+ * This function will be called with a number between 0 and 1 as the uploads progress. The
97
+ * completion percentage is for the entire submission, not individual files.
98
+ */
99
+ progress?: APIBaseProgressFn;
100
+ }): Promise<ReturnType>;
64
101
  protected analyticsQueue: InteractionEvent[];
65
102
  recordInteraction(evt: Optional<InteractionEvent, 'screen'>): void;
66
103
  /**
package/dist/api.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { toasts } from '@txstate-mws/svelte-components';
2
2
  import { error } from '@sveltejs/kit';
3
3
  import { get } from 'svelte/store';
4
- import { rescue, toArray } from 'txstate-utils';
4
+ import { pick, rescue, toArray } from 'txstate-utils';
5
5
  import { page } from '$app/stores';
6
6
  import { afterNavigate } from '$app/navigation';
7
7
  import { unifiedAuth } from './unifiedauth.js';
@@ -26,6 +26,33 @@ export function recordNavigations(callback) {
26
26
  }, 10);
27
27
  });
28
28
  }
29
+ /**
30
+ * A non-mutating function that will replace all File objects in the variables with an APIUploadInfo
31
+ * object, and put the original File object into the files array so it can be appended to the multipart
32
+ * upload.
33
+ *
34
+ * When I say non-mutating, I mean it will not modify the original variables object. It will mutate the
35
+ * files parameter, which should be passed an empty array.
36
+ */
37
+ function replaceFiles(variables, files) {
38
+ let newVariables;
39
+ for (const key in variables) {
40
+ const val = variables[key];
41
+ if (val instanceof File) {
42
+ files.push(val);
43
+ newVariables ??= { ...variables };
44
+ newVariables[key] = { _type: 'APIUploadInfo', multipartIndex: files.length - 1, name: val.name, mime: val.type, size: val.size };
45
+ }
46
+ else if (val instanceof Object) {
47
+ const newVal = replaceFiles(val, files);
48
+ if (newVal !== val) {
49
+ newVariables ??= { ...variables };
50
+ newVariables[key] = newVal;
51
+ }
52
+ }
53
+ }
54
+ return newVariables ?? variables;
55
+ }
29
56
  export class APIBase {
30
57
  apiBase;
31
58
  authRedirect;
@@ -101,6 +128,44 @@ export class APIBase {
101
128
  throw e;
102
129
  }
103
130
  }
131
+ async uploadWithProgress(path, formData, progress) {
132
+ await this.readyPromise;
133
+ try {
134
+ return await new Promise((resolve, reject) => {
135
+ try {
136
+ progress({ loaded: 0, total: 0, ratio: 0 });
137
+ const request = new XMLHttpRequest();
138
+ request.open('POST', this.apiBase + path);
139
+ if (this.token)
140
+ request.setRequestHeader('Authorization', `Bearer ${this.token}`);
141
+ request.setRequestHeader('Accept', 'application/json');
142
+ request.upload.addEventListener('progress', e => progress({ ...pick(e, 'loaded', 'total'), ratio: e.lengthComputable ? e.loaded / e.total : 0.1 }));
143
+ // request finished
144
+ request.addEventListener('load', e => {
145
+ if (request.status >= 400)
146
+ reject(new Error(request.responseText));
147
+ else {
148
+ try {
149
+ resolve(JSON.parse(request.responseText));
150
+ }
151
+ catch (e) {
152
+ reject(e);
153
+ }
154
+ }
155
+ });
156
+ request.addEventListener('abort', e => reject(new Error('Upload aborted.')));
157
+ request.addEventListener('error', e => reject(new Error('An error occurred during transfer. Upload not completed.')));
158
+ request.send(formData);
159
+ }
160
+ catch (e) {
161
+ reject(e);
162
+ }
163
+ });
164
+ }
165
+ finally {
166
+ progress(undefined);
167
+ }
168
+ }
104
169
  async get(path, query) {
105
170
  return await this.request(path, 'GET', { query });
106
171
  }
@@ -166,6 +231,36 @@ export class APIBase {
166
231
  }
167
232
  return gqlresponse.data;
168
233
  }
234
+ /**
235
+ * This is a special graphql request method that allows you to upload files. It will
236
+ * find all the File objects in the variables and replace them with an APIUploadInfo object.
237
+ *
238
+ * Then it will send a multipart/form-data request instead of a standard JSON body, and all
239
+ * the file data will be included in later parts.
240
+ */
241
+ async graphqlWithUploads(query, variables, options) {
242
+ const files = [];
243
+ variables = replaceFiles(variables, files);
244
+ // If we are only validating, we don't need to upload files
245
+ if (options?.omitUploads || !files.length)
246
+ return this.graphql(query, variables, options?.querySignature);
247
+ const form = new FormData();
248
+ form.set('body', JSON.stringify({
249
+ query,
250
+ variables,
251
+ extensions: {
252
+ querySignature: options?.querySignature
253
+ }
254
+ }));
255
+ for (let i = 0; i < files.length; i++)
256
+ form.set(`file${i}`, files[i]);
257
+ const gqlresponse = await this.uploadWithProgress('/graphql', form, options?.progress ?? (() => { }));
258
+ if (gqlresponse.errors?.length) {
259
+ toasts.add(gqlresponse.errors[0].message);
260
+ throw new Error(JSON.stringify(gqlresponse.errors));
261
+ }
262
+ return gqlresponse.data;
263
+ }
169
264
  analyticsQueue = [];
170
265
  recordInteraction(evt) {
171
266
  evt.screen ??= get(page).route.id;
@@ -173,7 +268,8 @@ export class APIBase {
173
268
  setTimeout(() => {
174
269
  const events = [...this.analyticsQueue];
175
270
  this.analyticsQueue.length = 0;
176
- this.post('/analytics', events).catch((e) => console.error(e));
271
+ if (events.length)
272
+ this.post('/analytics', events).catch((e) => console.error(e));
177
273
  }, 2000);
178
274
  }
179
275
  /**
@@ -0,0 +1 @@
1
+ {"root":["../src/api.ts","../src/index.ts","../src/unifiedauth.ts"],"version":"5.7.3"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@txstate-mws/sveltekit-utils",
3
- "version": "1.1.6",
3
+ "version": "1.2.1",
4
4
  "description": "Shared library for code that is specifically tied to sveltekit in addition to svelte.",
5
5
  "type": "module",
6
6
  "exports": {