@epic-web/workshop-utils 6.55.0 → 6.56.0

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.
@@ -2,6 +2,7 @@ import "./init-env.js";
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
4
  import { rehypeCodeBlocksShiki } from '@kentcdodds/md-temp';
5
+ import * as cookie from 'cookie';
5
6
  import lz from 'lz-string';
6
7
  import md5 from 'md5-hex';
7
8
  import { bundleMDX } from 'mdx-bundler';
@@ -12,7 +13,51 @@ import gfm from 'remark-gfm';
12
13
  import { visit } from 'unist-util-visit';
13
14
  import { cachified, compiledInstructionMarkdownCache, compiledMarkdownCache, shouldForceFresh, } from "./cache.server.js";
14
15
  import { checkConnection } from "./utils.server.js";
15
- function remarkMermaidCodeToSvg() {
16
+ const themeCookieName = 'EpicShop_theme';
17
+ const themeHintCookieName = 'EpicShop_CH-prefers-color-scheme';
18
+ function getMermaidTheme(request) {
19
+ if (!request)
20
+ return 'default';
21
+ const cookieHeader = request.headers.get('cookie');
22
+ if (!cookieHeader)
23
+ return 'default';
24
+ const parsed = cookie.parse(cookieHeader);
25
+ const themeCookie = parsed[themeCookieName];
26
+ if (themeCookie === 'dark')
27
+ return 'dark';
28
+ if (themeCookie === 'light')
29
+ return 'default';
30
+ const hintTheme = parsed[themeHintCookieName];
31
+ return hintTheme === 'dark' ? 'dark' : 'default';
32
+ }
33
+ function mdxStringExpressionAttribute(name, value) {
34
+ return {
35
+ type: 'mdxJsxAttribute',
36
+ name,
37
+ value: {
38
+ type: 'mdxJsxAttributeValueExpression',
39
+ value: JSON.stringify(value),
40
+ // This hack brought to you by this: https://github.com/syntax-tree/hast-util-to-estree/blob/e5ccb97e9f42bba90359ea6d0f83a11d74e0dad6/lib/handlers/mdx-expression.js#L35-L38
41
+ // no idea why we're required to have estree here, but I'm pretty sure someone is supposed to add it automatically for us and it just never happens...
42
+ data: {
43
+ estree: {
44
+ type: 'Program',
45
+ sourceType: 'script',
46
+ body: [
47
+ {
48
+ type: 'ExpressionStatement',
49
+ expression: {
50
+ type: 'Literal',
51
+ value,
52
+ },
53
+ },
54
+ ],
55
+ },
56
+ },
57
+ },
58
+ };
59
+ }
60
+ function remarkMermaidCodeToSvg({ theme }) {
16
61
  return async (tree) => {
17
62
  const promises = [];
18
63
  visit(tree, 'code', (node, index, parent) => {
@@ -21,79 +66,50 @@ function remarkMermaidCodeToSvg() {
21
66
  const isConnected = await checkConnection();
22
67
  if (isConnected) {
23
68
  const compressed = lz.compressToEncodedURIComponent(node.value);
24
- const url = `https://mermaid-to-svg.kentcdodds.workers.dev/svg?mermaid=${compressed}`;
69
+ const url = new URL('https://mermaid-to-svg.kentcdodds.workers.dev/svg');
70
+ url.searchParams.set('mermaid', compressed);
71
+ url.searchParams.set('theme', theme);
25
72
  const timeout = AbortSignal.timeout(5000);
26
- const svgResponse = await fetch(url, { signal: timeout }).catch(() => null);
73
+ const svgResponse = await fetch(url, {
74
+ signal: timeout,
75
+ }).catch(() => null);
27
76
  if (svgResponse?.ok) {
28
77
  const svgText = await svgResponse.text();
29
78
  if (svgText) {
79
+ const attributes = [
80
+ {
81
+ type: 'mdxJsxAttribute',
82
+ name: 'code',
83
+ value: node.value,
84
+ },
85
+ mdxStringExpressionAttribute('svg', svgText),
86
+ {
87
+ type: 'mdxJsxAttribute',
88
+ name: 'svgTheme',
89
+ value: theme,
90
+ },
91
+ ];
30
92
  parent.children[index] = {
31
93
  type: 'mdxJsxFlowElement',
32
- name: 'div',
33
- attributes: [
34
- {
35
- type: 'mdxJsxAttribute',
36
- name: 'className',
37
- value: 'mermaid not-prose',
38
- },
39
- {
40
- type: 'mdxJsxAttribute',
41
- name: 'dangerouslySetInnerHTML',
42
- value: {
43
- type: 'mdxJsxAttributeValueExpression',
44
- value: `{__html: ${JSON.stringify(svgText)}}`,
45
- // This hack brought to you by this: https://github.com/syntax-tree/hast-util-to-estree/blob/e5ccb97e9f42bba90359ea6d0f83a11d74e0dad6/lib/handlers/mdx-expression.js#L35-L38
46
- // no idea why we're required to have estree here, but I'm pretty sure someone is supposed to add it automatically for us and it just never happens...
47
- data: {
48
- estree: {
49
- type: 'Program',
50
- sourceType: 'script',
51
- body: [
52
- {
53
- type: 'ExpressionStatement',
54
- expression: {
55
- type: 'ObjectExpression',
56
- properties: [
57
- {
58
- type: 'Property',
59
- method: false,
60
- shorthand: false,
61
- computed: false,
62
- kind: 'init',
63
- key: {
64
- type: 'Identifier',
65
- name: '__html',
66
- },
67
- value: {
68
- type: 'Literal',
69
- value: svgText,
70
- },
71
- },
72
- ],
73
- },
74
- },
75
- ],
76
- },
77
- },
78
- },
79
- },
80
- ],
94
+ name: 'Mermaid',
95
+ attributes,
81
96
  children: [],
82
97
  };
83
98
  return;
84
99
  }
85
100
  }
86
101
  }
102
+ const attributes = [
103
+ {
104
+ type: 'mdxJsxAttribute',
105
+ name: 'code',
106
+ value: node.value,
107
+ },
108
+ ];
87
109
  parent.children[index] = {
88
110
  type: 'mdxJsxFlowElement',
89
111
  name: 'Mermaid',
90
- attributes: [
91
- {
92
- type: 'mdxJsxAttribute',
93
- name: 'code',
94
- value: node.value,
95
- },
96
- ],
112
+ attributes,
97
113
  children: [],
98
114
  };
99
115
  })();
@@ -147,13 +163,14 @@ const rehypePlugins = [
147
163
  ];
148
164
  const verboseLog = process.env.EPICSHOP_VERBOSE_LOG === 'true' ? console.log : () => { };
149
165
  export async function compileMdx(file, { request, timings, forceFresh, } = {}) {
166
+ const mermaidTheme = getMermaidTheme(request);
150
167
  const stat = await fs.promises
151
168
  .stat(file)
152
169
  .catch((error) => ({ error }));
153
170
  if ('error' in stat) {
154
171
  throw new Error(`File stat cannot be read: ${stat.error}`);
155
172
  }
156
- const key = `file:${file}`;
173
+ const key = `file:${file}:mermaid:${mermaidTheme}`;
157
174
  forceFresh = await shouldForceFresh({ forceFresh, request, key });
158
175
  const existingCacheEntry = await compiledInstructionMarkdownCache.get(key);
159
176
  if (!forceFresh && existingCacheEntry) {
@@ -165,10 +182,10 @@ export async function compileMdx(file, { request, timings, forceFresh, } = {}) {
165
182
  request,
166
183
  timings,
167
184
  forceFresh,
168
- getFreshValue: () => compileMdxImpl(file),
185
+ getFreshValue: () => compileMdxImpl(file, { mermaidTheme }),
169
186
  });
170
187
  }
171
- async function compileMdxImpl(file) {
188
+ async function compileMdxImpl(file, { mermaidTheme }) {
172
189
  let title = null;
173
190
  const epicVideoEmbeds = [];
174
191
  try {
@@ -180,7 +197,7 @@ async function compileMdxImpl(file) {
180
197
  options.remarkPlugins = [
181
198
  ...(options.remarkPlugins ?? []),
182
199
  gfm,
183
- remarkMermaidCodeToSvg,
200
+ [remarkMermaidCodeToSvg, { theme: mermaidTheme }],
184
201
  () => (tree) => {
185
202
  visit(tree, 'heading', (node) => {
186
203
  if (title)
@@ -113,6 +113,13 @@ declare const DataSchema: z.ZodObject<{
113
113
  defaultView?: string | undefined;
114
114
  activeSidebarTab?: number | undefined;
115
115
  }>>>;
116
+ offlineVideo: z.ZodDefault<z.ZodOptional<z.ZodObject<{
117
+ downloadResolution: z.ZodOptional<z.ZodEnum<["best", "high", "medium", "low"]>>;
118
+ }, "strip", z.ZodTypeAny, {
119
+ downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
120
+ }, {
121
+ downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
122
+ }>>>;
116
123
  presence: z.ZodDefault<z.ZodOptional<z.ZodObject<{
117
124
  optOut: z.ZodBoolean;
118
125
  }, "strip", z.ZodTypeAny, {
@@ -152,6 +159,9 @@ declare const DataSchema: z.ZodObject<{
152
159
  defaultView?: string | undefined;
153
160
  activeSidebarTab?: number | undefined;
154
161
  };
162
+ offlineVideo: {
163
+ downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
164
+ };
155
165
  presence: {
156
166
  optOut: boolean;
157
167
  };
@@ -179,6 +189,9 @@ declare const DataSchema: z.ZodObject<{
179
189
  defaultView?: string | undefined;
180
190
  activeSidebarTab?: number | undefined;
181
191
  } | undefined;
192
+ offlineVideo?: {
193
+ downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
194
+ } | undefined;
182
195
  presence?: {
183
196
  optOut: boolean;
184
197
  } | undefined;
@@ -282,6 +295,9 @@ declare const DataSchema: z.ZodObject<{
282
295
  defaultView?: string | undefined;
283
296
  activeSidebarTab?: number | undefined;
284
297
  };
298
+ offlineVideo: {
299
+ downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
300
+ };
285
301
  presence: {
286
302
  optOut: boolean;
287
303
  };
@@ -333,6 +349,9 @@ declare const DataSchema: z.ZodObject<{
333
349
  defaultView?: string | undefined;
334
350
  activeSidebarTab?: number | undefined;
335
351
  } | undefined;
352
+ offlineVideo?: {
353
+ downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
354
+ } | undefined;
336
355
  presence?: {
337
356
  optOut: boolean;
338
357
  } | undefined;
@@ -390,6 +409,9 @@ export declare function readDb(): Promise<{
390
409
  defaultView?: string | undefined;
391
410
  activeSidebarTab?: number | undefined;
392
411
  };
412
+ offlineVideo: {
413
+ downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
414
+ };
393
415
  presence: {
394
416
  optOut: boolean;
395
417
  };
@@ -487,6 +509,9 @@ export declare function getPreferences(): Promise<{
487
509
  defaultView?: string | undefined;
488
510
  activeSidebarTab?: number | undefined;
489
511
  };
512
+ offlineVideo: {
513
+ downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
514
+ };
490
515
  presence: {
491
516
  optOut: boolean;
492
517
  };
@@ -515,6 +540,9 @@ export declare function setPreferences(preferences: z.input<typeof DataSchema>['
515
540
  defaultView?: string | undefined;
516
541
  activeSidebarTab?: number | undefined;
517
542
  };
543
+ offlineVideo: {
544
+ downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
545
+ };
518
546
  presence: {
519
547
  optOut?: boolean | undefined;
520
548
  };
@@ -549,6 +577,9 @@ export declare function markOnboardingComplete(featureId: string): Promise<{
549
577
  defaultView?: string | undefined;
550
578
  activeSidebarTab?: number | undefined;
551
579
  } | undefined;
580
+ offlineVideo?: {
581
+ downloadResolution?: "best" | "high" | "medium" | "low" | undefined;
582
+ } | undefined;
552
583
  presence?: {
553
584
  optOut: boolean;
554
585
  } | undefined;
package/dist/db.server.js CHANGED
@@ -38,6 +38,13 @@ export const PlayerPreferencesSchema = z
38
38
  })
39
39
  .optional()
40
40
  .default({});
41
+ const OfflineVideoResolutionSchema = z.enum(['best', 'high', 'medium', 'low']);
42
+ const OfflineVideoPreferencesSchema = z
43
+ .object({
44
+ downloadResolution: OfflineVideoResolutionSchema.optional(),
45
+ })
46
+ .optional()
47
+ .default({});
41
48
  const PresencePreferencesSchema = z
42
49
  .object({
43
50
  optOut: z.boolean(),
@@ -55,6 +62,7 @@ const DataSchema = z.object({
55
62
  preferences: z
56
63
  .object({
57
64
  player: PlayerPreferencesSchema,
65
+ offlineVideo: OfflineVideoPreferencesSchema,
58
66
  presence: PresencePreferencesSchema,
59
67
  playground: z
60
68
  .object({
@@ -267,6 +275,10 @@ export async function setPreferences(preferences) {
267
275
  ...data?.preferences?.player,
268
276
  ...preferences?.player,
269
277
  },
278
+ offlineVideo: {
279
+ ...data?.preferences?.offlineVideo,
280
+ ...preferences?.offlineVideo,
281
+ },
270
282
  presence: {
271
283
  ...data?.preferences?.presence,
272
284
  ...preferences?.presence,
@@ -1,6 +1,7 @@
1
1
  import { invariant } from '@epic-web/invariant';
2
2
  import * as cookie from 'cookie';
3
3
  import md5 from 'md5-hex';
4
+ import PQueue from 'p-queue';
4
5
  import { z } from 'zod';
5
6
  import { getExerciseApp, getExercises, getWorkshopFinished, getWorkshopInstructions, } from "./apps.server.js";
6
7
  import { cachified, epicApiCache } from "./cache.server.js";
@@ -84,6 +85,7 @@ const CachedEpicVideoInfoSchema = z
84
85
  }))
85
86
  .or(z.null());
86
87
  const videoInfoLog = log.logger('video-info');
88
+ const EPIC_VIDEO_INFO_CONCURRENCY = 6;
87
89
  export async function getEpicVideoInfos(epicWebUrls, { request, timings } = {}) {
88
90
  if (!epicWebUrls) {
89
91
  videoInfoLog.warn('no epic web URLs provided, returning empty object');
@@ -92,18 +94,23 @@ export async function getEpicVideoInfos(epicWebUrls, { request, timings } = {})
92
94
  const authInfo = await getAuthInfo();
93
95
  if (getEnv().EPICSHOP_DEPLOYED)
94
96
  return {};
95
- videoInfoLog(`fetching epic video infos for ${epicWebUrls.length} URLs`);
97
+ const uniqueUrls = Array.from(new Set(epicWebUrls));
98
+ videoInfoLog(`fetching epic video infos for ${uniqueUrls.length} URLs`);
96
99
  const epicVideoInfos = {};
97
- for (const epicVideoEmbed of epicWebUrls) {
98
- const epicVideoInfo = await getEpicVideoInfo({
100
+ const queue = new PQueue({ concurrency: EPIC_VIDEO_INFO_CONCURRENCY });
101
+ const results = await Promise.all(uniqueUrls.map((epicVideoEmbed) => queue.add(async () => ({
102
+ epicVideoEmbed,
103
+ info: await getEpicVideoInfo({
99
104
  epicVideoEmbed,
100
105
  accessToken: authInfo?.tokenSet.access_token,
101
106
  request,
102
107
  timings,
103
- });
104
- if (epicVideoInfo) {
105
- epicVideoInfos[epicVideoEmbed] = epicVideoInfo;
106
- }
108
+ }),
109
+ }))));
110
+ for (const result of results) {
111
+ if (!result.info)
112
+ continue;
113
+ epicVideoInfos[result.epicVideoEmbed] = result.info;
107
114
  }
108
115
  videoInfoLog(`successfully fetched ${Object.keys(epicVideoInfos).length} epic video infos`);
109
116
  return epicVideoInfos;
@@ -62,6 +62,7 @@ export declare function getOfflineVideoDownloadState(): OfflineVideoDownloadStat
62
62
  export declare function getOfflineVideoSummary({ request, }?: {
63
63
  request?: Request;
64
64
  }): Promise<OfflineVideoSummary>;
65
+ export declare function warmOfflineVideoSummary(): Promise<void>;
65
66
  export declare function startOfflineVideoDownload({ request, }?: {
66
67
  request?: Request;
67
68
  }): Promise<OfflineVideoStartResult>;
@@ -6,11 +6,21 @@ import { pipeline } from 'node:stream/promises';
6
6
  import { getApps, getExercises, getWorkshopFinished, getWorkshopInstructions, } from "./apps.server.js";
7
7
  import { getWorkshopConfig } from "./config.server.js";
8
8
  import { resolvePrimaryDir } from "./data-storage.server.js";
9
- import { getAuthInfo, getClientId } from "./db.server.js";
9
+ import { getAuthInfo, getClientId, getPreferences } from "./db.server.js";
10
10
  import { getEpicVideoInfos } from "./epic-api.server.js";
11
11
  import { getEnv } from "./init-env.js";
12
12
  import { logger } from "./logger.js";
13
13
  import { OFFLINE_VIDEO_CRYPTO_VERSION, createOfflineVideoCipher, createOfflineVideoDecipher, createOfflineVideoIv, createOfflineVideoSalt, decodeOfflineVideoIv, deriveOfflineVideoKey, encodeOfflineVideoIv, getCryptoRange, incrementIv, } from "./offline-video-crypto.server.js";
14
+ const offlineVideoDownloadResolutions = [
15
+ 'best',
16
+ 'high',
17
+ 'medium',
18
+ 'low',
19
+ ];
20
+ function isOfflineVideoDownloadResolution(value) {
21
+ return (typeof value === 'string' &&
22
+ offlineVideoDownloadResolutions.includes(value));
23
+ }
14
24
  const log = logger('epic:offline-videos');
15
25
  const offlineVideoDirectoryName = 'offline-videos';
16
26
  const offlineVideoIndexFileName = 'index.json';
@@ -275,13 +285,29 @@ async function getWorkshopVideoCollection({ request, } = {}) {
275
285
  }
276
286
  return { videos, totalEmbeds: embedUrls.size, unavailable };
277
287
  }
278
- function getMuxMp4Urls(playbackId) {
279
- return [
280
- `https://stream.mux.com/${playbackId}/high.mp4`,
281
- `https://stream.mux.com/${playbackId}/medium.mp4`,
282
- `https://stream.mux.com/${playbackId}/low.mp4`,
283
- `https://stream.mux.com/${playbackId}.mp4`,
284
- ];
288
+ async function getOfflineVideoDownloadResolution() {
289
+ const preferences = await getPreferences();
290
+ const resolution = preferences?.offlineVideo?.downloadResolution;
291
+ if (isOfflineVideoDownloadResolution(resolution)) {
292
+ return resolution;
293
+ }
294
+ return 'best';
295
+ }
296
+ const muxMp4Variants = {
297
+ high: (playbackId) => `https://stream.mux.com/${playbackId}/high.mp4`,
298
+ medium: (playbackId) => `https://stream.mux.com/${playbackId}/medium.mp4`,
299
+ low: (playbackId) => `https://stream.mux.com/${playbackId}/low.mp4`,
300
+ source: (playbackId) => `https://stream.mux.com/${playbackId}.mp4`,
301
+ };
302
+ const muxResolutionOrder = {
303
+ best: ['source', 'high', 'medium', 'low'],
304
+ high: ['high', 'medium', 'low', 'source'],
305
+ medium: ['medium', 'low', 'high', 'source'],
306
+ low: ['low', 'medium', 'high', 'source'],
307
+ };
308
+ function getMuxMp4Urls(playbackId, resolution) {
309
+ const order = muxResolutionOrder[resolution] ?? muxResolutionOrder.best;
310
+ return order.map((variant) => muxMp4Variants[variant](playbackId));
285
311
  }
286
312
  async function isOfflineVideoReady(index, playbackId, keyId, cryptoVersion, workshop) {
287
313
  const entry = index[playbackId];
@@ -302,8 +328,8 @@ async function isOfflineVideoReady(index, playbackId, keyId, cryptoVersion, work
302
328
  return false;
303
329
  }
304
330
  }
305
- async function downloadMuxVideo({ playbackId, filePath, key, iv, }) {
306
- const urls = getMuxMp4Urls(playbackId);
331
+ async function downloadMuxVideo({ playbackId, filePath, key, iv, resolution, }) {
332
+ const urls = getMuxMp4Urls(playbackId, resolution);
307
333
  let lastError = null;
308
334
  for (const url of urls) {
309
335
  const response = await fetch(url).catch((error) => {
@@ -328,7 +354,7 @@ async function downloadMuxVideo({ playbackId, filePath, key, iv, }) {
328
354
  }
329
355
  throw lastError ?? new Error(`Unable to download video ${playbackId}`);
330
356
  }
331
- async function runOfflineVideoDownloads({ videos, index, keyInfo, workshop, }) {
357
+ async function runOfflineVideoDownloads({ videos, index, keyInfo, workshop, resolution, }) {
332
358
  for (const video of videos) {
333
359
  const updatedAt = new Date().toISOString();
334
360
  downloadState.current = {
@@ -357,6 +383,7 @@ async function runOfflineVideoDownloads({ videos, index, keyInfo, workshop, }) {
357
383
  filePath: path.join(getOfflineVideoDir(), entry.fileName),
358
384
  key: keyInfo.key,
359
385
  iv,
386
+ resolution,
360
387
  });
361
388
  index[video.playbackId] = {
362
389
  ...entry,
@@ -422,6 +449,9 @@ export async function getOfflineVideoSummary({ request, } = {}) {
422
449
  downloadState,
423
450
  };
424
451
  }
452
+ export async function warmOfflineVideoSummary() {
453
+ await getWorkshopVideoCollection();
454
+ }
425
455
  export async function startOfflineVideoDownload({ request, } = {}) {
426
456
  if (getEnv().EPICSHOP_DEPLOYED) {
427
457
  return {
@@ -502,11 +532,13 @@ export async function startOfflineVideoDownload({ request, } = {}) {
502
532
  errors: [],
503
533
  };
504
534
  if (downloads.length > 0) {
535
+ const resolution = await getOfflineVideoDownloadResolution();
505
536
  void runOfflineVideoDownloads({
506
537
  videos: downloads,
507
538
  index,
508
539
  keyInfo,
509
540
  workshop,
541
+ resolution,
510
542
  }).catch((error) => {
511
543
  log.error('Offline video downloads failed', error);
512
544
  downloadState.status = 'error';
@@ -531,6 +563,7 @@ export async function downloadOfflineVideo({ playbackId, title, url, }) {
531
563
  });
532
564
  if (!keyInfo)
533
565
  return { status: 'error' };
566
+ const resolution = await getOfflineVideoDownloadResolution();
534
567
  const existing = index[playbackId];
535
568
  if (existing?.status === 'ready' &&
536
569
  existing.keyId === keyInfo.keyId &&
@@ -564,6 +597,7 @@ export async function downloadOfflineVideo({ playbackId, title, url, }) {
564
597
  filePath: path.join(getOfflineVideoDir(), entry.fileName),
565
598
  key: keyInfo.key,
566
599
  iv,
600
+ resolution,
567
601
  });
568
602
  index[playbackId] = {
569
603
  ...entry,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@epic-web/workshop-utils",
3
- "version": "6.55.0",
3
+ "version": "6.56.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },