@burdenoff/microfe-buildmyiq 2026.1029.4 → 2026.1029.5

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/README.md CHANGED
@@ -10,3 +10,21 @@ See `AGENTS.md` for development commands and architecture details.
10
10
  ## License
11
11
 
12
12
  Proprietary — Copyright Burdenoff Consultancy Services Pvt. Ltd.
13
+
14
+ ## AI proctoring assets (app shells)
15
+
16
+ AI-monitored exams run a face detector in the learner's browser. Its
17
+ WebAssembly runtime and model are served by the app shell from its own origin,
18
+ never from a public CDN. Every shell that mounts this microfrontend must add
19
+ the plugin below to its Vite config; it emits the files under
20
+ `/static/vendor/mediapipe/` at build time and serves them in `vite dev`:
21
+
22
+ ```ts
23
+ import { buildmyiqProctoringAssets } from '@burdenoff/microfe-buildmyiq/vite';
24
+
25
+ export default defineConfig({ plugins: [buildmyiqProctoringAssets() /* , … */] });
26
+ ```
27
+
28
+ `@mediapipe/tasks-vision` is pinned to 0.10.35 because 1.0.0 and later send
29
+ usage metrics to Google with no opt-out. `proctoringAssets.test.ts` fails if an
30
+ upgrade reintroduces a third-party endpoint.
@@ -0,0 +1,17 @@
1
+ # Third-party model: BlazeFace short-range
2
+
3
+ `blaze_face_short_range.tflite` is the MediaPipe BlazeFace short-range face
4
+ detector (float16, model version 1), published by Google under the Apache
5
+ License 2.0.
6
+
7
+ - Source: https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite
8
+ - Model card: https://storage.googleapis.com/mediapipe-assets/MediaPipe%20BlazeFace%20Model%20Card%20(Short%20Range).pdf
9
+ - Size: 229,746 bytes
10
+ - SHA-256: `b4578f35940bf5a1a655214a1cce5cab13eba73c1297cd78e1a04c2380b0152f`
11
+ - MD5 (matches the bucket's `x-goog-hash`): `a3dd6ec31725290770b97cec0cbf94c9`
12
+
13
+ It is vendored here so that BuildMyIQ app shells can serve it from their own
14
+ origin (BOFF-10118) through the `buildmyiqProctoringAssets()` Vite plugin in
15
+ `vite/proctoring-assets.mjs`. To replace it, download the new model, update the
16
+ URL path in both `vite/proctoring-assets.mjs` and
17
+ `src/buildmyiq/pages/assessments/proctoringAssetPaths.ts`, and update this file.
@@ -1 +1 @@
1
- {"version":3,"file":"faceDetection.d.ts","sourceRoot":"","sources":["../../../../src/buildmyiq/pages/assessments/faceDetection.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAU5D,+CAA+C;AAC/C,eAAO,MAAM,cAAc,MAAM,CAAC;AAIlC,oFAAoF;AACpF,wBAAgB,gBAAgB,IAAI,OAAO,CAAC,YAAY,CAAC,CAiBxD;AAED,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,2CAA2C;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,6EAA6E;IAC7E,WAAW,EAAE,MAAM,CAAC;IACpB,wEAAwE;IACxE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,0FAA0F;AAC1F,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,YAAY,EACtB,KAAK,EAAE,gBAAgB,EACvB,WAAW,EAAE,MAAM,GAClB,eAAe,GAAG,IAAI,CAcxB"}
1
+ {"version":3,"file":"faceDetection.d.ts","sourceRoot":"","sources":["../../../../src/buildmyiq/pages/assessments/faceDetection.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAK5D,+CAA+C;AAC/C,eAAO,MAAM,cAAc,MAAM,CAAC;AAIlC,oFAAoF;AACpF,wBAAgB,gBAAgB,IAAI,OAAO,CAAC,YAAY,CAAC,CAiBxD;AAED,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,2CAA2C;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,6EAA6E;IAC7E,WAAW,EAAE,MAAM,CAAC;IACpB,wEAAwE;IACxE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,0FAA0F;AAC1F,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,YAAY,EACtB,KAAK,EAAE,gBAAgB,EACvB,WAAW,EAAE,MAAM,GAClB,eAAe,GAAG,IAAI,CAcxB"}
@@ -1,12 +1,13 @@
1
1
  import { estimateYawRatio as e } from "../../hooks/aiProctoringSignals.js";
2
+ import { FACE_MODEL_URL as t, TASKS_VISION_WASM_URL as n } from "./proctoringAssetPaths.js";
2
3
  //#region src/buildmyiq/pages/assessments/faceDetection.ts
3
- var t = "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.1.1/wasm", n = "https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite", r = .5, i = null;
4
+ var r = .5, i = null;
4
5
  function a() {
5
6
  return i || (i = (async () => {
6
- let { FaceDetector: e, FilesetResolver: i } = await import("@mediapipe/tasks-vision"), a = await i.forVisionTasks(t);
7
+ let { FaceDetector: e, FilesetResolver: i } = await import("@mediapipe/tasks-vision"), a = await i.forVisionTasks(n);
7
8
  return e.createFromOptions(a, {
8
9
  baseOptions: {
9
- modelAssetPath: n,
10
+ modelAssetPath: t,
10
11
  delegate: "CPU"
11
12
  },
12
13
  runningMode: "VIDEO",
@@ -1 +1 @@
1
- {"version":3,"file":"faceDetection.js","names":[],"sources":["../../../../src/buildmyiq/pages/assessments/faceDetection.ts"],"sourcesContent":["/**\n * In-browser face detection for AI proctoring (BOFF-9690).\n * @module buildmyiq/pages/assessments/faceDetection\n *\n * Uses MediaPipe's BlazeFace short-range detector (Apache-2.0) running in\n * WebAssembly on the learner's device. Frames never leave the browser: the\n * detector returns face boxes and six keypoints per face, from which we keep\n * only a face count, a score and a head-turn ratio.\n *\n * The JavaScript is code-split (dynamic import) so learners who never sit an\n * AI-monitored exam never download it. The WebAssembly runtime and the model\n * file are fetched once from version-pinned public CDNs and then cached by\n * the browser; only static assets are fetched, nothing is uploaded.\n */\n\nimport type { FaceDetector } from '@mediapipe/tasks-vision';\n\nimport { estimateYawRatio } from '../../hooks/aiProctoringSignals';\n\n/** Must match the `@mediapipe/tasks-vision` version in package.json. */\nconst TASKS_VISION_VERSION = '1.1.1';\nconst WASM_BASE_URL = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${TASKS_VISION_VERSION}/wasm`;\nconst FACE_MODEL_URL =\n 'https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite';\n\n/** Detections below this score are ignored. */\nexport const MIN_FACE_SCORE = 0.5;\n\nlet detectorPromise: Promise<FaceDetector> | null = null;\n\n/** Load (once per page) the face detector. Rejects if the browser cannot run it. */\nexport function loadFaceDetector(): Promise<FaceDetector> {\n if (!detectorPromise) {\n detectorPromise = (async () => {\n const { FaceDetector: Detector, FilesetResolver } = await import('@mediapipe/tasks-vision');\n const fileset = await FilesetResolver.forVisionTasks(WASM_BASE_URL);\n return Detector.createFromOptions(fileset, {\n baseOptions: { modelAssetPath: FACE_MODEL_URL, delegate: 'CPU' },\n runningMode: 'VIDEO',\n minDetectionConfidence: MIN_FACE_SCORE,\n });\n })();\n // A failed load (offline, blocked CDN) may be retried on the next call.\n detectorPromise.catch(() => {\n detectorPromise = null;\n });\n }\n return detectorPromise;\n}\n\nexport interface FaceObservation {\n faceCount: number;\n /** Highest face score (0 when no face). */\n topScore: number;\n /** Second-highest score, i.e. confidence that a second person is present. */\n secondScore: number;\n /** Head-turn ratio of the single face, null unless exactly one face. */\n yawRatio: number | null;\n}\n\n/** Run the detector on the current video frame. Null while the video has no frame yet. */\nexport function observeFaces(\n detector: FaceDetector,\n video: HTMLVideoElement,\n timestampMs: number\n): FaceObservation | null {\n if (video.readyState < 2 || video.videoWidth === 0) return null;\n const result = detector.detectForVideo(video, timestampMs);\n const scored = result.detections\n .map((d) => ({ score: d.categories[0]?.score ?? 0, keypoints: d.keypoints }))\n .filter((d) => d.score >= MIN_FACE_SCORE)\n .sort((a, b) => b.score - a.score);\n const [first, second] = scored;\n return {\n faceCount: scored.length,\n topScore: first?.score ?? 0,\n secondScore: second?.score ?? 0,\n yawRatio: scored.length === 1 && first ? estimateYawRatio(first.keypoints) : null,\n };\n}\n"],"mappings":";;AAqBA,IAAM,IAAgB,mEAChB,IACJ,gIAGW,IAAiB,IAE1B,IAAgD;AAGpD,SAAgB,IAA0C;CAgBxD,OAfK,MACH,KAAmB,YAAY;EAC7B,IAAM,EAAE,cAAc,GAAU,uBAAoB,MAAM,OAAO,4BAC3D,IAAU,MAAM,EAAgB,eAAe,CAAa;EAClE,OAAO,EAAS,kBAAkB,GAAS;GACzC,aAAa;IAAE,gBAAgB;IAAgB,UAAU;GAAM;GAC/D,aAAa;GACb,wBAAwB;EAC1B,CAAC;CACH,GAAG,GAEH,EAAgB,YAAY;EAC1B,IAAkB;CACpB,CAAC,IAEI;AACT;AAaA,SAAgB,EACd,GACA,GACA,GACwB;CACxB,IAAI,EAAM,aAAa,KAAK,EAAM,eAAe,GAAG,OAAO;CAE3D,IAAM,IADS,EAAS,eAAe,GAAO,CAC/B,EAAO,WACnB,KAAK,OAAO;EAAE,OAAO,EAAE,WAAW,IAAI,SAAS;EAAG,WAAW,EAAE;CAAU,EAAE,EAC3E,QAAQ,MAAM,EAAE,SAAS,CAAc,EACvC,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,KAAK,GAC7B,CAAC,GAAO,KAAU;CACxB,OAAO;EACL,WAAW,EAAO;EAClB,UAAU,GAAO,SAAS;EAC1B,aAAa,GAAQ,SAAS;EAC9B,UAAU,EAAO,WAAW,KAAK,IAAQ,EAAiB,EAAM,SAAS,IAAI;CAC/E;AACF"}
1
+ {"version":3,"file":"faceDetection.js","names":[],"sources":["../../../../src/buildmyiq/pages/assessments/faceDetection.ts"],"sourcesContent":["/**\n * In-browser face detection for AI proctoring (BOFF-9690).\n * @module buildmyiq/pages/assessments/faceDetection\n *\n * Uses MediaPipe's BlazeFace short-range detector (Apache-2.0) running in\n * WebAssembly on the learner's device. Frames never leave the browser: the\n * detector returns face boxes and six keypoints per face, from which we keep\n * only a face count, a score and a head-turn ratio.\n *\n * The JavaScript is code-split (dynamic import) so learners who never sit an\n * AI-monitored exam never download it. The WebAssembly runtime and the model\n * file are served by the app shell itself, from its own origin, under\n * version-pinned paths (BOFF-10118). The learner's browser therefore contacts\n * no third party at all while the detector runs.\n *\n * tasks-vision is pinned to 0.10.35 on purpose: from 1.0.0 the library sends\n * usage metrics to Google (odml.pa.googleapis.com) with no opt-out, which\n * would break the \"nothing leaves the device\" promise made to learners. A\n * unit test fails if an upgrade reintroduces that endpoint.\n */\n\nimport type { FaceDetector } from '@mediapipe/tasks-vision';\n\nimport { estimateYawRatio } from '../../hooks/aiProctoringSignals';\nimport { FACE_MODEL_URL, TASKS_VISION_WASM_URL } from './proctoringAssetPaths';\n\n/** Detections below this score are ignored. */\nexport const MIN_FACE_SCORE = 0.5;\n\nlet detectorPromise: Promise<FaceDetector> | null = null;\n\n/** Load (once per page) the face detector. Rejects if the browser cannot run it. */\nexport function loadFaceDetector(): Promise<FaceDetector> {\n if (!detectorPromise) {\n detectorPromise = (async () => {\n const { FaceDetector: Detector, FilesetResolver } = await import('@mediapipe/tasks-vision');\n const fileset = await FilesetResolver.forVisionTasks(TASKS_VISION_WASM_URL);\n return Detector.createFromOptions(fileset, {\n baseOptions: { modelAssetPath: FACE_MODEL_URL, delegate: 'CPU' },\n runningMode: 'VIDEO',\n minDetectionConfidence: MIN_FACE_SCORE,\n });\n })();\n // A failed load (offline, assets not deployed) may be retried on the next call.\n detectorPromise.catch(() => {\n detectorPromise = null;\n });\n }\n return detectorPromise;\n}\n\nexport interface FaceObservation {\n faceCount: number;\n /** Highest face score (0 when no face). */\n topScore: number;\n /** Second-highest score, i.e. confidence that a second person is present. */\n secondScore: number;\n /** Head-turn ratio of the single face, null unless exactly one face. */\n yawRatio: number | null;\n}\n\n/** Run the detector on the current video frame. Null while the video has no frame yet. */\nexport function observeFaces(\n detector: FaceDetector,\n video: HTMLVideoElement,\n timestampMs: number\n): FaceObservation | null {\n if (video.readyState < 2 || video.videoWidth === 0) return null;\n const result = detector.detectForVideo(video, timestampMs);\n const scored = result.detections\n .map((d) => ({ score: d.categories[0]?.score ?? 0, keypoints: d.keypoints }))\n .filter((d) => d.score >= MIN_FACE_SCORE)\n .sort((a, b) => b.score - a.score);\n const [first, second] = scored;\n return {\n faceCount: scored.length,\n topScore: first?.score ?? 0,\n secondScore: second?.score ?? 0,\n yawRatio: scored.length === 1 && first ? estimateYawRatio(first.keypoints) : null,\n };\n}\n"],"mappings":";;;AA2BA,IAAa,IAAiB,IAE1B,IAAgD;AAGpD,SAAgB,IAA0C;CAgBxD,OAfK,MACH,KAAmB,YAAY;EAC7B,IAAM,EAAE,cAAc,GAAU,uBAAoB,MAAM,OAAO,4BAC3D,IAAU,MAAM,EAAgB,eAAe,CAAqB;EAC1E,OAAO,EAAS,kBAAkB,GAAS;GACzC,aAAa;IAAE,gBAAgB;IAAgB,UAAU;GAAM;GAC/D,aAAa;GACb,wBAAwB;EAC1B,CAAC;CACH,GAAG,GAEH,EAAgB,YAAY;EAC1B,IAAkB;CACpB,CAAC,IAEI;AACT;AAaA,SAAgB,EACd,GACA,GACA,GACwB;CACxB,IAAI,EAAM,aAAa,KAAK,EAAM,eAAe,GAAG,OAAO;CAE3D,IAAM,IADS,EAAS,eAAe,GAAO,CAC/B,EAAO,WACnB,KAAK,OAAO;EAAE,OAAO,EAAE,WAAW,IAAI,SAAS;EAAG,WAAW,EAAE;CAAU,EAAE,EAC3E,QAAQ,MAAM,EAAE,SAAS,CAAc,EACvC,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,KAAK,GAC7B,CAAC,GAAO,KAAU;CACxB,OAAO;EACL,WAAW,EAAO;EAClB,UAAU,GAAO,SAAS;EAC1B,aAAa,GAAQ,SAAS;EAC9B,UAAU,EAAO,WAAW,KAAK,IAAQ,EAAiB,EAAM,SAAS,IAAI;CAC/E;AACF"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Same-origin URLs of the AI proctoring face-detector assets (BOFF-10118).
3
+ * @module buildmyiq/pages/assessments/proctoringAssetPaths
4
+ *
5
+ * The app shell serves these files itself: its Vite config adds the
6
+ * `buildmyiqProctoringAssets()` plugin from `@burdenoff/microfe-buildmyiq/vite`
7
+ * (source: `vite/proctoring-assets.mjs`), which copies them into the build at
8
+ * exactly these paths. The plugin defines the same constants; a unit test
9
+ * fails if the two drift apart or if the installed package version changes.
10
+ */
11
+ /** Must equal the exact `@mediapipe/tasks-vision` version pinned in package.json. */
12
+ export declare const TASKS_VISION_VERSION = "0.10.35";
13
+ /** URL prefix under which the app shell serves every asset. */
14
+ export declare const PROCTORING_ASSET_BASE = "/static/vendor/mediapipe";
15
+ /** Directory URL handed to `FilesetResolver.forVisionTasks`. */
16
+ export declare const TASKS_VISION_WASM_URL = "/static/vendor/mediapipe/tasks-vision/0.10.35/wasm";
17
+ /** BlazeFace short-range model (float16, model version 1). */
18
+ export declare const FACE_MODEL_URL = "/static/vendor/mediapipe/models/blaze_face_short_range/float16/1/blaze_face_short_range.tflite";
19
+ //# sourceMappingURL=proctoringAssetPaths.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proctoringAssetPaths.d.ts","sourceRoot":"","sources":["../../../../src/buildmyiq/pages/assessments/proctoringAssetPaths.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,qFAAqF;AACrF,eAAO,MAAM,oBAAoB,YAAY,CAAC;AAE9C,+DAA+D;AAC/D,eAAO,MAAM,qBAAqB,6BAA6B,CAAC;AAEhE,gEAAgE;AAChE,eAAO,MAAM,qBAAqB,uDAAuE,CAAC;AAE1G,8DAA8D;AAC9D,eAAO,MAAM,cAAc,mGAAmG,CAAC"}
@@ -0,0 +1,6 @@
1
+ //#region src/buildmyiq/pages/assessments/proctoringAssetPaths.ts
2
+ var e = "0.10.35", t = "/static/vendor/mediapipe", n = `${t}/tasks-vision/${e}/wasm`, r = `${t}/models/blaze_face_short_range/float16/1/blaze_face_short_range.tflite`;
3
+ //#endregion
4
+ export { r as FACE_MODEL_URL, n as TASKS_VISION_WASM_URL };
5
+
6
+ //# sourceMappingURL=proctoringAssetPaths.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proctoringAssetPaths.js","names":[],"sources":["../../../../src/buildmyiq/pages/assessments/proctoringAssetPaths.ts"],"sourcesContent":["/**\n * Same-origin URLs of the AI proctoring face-detector assets (BOFF-10118).\n * @module buildmyiq/pages/assessments/proctoringAssetPaths\n *\n * The app shell serves these files itself: its Vite config adds the\n * `buildmyiqProctoringAssets()` plugin from `@burdenoff/microfe-buildmyiq/vite`\n * (source: `vite/proctoring-assets.mjs`), which copies them into the build at\n * exactly these paths. The plugin defines the same constants; a unit test\n * fails if the two drift apart or if the installed package version changes.\n */\n\n/** Must equal the exact `@mediapipe/tasks-vision` version pinned in package.json. */\nexport const TASKS_VISION_VERSION = '0.10.35';\n\n/** URL prefix under which the app shell serves every asset. */\nexport const PROCTORING_ASSET_BASE = '/static/vendor/mediapipe';\n\n/** Directory URL handed to `FilesetResolver.forVisionTasks`. */\nexport const TASKS_VISION_WASM_URL = `${PROCTORING_ASSET_BASE}/tasks-vision/${TASKS_VISION_VERSION}/wasm`;\n\n/** BlazeFace short-range model (float16, model version 1). */\nexport const FACE_MODEL_URL = `${PROCTORING_ASSET_BASE}/models/blaze_face_short_range/float16/1/blaze_face_short_range.tflite`;\n"],"mappings":";AAYA,IAAa,IAAuB,WAGvB,IAAwB,4BAGxB,IAAwB,GAAG,EAAsB,gBAAgB,EAAqB,QAGtF,IAAiB,GAAG,EAAsB"}
package/package.json CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "@burdenoff/microfe-buildmyiq",
3
- "version": "2026.1029.4",
3
+ "version": "2026.1029.5",
4
4
  "description": "Burdenoff BuildMyIQ Microfrontend — education, learning & development",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist",
8
+ "vite",
9
+ "assets",
8
10
  "package.json",
9
11
  "README.md"
10
12
  ],
@@ -20,6 +22,10 @@
20
22
  "./services/createSandboxAssistantTransport": {
21
23
  "types": "./dist/services/createSandboxAssistantTransport.d.ts",
22
24
  "import": "./dist/services/createSandboxAssistantTransport.js"
25
+ },
26
+ "./vite": {
27
+ "types": "./vite/proctoring-assets.d.mts",
28
+ "import": "./vite/proctoring-assets.mjs"
23
29
  }
24
30
  },
25
31
  "main": "./dist/index.js",
@@ -48,7 +54,7 @@
48
54
  "dependencies": {
49
55
  "@apollo/client": "^4.0.11",
50
56
  "@burdenoff/fe-libs": "2026.1006.1",
51
- "@mediapipe/tasks-vision": "1.1.1",
57
+ "@mediapipe/tasks-vision": "0.10.35",
52
58
  "@tanstack/react-query": "^5.90.16",
53
59
  "clsx": "^2.1.1",
54
60
  "graphql": "^16.10.0",
@@ -0,0 +1,20 @@
1
+ import type { Plugin } from 'vite';
2
+
3
+ export declare const TASKS_VISION_VERSION: string;
4
+ export declare const PROCTORING_ASSET_BASE: string;
5
+ export declare const TASKS_VISION_WASM_URL: string;
6
+ export declare const FACE_MODEL_URL: string;
7
+ export declare const TASKS_VISION_WASM_FILES: readonly string[];
8
+
9
+ export interface ProctoringAssetsOptions {
10
+ /** Root of the microfe-buildmyiq package. Only tests override it. */
11
+ packageDir?: string;
12
+ }
13
+
14
+ /** Map from served URL path to the absolute file that backs it. */
15
+ export declare function resolveProctoringAssets(options?: ProctoringAssetsOptions): Map<string, string>;
16
+
17
+ /** Vite plugin that emits and dev-serves the AI proctoring face-detector assets. */
18
+ export declare function buildmyiqProctoringAssets(options?: ProctoringAssetsOptions): Plugin;
19
+
20
+ export default buildmyiqProctoringAssets;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Vite plugin that self-hosts the AI proctoring face-detector assets (BOFF-10118).
3
+ *
4
+ * The in-browser face detector used by AI-monitored exams needs two kinds of
5
+ * static files at run time: the MediaPipe tasks-vision WebAssembly runtime and
6
+ * the BlazeFace short-range model. Instead of fetching them from public CDNs,
7
+ * every app shell that mounts this microfrontend adds this plugin to its Vite
8
+ * config. It then:
9
+ *
10
+ * - emits the files into the build output under `/static/vendor/mediapipe/…`
11
+ * (the deploy workflow uploads `static/*` with an immutable cache header,
12
+ * and every path is versioned so a later upgrade lands on a new URL), and
13
+ * - serves the same paths from the Vite dev server.
14
+ *
15
+ * The WebAssembly files are copied from the installed, version-pinned
16
+ * `@mediapipe/tasks-vision` package; the model ships inside this package under
17
+ * `assets/mediapipe/`. The browser code in `faceDetection.ts` reads the same
18
+ * paths from `proctoringAssetPaths.ts`; a unit test keeps the two in step.
19
+ *
20
+ * Usage in an app shell's vite.config.ts:
21
+ *
22
+ * import { buildmyiqProctoringAssets } from '@burdenoff/microfe-buildmyiq/vite';
23
+ * export default defineConfig({ plugins: [buildmyiqProctoringAssets(), …] });
24
+ */
25
+
26
+ import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs';
27
+ import { createRequire } from 'node:module';
28
+ import { dirname, join, resolve } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ /** Must equal the exact `@mediapipe/tasks-vision` version pinned in package.json. */
32
+ export const TASKS_VISION_VERSION = '0.10.35';
33
+
34
+ /** URL prefix (same origin as the app shell) under which every asset is served. */
35
+ export const PROCTORING_ASSET_BASE = '/static/vendor/mediapipe';
36
+
37
+ /** Directory URL handed to `FilesetResolver.forVisionTasks`. */
38
+ export const TASKS_VISION_WASM_URL = `${PROCTORING_ASSET_BASE}/tasks-vision/${TASKS_VISION_VERSION}/wasm`;
39
+
40
+ /** URL of the BlazeFace short-range model (float16, model version 1). */
41
+ export const FACE_MODEL_URL = `${PROCTORING_ASSET_BASE}/models/blaze_face_short_range/float16/1/blaze_face_short_range.tflite`;
42
+
43
+ /**
44
+ * Runtime files `FilesetResolver.forVisionTasks` may pick: the SIMD build, and
45
+ * the non-SIMD fallback for browsers without WebAssembly SIMD.
46
+ */
47
+ export const TASKS_VISION_WASM_FILES = Object.freeze([
48
+ 'vision_wasm_internal.js',
49
+ 'vision_wasm_internal.wasm',
50
+ 'vision_wasm_nosimd_internal.js',
51
+ 'vision_wasm_nosimd_internal.wasm',
52
+ ]);
53
+
54
+ const CONTENT_TYPES = {
55
+ '.js': 'text/javascript; charset=utf-8',
56
+ '.wasm': 'application/wasm',
57
+ '.tflite': 'application/octet-stream',
58
+ };
59
+
60
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
61
+
62
+ function contentTypeFor(file) {
63
+ const dot = file.lastIndexOf('.');
64
+ return CONTENT_TYPES[dot === -1 ? '' : file.slice(dot)] ?? 'application/octet-stream';
65
+ }
66
+
67
+ /**
68
+ * Locate every asset on disk. Returns a map from served URL path to absolute
69
+ * file path, and throws a descriptive error when anything is missing or when
70
+ * the installed tasks-vision version differs from the pinned one.
71
+ *
72
+ * @param {{ packageDir?: string }} [options]
73
+ * @returns {Map<string, string>}
74
+ */
75
+ export function resolveProctoringAssets(options = {}) {
76
+ const baseDir = options.packageDir ?? packageRoot;
77
+ const require = createRequire(join(baseDir, 'package.json'));
78
+
79
+ /** @type {Map<string, string>} */
80
+ const assets = new Map();
81
+ let wasmDir = '';
82
+ for (const file of TASKS_VISION_WASM_FILES) {
83
+ // Each runtime file is an explicit subpath export of @mediapipe/tasks-vision.
84
+ const resolved = require.resolve(`@mediapipe/tasks-vision/${file}`);
85
+ wasmDir = dirname(resolved);
86
+ assets.set(`${TASKS_VISION_WASM_URL}/${file}`, resolved);
87
+ }
88
+
89
+ const installedManifest = join(wasmDir, '..', 'package.json');
90
+ const installed = JSON.parse(readFileSync(installedManifest, 'utf8')).version;
91
+ if (installed !== TASKS_VISION_VERSION) {
92
+ throw new Error(
93
+ `[buildmyiq:proctoring-assets] @mediapipe/tasks-vision ${installed} is installed, ` +
94
+ `but the face detector is pinned to ${TASKS_VISION_VERSION}. Install the pinned version.`
95
+ );
96
+ }
97
+
98
+ const model = join(baseDir, 'assets', 'mediapipe', 'blaze_face_short_range.tflite');
99
+ assets.set(FACE_MODEL_URL, model);
100
+
101
+ for (const [url, file] of assets) {
102
+ if (!existsSync(file) || statSync(file).size === 0) {
103
+ throw new Error(`[buildmyiq:proctoring-assets] missing asset for ${url}: ${file}`);
104
+ }
105
+ }
106
+ return assets;
107
+ }
108
+
109
+ /**
110
+ * The Vite plugin. Pass `packageDir` only in tests.
111
+ *
112
+ * @param {{ packageDir?: string }} [options]
113
+ * @returns {import('vite').Plugin}
114
+ */
115
+ export function buildmyiqProctoringAssets(options = {}) {
116
+ /** @type {Map<string, string> | null} */
117
+ let assets = null;
118
+ const getAssets = () => {
119
+ assets ??= resolveProctoringAssets(options);
120
+ return assets;
121
+ };
122
+
123
+ return {
124
+ name: 'buildmyiq:proctoring-assets',
125
+
126
+ buildStart() {
127
+ getAssets();
128
+ },
129
+
130
+ configureServer(server) {
131
+ server.middlewares.use((req, res, next) => {
132
+ const path = (req.url ?? '').split('?')[0];
133
+ const file = path.startsWith(PROCTORING_ASSET_BASE) ? getAssets().get(path) : undefined;
134
+ if (!file) {
135
+ next();
136
+ return;
137
+ }
138
+ res.statusCode = 200;
139
+ res.setHeader('Content-Type', contentTypeFor(file));
140
+ res.setHeader('Content-Length', String(statSync(file).size));
141
+ res.setHeader('Cache-Control', 'no-cache');
142
+ createReadStream(file).pipe(res);
143
+ });
144
+ },
145
+
146
+ generateBundle() {
147
+ for (const [url, file] of getAssets()) {
148
+ this.emitFile({ type: 'asset', fileName: url.slice(1), source: readFileSync(file) });
149
+ }
150
+ },
151
+ };
152
+ }
153
+
154
+ export default buildmyiqProctoringAssets;