-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathpapi-util.ts
374 lines (336 loc) · 15 KB
/
papi-util.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
import { ProcessType } from '@shared/global-this.model';
// There is a circular version https://www.npmjs.com/package/fast-equals#circulardeepequal that I
// think allows comparing React refs (which have circular references in particular places that this
// library would ignore). Maybe we can change to that version sometime if needed.
import { deepEqual as isEqualDeep } from 'fast-equals';
import { isString } from '@shared/utils/util';
// #region Unsubscriber stuff
/** Function to run to dispose of something. Returns true if successfully unsubscribed */
export type Unsubscriber = () => boolean;
/**
* Returns an Unsubscriber function that combines all the unsubscribers passed in.
*
* @param unsubscribers All unsubscribers to aggregate into one unsubscriber
* @returns Function that unsubscribes from all passed in unsubscribers when run
*/
export const aggregateUnsubscribers = (unsubscribers: Unsubscriber[]): Unsubscriber => {
return (...args) => {
// Run the unsubscriber for each handler
const unsubs = unsubscribers.map((unsubscriber) => unsubscriber(...args));
// If all the unsubscribers resolve to truthiness, we succeed
return unsubs.every((success) => success);
};
};
/**
* Function to run to dispose of something that runs asynchronously. The promise resolves to true if
* successfully unsubscribed
*/
export type UnsubscriberAsync = () => Promise<boolean>;
/**
* Returns an UnsubscriberAsync function that combines all the unsubscribers passed in.
*
* @param unsubscribers - All unsubscribers to aggregate into one unsubscriber.
* @returns Function that unsubscribes from all passed in unsubscribers when run
*/
export const aggregateUnsubscriberAsyncs = (
unsubscribers: (UnsubscriberAsync | Unsubscriber)[],
): UnsubscriberAsync => {
return async (...args) => {
// Run the unsubscriber for each handler
const unsubPromises = unsubscribers.map(async (unsubscriber) => unsubscriber(...args));
// If all the unsubscribers resolve to truthiness, we succeed
return (await Promise.all(unsubPromises)).every((success) => success);
};
};
/**
* Creates a safe version of a register function that returns a Promise<UnsubscriberAsync>.
*
* @param unsafeRegisterFn Function that does some kind of async registration and returns an
* unsubscriber and a promise that resolves when the registration is finished
* @param isInitialized Whether the service associated with this safe UnsubscriberAsync function is
* initialized
* @param initialize Promise that resolves when the service is finished initializing
* @returns Safe version of an unsafe function that returns a promise to an UnsubscriberAsync
* (meaning it will wait to register until the service is initialized)
*/
export const createSafeRegisterFn = <TParam extends Array<unknown>>(
unsafeRegisterFn: (...args: TParam) => Promise<UnsubscriberAsync>,
isInitialized: boolean,
initialize: () => Promise<void>,
): ((...args: TParam) => Promise<UnsubscriberAsync>) => {
return async (...args: TParam) => {
if (!isInitialized) await initialize();
return unsafeRegisterFn(...args);
};
};
// #endregion
// #region Request/Response types
/**
* Type of object passed to a complex request handler that provides information about the request.
* This type is used as the public-facing interface for requests
*/
export type ComplexRequest<TParam = unknown> = {
contents: TParam;
};
type ComplexResponseSuccess<TReturn = unknown> = {
/** Whether the handler that created this response was successful in handling the request */
success: true;
/**
* Content with which to respond to the request. Must be provided unless the response failed or
* TReturn is undefined
*/
contents: TReturn;
};
type ComplexResponseFailure = {
/** Whether the handler that created this response was successful in handling the request */
success: false;
/**
* Content with which to respond to the request. Must be provided unless the response failed or
* TReturn is undefined Removed from failure so we do not change the type of contents for type
* safety. We could add errorContents one day if we really need it
*/
/* contents?: TReturn; */
/** Error explaining the problem that is only populated if success is false */
errorMessage: string;
};
/**
* Type of object to create when handling a complex request where you desire to provide additional
* information beyond the contents of the response This type is used as the public-facing interface
* for responses
*/
export type ComplexResponse<TReturn = unknown> =
| ComplexResponseSuccess<TReturn>
| ComplexResponseFailure;
/** Type of request handler - indicates what type of parameters and what return type the handler has */
export enum RequestHandlerType {
Args = 'args',
Contents = 'contents',
Complex = 'complex',
}
// #endregion
// #region Equality checking functions
/**
* Check that two objects are deeply equal, comparing members of each object and such
*
* @param a The first object to compare
* @param b The second object to compare
*
* WARNING: Objects like arrays from different iframes have different constructor function
* references even if they do the same thing, so this deep equality comparison fails objects that
* look the same but have different constructors because different constructors could produce
* false positives in [a few specific
* situations](https://github.com/planttheidea/fast-equals/blob/a41afc0a240ad5a472e47b53791e9be017f52281/src/comparator.ts#L96).
* This means that two objects like arrays from different iframes that look the same will fail
* this check. Please use some other means to check deep equality in those situations.
*
* Note: This deep equality check considers `undefined` values on keys of objects NOT to be equal to
* not specifying the key at all. For example, `{ stuff: 3, things: undefined }` and `{ stuff: 3
* }` are not considered equal in this case
*
* - For more information and examples, see [this
* CodeSandbox](https://codesandbox.io/s/deepequallibrarycomparison-4g4kk4?file=/src/index.mjs).
*
* @returns True if a and b are deeply equal; false otherwise
*/
export function deepEqual(a: unknown, b: unknown) {
return isEqualDeep(a, b);
}
// #endregion
// #region Serialization, deserialization, encoding, and decoding functions
// Something to stand for both "undefined" and "null"
const NIL_MONIKER: string = '__NIL__';
/**
* Converts a JavaScript value to a JSON string, changing `null` and `undefined` values to a moniker
* that deserializes to `undefined`.
*
* WARNING: `null` and `undefined` values are treated as the same thing by this function and will be
* dropped when passed to {@link deserialize}. For example, `{ a: 1, b: undefined, c: null }` will
* become `{ a: 1 }` after passing through {@link serialize} then {@link deserialize}. If you are
* passing around user data that needs to retain `null` and/or `undefined` values, you should wrap
* them yourself in a string before using this function. Alternatively, you can write your own
* replacer that will preserve `null` and `undefined` values in a way that a custom reviver will
* understand when deserializing.
*
* @param value A JavaScript value, usually an object or array, to be converted.
* @param replacer A function that transforms the results. Note that all `null` and `undefined`
* values returned by the replacer will be further transformed into a moniker that deserializes
* into `undefined`.
* @param space Adds indentation, white space, and line break characters to the return-value JSON
* text to make it easier to read. See the `space` parameter of `JSON.stringify` for more
* details.
*/
export function serialize(
value: unknown,
replacer?: (this: unknown, key: string, value: unknown) => unknown,
space?: string | number,
): string {
const undefinedReplacer = (replacerKey: string, replacerValue: unknown) => {
let newValue = replacerValue;
if (replacer) newValue = replacer(replacerKey, newValue);
// If a "null" slips into the data somehow, we need to deal with it
// eslint-disable-next-line no-null/no-null
if (newValue === undefined || newValue === null) newValue = NIL_MONIKER;
return newValue;
};
return JSON.stringify(value, undefinedReplacer, space);
}
/**
* Converts a JSON string into a value.
*
* WARNING: `null` and `undefined` values that were serialized by {@link serialize} will both be made
* into `undefined` values by this function. If those values are properties of objects, those
* properties will simply be dropped. For example, `{ a: 1, b: undefined, c: null }` will become `{
* a: 1 }` after passing through {@link serialize} then {@link deserialize}. If you are passing around
* user data that needs to retain `null` and/or `undefined` values, you should wrap them yourself in
* a string before using this function. Alternatively, you can write your own reviver that will
* preserve `null` and `undefined` values in a way that a custom replacer will encode when
* serializing.
*
* @param text A valid JSON string.
* @param reviver A function that transforms the results. This function is called for each member of
* the object. If a member contains nested objects, the nested objects are transformed before the
* parent object is.
*/
export function deserialize(
value: string,
reviver?: (this: unknown, key: string, value: unknown) => unknown,
// Need to use `any` instead of `unknown` here to match the signature of JSON.parse
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): any {
const undefinedReviver = (replacerKey: string, replacerValue: unknown) => {
let newValue = replacerValue;
// If someone passes through a value with "null", we need to handle it
// eslint-disable-next-line no-null/no-null
if (newValue === NIL_MONIKER || newValue === null) newValue = undefined;
if (reviver) newValue = reviver(replacerKey, newValue);
return newValue;
};
return JSON.parse(value, undefinedReviver);
}
/**
* Check to see if the value is serializable without losing information
*
* @param value Value to test
* @returns True if serializable; false otherwise
*
* Note: the values `undefined` and `null` are serializable (on their own or in an array), but
* `undefined` and `null` properties of objects are dropped when serializing/deserializing. That
* means `undefined` and `null` properties on a value passed in will cause it to fail.
*
* WARNING: This is inefficient right now as it stringifies, parses, stringifies, and === the value.
* Please only use this if you need to
*
* DISCLAIMER: this does not successfully detect that values are not serializable in some cases:
*
* - Losses of removed properties like functions and `Map`s
* - Class instances (not deserializable into class instances without special code)
*
* We intend to improve this in the future if it becomes important to do so. See [`JSON.stringify`
* documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#description)
* for more information.
*/
export function isSerializable(value: unknown): boolean {
try {
const serializedValue = serialize(value);
return serializedValue === serialize(deserialize(serializedValue));
} catch (e) {
return false;
}
}
/** Separator between parts of a serialized request */
const REQUEST_TYPE_SEPARATOR = ':';
/** Information about a request that tells us what to do with it */
export type RequestType = {
/** The general category of request */
category: string;
/** Specific identifier for this type of request */
directive: string;
};
/**
* String version of a request type that tells us what to do with a request.
*
* Consists of two strings concatenated by a colon
*/
export type SerializedRequestType = `${string}${typeof REQUEST_TYPE_SEPARATOR}${string}`;
/**
* Create a request message requestType string from a category and a directive
*
* @param category The general category of request
* @param directive Specific identifier for this type of request
* @returns Full requestType for use in network calls
*/
export function serializeRequestType(category: string, directive: string): SerializedRequestType {
if (!category) throw new Error('serializeRequestType: "category" is not defined or empty.');
if (!directive) throw new Error('serializeRequestType: "directive" is not defined or empty.');
return `${category}${REQUEST_TYPE_SEPARATOR}${directive}`;
}
/** Split a request message requestType string into its parts */
export function deserializeRequestType(requestType: SerializedRequestType): RequestType {
if (!requestType) throw new Error('deserializeRequestType: must be a non-empty string');
const colonIndex = requestType.indexOf(REQUEST_TYPE_SEPARATOR);
if (colonIndex <= 0 || colonIndex >= requestType.length - 1)
throw new Error(
`deserializeRequestType: Must have two parts divided by a ${REQUEST_TYPE_SEPARATOR}`,
);
const category = requestType.substring(0, colonIndex);
const directive = requestType.substring(colonIndex + 1);
return { category, directive };
}
/**
* HTML Encodes the provided string. Thanks to ChatGPT
*
* @param str String to HTML encode
* @returns HTML-encoded string
*/
export const htmlEncode = (str: string): string =>
str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/\//g, '/');
// #endregion
// #region Module loading
/**
* Modules that someone might try to require in their extensions that we have similar apis for. When
* an extension requires these modules, an error throws that lets them know about our similar api.
*/
export const MODULE_SIMILAR_APIS: Readonly<{
[moduleName: string]: string | { [process in ProcessType | 'default']?: string } | undefined;
}> = Object.freeze({
http: 'fetch',
https: 'fetch',
fs: {
[ProcessType.Renderer]: 'the papi-extension: protocol',
[ProcessType.ExtensionHost]: 'papi.storage',
},
});
/**
* Get a message that says the module import was rejected and to try a similar api if available.
*
* @param moduleName Name of `require`d module that was rejected
* @returns String that says the import was rejected and a similar api to try
*/
export function getModuleSimilarApiMessage(moduleName: string) {
const similarApi = MODULE_SIMILAR_APIS[moduleName] || MODULE_SIMILAR_APIS[`node:${moduleName}`];
let similarApiName: string | undefined;
if (similarApi)
if (isString(similarApi)) {
similarApiName = similarApi;
} else {
similarApiName = similarApi[globalThis.processType] || similarApi.default;
}
return `Rejected require('${moduleName}'). Try${
similarApiName ? ` using ${similarApiName} or` : ''
} bundling the module into your code with a build tool like webpack`;
}
// #endregion
/**
* JSDOC SOURCE papiUtil
*
* PapiUtil is a collection of functions, objects, and types that are used as helpers in other
* services. Extensions should not use or rely on anything in papiUtil unless some other service
* requires it.
*/
export type moduleSummaryComments = {};