@learnpack/learnpack 5.0.355 → 5.0.357

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.
Files changed (120) hide show
  1. package/lib/commands/serve.d.ts +0 -17
  2. package/lib/commands/serve.js +159 -129
  3. package/lib/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
  4. package/lib/creatorDist/assets/index-zrPponAn.css +1701 -0
  5. package/lib/creatorDist/index.html +2 -2
  6. package/lib/models/creator.d.ts +1 -1
  7. package/lib/services/ingest/adapters/gcsCourseStorage.d.ts +13 -0
  8. package/lib/services/ingest/adapters/gcsCourseStorage.js +58 -0
  9. package/lib/services/ingest/adapters/rigoUserTokenAuth.d.ts +15 -0
  10. package/lib/services/ingest/adapters/rigoUserTokenAuth.js +46 -0
  11. package/lib/services/ingest/adapters/rigobotPackageRegistry.d.ts +7 -0
  12. package/lib/services/ingest/adapters/rigobotPackageRegistry.js +119 -0
  13. package/lib/services/ingest/core/buildIngestConfig.d.ts +27 -0
  14. package/lib/services/ingest/core/buildIngestConfig.js +40 -0
  15. package/lib/services/ingest/core/buildInitialSyllabus.d.ts +24 -0
  16. package/lib/services/ingest/core/buildInitialSyllabus.js +104 -0
  17. package/lib/services/ingest/core/buildSidebar.d.ts +13 -0
  18. package/lib/services/ingest/core/buildSidebar.js +28 -0
  19. package/lib/services/ingest/core/coursePaths.d.ts +31 -0
  20. package/lib/services/ingest/core/coursePaths.js +50 -0
  21. package/lib/services/ingest/core/ingestCoursePackage.d.ts +52 -0
  22. package/lib/services/ingest/core/ingestCoursePackage.js +217 -0
  23. package/lib/services/ingest/core/normalizeLearnJson.d.ts +25 -0
  24. package/lib/services/ingest/core/normalizeLearnJson.js +29 -0
  25. package/lib/services/ingest/core/pairLessons.d.ts +29 -0
  26. package/lib/services/ingest/core/pairLessons.js +102 -0
  27. package/lib/services/ingest/core/planWrites.d.ts +44 -0
  28. package/lib/services/ingest/core/planWrites.js +59 -0
  29. package/lib/services/ingest/core/resolveSlug.d.ts +53 -0
  30. package/lib/services/ingest/core/resolveSlug.js +93 -0
  31. package/lib/services/ingest/core/types.d.ts +53 -0
  32. package/lib/services/ingest/core/types.js +2 -0
  33. package/lib/services/ingest/core/validatePackage.d.ts +38 -0
  34. package/lib/services/ingest/core/validatePackage.js +97 -0
  35. package/lib/services/ingest/createIngestService.d.ts +34 -0
  36. package/lib/services/ingest/createIngestService.js +38 -0
  37. package/lib/services/ingest/errors.d.ts +22 -0
  38. package/lib/services/ingest/errors.js +34 -0
  39. package/lib/services/ingest/http/errorMapping.d.ts +8 -0
  40. package/lib/services/ingest/http/errorMapping.js +40 -0
  41. package/lib/services/ingest/http/router.d.ts +32 -0
  42. package/lib/services/ingest/http/router.js +124 -0
  43. package/lib/services/ingest/http/zipPackageReader.d.ts +26 -0
  44. package/lib/services/ingest/http/zipPackageReader.js +62 -0
  45. package/lib/services/ingest/ports/courseStorage.d.ts +25 -0
  46. package/lib/services/ingest/ports/courseStorage.js +2 -0
  47. package/lib/services/ingest/ports/packageRegistry.d.ts +58 -0
  48. package/lib/services/ingest/ports/packageRegistry.js +2 -0
  49. package/lib/services/ingest/ports/requestAuthenticator.d.ts +25 -0
  50. package/lib/services/ingest/ports/requestAuthenticator.js +2 -0
  51. package/lib/utils/coursePackage/bucketIo.d.ts +23 -0
  52. package/lib/utils/coursePackage/bucketIo.js +36 -0
  53. package/lib/utils/coursePackage/learnJson.d.ts +26 -0
  54. package/lib/utils/coursePackage/learnJson.js +36 -0
  55. package/lib/utils/coursePackage/sidebar.d.ts +15 -0
  56. package/lib/utils/coursePackage/sidebar.js +26 -0
  57. package/lib/utils/creatorSocket.js +15 -21
  58. package/lib/utils/gcpCredentials.d.ts +18 -0
  59. package/lib/utils/gcpCredentials.js +27 -0
  60. package/lib/utils/rigoActions.d.ts +16 -0
  61. package/lib/utils/rigoActions.js +102 -1
  62. package/lib/utils/socketRegistry.d.ts +46 -0
  63. package/lib/utils/socketRegistry.js +83 -0
  64. package/package.json +1 -1
  65. package/src/commands/serve.ts +162 -114
  66. package/src/creator/README.md +63 -51
  67. package/src/creator/package-lock.json +1188 -8
  68. package/src/creator/package.json +5 -1
  69. package/src/creator/src/App.tsx +120 -54
  70. package/src/creator/src/components/FileUploader.tsx +1 -12
  71. package/src/creator/src/components/NotificationListener.tsx +1 -8
  72. package/src/creator/src/components/syllabus/SyllabusEditor.tsx +101 -36
  73. package/src/creator/src/locales/en.json +8 -0
  74. package/src/creator/src/locales/es.json +8 -0
  75. package/src/creator/src/utils/completionResult.test.ts +144 -0
  76. package/src/creator/src/utils/completionResult.ts +154 -0
  77. package/src/creator/src/utils/constants.ts +17 -4
  78. package/src/creator/src/utils/socket.test.ts +113 -0
  79. package/src/creator/src/utils/socket.ts +60 -37
  80. package/src/creator/src/utils/store.ts +84 -67
  81. package/src/creator/src/utils/useCompletionWatchdog.test.ts +99 -0
  82. package/src/creator/src/utils/useCompletionWatchdog.ts +37 -0
  83. package/src/creator/vitest.config.ts +16 -0
  84. package/src/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
  85. package/src/creatorDist/assets/index-zrPponAn.css +1701 -0
  86. package/src/creatorDist/index.html +2 -2
  87. package/src/models/creator.ts +1 -1
  88. package/src/services/ingest/adapters/gcsCourseStorage.ts +70 -0
  89. package/src/services/ingest/adapters/rigoUserTokenAuth.ts +65 -0
  90. package/src/services/ingest/adapters/rigobotPackageRegistry.ts +161 -0
  91. package/src/services/ingest/core/buildIngestConfig.ts +51 -0
  92. package/src/services/ingest/core/buildInitialSyllabus.ts +122 -0
  93. package/src/services/ingest/core/buildSidebar.ts +32 -0
  94. package/src/services/ingest/core/coursePaths.ts +59 -0
  95. package/src/services/ingest/core/ingestCoursePackage.ts +343 -0
  96. package/src/services/ingest/core/normalizeLearnJson.ts +47 -0
  97. package/src/services/ingest/core/pairLessons.ts +132 -0
  98. package/src/services/ingest/core/planWrites.ts +91 -0
  99. package/src/services/ingest/core/resolveSlug.ts +119 -0
  100. package/src/services/ingest/core/types.ts +57 -0
  101. package/src/services/ingest/core/validatePackage.ts +132 -0
  102. package/src/services/ingest/createIngestService.ts +66 -0
  103. package/src/services/ingest/errors.ts +45 -0
  104. package/src/services/ingest/http/errorMapping.ts +49 -0
  105. package/src/services/ingest/http/router.ts +156 -0
  106. package/src/services/ingest/http/zipPackageReader.ts +81 -0
  107. package/src/services/ingest/ports/courseStorage.ts +25 -0
  108. package/src/services/ingest/ports/packageRegistry.ts +60 -0
  109. package/src/services/ingest/ports/requestAuthenticator.ts +28 -0
  110. package/src/ui/_app/app.js +325 -325
  111. package/src/ui/app.tar.gz +0 -0
  112. package/src/utils/coursePackage/bucketIo.ts +46 -0
  113. package/src/utils/coursePackage/learnJson.ts +37 -0
  114. package/src/utils/coursePackage/sidebar.ts +28 -0
  115. package/src/utils/creatorSocket.ts +16 -22
  116. package/src/utils/gcpCredentials.ts +38 -0
  117. package/src/utils/rigoActions.ts +163 -0
  118. package/src/utils/socketRegistry.ts +85 -0
  119. package/lib/creatorDist/assets/index-CjddKHB_.css +0 -1
  120. package/src/creatorDist/assets/index-CjddKHB_.css +0 -1
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isSafeEntryPath = isSafeEntryPath;
4
+ exports.readPackageZip = readPackageZip;
5
+ const JSZip = require("jszip");
6
+ const errors_1 = require("../errors");
7
+ /**
8
+ * Rejects an entry whose path could escape the course prefix.
9
+ *
10
+ * Every entry eventually becomes a bucket key under `courses/{slug}/`. A zip is
11
+ * attacker-controlled input, and an entry named `../../other-course/learn.json`
12
+ * would land outside the prefix the caller was authorized for — the storage
13
+ * layer concatenates, it does not resolve.
14
+ * Exported for its own test: JSZip's writer normalizes `..` away, so a zip
15
+ * built through that API cannot express the traversal case, while an archive
16
+ * from any other tool can — its reader passes entry names through untouched.
17
+ * @param path - Entry name as stored in the zip.
18
+ * @returns True when the entry is safe to keep.
19
+ */
20
+ function isSafeEntryPath(path) {
21
+ if (path.startsWith("/") || /^[a-z]:/i.test(path)) {
22
+ return false;
23
+ }
24
+ return !path.split("/").includes("..");
25
+ }
26
+ /**
27
+ * Reads a course package zip into memory.
28
+ *
29
+ * Lives beside the router rather than in `core/` on purpose: this is decoding a
30
+ * transport format, and the core is meant to know nothing about how a package
31
+ * reached it — not even that it arrived compressed.
32
+ * @param buffer - The uploaded zip.
33
+ * @returns Entry path relative to the package root → text contents.
34
+ * @throws IngestError `INVALID_PACKAGE` when the archive cannot be read.
35
+ */
36
+ async function readPackageZip(buffer) {
37
+ let archive;
38
+ try {
39
+ archive = await JSZip.loadAsync(buffer);
40
+ }
41
+ catch (error) {
42
+ throw new errors_1.IngestError("INVALID_PACKAGE", `The uploaded file is not a readable zip: ${error.message}`, error);
43
+ }
44
+ const files = {};
45
+ const unsafe = [];
46
+ const entries = Object.values(archive.files).filter(entry => !entry.dir);
47
+ await Promise.all(entries.map(async (entry) => {
48
+ const path = entry.name.replace(/\\/g, "/");
49
+ if (!isSafeEntryPath(path)) {
50
+ unsafe.push(entry.name);
51
+ return;
52
+ }
53
+ files[path] = await entry.async("string");
54
+ }));
55
+ if (unsafe.length > 0) {
56
+ throw new errors_1.IngestError("INVALID_PACKAGE", `The zip contains ${unsafe.length} entr${unsafe.length === 1 ? "y" : "ies"} pointing outside the package: ${unsafe.slice(0, 3).join(", ")}`);
57
+ }
58
+ if (Object.keys(files).length === 0) {
59
+ throw new errors_1.IngestError("INVALID_PACKAGE", "The zip holds no files");
60
+ }
61
+ return files;
62
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Where an ingested course's files live.
3
+ *
4
+ * Object-level operations only: the ingest core decides *what* the course
5
+ * layout is, and this port only knows how to put bytes somewhere and take them
6
+ * back. Keeping it this small is what lets the core run against an in-memory
7
+ * double in tests, with no credentials and no network.
8
+ */
9
+ export interface CourseStorage {
10
+ /** Writes (or overwrites) a text object at `path`. */
11
+ write(path: string, content: string): Promise<void>;
12
+ /** Reads an object, or null when it does not exist. */
13
+ read(path: string): Promise<string | null>;
14
+ /** Lists every object path under `prefix`. */
15
+ list(prefix: string): Promise<string[]>;
16
+ /**
17
+ * Deletes the given paths.
18
+ *
19
+ * Re-ingesting with `--overwrite` has to remove lessons the regenerated
20
+ * course no longer has: the IDE derives its exercise list from whatever files
21
+ * it finds, so an upsert would leave the old folders behind as phantom
22
+ * lessons.
23
+ */
24
+ deleteMany(paths: string[]): Promise<void>;
25
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The subset of a Rigobot learning package the ingest service reads.
3
+ *
4
+ * Deliberately not the full payload: naming only what is used keeps the
5
+ * contract honest about what a replacement registry would have to provide.
6
+ */
7
+ export type RigoPackage = {
8
+ id: number;
9
+ package_slug: string;
10
+ /**
11
+ * Organization **id**, or null when Rigobot could not auto-detect one.
12
+ *
13
+ * An id rather than a slug because that is what comes back: the field is a
14
+ * plain ForeignKey on a ModelSerializer, so it serializes to the primary key.
15
+ * The assignment endpoint accepts either form, but nothing ever reads a slug
16
+ * back, which is why ids are the only representation the two ends share.
17
+ */
18
+ organization: number | null;
19
+ status: string;
20
+ };
21
+ /** One of the organizations the calling user belongs to. */
22
+ export type RigoOrganization = {
23
+ id: number;
24
+ name: string;
25
+ };
26
+ /**
27
+ * The registry that owns course slugs and their ownership.
28
+ *
29
+ * Slugs are globally unique across every Rigobot user, and in practice
30
+ * immutable — the rename endpoint does not update the package on Rigobot's
31
+ * side — so the ingest resolves availability *before* writing anything.
32
+ */
33
+ export interface PackageRegistry {
34
+ /** True when no package holds this slug yet. */
35
+ isSlugAvailable(slug: string): Promise<boolean>;
36
+ /**
37
+ * Registers a new package.
38
+ * @throws IngestError `SLUG_TAKEN` when the slug is already registered.
39
+ */
40
+ createPackage(slug: string, config: unknown): Promise<RigoPackage>;
41
+ /** Reads a package, or null when it does not exist or is not ours. */
42
+ getPackageBySlug(slug: string): Promise<RigoPackage | null>;
43
+ /**
44
+ * Attaches a package to an organization.
45
+ *
46
+ * One-shot: Rigobot only accepts this while the package's organization is
47
+ * still null, and offers no way to detach it afterwards.
48
+ */
49
+ assignOrganization(packageId: number, organizationId: number): Promise<void>;
50
+ /**
51
+ * The organizations the calling user belongs to.
52
+ *
53
+ * Needed because configuration names an organization the way a human would,
54
+ * while packages report it as an id. This is the only lookup that bridges
55
+ * the two — and it doubles as the list to show when the name matches none.
56
+ */
57
+ listOrganizations(): Promise<RigoOrganization[]>;
58
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Who is making an ingest request.
3
+ *
4
+ * Carried as data through the core, never re-read from headers or the
5
+ * environment further in: the moment the core reaches for a credential itself,
6
+ * swapping the auth scheme stops being a one-file change.
7
+ */
8
+ export type Identity = {
9
+ rigoToken: string;
10
+ };
11
+ /**
12
+ * Resolves an identity from a request's headers.
13
+ *
14
+ * Takes headers rather than a request object on purpose — the port must not
15
+ * know that Express exists, so the day this service moves behind a different
16
+ * transport only the adapter changes. Today the credential is a Rigobot user
17
+ * token; when the machine-to-machine token arrives, it is a second adapter
18
+ * here and nothing else moves.
19
+ */
20
+ export interface RequestAuthenticator {
21
+ /**
22
+ * @throws IngestError `INVALID_TOKEN` when the credential is missing or rejected.
23
+ */
24
+ authenticate(headers: Record<string, string | string[] | undefined>): Promise<Identity>;
25
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,23 @@
1
+ import { Buffer } from "buffer";
2
+ import { Bucket } from "@google-cloud/storage";
3
+ /**
4
+ * Writes a text file to the course bucket.
5
+ *
6
+ * READMEs are sanitized on the way in: the newline collapsing has to happen at
7
+ * the single write path, otherwise a caller that forgets it stores content the
8
+ * translation step later chokes on.
9
+ * @param bucket - Target GCS bucket.
10
+ * @param file - File contents as a string.
11
+ * @param path - Full object path inside the bucket.
12
+ * @returns Resolves once the object is stored.
13
+ */
14
+ export declare const uploadFileToBucket: (bucket: Bucket, file: string, path: string) => Promise<void>;
15
+ /**
16
+ * Writes a binary file (images, assets) to the course bucket.
17
+ * @param bucket - Target GCS bucket.
18
+ * @param buffer - File contents as a buffer.
19
+ * @param path - Full object path inside the bucket.
20
+ * @param contentType - Optional MIME type to store alongside the object.
21
+ * @returns Resolves once the object is stored.
22
+ */
23
+ export declare const uploadBinaryToBucket: (bucket: Bucket, buffer: Buffer, path: string, contentType?: string) => Promise<void>;
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.uploadBinaryToBucket = exports.uploadFileToBucket = void 0;
4
+ const buffer_1 = require("buffer");
5
+ const readmeSanitizer_1 = require("../readmeSanitizer");
6
+ /**
7
+ * Writes a text file to the course bucket.
8
+ *
9
+ * READMEs are sanitized on the way in: the newline collapsing has to happen at
10
+ * the single write path, otherwise a caller that forgets it stores content the
11
+ * translation step later chokes on.
12
+ * @param bucket - Target GCS bucket.
13
+ * @param file - File contents as a string.
14
+ * @param path - Full object path inside the bucket.
15
+ * @returns Resolves once the object is stored.
16
+ */
17
+ const uploadFileToBucket = async (bucket, file, path) => {
18
+ const isReadme = /readme(\.\w+)?\.md$/i.test(path);
19
+ const content = isReadme ? (0, readmeSanitizer_1.sanitizeReadmeNewlines)(file) : file;
20
+ const fileRef = bucket.file(path);
21
+ await fileRef.save(buffer_1.Buffer.from(content, "utf8"));
22
+ };
23
+ exports.uploadFileToBucket = uploadFileToBucket;
24
+ /**
25
+ * Writes a binary file (images, assets) to the course bucket.
26
+ * @param bucket - Target GCS bucket.
27
+ * @param buffer - File contents as a buffer.
28
+ * @param path - Full object path inside the bucket.
29
+ * @param contentType - Optional MIME type to store alongside the object.
30
+ * @returns Resolves once the object is stored.
31
+ */
32
+ const uploadBinaryToBucket = async (bucket, buffer, path, contentType) => {
33
+ const fileRef = bucket.file(path);
34
+ await fileRef.save(buffer, Object.assign({ resumable: false }, (contentType && { contentType })));
35
+ };
36
+ exports.uploadBinaryToBucket = uploadBinaryToBucket;
@@ -0,0 +1,26 @@
1
+ import { FormState } from "../../models/creator";
2
+ /**
3
+ * Builds the `learn.json` for a freshly created course.
4
+ *
5
+ * The preview URL is derived from the slug rather than stored: the published
6
+ * package always lives at `{slug}.learn-pack.com`, so deriving it keeps the two
7
+ * from drifting apart when the slug changes.
8
+ * @param courseInfo - Course metadata collected by the creator wizard.
9
+ * @returns The `learn.json` object, ready to be serialized.
10
+ */
11
+ export declare const createLearnJson: (courseInfo: FormState) => {
12
+ slug: string;
13
+ title: {
14
+ [x: string]: string;
15
+ };
16
+ technologies: string[];
17
+ difficulty: string;
18
+ description: {
19
+ [x: string]: string;
20
+ };
21
+ grading: string;
22
+ telemetry: {
23
+ batch: string;
24
+ };
25
+ preview: string;
26
+ };
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createLearnJson = void 0;
4
+ const api_1 = require("../api");
5
+ /**
6
+ * Builds the `learn.json` for a freshly created course.
7
+ *
8
+ * The preview URL is derived from the slug rather than stored: the published
9
+ * package always lives at `{slug}.learn-pack.com`, so deriving it keeps the two
10
+ * from drifting apart when the slug changes.
11
+ * @param courseInfo - Course metadata collected by the creator wizard.
12
+ * @returns The `learn.json` object, ready to be serialized.
13
+ */
14
+ const createLearnJson = (courseInfo) => {
15
+ // console.log("courseInfo to create learn json", courseInfo)
16
+ const expectedPreviewUrl = `https://${courseInfo.slug}.learn-pack.com/preview.png`;
17
+ const language = courseInfo.language || "en";
18
+ const learnJson = {
19
+ slug: courseInfo.slug,
20
+ title: {
21
+ [language]: courseInfo.title,
22
+ },
23
+ technologies: courseInfo.technologies || [],
24
+ difficulty: "beginner",
25
+ description: {
26
+ [language]: courseInfo.description,
27
+ },
28
+ grading: "isolated",
29
+ telemetry: {
30
+ batch: api_1.BREATHECODE_TELEMETRY_URL,
31
+ },
32
+ preview: expectedPreviewUrl,
33
+ };
34
+ return learnJson;
35
+ };
36
+ exports.createLearnJson = createLearnJson;
@@ -0,0 +1,15 @@
1
+ import { TSidebar } from "../sidebarGenerator";
2
+ /**
3
+ * Builds the initial sidebar for a course whose lessons have no title yet.
4
+ *
5
+ * Each entry maps to its own slug as a placeholder: at creation time the
6
+ * generator has not produced titles, and the sidebar is rewritten once it does.
7
+ *
8
+ * Unlike the helpers in `sidebarGenerator`, this one is pure — it returns the
9
+ * sidebar instead of writing it to disk, because the bucket path persists it
10
+ * through the storage layer rather than through the local filesystem.
11
+ * @param slugs - Exercise slugs, in the order they should appear.
12
+ * @param initialLanguage - Language code the placeholder titles are filed under.
13
+ * @returns The sidebar object, ready to be serialized.
14
+ */
15
+ export declare const createInitialSidebar: (slugs: string[], initialLanguage?: string) => Promise<TSidebar>;
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createInitialSidebar = void 0;
4
+ /**
5
+ * Builds the initial sidebar for a course whose lessons have no title yet.
6
+ *
7
+ * Each entry maps to its own slug as a placeholder: at creation time the
8
+ * generator has not produced titles, and the sidebar is rewritten once it does.
9
+ *
10
+ * Unlike the helpers in `sidebarGenerator`, this one is pure — it returns the
11
+ * sidebar instead of writing it to disk, because the bucket path persists it
12
+ * through the storage layer rather than through the local filesystem.
13
+ * @param slugs - Exercise slugs, in the order they should appear.
14
+ * @param initialLanguage - Language code the placeholder titles are filed under.
15
+ * @returns The sidebar object, ready to be serialized.
16
+ */
17
+ const createInitialSidebar = async (slugs, initialLanguage = "en") => {
18
+ const sidebar = {};
19
+ for (const slug of slugs) {
20
+ sidebar[slug] = {
21
+ [initialLanguage]: slug,
22
+ };
23
+ }
24
+ return sidebar;
25
+ };
26
+ exports.createInitialSidebar = createInitialSidebar;
@@ -5,8 +5,9 @@ exports.emitToCourse = emitToCourse;
5
5
  exports.emitToNotification = emitToNotification;
6
6
  exports.getSocketIO = getSocketIO;
7
7
  const socket_io_1 = require("socket.io");
8
- const courseSocketMap = new Map(); // slug -> Set<socket.id>
9
- const notificationSocketMap = new Map(); // notificationId -> Set<socket.id>
8
+ const socketRegistry_1 = require("./socketRegistry");
9
+ const courseRegistry = new socketRegistry_1.SocketRegistry(); // courseSlug -> socket ids
10
+ const notificationRegistry = new socketRegistry_1.SocketRegistry(); // notificationId -> socket ids
10
11
  const socketStore = new Map(); // socket.id -> socket
11
12
  let io = null;
12
13
  function initSocketIO(server) {
@@ -21,50 +22,43 @@ function initSocketIO(server) {
21
22
  console.log("🧠 Socket connected:", socket.id);
22
23
  socketStore.set(socket.id, socket);
23
24
  socket.on("register", (data) => {
24
- var _a;
25
25
  const { courseSlug } = data;
26
26
  if (!courseSlug)
27
27
  return;
28
- if (!courseSocketMap.has(courseSlug)) {
29
- courseSocketMap.set(courseSlug, new Set());
30
- }
31
- (_a = courseSocketMap.get(courseSlug)) === null || _a === void 0 ? void 0 : _a.add(socket.id);
28
+ courseRegistry.register(courseSlug, socket.id);
32
29
  console.log(`📦 Socket ${socket.id} registered to course: ${courseSlug}`);
33
30
  });
34
31
  socket.on("registerNotification", (data) => {
35
- var _a;
36
32
  const { notificationId } = data;
37
33
  if (!notificationId)
38
34
  return;
39
- if (!notificationSocketMap.has(notificationId)) {
40
- notificationSocketMap.set(notificationId, new Set());
41
- }
42
- (_a = notificationSocketMap.get(notificationId)) === null || _a === void 0 ? void 0 : _a.add(socket.id);
35
+ notificationRegistry.register(notificationId, socket.id);
43
36
  console.log(`📧 Socket ${socket.id} registered to notification: ${notificationId}`);
44
37
  });
45
38
  socket.on("disconnect", () => {
46
39
  console.log("🔥 Socket disconnected:", socket.id);
47
40
  socketStore.delete(socket.id);
48
- for (const set of courseSocketMap.values()) {
49
- set.delete(socket.id);
50
- }
41
+ // Both registries, not just the course one. Leaving the socket in the
42
+ // notification registry leaked it for the lifetime of the process.
43
+ courseRegistry.removeSocket(socket.id);
44
+ notificationRegistry.removeSocket(socket.id);
51
45
  });
52
46
  });
53
47
  return io;
54
48
  }
55
49
  function emitToCourse(courseSlug, event, payload) {
56
- const socketIds = courseSocketMap.get(courseSlug);
57
- if (!socketIds || socketIds.size === 0)
58
- return;
59
- for (const id of socketIds) {
50
+ for (const id of courseRegistry.listeners(courseSlug)) {
60
51
  const socket = socketStore.get(id);
61
52
  if (socket)
62
53
  socket.emit(event, payload);
63
54
  }
64
55
  }
65
56
  function emitToNotification(notificationId, payload, retry = 0) {
66
- const socketIds = notificationSocketMap.get(notificationId);
67
- if (!socketIds || socketIds.size === 0) {
57
+ const socketIds = notificationRegistry.listeners(notificationId);
58
+ // The webhook can beat the browser to the registration: the creator only
59
+ // subscribes once the initial POST resolves, and a job that fails fast comes
60
+ // back sooner than that.
61
+ if (socketIds.length === 0) {
68
62
  if (retry > 3) {
69
63
  console.log("❌ Notification", notificationId, "not found");
70
64
  return;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * GCP service-account credentials, required and parsed rather than assumed.
3
+ *
4
+ * Every caller today re-reads `GCP_CREDENTIALS_JSON` inline and handles it a
5
+ * little differently: `serve.ts` exits silently when it is missing and then
6
+ * parses without a try/catch, so malformed JSON surfaces as a bare
7
+ * SyntaxError with no hint about which variable produced it. Validating in one
8
+ * place turns both cases into a message that names the variable.
9
+ *
10
+ * Mirrors `requireGcsBucketName` and `requireAwsCredentials`, including their
11
+ * refusal of the empty-string fallback.
12
+ */
13
+ export type GcpCredentials = {
14
+ client_email?: string;
15
+ private_key?: string;
16
+ [key: string]: unknown;
17
+ };
18
+ export declare function requireGcpCredentials(): GcpCredentials;
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ /**
3
+ * GCP service-account credentials, required and parsed rather than assumed.
4
+ *
5
+ * Every caller today re-reads `GCP_CREDENTIALS_JSON` inline and handles it a
6
+ * little differently: `serve.ts` exits silently when it is missing and then
7
+ * parses without a try/catch, so malformed JSON surfaces as a bare
8
+ * SyntaxError with no hint about which variable produced it. Validating in one
9
+ * place turns both cases into a message that names the variable.
10
+ *
11
+ * Mirrors `requireGcsBucketName` and `requireAwsCredentials`, including their
12
+ * refusal of the empty-string fallback.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.requireGcpCredentials = requireGcpCredentials;
16
+ function requireGcpCredentials() {
17
+ const raw = (process.env.GCP_CREDENTIALS_JSON || "").trim();
18
+ if (!raw) {
19
+ throw new Error("GCP_CREDENTIALS_JSON (env) is required: it holds the service account that reaches the course bucket");
20
+ }
21
+ try {
22
+ return JSON.parse(raw);
23
+ }
24
+ catch (error) {
25
+ throw new Error(`GCP_CREDENTIALS_JSON (env) is not valid JSON: ${error.message}`);
26
+ }
27
+ }
@@ -21,6 +21,22 @@ type TTranslateInputs = {
21
21
  output_language: string;
22
22
  };
23
23
  export declare const translateExercise: (token: string, inputs: TTranslateInputs, webhookUrl: string) => Promise<any>;
24
+ export type TCompletionJob = {
25
+ id: number;
26
+ status: "PENDING" | "SUCCESS" | "ERROR" | string;
27
+ status_text?: string | null;
28
+ parsed?: Record<string, any> | null;
29
+ };
30
+ type TTranslateAndWaitOptions = {
31
+ /** How often the job status is checked while it is still running. */
32
+ pollIntervalMs?: number;
33
+ /** How long we keep waiting before giving up on the job. */
34
+ timeoutMs?: number;
35
+ /** Called after every successful poll, useful to keep a heartbeat alive. */
36
+ onPoll?: (job: TCompletionJob) => void | Promise<void>;
37
+ };
38
+ export declare const getCompletionJob: (token: string, completionId: number | string) => Promise<TCompletionJob>;
39
+ export declare const translateExerciseAndWait: (token: string, inputs: TTranslateInputs, options?: TTranslateAndWaitOptions) => Promise<TCompletionJob>;
24
40
  type TGenerateCourseIntroductionInputs = {
25
41
  course_title: string;
26
42
  lessons_context: string;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.generateStepSlug = exports.generateCodeChallenge = exports.addInteractivity = exports.generateStepDescriptions = exports.initialContentGenerator = exports.getLanguageCodes = exports.isPackageAuthor = exports.fillSidebarJSON = exports.generateCourseShortName = exports.isValidRigoToken = exports.translateCourseMetadata = exports.createStructuredPreviewReadme = exports.readmeCreator = exports.createCodingReadme = exports.createCodeFile = exports.interactiveCreation = exports.generateCourseIntroduction = exports.translateExercise = exports.generateImage = exports.hasCreatorPermission = exports.createReadme = void 0;
3
+ exports.generateStepSlug = exports.generateCodeChallenge = exports.addInteractivity = exports.generateStepDescriptions = exports.initialContentGenerator = exports.getLanguageCodes = exports.isPackageAuthor = exports.fillSidebarJSON = exports.generateCourseShortName = exports.isValidRigoToken = exports.translateCourseMetadata = exports.createStructuredPreviewReadme = exports.readmeCreator = exports.createCodingReadme = exports.createCodeFile = exports.interactiveCreation = exports.generateCourseIntroduction = exports.translateExerciseAndWait = exports.getCompletionJob = exports.translateExercise = exports.generateImage = exports.hasCreatorPermission = exports.createReadme = void 0;
4
4
  exports.downloadImage = downloadImage;
5
5
  exports.createPreviewReadme = createPreviewReadme;
6
6
  exports.makeReadmeReadable = makeReadmeReadable;
@@ -96,6 +96,107 @@ const translateExercise = async (token, inputs, webhookUrl) => {
96
96
  return response.data;
97
97
  };
98
98
  exports.translateExercise = translateExercise;
99
+ const POST_RETRY_DELAYS_MS = [1000, 3000, 6000];
100
+ const MAX_CONSECUTIVE_POLL_ERRORS = 5;
101
+ const sleep = (ms) => new Promise(resolve => {
102
+ setTimeout(resolve, ms);
103
+ });
104
+ const isFinalStatus = (status) => status === "SUCCESS" || status === "ERROR";
105
+ // Network failures and 5xx are worth retrying, a 4xx is not going to fix itself.
106
+ const isRetriablePostError = (error) => {
107
+ var _a;
108
+ const status = (_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status;
109
+ return status === undefined || status >= 500;
110
+ };
111
+ const assertJobSucceeded = (job) => {
112
+ if (job.status === "ERROR") {
113
+ throw new Error(job.status_text || `Rigobot translation job ${job.id} finished with ERROR`);
114
+ }
115
+ return job;
116
+ };
117
+ const getCompletionJob = async (token, completionId) => {
118
+ const response = await axios_1.default.get(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/${completionId}/`, {
119
+ headers: {
120
+ "Content-Type": "application/json",
121
+ Authorization: "Token " + token,
122
+ },
123
+ });
124
+ return response.data;
125
+ };
126
+ exports.getCompletionJob = getCompletionJob;
127
+ // Translates an exercise as an asynchronous Rigobot job and waits for the
128
+ // result by polling.
129
+ //
130
+ // Running the completion synchronously (execute_async: false) keeps the HTTP
131
+ // request open until the LLM is done, which for a full README takes 28-45s and
132
+ // gets killed by Heroku's 30s router limit. Firing the job asynchronously
133
+ // answers in about a second and moves the waiting to the polling loop, so it
134
+ // is no longer bound to the lifetime of a single request.
135
+ const translateExerciseAndWait = async (token, inputs, options = {}) => {
136
+ const { pollIntervalMs = 3000, timeoutMs = 6 * 60 * 1000, onPoll } = options;
137
+ let job;
138
+ for (let attempt = 0; attempt <= POST_RETRY_DELAYS_MS.length; attempt++) {
139
+ try {
140
+ // eslint-disable-next-line no-await-in-loop
141
+ const response = await axios_1.default.post(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/translate-asset-markdown/`, {
142
+ inputs: inputs,
143
+ include_purpose_objective: false,
144
+ execute_async: true,
145
+ }, {
146
+ headers: {
147
+ "Content-Type": "application/json",
148
+ Authorization: "Token " + token,
149
+ },
150
+ });
151
+ job = response.data;
152
+ break;
153
+ }
154
+ catch (error) {
155
+ if (!isRetriablePostError(error) ||
156
+ attempt === POST_RETRY_DELAYS_MS.length) {
157
+ throw error;
158
+ }
159
+ // eslint-disable-next-line no-await-in-loop
160
+ await sleep(POST_RETRY_DELAYS_MS[attempt]);
161
+ }
162
+ }
163
+ if (!job) {
164
+ throw new Error("Rigobot did not return a translation job");
165
+ }
166
+ // Rigobot answers straight away with the cached completion when the very
167
+ // same inputs were translated before, so there is nothing to wait for.
168
+ if (isFinalStatus(job.status)) {
169
+ return assertJobSucceeded(job);
170
+ }
171
+ const jobId = job.id;
172
+ const startedAt = Date.now();
173
+ let consecutivePollErrors = 0;
174
+ while (Date.now() - startedAt < timeoutMs) {
175
+ // eslint-disable-next-line no-await-in-loop
176
+ await sleep(pollIntervalMs);
177
+ try {
178
+ // eslint-disable-next-line no-await-in-loop
179
+ job = await (0, exports.getCompletionJob)(token, jobId);
180
+ consecutivePollErrors = 0;
181
+ }
182
+ catch (error) {
183
+ consecutivePollErrors++;
184
+ if (consecutivePollErrors >= MAX_CONSECUTIVE_POLL_ERRORS) {
185
+ throw new Error(`Could not read the status of the translation job ${jobId} after ${MAX_CONSECUTIVE_POLL_ERRORS} attempts: ${error.message}`);
186
+ }
187
+ continue;
188
+ }
189
+ if (onPoll) {
190
+ // eslint-disable-next-line no-await-in-loop
191
+ await onPoll(job);
192
+ }
193
+ if (isFinalStatus(job.status)) {
194
+ return assertJobSucceeded(job);
195
+ }
196
+ }
197
+ throw new Error(`The translation job ${jobId} did not finish after ${Math.round(timeoutMs / 1000)}s, its last known status was ${job.status}`);
198
+ };
199
+ exports.translateExerciseAndWait = translateExerciseAndWait;
99
200
  const generateCourseIntroduction = async (token, inputs) => {
100
201
  const response = await axios_1.default.post(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/192/`, {
101
202
  inputs: inputs,