roblox-deeplink-parser 0.1.20

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.
@@ -0,0 +1,473 @@
1
+ import ParsedDeepLink, { type ExtractParameterType } from "./ParsedDeepLink";
2
+ import {
3
+ DEFAULT_APPSYFLYER_BASE_URL,
4
+ DEFAULT_ROBLOX_PLAYER_DEEPLINK_PROTOCOL,
5
+ DEFAULT_ROBLOX_WEBSITE_URL,
6
+ DEFAULT_ROBLOX_API_DOMAIN,
7
+ LOCALE_REGEX,
8
+ } from "./utils/constants";
9
+ import { getDeepLinks, type DeepLink } from "./utils/deepLinks";
10
+
11
+ export type DeepLinkParserUrls = {
12
+ appsFlyerBaseUrl: string;
13
+ robloxPlayerDeepLinkProtocol: string;
14
+ robloxUrl: string;
15
+ robloxApiDomain: string;
16
+ };
17
+
18
+ export type DeepLinkParserFns = {
19
+ getPlaceUniverseId: (placeId: number) => Promise<number | null>;
20
+ getUniverseRootPlaceId: (placeId: number) => Promise<number | null>;
21
+ };
22
+
23
+ export type DisallowedParams<
24
+ // biome-ignore lint/suspicious/noExplicitAny: A very strange typescript issue
25
+ T extends DeepLink<string, any, any, any>,
26
+ // biome-ignore lint/suspicious/noExplicitAny: A very strange typescript issue
27
+ > = T extends DeepLink<infer K, any, any, infer P>
28
+ ? Record<K, Array<keyof P>>
29
+ : never;
30
+
31
+ export type DeepLinkParserConstructorProps<
32
+ // biome-ignore lint/suspicious/noExplicitAny: A very strange typescript issue
33
+ T extends DeepLink<string, any, any, any>,
34
+ > = {
35
+ urls?: Partial<DeepLinkParserUrls>;
36
+ fns?: Partial<DeepLinkParserFns>;
37
+ fetchFn?: typeof fetch;
38
+ disallowedParams?: DisallowedParams<T>;
39
+ };
40
+
41
+ export default class DeepLinkParser<
42
+ // biome-ignore lint/suspicious/noExplicitAny: A very strange typescript issue
43
+ T extends DeepLink<string, any, any, any> = ReturnType<
44
+ typeof getDeepLinks
45
+ >[number],
46
+ > {
47
+ public _urls: DeepLinkParserUrls = {
48
+ appsFlyerBaseUrl: DEFAULT_APPSYFLYER_BASE_URL,
49
+ robloxPlayerDeepLinkProtocol: DEFAULT_ROBLOX_PLAYER_DEEPLINK_PROTOCOL,
50
+ robloxUrl: DEFAULT_ROBLOX_WEBSITE_URL,
51
+ robloxApiDomain: DEFAULT_ROBLOX_API_DOMAIN,
52
+ };
53
+ private _fns: DeepLinkParserFns;
54
+ private _fetchFn: typeof fetch = fetch.bind(globalThis);
55
+ private _disallowedParams: DisallowedParams<T> | undefined;
56
+
57
+ public _deepLinks: T[];
58
+
59
+ constructor(data?: DeepLinkParserConstructorProps<T>) {
60
+ this._fns = {
61
+ getPlaceUniverseId:
62
+ data?.fns?.getPlaceUniverseId ??
63
+ ((placeId) =>
64
+ this._fetchFn(
65
+ `https://apis${this._urls.robloxApiDomain}/universes/v1/places/${placeId}/universe`,
66
+ )
67
+ .then((res) => res.json())
68
+ .then((res) => res.universeId ?? null)),
69
+ getUniverseRootPlaceId:
70
+ data?.fns?.getUniverseRootPlaceId ??
71
+ ((universeId) =>
72
+ this._fetchFn(
73
+ `https://games${this._urls.robloxApiDomain}/v1/games?universeIds=${universeId}`,
74
+ )
75
+ .then((res) => res.json())
76
+ .then((res) => res?.data?.[0]?.rootPlaceId ?? null)),
77
+ };
78
+
79
+ if (data?.urls) {
80
+ for (const _key in data.urls) {
81
+ const key = _key as keyof DeepLinkParserUrls;
82
+ if (data.urls[key]) this._urls[key] = data.urls[key] as string;
83
+ }
84
+ }
85
+
86
+ if (data?.disallowedParams) {
87
+ this._disallowedParams = data.disallowedParams;
88
+ }
89
+
90
+ if (data?.fetchFn) this._fetchFn = data.fetchFn;
91
+ this._deepLinks = getDeepLinks(
92
+ this._fns.getUniverseRootPlaceId.bind(this),
93
+ this._fns.getPlaceUniverseId.bind(this),
94
+ this._urls.robloxUrl,
95
+ ) as T[];
96
+ }
97
+
98
+ public createDeepLink<U extends T["name"]>(
99
+ type: U,
100
+ params: ExtractParameterType<
101
+ Extract<
102
+ T,
103
+ {
104
+ name: U;
105
+ }
106
+ >
107
+ >["params"],
108
+ ): ParsedDeepLink<
109
+ Extract<
110
+ T,
111
+ {
112
+ name: U;
113
+ }
114
+ >
115
+ > | null {
116
+ const deepLink = this._deepLinks.find((link) => link.name === type);
117
+ if (!deepLink) return null;
118
+
119
+ const validatedParams: Record<string, string> = {};
120
+ const requiredParams: Set<string> = new Set();
121
+
122
+ if (deepLink.protocolUrls) {
123
+ for (const url of deepLink.protocolUrls) {
124
+ if (url.path) {
125
+ for (const path of url.path) {
126
+ if (params[path.name]) {
127
+ // @ts-expect-error: fine
128
+ validatedParams[path.name] = String(params[path.name]);
129
+ }
130
+ }
131
+ }
132
+ if (url.query) {
133
+ for (const query of url.query) {
134
+ const paramName =
135
+ "mappedName" in query ? query.mappedName : query.name;
136
+
137
+ // Track required parameters
138
+ if (query.required === true) {
139
+ requiredParams.add(String(paramName));
140
+ }
141
+
142
+ // Validate and add parameter if it exists
143
+ if (params[paramName] !== undefined) {
144
+ const value = String(params[paramName]);
145
+ // Check regex pattern if available
146
+ if (query.regex && !query.regex.test(value)) {
147
+ continue;
148
+ }
149
+
150
+ // @ts-expect-error: fine
151
+ validatedParams[paramName] = value;
152
+ }
153
+ }
154
+ }
155
+ }
156
+ }
157
+
158
+ if (deepLink.websiteUrls) {
159
+ for (const url of deepLink.websiteUrls) {
160
+ if (url.path) {
161
+ for (const path of url.path) {
162
+ if (params[path.name]) {
163
+ // @ts-expect-error: fine
164
+ validatedParams[path.name] = String(params[path.name]);
165
+ }
166
+ }
167
+ }
168
+ if (url.query) {
169
+ for (const query of url.query) {
170
+ const paramName =
171
+ "mappedName" in query ? query.mappedName : query.name;
172
+
173
+ if (query.required === true) {
174
+ requiredParams.add(String(paramName));
175
+ }
176
+
177
+ if (params[paramName] !== undefined) {
178
+ const value = String(params[paramName]);
179
+ // Check regex pattern if available
180
+ if (query.regex && !query.regex.test(value)) {
181
+ continue;
182
+ }
183
+
184
+ // @ts-expect-error: fine
185
+ validatedParams[paramName] = value;
186
+ }
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ if (deepLink.arbitaryParameters) {
193
+ for (const paramName in deepLink.arbitaryParameters) {
194
+ const allowed = deepLink.arbitaryParameters[paramName];
195
+
196
+ if (allowed && params[paramName] !== undefined) {
197
+ validatedParams[paramName] = String(params[paramName]);
198
+ }
199
+ }
200
+ }
201
+
202
+ if (this._disallowedParams?.[type]) {
203
+ for (const param of this._disallowedParams[type]) {
204
+ delete validatedParams[param as string];
205
+ }
206
+ }
207
+
208
+ for (const requiredParam of requiredParams) {
209
+ if (validatedParams[requiredParam] === undefined) {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ return new ParsedDeepLink<
215
+ Extract<
216
+ T,
217
+ {
218
+ name: U;
219
+ }
220
+ >
221
+ >(
222
+ {
223
+ type,
224
+
225
+ params: validatedParams,
226
+ // biome-ignore lint/suspicious/noExplicitAny: Fine
227
+ } as any,
228
+ this as DeepLinkParser,
229
+ );
230
+ }
231
+
232
+ public async parseAppsFlyerLink(
233
+ url: string,
234
+ ): Promise<ParsedDeepLink<T> | null> {
235
+ if (!url.startsWith(`https://${this._urls.appsFlyerBaseUrl}`)) {
236
+ return null;
237
+ }
238
+ const urlObj = new URL(url);
239
+
240
+ const deepLinkMobile =
241
+ urlObj.searchParams.get("af_dp") ||
242
+ urlObj.searchParams.get("deep_link_value");
243
+ const deepLinkWeb = urlObj.searchParams.get("af_web_dp");
244
+
245
+ // prioritize mobile over web
246
+ if (deepLinkMobile) {
247
+ return this.parseProtocolLink(deepLinkMobile);
248
+ }
249
+
250
+ if (deepLinkWeb) {
251
+ return this.parseWebsiteLink(deepLinkWeb);
252
+ }
253
+
254
+ return null;
255
+ }
256
+
257
+ public async parseWebsiteLink(
258
+ url: string,
259
+ ): Promise<ParsedDeepLink<T> | null> {
260
+ const urlObj = new URL(url);
261
+ let pathName = urlObj.pathname
262
+ .replace(/([^:]\/)\/+/g, "$1")
263
+ .replace(/\/$/, "");
264
+
265
+ try {
266
+ const split = pathName.split("/");
267
+ const localeMaybe = split[1];
268
+ if (
269
+ localeMaybe &&
270
+ LOCALE_REGEX.test(localeMaybe) &&
271
+ !["js", "my"].includes(localeMaybe)
272
+ ) {
273
+ split.splice(1, 1);
274
+ }
275
+
276
+ pathName = split.join("/");
277
+ } catch {
278
+ /* catch error */
279
+ }
280
+
281
+ const searchParams = urlObj.searchParams;
282
+
283
+ for (const deepLink of this._deepLinks) {
284
+ if (!deepLink.websiteUrls) continue;
285
+
286
+ for (const url of deepLink.websiteUrls) {
287
+ const match = url.regex.exec(pathName);
288
+ if (!match) continue;
289
+
290
+ const requiredGroups: string[] = [];
291
+ let passing = true;
292
+
293
+ let params: Record<string, string> = {};
294
+ if (url.path) {
295
+ if (!match.groups) {
296
+ continue;
297
+ }
298
+
299
+ for (const path of url.path) {
300
+ // @ts-expect-error: A very strange typescript issue
301
+ params[path.name] = match.groups?.[path.name];
302
+ }
303
+ }
304
+
305
+ if (url.query) {
306
+ for (const search of url.query) {
307
+ // @ts-expect-error: A very strange typescript issue
308
+ const value = searchParams.get(search.name);
309
+ if (!value) {
310
+ if (
311
+ search.required === true ||
312
+ (typeof search.required === "string" &&
313
+ // @ts-expect-error: A very strange typescript issue
314
+ !requiredGroups.includes(search.name))
315
+ ) {
316
+ passing = false;
317
+ break;
318
+ }
319
+
320
+ continue;
321
+ }
322
+
323
+ if (typeof search.required === "string") {
324
+ requiredGroups.push(search.required);
325
+ }
326
+
327
+ // @ts-expect-error: A very strange typescript issue
328
+ params["mappedName" in search ? search.mappedName : search.name] =
329
+ value;
330
+ }
331
+ }
332
+
333
+ if (!passing) continue;
334
+ if (deepLink.transformWebsiteParams) {
335
+ const transformedParams = await deepLink.transformWebsiteParams(
336
+ params,
337
+ urlObj,
338
+ );
339
+ if (!transformedParams) continue;
340
+
341
+ params = transformedParams;
342
+ }
343
+
344
+ if (this._disallowedParams?.[deepLink.name]) {
345
+ for (const param of this._disallowedParams[deepLink.name]) {
346
+ delete params[param as string];
347
+ }
348
+ }
349
+
350
+ return new ParsedDeepLink<T>(
351
+ {
352
+ type: deepLink.name,
353
+ params,
354
+ // biome-ignore lint/suspicious/noExplicitAny: Fine
355
+ } as any,
356
+ this as DeepLinkParser,
357
+ );
358
+ }
359
+ }
360
+
361
+ return null;
362
+ }
363
+
364
+ public async parseProtocolLink(
365
+ url: string,
366
+ ): Promise<ParsedDeepLink<T> | null> {
367
+ const prepend = `${this._urls.robloxPlayerDeepLinkProtocol}://`;
368
+ const urlObj = new URL(url.replace(prepend, `${prepend}/`));
369
+ if (urlObj.protocol !== `${this._urls.robloxPlayerDeepLinkProtocol}:`) {
370
+ return null;
371
+ }
372
+ let searchParams = urlObj.searchParams;
373
+
374
+ const pathNameSplit = urlObj.pathname.split("/");
375
+ pathNameSplit.shift();
376
+
377
+ let pathName = pathNameSplit.join("/");
378
+ if (pathName.includes("=") && !urlObj.href.includes("?")) {
379
+ searchParams = new URLSearchParams(pathName);
380
+ pathName = "";
381
+ }
382
+
383
+ for (const deepLink of this._deepLinks) {
384
+ if (!deepLink.protocolUrls) continue;
385
+
386
+ for (const url of deepLink.protocolUrls) {
387
+ const match = url.regex.exec(pathName);
388
+ if (!match) continue;
389
+
390
+ const requiredGroups: string[] = [];
391
+ let passing = true;
392
+
393
+ let params: Record<string, string> = {};
394
+ if (url.path) {
395
+ if (!match.groups) {
396
+ continue;
397
+ }
398
+
399
+ for (const path of url.path) {
400
+ // @ts-expect-error: A very strange typescript issue
401
+ params[path.name] = match.groups[path.name];
402
+ }
403
+ }
404
+
405
+ if (url.query) {
406
+ for (const search of url.query) {
407
+ // @ts-expect-error: A very strange typescript issue
408
+ const value = searchParams.get(search.name);
409
+ if (!value) {
410
+ if (
411
+ search.required === true ||
412
+ (typeof search.required === "string" &&
413
+ // @ts-expect-error: A very strange typescript issue
414
+ !requiredGroups.includes(search.name))
415
+ ) {
416
+ passing = false;
417
+ break;
418
+ }
419
+
420
+ continue;
421
+ }
422
+
423
+ if (typeof search.required === "string") {
424
+ requiredGroups.push(search.required);
425
+ }
426
+
427
+ // @ts-expect-error: A very strange typescript issue
428
+ params["mappedName" in search ? search.mappedName : search.name] =
429
+ value;
430
+ }
431
+ }
432
+
433
+ if (!passing) continue;
434
+ if (deepLink.transformProtocolParams) {
435
+ const transformedParams =
436
+ await deepLink.transformProtocolParams(params);
437
+ if (!transformedParams) continue;
438
+
439
+ params = transformedParams;
440
+ }
441
+
442
+ if (this._disallowedParams?.[deepLink.name]) {
443
+ for (const param of this._disallowedParams[deepLink.name]) {
444
+ delete params[param as string];
445
+ }
446
+ }
447
+
448
+ return new ParsedDeepLink<T>(
449
+ {
450
+ type: deepLink.name,
451
+ params,
452
+ // biome-ignore lint/suspicious/noExplicitAny: Fine
453
+ } as any,
454
+ this as DeepLinkParser,
455
+ );
456
+ }
457
+ }
458
+
459
+ return null;
460
+ }
461
+
462
+ public parseLink(url: string): Promise<ParsedDeepLink<T> | null> {
463
+ if (url.startsWith(`https://${this._urls.appsFlyerBaseUrl}`)) {
464
+ return this.parseAppsFlyerLink(url);
465
+ }
466
+
467
+ if (url.startsWith(`${this._urls.robloxPlayerDeepLinkProtocol}://`)) {
468
+ return this.parseProtocolLink(url);
469
+ }
470
+
471
+ return this.parseWebsiteLink(url);
472
+ }
473
+ }
@@ -0,0 +1,114 @@
1
+ import type DeepLinkParser from "./DeepLinkParser";
2
+ import type { DeepLink } from "./utils/deepLinks";
3
+
4
+ export type ExtractParameterType<T> = T extends DeepLink<
5
+ infer U,
6
+ infer _V,
7
+ infer _W,
8
+ infer X
9
+ >
10
+ ? {
11
+ type: U;
12
+ params: X;
13
+ }
14
+ : never;
15
+
16
+ export default class ParsedDeepLink<T extends DeepLink<string>> {
17
+ private _deepLink: T;
18
+ constructor(
19
+ public data: ExtractParameterType<T>,
20
+ private _deepLinkParser: DeepLinkParser,
21
+ ) {
22
+ this._deepLink = (_deepLinkParser._deepLinks.find(
23
+ (link) => link.name === data.type,
24
+ ) ?? {
25
+ name: data.type,
26
+ }) as T;
27
+ }
28
+
29
+ public toProtocolUrl(): string | null {
30
+ if (!this._deepLink.toProtocolUrl) return null;
31
+
32
+ const data = this.data;
33
+ let path =
34
+ typeof this._deepLink.toProtocolUrl === "function"
35
+ ? this._deepLink.toProtocolUrl(data.params)
36
+ : this._deepLink.toProtocolUrl;
37
+
38
+ const search = new URLSearchParams();
39
+
40
+ for (const param in data.params) {
41
+ const check = this._deepLink.arbitaryParameters?.[param];
42
+ if (check && check !== "protocol") continue;
43
+
44
+ if (path.includes(`{${param}}`)) {
45
+ path = path.replace(
46
+ `{${param}}`,
47
+ data.params[param as keyof typeof data.params],
48
+ );
49
+ continue;
50
+ }
51
+ search.append(param, data.params[param as keyof typeof data.params]);
52
+ }
53
+
54
+ const url = new URL(
55
+ `${this._deepLinkParser._urls.robloxPlayerDeepLinkProtocol}://${path}`,
56
+ );
57
+ url.search = search.toString();
58
+
59
+ return url.toString();
60
+ }
61
+
62
+ public toWebsiteUrl(): string | null {
63
+ if (!this._deepLink.toWebsiteUrl) return null;
64
+
65
+ const data = this.data;
66
+ let path =
67
+ typeof this._deepLink.toWebsiteUrl === "function"
68
+ ? this._deepLink.toWebsiteUrl(data.params)
69
+ : this._deepLink.toWebsiteUrl;
70
+
71
+ const search = new URLSearchParams();
72
+
73
+ for (const param in data.params) {
74
+ const check = this._deepLink.arbitaryParameters?.[param];
75
+ if (check && check !== "website") continue;
76
+
77
+ if (path.includes(`{${param}}`)) {
78
+ path = path.replace(
79
+ `{${param}}`,
80
+ data.params[param as keyof typeof data.params],
81
+ );
82
+ continue;
83
+ }
84
+ search.append(param, data.params[param as keyof typeof data.params]);
85
+ }
86
+
87
+ const url = new URL(
88
+ `https://${this._deepLinkParser._urls.robloxUrl}${path}`,
89
+ );
90
+ url.search = search.toString();
91
+
92
+ return url.toString();
93
+ }
94
+
95
+ public toAppsFlyerUrl(): string | null {
96
+ const deepLinkMobile = this.toProtocolUrl();
97
+ const deepLinkWeb = this.toWebsiteUrl();
98
+
99
+ if (!deepLinkMobile && !deepLinkWeb) return null;
100
+
101
+ const url = new URL(
102
+ `https://${this._deepLinkParser._urls.appsFlyerBaseUrl}`,
103
+ );
104
+ if (deepLinkMobile) {
105
+ url.searchParams.append("af_dp", deepLinkMobile);
106
+ url.searchParams.append("deep_link_value", deepLinkMobile);
107
+ }
108
+ if (deepLinkWeb) {
109
+ url.searchParams.append("af_web_dp", deepLinkWeb);
110
+ }
111
+
112
+ return url.toString();
113
+ }
114
+ }
@@ -0,0 +1,14 @@
1
+ export const DEFAULT_APPSYFLYER_BASE_URL = "ro.blox.com/Ebh5";
2
+ export const DEFAULT_ROBLOX_PLAYER_DEEPLINK_PROTOCOL = "roblox";
3
+ export const DEFAULT_ROBLOX_WEBSITE_URL = "www.roblox.com";
4
+ export const DEFAULT_ROBLOX_API_DOMAIN = ".roblox.com";
5
+ export const UUID_REGEX =
6
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
7
+ export const LOCALE_REGEX = /^[a-z]{2}(\-[a-z0-9]{2,3})?$/i;
8
+
9
+ export const DEFAULT_ROBLOX_PLAYER_AUTHED_PROTOCOL = "roblox-player";
10
+ export const DEFAULT_ROBLOX_STUDIO_AUTHED_PROTOCOL = "roblox-studio";
11
+ export const DEFAULT_ROBLOX_STUDIO_AUTH_AUTHED_PROTOCOL = "roblox-studio-auth";
12
+
13
+ export const DEFAULT_PLACELAUNCHER_URL =
14
+ "https://www.roblox.com/game/PlaceLauncher.ashx";