@learnpack/learnpack 5.0.351 → 5.0.353
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/lib/commands/publish.js +6 -0
- package/lib/commands/serve.js +85 -259
- package/lib/models/creator.d.ts +6 -0
- package/lib/scripts/descriptionsGcsBackfill.d.ts +1 -0
- package/lib/scripts/descriptionsGcsBackfill.js +141 -0
- package/lib/scripts/descriptionsS3Backfill.d.ts +1 -0
- package/lib/scripts/descriptionsS3Backfill.js +157 -0
- package/lib/scripts/descriptionsSweep.d.ts +1 -0
- package/lib/scripts/descriptionsSweep.js +142 -0
- package/lib/utils/api.d.ts +7 -0
- package/lib/utils/api.js +8 -1
- package/lib/utils/creatorUtilities.js +2 -1
- package/lib/utils/descriptionHash.d.ts +66 -0
- package/lib/utils/descriptionHash.js +173 -0
- package/lib/utils/descriptions/gcsStorage.d.ts +16 -0
- package/lib/utils/descriptions/gcsStorage.js +60 -0
- package/lib/utils/descriptions/generateCourseDescriptions.d.ts +59 -0
- package/lib/utils/descriptions/generateCourseDescriptions.js +173 -0
- package/lib/utils/descriptions/mirrorDescriptions.d.ts +49 -0
- package/lib/utils/descriptions/mirrorDescriptions.js +109 -0
- package/lib/utils/descriptions/publishStage.d.ts +69 -0
- package/lib/utils/descriptions/publishStage.js +234 -0
- package/lib/utils/descriptions/resumePublication.d.ts +36 -0
- package/lib/utils/descriptions/resumePublication.js +113 -0
- package/lib/utils/descriptions/s3Storage.d.ts +30 -0
- package/lib/utils/descriptions/s3Storage.js +134 -0
- package/lib/utils/descriptions/workList.d.ts +75 -0
- package/lib/utils/descriptions/workList.js +177 -0
- package/lib/utils/gcsBucketName.d.ts +10 -0
- package/lib/utils/gcsBucketName.js +19 -0
- package/lib/utils/packageManifest.d.ts +18 -0
- package/lib/utils/packageManifest.js +81 -7
- package/lib/utils/publishEvents.d.ts +66 -0
- package/lib/utils/publishEvents.js +111 -0
- package/lib/utils/publishJournal.d.ts +119 -0
- package/lib/utils/publishJournal.js +275 -0
- package/lib/utils/rigoActions.d.ts +44 -0
- package/lib/utils/rigoActions.js +75 -1
- package/lib/utils/s3/packageManifestBackfill.d.ts +84 -0
- package/lib/utils/s3/packageManifestBackfill.js +487 -0
- package/lib/utils/syllabusSync.d.ts +71 -0
- package/lib/utils/syllabusSync.js +273 -0
- package/package.json +4 -1
- package/src/commands/publish.ts +7 -0
- package/src/commands/serve.ts +144 -335
- package/src/models/creator.ts +9 -0
- package/src/scripts/descriptionsGcsBackfill.ts +193 -0
- package/src/scripts/descriptionsS3Backfill.ts +208 -0
- package/src/scripts/descriptionsSweep.ts +185 -0
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +143 -141
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/api.ts +9 -0
- package/src/utils/creatorUtilities.ts +2 -1
- package/src/utils/descriptionHash.ts +196 -0
- package/src/utils/descriptions/gcsStorage.ts +67 -0
- package/src/utils/descriptions/generateCourseDescriptions.ts +301 -0
- package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
- package/src/utils/descriptions/publishStage.ts +382 -0
- package/src/utils/descriptions/resumePublication.ts +200 -0
- package/src/utils/descriptions/s3Storage.ts +206 -0
- package/src/utils/descriptions/workList.ts +283 -0
- package/src/utils/export/README.md +0 -2
- package/src/utils/gcsBucketName.ts +19 -0
- package/src/utils/packageManifest.ts +101 -10
- package/src/utils/publishEvents.ts +181 -0
- package/src/utils/publishJournal.ts +383 -0
- package/src/utils/rigoActions.ts +130 -0
- package/src/utils/s3/packageManifestBackfill.ts +776 -0
- package/src/utils/syllabusSync.ts +390 -0
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CreateInvalidationCommand,
|
|
3
|
+
CloudFrontClient,
|
|
4
|
+
} from "@aws-sdk/client-cloudfront"
|
|
5
|
+
import {
|
|
6
|
+
GetObjectCommand,
|
|
7
|
+
ListObjectsV2Command,
|
|
8
|
+
PutObjectCommand,
|
|
9
|
+
S3Client,
|
|
10
|
+
} from "@aws-sdk/client-s3"
|
|
11
|
+
import { Exercise } from "../configBuilder"
|
|
12
|
+
import { Syllabus } from "../../models/creator"
|
|
13
|
+
import {
|
|
14
|
+
buildPackageManifestFromSources,
|
|
15
|
+
comparePackageManifests,
|
|
16
|
+
PACKAGE_MANIFEST_REL_PATH,
|
|
17
|
+
parseReadmeContent,
|
|
18
|
+
PackageManifest,
|
|
19
|
+
ReadmeFrontmatter,
|
|
20
|
+
serializePackageManifest,
|
|
21
|
+
} from "../packageManifest"
|
|
22
|
+
|
|
23
|
+
export type PackageResultStatus = "created" | "updated" | "skipped" | "failed";
|
|
24
|
+
|
|
25
|
+
export type PackageResult = {
|
|
26
|
+
slug: string;
|
|
27
|
+
status: PackageResultStatus;
|
|
28
|
+
durationMs: number;
|
|
29
|
+
error?: string;
|
|
30
|
+
step?: string;
|
|
31
|
+
sources?: {
|
|
32
|
+
learnJson: boolean;
|
|
33
|
+
configJson: boolean;
|
|
34
|
+
sidebar: boolean;
|
|
35
|
+
syllabus: boolean;
|
|
36
|
+
readmes: number;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type BackfillOptions = {
|
|
41
|
+
dryRun: boolean;
|
|
42
|
+
force: boolean;
|
|
43
|
+
skipExisting: boolean;
|
|
44
|
+
noInvalidate: boolean;
|
|
45
|
+
concurrency: number;
|
|
46
|
+
publishedAt: string | null;
|
|
47
|
+
resumeFrom?: string;
|
|
48
|
+
reportOut: string;
|
|
49
|
+
slug?: string;
|
|
50
|
+
bucket: string;
|
|
51
|
+
region: string;
|
|
52
|
+
cloudFrontDistributionId?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type BatchReport = {
|
|
56
|
+
total: number;
|
|
57
|
+
created: number;
|
|
58
|
+
updated: number;
|
|
59
|
+
skipped: number;
|
|
60
|
+
failed: number;
|
|
61
|
+
startedAt: string;
|
|
62
|
+
finishedAt: string;
|
|
63
|
+
options: BackfillOptions;
|
|
64
|
+
results: PackageResult[];
|
|
65
|
+
invalidation?: {
|
|
66
|
+
attempted: boolean;
|
|
67
|
+
skippedReason?: string;
|
|
68
|
+
id?: string;
|
|
69
|
+
paths?: string[];
|
|
70
|
+
error?: string;
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type AwsClient = {
|
|
75
|
+
send(command: unknown): Promise<unknown>;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
type ConfigJsonShape = {
|
|
79
|
+
config?: Record<string, unknown>;
|
|
80
|
+
exercises?: Exercise[];
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type PackageSources = {
|
|
84
|
+
learnJson: Record<string, unknown>;
|
|
85
|
+
exercises: Exercise[];
|
|
86
|
+
sidebar: Record<string, Record<string, string>>;
|
|
87
|
+
syllabus: Syllabus | null;
|
|
88
|
+
readmeFrontmatters: Record<string, Record<string, ReadmeFrontmatter>>;
|
|
89
|
+
meta: PackageResult["sources"];
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const README_FILENAME_PATTERN = /^readme(?:\.[a-z]{2})?\.md$/i
|
|
93
|
+
const INDEX_HTML_PATTERN = /^[^/]+\/index\.html$/
|
|
94
|
+
const TRANSIENT_ERROR_CODES = new Set([
|
|
95
|
+
"SlowDown",
|
|
96
|
+
"ServiceUnavailable",
|
|
97
|
+
"RequestTimeout",
|
|
98
|
+
"Throttling",
|
|
99
|
+
"ThrottlingException",
|
|
100
|
+
"TooManyRequestsException",
|
|
101
|
+
])
|
|
102
|
+
const CLOUDFRONT_INVALIDATION_CHUNK_SIZE = 3000
|
|
103
|
+
|
|
104
|
+
export async function streamToString(body: unknown): Promise<string> {
|
|
105
|
+
if (!body) {
|
|
106
|
+
return ""
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (typeof body === "string") {
|
|
110
|
+
return body
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (Buffer.isBuffer(body)) {
|
|
114
|
+
return body.toString("utf-8")
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (body instanceof Uint8Array) {
|
|
118
|
+
return Buffer.from(body).toString("utf-8")
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const chunks: Buffer[] = []
|
|
122
|
+
for await (const chunk of body as AsyncIterable<Buffer | string>) {
|
|
123
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return Buffer.concat(chunks).toString("utf-8")
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isTransientError(error: unknown): boolean {
|
|
130
|
+
const err = error as {
|
|
131
|
+
name?: string;
|
|
132
|
+
Code?: string;
|
|
133
|
+
$metadata?: { httpStatusCode?: number };
|
|
134
|
+
}
|
|
135
|
+
const code = err.name || err.Code || ""
|
|
136
|
+
if (TRANSIENT_ERROR_CODES.has(code)) {
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const status = err.$metadata?.httpStatusCode
|
|
141
|
+
return status === 429 || status === 503
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function sleep(ms: number): Promise<void> {
|
|
145
|
+
return new Promise(resolve => {
|
|
146
|
+
setTimeout(resolve, ms)
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function withRetry<T>(
|
|
151
|
+
fn: () => Promise<T>,
|
|
152
|
+
attempts = 3,
|
|
153
|
+
attempt = 1
|
|
154
|
+
): Promise<T> {
|
|
155
|
+
try {
|
|
156
|
+
return await fn()
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (!isTransientError(error) || attempt === attempts) {
|
|
159
|
+
throw error
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const multiplier = 2 ** (attempt - 1)
|
|
163
|
+
const delayMs = 250 * multiplier
|
|
164
|
+
await sleep(delayMs)
|
|
165
|
+
return withRetry(fn, attempts, attempt + 1)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function mapWithConcurrency<T, R>(
|
|
170
|
+
items: T[],
|
|
171
|
+
limit: number,
|
|
172
|
+
fn: (item: T) => Promise<R>
|
|
173
|
+
): Promise<R[]> {
|
|
174
|
+
const results: R[] = Array.from({ length: items.length })
|
|
175
|
+
let nextIndex = 0
|
|
176
|
+
|
|
177
|
+
async function worker(): Promise<void> {
|
|
178
|
+
while (nextIndex < items.length) {
|
|
179
|
+
const currentIndex = nextIndex
|
|
180
|
+
nextIndex += 1
|
|
181
|
+
// eslint-disable-next-line no-await-in-loop -- each worker processes items sequentially
|
|
182
|
+
results[currentIndex] = await fn(items[currentIndex])
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, () =>
|
|
187
|
+
worker()
|
|
188
|
+
)
|
|
189
|
+
await Promise.all(workers)
|
|
190
|
+
return results
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function manifestKey(slug: string): string {
|
|
194
|
+
return `${slug}/${PACKAGE_MANIFEST_REL_PATH}`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function cloudFrontManifestPath(slug: string): string {
|
|
198
|
+
return `/${slug}/${PACKAGE_MANIFEST_REL_PATH}`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isNotFoundError(error: unknown): boolean {
|
|
202
|
+
const err = error as { name?: string; Code?: string }
|
|
203
|
+
const code = err.name || err.Code || ""
|
|
204
|
+
return code === "NoSuchKey" || code === "NotFound" || code === "NoSuchBucket"
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function listPublishedSlugs(
|
|
208
|
+
s3: AwsClient,
|
|
209
|
+
bucket: string
|
|
210
|
+
): Promise<string[]> {
|
|
211
|
+
const slugs = new Set<string>()
|
|
212
|
+
let continuationToken: string | undefined
|
|
213
|
+
|
|
214
|
+
do {
|
|
215
|
+
// eslint-disable-next-line no-await-in-loop -- S3 pagination requires sequential requests
|
|
216
|
+
const response = (await withRetry(() =>
|
|
217
|
+
s3.send(
|
|
218
|
+
new ListObjectsV2Command({
|
|
219
|
+
Bucket: bucket,
|
|
220
|
+
ContinuationToken: continuationToken,
|
|
221
|
+
})
|
|
222
|
+
)
|
|
223
|
+
)) as {
|
|
224
|
+
Contents?: Array<{ Key?: string }>;
|
|
225
|
+
NextContinuationToken?: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const item of response.Contents || []) {
|
|
229
|
+
const key = item.Key
|
|
230
|
+
if (key && INDEX_HTML_PATTERN.test(key)) {
|
|
231
|
+
slugs.add(key.split("/")[0])
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
continuationToken = response.NextContinuationToken
|
|
236
|
+
} while (continuationToken)
|
|
237
|
+
|
|
238
|
+
return [...slugs].sort()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function fetchJsonObject<T>(
|
|
242
|
+
s3: AwsClient,
|
|
243
|
+
bucket: string,
|
|
244
|
+
key: string
|
|
245
|
+
): Promise<T | null> {
|
|
246
|
+
try {
|
|
247
|
+
const response = (await withRetry(() =>
|
|
248
|
+
s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
|
|
249
|
+
)) as { Body?: unknown }
|
|
250
|
+
|
|
251
|
+
const content = await streamToString(response.Body)
|
|
252
|
+
return JSON.parse(content) as T
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (isNotFoundError(error)) {
|
|
255
|
+
return null
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
throw error
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function fetchTextObject(
|
|
263
|
+
s3: AwsClient,
|
|
264
|
+
bucket: string,
|
|
265
|
+
key: string
|
|
266
|
+
): Promise<string | null> {
|
|
267
|
+
try {
|
|
268
|
+
const response = (await withRetry(() =>
|
|
269
|
+
s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
|
|
270
|
+
)) as { Body?: unknown }
|
|
271
|
+
|
|
272
|
+
return await streamToString(response.Body)
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (isNotFoundError(error)) {
|
|
275
|
+
return null
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
throw error
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function listObjectKeys(
|
|
283
|
+
s3: AwsClient,
|
|
284
|
+
bucket: string,
|
|
285
|
+
prefix: string
|
|
286
|
+
): Promise<string[]> {
|
|
287
|
+
const keys: string[] = []
|
|
288
|
+
let continuationToken: string | undefined
|
|
289
|
+
|
|
290
|
+
do {
|
|
291
|
+
// eslint-disable-next-line no-await-in-loop -- S3 pagination requires sequential requests
|
|
292
|
+
const response = (await withRetry(() =>
|
|
293
|
+
s3.send(
|
|
294
|
+
new ListObjectsV2Command({
|
|
295
|
+
Bucket: bucket,
|
|
296
|
+
Prefix: prefix,
|
|
297
|
+
ContinuationToken: continuationToken,
|
|
298
|
+
})
|
|
299
|
+
)
|
|
300
|
+
)) as {
|
|
301
|
+
Contents?: Array<{ Key?: string }>;
|
|
302
|
+
NextContinuationToken?: string;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
for (const item of response.Contents || []) {
|
|
306
|
+
if (item.Key) {
|
|
307
|
+
keys.push(item.Key)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
continuationToken = response.NextContinuationToken
|
|
312
|
+
} while (continuationToken)
|
|
313
|
+
|
|
314
|
+
return keys
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function resolveReadmeLang(
|
|
318
|
+
filename: string,
|
|
319
|
+
exercise: Exercise
|
|
320
|
+
): string | null {
|
|
321
|
+
const lower = filename.toLowerCase()
|
|
322
|
+
for (const [lang, readmeFile] of Object.entries(exercise.translations)) {
|
|
323
|
+
if (readmeFile.toLowerCase() === lower) {
|
|
324
|
+
return lang
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return null
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function normalizeLearnAndExercises(
|
|
332
|
+
slug: string,
|
|
333
|
+
learnJson: Record<string, unknown> | null,
|
|
334
|
+
configJson: ConfigJsonShape | null
|
|
335
|
+
): { learnJson: Record<string, unknown>; exercises: Exercise[] } {
|
|
336
|
+
const exercises = configJson?.exercises
|
|
337
|
+
if (!Array.isArray(exercises) || exercises.length === 0) {
|
|
338
|
+
throw new Error(`Missing or empty exercises array in ${slug}/config.json`)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const resolvedLearn =
|
|
342
|
+
learnJson ||
|
|
343
|
+
(configJson?.config as Record<string, unknown> | undefined) ||
|
|
344
|
+
null
|
|
345
|
+
|
|
346
|
+
if (!resolvedLearn) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
`Missing learn metadata: no ${slug}/learn.json and no config.config`
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return { learnJson: resolvedLearn, exercises }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export async function fetchPackageSources(
|
|
356
|
+
s3: AwsClient,
|
|
357
|
+
bucket: string,
|
|
358
|
+
slug: string
|
|
359
|
+
): Promise<PackageSources> {
|
|
360
|
+
const [
|
|
361
|
+
learnJsonRaw,
|
|
362
|
+
configJsonRoot,
|
|
363
|
+
configJsonLearn,
|
|
364
|
+
sidebarLearn,
|
|
365
|
+
sidebarRoot,
|
|
366
|
+
syllabus,
|
|
367
|
+
] = await Promise.all([
|
|
368
|
+
fetchJsonObject<Record<string, unknown>>(s3, bucket, `${slug}/learn.json`),
|
|
369
|
+
fetchJsonObject<ConfigJsonShape>(s3, bucket, `${slug}/config.json`),
|
|
370
|
+
fetchJsonObject<ConfigJsonShape>(s3, bucket, `${slug}/.learn/config.json`),
|
|
371
|
+
fetchJsonObject<Record<string, Record<string, string>>>(
|
|
372
|
+
s3,
|
|
373
|
+
bucket,
|
|
374
|
+
`${slug}/.learn/sidebar.json`
|
|
375
|
+
),
|
|
376
|
+
fetchJsonObject<Record<string, Record<string, string>>>(
|
|
377
|
+
s3,
|
|
378
|
+
bucket,
|
|
379
|
+
`${slug}/sidebar.json`
|
|
380
|
+
),
|
|
381
|
+
fetchJsonObject<Syllabus>(
|
|
382
|
+
s3,
|
|
383
|
+
bucket,
|
|
384
|
+
`${slug}/.learn/initialSyllabus.json`
|
|
385
|
+
),
|
|
386
|
+
])
|
|
387
|
+
|
|
388
|
+
const configJson = configJsonRoot || configJsonLearn
|
|
389
|
+
if (!configJson) {
|
|
390
|
+
throw new Error(
|
|
391
|
+
`Missing required file: ${slug}/config.json or ${slug}/.learn/config.json`
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const { learnJson, exercises } = normalizeLearnAndExercises(
|
|
396
|
+
slug,
|
|
397
|
+
learnJsonRaw,
|
|
398
|
+
configJson
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
const exerciseSlugs = new Set(exercises.map(exercise => exercise.slug))
|
|
402
|
+
const readmeKeys = (await listObjectKeys(s3, bucket, `${slug}/exercises/`))
|
|
403
|
+
.filter(key => {
|
|
404
|
+
const filename = key.split("/").pop() || ""
|
|
405
|
+
return README_FILENAME_PATTERN.test(filename)
|
|
406
|
+
})
|
|
407
|
+
.filter(key => {
|
|
408
|
+
const parts = key.split("/")
|
|
409
|
+
const exerciseSlug = parts[2]
|
|
410
|
+
return Boolean(exerciseSlug && exerciseSlugs.has(exerciseSlug))
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
const readmeFrontmatters: Record<
|
|
414
|
+
string,
|
|
415
|
+
Record<string, ReadmeFrontmatter>
|
|
416
|
+
> = {}
|
|
417
|
+
|
|
418
|
+
await Promise.all(
|
|
419
|
+
readmeKeys.map(async key => {
|
|
420
|
+
const parts = key.split("/")
|
|
421
|
+
const exerciseSlug = parts[2]
|
|
422
|
+
const filename = parts[parts.length - 1]
|
|
423
|
+
const exercise = exercises.find(item => item.slug === exerciseSlug)
|
|
424
|
+
if (!exercise) {
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const lang = resolveReadmeLang(filename, exercise)
|
|
429
|
+
if (!lang) {
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const content = await fetchTextObject(s3, bucket, key)
|
|
434
|
+
if (!content) {
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (!readmeFrontmatters[exerciseSlug]) {
|
|
439
|
+
readmeFrontmatters[exerciseSlug] = {}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
readmeFrontmatters[exerciseSlug][lang] = parseReadmeContent(content)
|
|
443
|
+
})
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
const readmeCount = Object.values(readmeFrontmatters).reduce(
|
|
447
|
+
(total, byLang) => total + Object.keys(byLang).length,
|
|
448
|
+
0
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
learnJson,
|
|
453
|
+
exercises,
|
|
454
|
+
sidebar: sidebarLearn || sidebarRoot || {},
|
|
455
|
+
syllabus,
|
|
456
|
+
readmeFrontmatters,
|
|
457
|
+
meta: {
|
|
458
|
+
learnJson: Boolean(learnJsonRaw || configJson.config),
|
|
459
|
+
configJson: true,
|
|
460
|
+
sidebar: Boolean(sidebarLearn || sidebarRoot),
|
|
461
|
+
syllabus: Boolean(syllabus),
|
|
462
|
+
readmes: readmeCount,
|
|
463
|
+
},
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function buildManifestFromS3Sources(
|
|
468
|
+
slug: string,
|
|
469
|
+
sources: PackageSources,
|
|
470
|
+
options: { publishedAt: string | null; generatedAt?: string }
|
|
471
|
+
): PackageManifest {
|
|
472
|
+
return buildPackageManifestFromSources({
|
|
473
|
+
learnJson: sources.learnJson,
|
|
474
|
+
syllabus: sources.syllabus,
|
|
475
|
+
sidebar: sources.sidebar,
|
|
476
|
+
exercises: sources.exercises,
|
|
477
|
+
readmeFrontmatters: sources.readmeFrontmatters,
|
|
478
|
+
options: {
|
|
479
|
+
slug,
|
|
480
|
+
generatedAt: options.generatedAt || new Date().toISOString(),
|
|
481
|
+
publishedAt: options.publishedAt,
|
|
482
|
+
},
|
|
483
|
+
})
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export async function processPackage(
|
|
487
|
+
s3: AwsClient,
|
|
488
|
+
bucket: string,
|
|
489
|
+
slug: string,
|
|
490
|
+
options: Pick<
|
|
491
|
+
BackfillOptions,
|
|
492
|
+
"dryRun" | "force" | "skipExisting" | "publishedAt"
|
|
493
|
+
>
|
|
494
|
+
): Promise<PackageResult> {
|
|
495
|
+
const started = Date.now()
|
|
496
|
+
|
|
497
|
+
try {
|
|
498
|
+
const existing = await fetchJsonObject<PackageManifest>(
|
|
499
|
+
s3,
|
|
500
|
+
bucket,
|
|
501
|
+
manifestKey(slug)
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
if (options.skipExisting && existing) {
|
|
505
|
+
return {
|
|
506
|
+
slug,
|
|
507
|
+
status: "skipped",
|
|
508
|
+
durationMs: Date.now() - started,
|
|
509
|
+
sources: {
|
|
510
|
+
learnJson: false,
|
|
511
|
+
configJson: false,
|
|
512
|
+
sidebar: false,
|
|
513
|
+
syllabus: false,
|
|
514
|
+
readmes: 0,
|
|
515
|
+
},
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const sources = await fetchPackageSources(s3, bucket, slug)
|
|
520
|
+
const next = buildManifestFromS3Sources(slug, sources, {
|
|
521
|
+
publishedAt: options.publishedAt,
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
let status = comparePackageManifests(existing, next)
|
|
525
|
+
|
|
526
|
+
if (options.force && status === "skipped") {
|
|
527
|
+
status = "updated"
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (!options.dryRun && (status === "created" || status === "updated")) {
|
|
531
|
+
await withRetry(() =>
|
|
532
|
+
s3.send(
|
|
533
|
+
new PutObjectCommand({
|
|
534
|
+
Bucket: bucket,
|
|
535
|
+
Key: manifestKey(slug),
|
|
536
|
+
Body: serializePackageManifest(next),
|
|
537
|
+
ContentType: "application/json",
|
|
538
|
+
})
|
|
539
|
+
)
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
slug,
|
|
545
|
+
status,
|
|
546
|
+
durationMs: Date.now() - started,
|
|
547
|
+
sources: sources.meta,
|
|
548
|
+
}
|
|
549
|
+
} catch (error) {
|
|
550
|
+
const err = error as Error & { step?: string }
|
|
551
|
+
return {
|
|
552
|
+
slug,
|
|
553
|
+
status: "failed",
|
|
554
|
+
durationMs: Date.now() - started,
|
|
555
|
+
error: err.message,
|
|
556
|
+
step: err.step || inferFailureStep(err.message),
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function inferFailureStep(message: string): string {
|
|
562
|
+
if (message.includes("config.json") || message.includes("learn")) {
|
|
563
|
+
return "fetch_sources"
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (message.includes("PutObject")) {
|
|
567
|
+
return "put_object"
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return "build"
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export async function invalidatePackageManifestPaths(
|
|
574
|
+
cf: AwsClient,
|
|
575
|
+
distributionId: string,
|
|
576
|
+
paths: string[]
|
|
577
|
+
): Promise<{ id: string }> {
|
|
578
|
+
if (paths.length === 0) {
|
|
579
|
+
throw new Error("No paths to invalidate")
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const callerReference = `package-manifest-backfill-${Date.now()}`
|
|
583
|
+
let lastId = ""
|
|
584
|
+
|
|
585
|
+
for (
|
|
586
|
+
let index = 0;
|
|
587
|
+
index < paths.length;
|
|
588
|
+
index += CLOUDFRONT_INVALIDATION_CHUNK_SIZE
|
|
589
|
+
) {
|
|
590
|
+
const chunk = paths.slice(
|
|
591
|
+
index,
|
|
592
|
+
index + CLOUDFRONT_INVALIDATION_CHUNK_SIZE
|
|
593
|
+
)
|
|
594
|
+
// eslint-disable-next-line no-await-in-loop -- sequential invalidation chunks to avoid rate limits
|
|
595
|
+
const response = (await withRetry(() =>
|
|
596
|
+
cf.send(
|
|
597
|
+
new CreateInvalidationCommand({
|
|
598
|
+
DistributionId: distributionId,
|
|
599
|
+
InvalidationBatch: {
|
|
600
|
+
CallerReference: `${callerReference}-${index}`,
|
|
601
|
+
Paths: {
|
|
602
|
+
Quantity: chunk.length,
|
|
603
|
+
Items: chunk,
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
})
|
|
607
|
+
)
|
|
608
|
+
)) as { Invalidation?: { Id?: string } }
|
|
609
|
+
|
|
610
|
+
lastId = response.Invalidation?.Id || lastId
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return { id: lastId }
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function loadResumeSlugs(reportPath: string): Set<string> {
|
|
617
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
618
|
+
const fs = require("fs") as typeof import("fs")
|
|
619
|
+
const raw = fs.readFileSync(reportPath, "utf-8")
|
|
620
|
+
const report = JSON.parse(raw) as BatchReport
|
|
621
|
+
const skipStatuses = new Set<PackageResultStatus>([
|
|
622
|
+
"created",
|
|
623
|
+
"updated",
|
|
624
|
+
"skipped",
|
|
625
|
+
])
|
|
626
|
+
|
|
627
|
+
return new Set(
|
|
628
|
+
report.results
|
|
629
|
+
.filter(result => skipStatuses.has(result.status))
|
|
630
|
+
.map(result => result.slug)
|
|
631
|
+
)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function summarizeResults(
|
|
635
|
+
results: PackageResult[]
|
|
636
|
+
): Pick<BatchReport, "created" | "updated" | "skipped" | "failed"> {
|
|
637
|
+
return {
|
|
638
|
+
created: results.filter(result => result.status === "created").length,
|
|
639
|
+
updated: results.filter(result => result.status === "updated").length,
|
|
640
|
+
skipped: results.filter(result => result.status === "skipped").length,
|
|
641
|
+
failed: results.filter(result => result.status === "failed").length,
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export async function runBatch(
|
|
646
|
+
options: BackfillOptions,
|
|
647
|
+
clients?: {
|
|
648
|
+
s3?: AwsClient;
|
|
649
|
+
cf?: AwsClient;
|
|
650
|
+
onProgress?: (index: number, total: number, result: PackageResult) => void;
|
|
651
|
+
}
|
|
652
|
+
): Promise<BatchReport> {
|
|
653
|
+
const startedAt = new Date().toISOString()
|
|
654
|
+
const s3 =
|
|
655
|
+
clients?.s3 ||
|
|
656
|
+
new S3Client({
|
|
657
|
+
region: options.region,
|
|
658
|
+
credentials: {
|
|
659
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
|
|
660
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
|
|
661
|
+
},
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
const cf =
|
|
665
|
+
clients?.cf ||
|
|
666
|
+
new CloudFrontClient({
|
|
667
|
+
region: "us-east-1",
|
|
668
|
+
credentials: {
|
|
669
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
|
|
670
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
|
|
671
|
+
},
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
let slugs = options.slug ?
|
|
675
|
+
[options.slug] :
|
|
676
|
+
await listPublishedSlugs(s3, options.bucket)
|
|
677
|
+
|
|
678
|
+
if (options.resumeFrom) {
|
|
679
|
+
const resumeSlugs = loadResumeSlugs(options.resumeFrom)
|
|
680
|
+
slugs = slugs.filter(slug => !resumeSlugs.has(slug))
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
let completed = 0
|
|
684
|
+
const results = await mapWithConcurrency(
|
|
685
|
+
slugs,
|
|
686
|
+
options.concurrency,
|
|
687
|
+
async slug => {
|
|
688
|
+
const result = await processPackage(s3, options.bucket, slug, options)
|
|
689
|
+
completed += 1
|
|
690
|
+
clients?.onProgress?.(completed, slugs.length, result)
|
|
691
|
+
return result
|
|
692
|
+
}
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
const summary = summarizeResults(results)
|
|
696
|
+
const invalidationPaths = results
|
|
697
|
+
.filter(
|
|
698
|
+
result => result.status === "created" || result.status === "updated"
|
|
699
|
+
)
|
|
700
|
+
.map(result => cloudFrontManifestPath(result.slug))
|
|
701
|
+
|
|
702
|
+
let invalidation: BatchReport["invalidation"]
|
|
703
|
+
|
|
704
|
+
if (options.dryRun) {
|
|
705
|
+
invalidation = {
|
|
706
|
+
attempted: false,
|
|
707
|
+
skippedReason: "dry-run",
|
|
708
|
+
}
|
|
709
|
+
} else if (options.noInvalidate) {
|
|
710
|
+
invalidation = {
|
|
711
|
+
attempted: false,
|
|
712
|
+
skippedReason: "no-invalidate",
|
|
713
|
+
}
|
|
714
|
+
} else if (invalidationPaths.length === 0) {
|
|
715
|
+
invalidation = {
|
|
716
|
+
attempted: false,
|
|
717
|
+
skippedReason: "no-paths",
|
|
718
|
+
}
|
|
719
|
+
} else if (!options.cloudFrontDistributionId) {
|
|
720
|
+
invalidation = {
|
|
721
|
+
attempted: true,
|
|
722
|
+
paths: invalidationPaths,
|
|
723
|
+
error: "Missing CLOUDFRONT_DISTRIBUTION_ID",
|
|
724
|
+
}
|
|
725
|
+
} else {
|
|
726
|
+
try {
|
|
727
|
+
const result = await invalidatePackageManifestPaths(
|
|
728
|
+
cf,
|
|
729
|
+
options.cloudFrontDistributionId,
|
|
730
|
+
invalidationPaths
|
|
731
|
+
)
|
|
732
|
+
invalidation = {
|
|
733
|
+
attempted: true,
|
|
734
|
+
id: result.id,
|
|
735
|
+
paths: invalidationPaths,
|
|
736
|
+
}
|
|
737
|
+
} catch (error) {
|
|
738
|
+
invalidation = {
|
|
739
|
+
attempted: true,
|
|
740
|
+
paths: invalidationPaths,
|
|
741
|
+
error: (error as Error).message,
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const report: BatchReport = {
|
|
747
|
+
total: slugs.length,
|
|
748
|
+
...summary,
|
|
749
|
+
startedAt,
|
|
750
|
+
finishedAt: new Date().toISOString(),
|
|
751
|
+
options,
|
|
752
|
+
results,
|
|
753
|
+
invalidation,
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
757
|
+
const fs = require("fs") as typeof import("fs")
|
|
758
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
759
|
+
const path = require("path") as typeof import("path")
|
|
760
|
+
fs.mkdirSync(path.dirname(options.reportOut), { recursive: true })
|
|
761
|
+
fs.writeFileSync(options.reportOut, JSON.stringify(report, null, 2), "utf-8")
|
|
762
|
+
|
|
763
|
+
return report
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export function getBatchExitCode(report: BatchReport): number {
|
|
767
|
+
if (report.failed > 0) {
|
|
768
|
+
return 1
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (report.invalidation?.attempted && report.invalidation.error) {
|
|
772
|
+
return 1
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return 0
|
|
776
|
+
}
|