@mpgd/cli 0.28.1 → 0.29.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.
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { renderGameAcceptanceMarkdown, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, defaultGameAcceptanceCommandTimeoutMs, maximumGameplayE2EReportBytes, type GameAcceptanceCommandResult, type GameAcceptanceCommandRunner, type GameAcceptanceReport, type GameAcceptanceStatus, type GameAcceptanceStep, type GameAcceptanceStepResult, type GameAcceptanceStepStatus, type RunGameAcceptanceInput, type RunGameAcceptanceResult, } from './game-acceptance.js';
2
2
  export { createBrowserGameplayE2EDriver, type BrowserGameplayE2EInspectInput, type BrowserGameplayE2EPage, type BrowserGameplayE2EScreenshotOptions, type BrowserGameplayE2EViewport, type CreateBrowserGameplayE2EDriverInput, } from './browser-gameplay-e2e.js';
3
3
  export { collectGameplayE2EPathEvidence, defaultGameplayE2EReportFile, maximumGameplayE2EStates, parseGameplayE2EPlan, readGameplayE2EPlan, renderGameplayE2EMarkdown, resolveGameplayE2EReportFile, runGameplayE2E, type GameplayE2EAction, type GameplayE2EActionResult, type GameplayE2EDriver, type GameplayE2EHashLimits, type GameplayE2EInputAction, type GameplayE2EObservation, type GameplayE2EPauseResumeAction, type GameplayE2EPathEvidence, type GameplayE2EPlan, type GameplayE2EReport, type GameplayE2EResultStatus, type GameplayE2EState, type GameplayE2EStateResult, type GameplayE2EStatus, type RunGameplayE2EInput, type RunGameplayE2EResult, } from './gameplay-e2e.js';
4
+ export { cloudflarePagesDeploymentProfiles, hostedPwaDeploymentHosts, isSupportedHostedPwaProfile, verifyHostedPwaDeployment, type CloudflarePagesDeploymentProfile, type HostedPwaDeploymentHost, type HostedPwaDeploymentVerification, type VerifyHostedPwaDeploymentInput, } from './hosted-pwa-deployment.js';
4
5
  export { defaultOfflinePlaytestArtifactDir, defaultOfflinePlaytestMaximumBytes, defaultOfflinePlaytestOutputDir, offlinePlaytestSchemaVersion, runOfflinePlaytestPackaging, type OfflinePlaytestEvidence, type RunOfflinePlaytestPackagingInput, type RunOfflinePlaytestPackagingResult, } from './offline-playtest.js';
5
6
  export { assertMicrosoftStorePwaCacheTransition, assertMicrosoftStorePwaPrecacheUrl, assertMicrosoftStorePwaReleaseEvidence, inspectMicrosoftStorePwaBrowserCacheTransition, microsoftStorePwaCacheSchema, readMicrosoftStorePwaBrowserReleaseEvidence, requestMicrosoftStorePwaBrowserUpdate, resolveMicrosoftStorePwaScopedCacheName, type AssertMicrosoftStorePwaCacheTransitionInput, type InspectMicrosoftStorePwaCacheTransitionInput, type MicrosoftStorePwaBrowserPage, type MicrosoftStorePwaCachedIndexObservation, type MicrosoftStorePwaCacheTransitionEvidence, type MicrosoftStorePwaCacheTransitionObservation, type MicrosoftStorePwaReleaseEvidence, } from './microsoft-store-pwa-e2e.js';
6
7
  export { createMicrosoftStorePackageAcceptanceRuntime, microsoftStorePackageAcceptanceSchemaVersion, parseMicrosoftStorePackageIdentity, parseWindowsAppCertificationResult, renderMicrosoftStorePackageAcceptanceMarkdown, runMicrosoftStorePackageAcceptance, type CreateMicrosoftStorePackageAcceptanceRuntimeInput, type MicrosoftStorePackageAcceptanceEvidence, type MicrosoftStorePackageAcceptanceRuntime, type MicrosoftStorePackageIdentity, type RunMicrosoftStorePackageAcceptanceInput, } from './microsoft-store-package-acceptance.js';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
2
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import completion from '@gunshi/plugin-completion';
@@ -7,8 +7,10 @@ import i18n, { defineI18n } from '@gunshi/plugin-i18n';
7
7
  import resources from '@gunshi/resources';
8
8
  import { cli } from 'gunshi';
9
9
  import { normalizeConfiguredBuildTargets, normalizeBuildTarget as normalizeConfiguredTargetName, supportedBuildTargets, } from './build-targets.js';
10
+ import { escapeMarkdownInline as escapeEvidenceMarkdownInline, writeEvidenceReportFiles, } from './evidence-io.js';
10
11
  import { defaultGameAcceptanceCommandTimeoutMs, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, } from './game-acceptance.js';
11
12
  import { resolveGameplayE2EReportFile } from './gameplay-e2e.js';
13
+ import { cloudflarePagesDeploymentProfiles, hostedPwaDeploymentHosts, verifyHostedPwaDeployment, } from './hosted-pwa-deployment.js';
12
14
  import { createMicrosoftStorePackageAcceptanceRuntime, runMicrosoftStorePackageAcceptance, } from './microsoft-store-package-acceptance.js';
13
15
  import { runMicrosoftStorePackageGeneration } from './microsoft-store-package-generation.js';
14
16
  import { defaultMpgdKitPath, initializeMicrosoftStoreStarter, listMicrosoftStoreInitializerTemplateFiles, prepareBaseGameTemplateFile, resolveMicrosoftStoreAdapterDependencyVersion, } from './microsoft-store-starter.js';
@@ -18,6 +20,7 @@ import { targetConfigExtensionsFileEnv } from './target-config-env.js';
18
20
  export { renderGameAcceptanceMarkdown, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, defaultGameAcceptanceCommandTimeoutMs, maximumGameplayE2EReportBytes, } from './game-acceptance.js';
19
21
  export { createBrowserGameplayE2EDriver, } from './browser-gameplay-e2e.js';
20
22
  export { collectGameplayE2EPathEvidence, defaultGameplayE2EReportFile, maximumGameplayE2EStates, parseGameplayE2EPlan, readGameplayE2EPlan, renderGameplayE2EMarkdown, resolveGameplayE2EReportFile, runGameplayE2E, } from './gameplay-e2e.js';
23
+ export { cloudflarePagesDeploymentProfiles, hostedPwaDeploymentHosts, isSupportedHostedPwaProfile, verifyHostedPwaDeployment, } from './hosted-pwa-deployment.js';
21
24
  export { defaultOfflinePlaytestArtifactDir, defaultOfflinePlaytestMaximumBytes, defaultOfflinePlaytestOutputDir, offlinePlaytestSchemaVersion, runOfflinePlaytestPackaging, } from './offline-playtest.js';
22
25
  export { assertMicrosoftStorePwaCacheTransition, assertMicrosoftStorePwaPrecacheUrl, assertMicrosoftStorePwaReleaseEvidence, inspectMicrosoftStorePwaBrowserCacheTransition, microsoftStorePwaCacheSchema, readMicrosoftStorePwaBrowserReleaseEvidence, requestMicrosoftStorePwaBrowserUpdate, resolveMicrosoftStorePwaScopedCacheName, } from './microsoft-store-pwa-e2e.js';
23
26
  export { createMicrosoftStorePackageAcceptanceRuntime, microsoftStorePackageAcceptanceSchemaVersion, parseMicrosoftStorePackageIdentity, parseWindowsAppCertificationResult, renderMicrosoftStorePackageAcceptanceMarkdown, runMicrosoftStorePackageAcceptance, } from './microsoft-store-package-acceptance.js';
@@ -989,6 +992,111 @@ const targetCommand = defineI18n({
989
992
  });
990
993
  },
991
994
  }),
995
+ 'verify-deployment': defineI18n({
996
+ name: 'verify-deployment',
997
+ description: 'Verify that a hosted deployment directory serves a verified PWA artifact unchanged.',
998
+ resource: commandResource({
999
+ en: 'Verify that a hosted deployment directory serves a verified PWA artifact unchanged.',
1000
+ ko: '호스트 배포 디렉터리가 검증된 PWA 산출물을 변조 없이 서비스하는지 검증합니다.',
1001
+ }, {
1002
+ target: {
1003
+ en: 'Target that produced the source PWA artifact (microsoft-store).',
1004
+ ko: '원본 PWA 산출물을 생성한 타깃(microsoft-store).',
1005
+ },
1006
+ 'deployment-root': {
1007
+ en: 'Host deployment directory to verify.',
1008
+ ko: '검증할 호스트 배포 디렉터리.',
1009
+ },
1010
+ 'source-artifact-root': {
1011
+ en: 'Directory of the verified source PWA artifact.',
1012
+ ko: '검증된 원본 PWA 산출물 디렉터리.',
1013
+ },
1014
+ host: {
1015
+ en: `Hosting profile (${hostedPwaDeploymentHosts.join(', ')}).`,
1016
+ ko: `호스팅 프로필(${hostedPwaDeploymentHosts.join(', ')}).`,
1017
+ },
1018
+ profile: {
1019
+ en: `Host routing profile (${cloudflarePagesDeploymentProfiles.join(', ')}).`,
1020
+ ko: `호스트 라우팅 프로필(${cloudflarePagesDeploymentProfiles.join(', ')}).`,
1021
+ },
1022
+ 'report-dir': {
1023
+ en: 'Directory for verification evidence files (defaults to the working directory).',
1024
+ ko: '검증 결과 파일을 기록할 디렉터리(기본값은 작업 디렉터리).',
1025
+ },
1026
+ }),
1027
+ args: {
1028
+ target: {
1029
+ type: 'positional',
1030
+ required: true,
1031
+ description: 'Target that produced the source PWA artifact (microsoft-store)',
1032
+ },
1033
+ 'deployment-root': {
1034
+ type: 'string',
1035
+ required: true,
1036
+ description: 'Host deployment directory to verify.',
1037
+ },
1038
+ 'source-artifact-root': {
1039
+ type: 'string',
1040
+ required: true,
1041
+ description: 'Directory of the verified source PWA artifact.',
1042
+ },
1043
+ host: {
1044
+ type: 'string',
1045
+ required: false,
1046
+ default: 'cloudflare-pages',
1047
+ description: `Hosting profile (${hostedPwaDeploymentHosts.join(', ')}).`,
1048
+ },
1049
+ profile: {
1050
+ type: 'string',
1051
+ required: false,
1052
+ default: 'api-only',
1053
+ description: `Host routing profile (${cloudflarePagesDeploymentProfiles.join(', ')}).`,
1054
+ },
1055
+ 'report-dir': {
1056
+ type: 'string',
1057
+ required: false,
1058
+ description: 'Directory for verification evidence files (defaults to the working directory).',
1059
+ },
1060
+ },
1061
+ run: (ctx) => {
1062
+ const positionals = readLocalPositionals(ctx.positionals, [
1063
+ 'target',
1064
+ 'verify-deployment',
1065
+ ]);
1066
+ const target = normalizeConfiguredTargetName(readRequiredPositional(positionals, 0, 'target'));
1067
+ if (target !== 'microsoft-store') {
1068
+ throw new Error(`Hosted deployment verification is not available for target: ${target}`);
1069
+ }
1070
+ const deploymentRoot = readRequiredCliOption(ctx.values['deployment-root'], '--deployment-root');
1071
+ const sourceArtifactRoot = readRequiredCliOption(ctx.values['source-artifact-root'], '--source-artifact-root');
1072
+ const host = readOptionalString(ctx.values.host) ?? 'cloudflare-pages';
1073
+ const profile = readOptionalString(ctx.values.profile) ?? 'api-only';
1074
+ if (!hostedPwaDeploymentHosts.includes(host)) {
1075
+ throw new Error(`Unsupported hosted PWA deployment host: ${host} `
1076
+ + `(supported: ${hostedPwaDeploymentHosts.join(', ')}).`);
1077
+ }
1078
+ if (!cloudflarePagesDeploymentProfiles.includes(profile)) {
1079
+ throw new Error(`Unsupported ${host} deployment profile: ${profile} `
1080
+ + `(supported: ${cloudflarePagesDeploymentProfiles.join(', ')}).`);
1081
+ }
1082
+ const reportDir = path.resolve(readOptionalString(ctx.values['report-dir']) ?? process.cwd());
1083
+ assertReportDirectoryOutsideVerifiedTrees(reportDir, sourceArtifactRoot, deploymentRoot);
1084
+ assertReportFilesSafeToWrite(reportDir);
1085
+ const verification = verifyHostedPwaDeployment({
1086
+ sourceArtifactRoot,
1087
+ deploymentRoot,
1088
+ host: host,
1089
+ profile: profile,
1090
+ });
1091
+ mkdirSync(reportDir, { recursive: true });
1092
+ writeEvidenceReportFiles({
1093
+ jsonFile: path.join(reportDir, 'hosted-pwa-verification.json'),
1094
+ markdownFile: path.join(reportDir, 'hosted-pwa-verification.md'),
1095
+ report: verification,
1096
+ markdown: renderHostedPwaVerificationMarkdown(verification),
1097
+ });
1098
+ },
1099
+ }),
992
1100
  'generate-package': defineI18n({
993
1101
  name: 'generate-package',
994
1102
  description: 'Generate a packaged target artifact with an external platform service.',
@@ -2258,6 +2366,109 @@ function readLocalPositionals(positionals, commandPath) {
2258
2366
  }
2259
2367
  return positionals.slice(commandPath.length);
2260
2368
  }
2369
+ /** Reject existing evidence destinations that are links into verified trees. */
2370
+ function assertReportFilesSafeToWrite(reportDir) {
2371
+ for (const name of ['hosted-pwa-verification.json', 'hosted-pwa-verification.md']) {
2372
+ const destination = path.join(reportDir, name);
2373
+ let stat;
2374
+ try {
2375
+ stat = lstatSync(destination);
2376
+ }
2377
+ catch {
2378
+ continue;
2379
+ }
2380
+ if (stat.isSymbolicLink()) {
2381
+ throw new Error(`The report destination ${destination} is a symbolic link; refusing to follow `
2382
+ + 'it because the report write must stay outside the verified trees.');
2383
+ }
2384
+ if (!stat.isFile()) {
2385
+ throw new Error(`The report destination ${destination} is not a regular file; refusing to `
2386
+ + 'write evidence through sockets, FIFOs, devices, or directories.');
2387
+ }
2388
+ if (stat.nlink > 1) {
2389
+ throw new Error(`The report destination ${destination} has ${String(stat.nlink)} hard links; `
2390
+ + 'refusing to write through a shared inode into the verified trees.');
2391
+ }
2392
+ }
2393
+ }
2394
+ function assertReportDirectoryOutsideVerifiedTrees(reportDir, sourceArtifactRoot, deploymentRoot) {
2395
+ const canonicalReportDir = realpathThroughExistingAncestor(reportDir);
2396
+ for (const [label, root] of [
2397
+ ['source artifact', sourceArtifactRoot],
2398
+ ['deployment', deploymentRoot],
2399
+ ]) {
2400
+ const resolvedRoot = realpathThroughExistingAncestor(root);
2401
+ const distance = path.relative(resolvedRoot, canonicalReportDir);
2402
+ if (distance === '' || (!distance.startsWith('..') && !path.isAbsolute(distance))) {
2403
+ throw new Error(`The --report-dir must stay outside the verified ${label} directory; `
2404
+ + `${reportDir} is inside ${resolvedRoot}, and writing evidence there would `
2405
+ + 'mutate a read-only verification input.');
2406
+ }
2407
+ }
2408
+ }
2409
+ /** Canonicalize a possibly missing path through its nearest existing ancestor. */
2410
+ function realpathThroughExistingAncestor(candidate) {
2411
+ let absolute = path.resolve(candidate);
2412
+ while (true) {
2413
+ try {
2414
+ return realpathSync(absolute);
2415
+ }
2416
+ catch {
2417
+ const parent = path.dirname(absolute);
2418
+ if (parent === absolute) {
2419
+ return absolute;
2420
+ }
2421
+ absolute = parent;
2422
+ }
2423
+ }
2424
+ }
2425
+ /** Markdown-escape a verification value, normalizing CR line endings first. */
2426
+ function escapeVerificationMarkdown(value) {
2427
+ return escapeEvidenceMarkdownInline(value.replaceAll('\r\n', ' ').replaceAll('\r', ' '));
2428
+ }
2429
+ function renderHostedPwaVerificationMarkdown(verification) {
2430
+ return [
2431
+ '# Hosted PWA Deployment Verification',
2432
+ '',
2433
+ `- Host: ${escapeVerificationMarkdown(verification.host)} (${escapeVerificationMarkdown(verification.profile)} profile)`,
2434
+ `- Source artifact: ${escapeVerificationMarkdown(verification.sourceArtifactRoot)}`,
2435
+ `- Deployment: ${escapeVerificationMarkdown(verification.deploymentRoot)}`,
2436
+ '- Release: '
2437
+ + `${escapeVerificationMarkdown(verification.appVersion)} (build `
2438
+ + `${escapeVerificationMarkdown(verification.buildId)}, revision `
2439
+ + `${escapeVerificationMarkdown(verification.revision)})`,
2440
+ `- Verified game files: ${verification.verifiedGameFileCount}`,
2441
+ `- Recognized host files: ${verification.hostFileCount}`,
2442
+ `- Worker routes (include): ${escapeVerificationMarkdown(verification.workerRoutes.include.join(', '))}`,
2443
+ '',
2444
+ '## Verified',
2445
+ '',
2446
+ '- Every source artifact file is present in the deployment with an identical sha256 digest.',
2447
+ '- The source artifact release evidence revision recomputes from its own files.',
2448
+ '- All local index.html references resolve inside the deployment.',
2449
+ '- The `_routes.json` worker routing matches the reviewed profile exactly.',
2450
+ '- Cache-Control policies are explicit for every asset URL: immutable',
2451
+ ' (content-hashed pipelines) or revalidation (stable names) both satisfy',
2452
+ ' the contract; missing or conflicting policies fail. The service worker',
2453
+ ' and stable-name icons use no-store; the index, manifest, release',
2454
+ ' evidence, and legal pages use revalidation.',
2455
+ '- No redirect moves the PWA entry point or PWA-critical files.',
2456
+ '',
2457
+ '## Not verified',
2458
+ '',
2459
+ '- The Pages worker runtime behavior; exercise it with the host preview',
2460
+ ' (for example `wrangler pages dev`) before deploying.',
2461
+ '- CDN or account-level cache settings outside the deployment directory.',
2462
+ '',
2463
+ ].join('\n');
2464
+ }
2465
+ function readRequiredCliOption(value, label) {
2466
+ const read = readOptionalString(value);
2467
+ if (read === undefined) {
2468
+ throw new Error(`${label} is required.`);
2469
+ }
2470
+ return read;
2471
+ }
2261
2472
  function readOptionalString(value) {
2262
2473
  return typeof value === 'string' && value.length > 0 ? value : undefined;
2263
2474
  }
@@ -0,0 +1,58 @@
1
+ import { type MicrosoftStorePwaReleaseEvidence } from './microsoft-store-pwa-e2e.js';
2
+ /**
3
+ * Release-evidence primitives shared by the build-time artifact writer and the
4
+ * hosted-deployment verifier. Kept free of repo-internal imports so the public
5
+ * CLI can verify deployments without the mpgd-kit checkout.
6
+ */
7
+ export interface PrecacheEntry {
8
+ readonly url: string;
9
+ readonly source: string | Uint8Array;
10
+ }
11
+ export interface MicrosoftStorePwaProvenance {
12
+ readonly appVersion: string;
13
+ readonly buildId: string;
14
+ readonly sourceGitSha: string;
15
+ readonly kitGitSha: string;
16
+ }
17
+ export declare function assertMicrosoftStorePwaProvenance(input: MicrosoftStorePwaProvenance): MicrosoftStorePwaProvenance;
18
+ export declare function createMicrosoftStorePwaRevision(input: {
19
+ readonly appVersion: string;
20
+ readonly buildId: string;
21
+ readonly sourceGitSha: string;
22
+ readonly kitGitSha: string;
23
+ readonly precacheEntries: readonly PrecacheEntry[];
24
+ }): string;
25
+ /**
26
+ * Recompute a release revision by streaming each precached file through the
27
+ * digest instead of materializing every payload in memory, for verifiers
28
+ * that only have the artifact root and the recorded URL list.
29
+ */
30
+ export declare function createMicrosoftStorePwaRevisionFromFiles(input: {
31
+ readonly appVersion: string;
32
+ readonly buildId: string;
33
+ readonly sourceGitSha: string;
34
+ readonly kitGitSha: string;
35
+ readonly precacheUrls: readonly string[];
36
+ readonly artifactRoot: string;
37
+ }): string;
38
+ export declare function createMicrosoftStorePwaReleaseEvidence(input: {
39
+ readonly pwaId: string;
40
+ readonly appVersion: string;
41
+ readonly buildId: string;
42
+ readonly sourceGitSha: string;
43
+ readonly kitGitSha: string;
44
+ readonly revision: string;
45
+ readonly precacheUrls: readonly string[];
46
+ }): MicrosoftStorePwaReleaseEvidence;
47
+ export declare function createMicrosoftStorePwaServiceWorker(evidence: MicrosoftStorePwaReleaseEvidence): string;
48
+ export declare function readMicrosoftStorePwaReleaseEvidence(path: string): MicrosoftStorePwaReleaseEvidence;
49
+ /**
50
+ * Enumerate the precacheable files of a PWA artifact. Sourcemaps, the release
51
+ * evidence document, and the service worker itself are excluded because the
52
+ * release revision only covers the precached payload. Symbolic links are
53
+ * rejected so callers cannot be tricked into hashing files outside the root.
54
+ */
55
+ /** Enumerate only precache URLs without reading any payload bytes. */
56
+ export declare function listPrecacheUrls(artifactRoot: string): readonly string[];
57
+ export declare function listPrecacheEntries(artifactRoot: string): readonly PrecacheEntry[];
58
+ export declare function requireGameSpecificPwaId(value: unknown): string;
@@ -0,0 +1,343 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync, } from 'node:fs';
3
+ import { relative, resolve, sep } from 'node:path';
4
+ import { assertMicrosoftStorePwaPrecacheUrl, assertMicrosoftStorePwaReleaseEvidence, microsoftStorePwaCacheSchema, } from './microsoft-store-pwa-e2e.js';
5
+ export function assertMicrosoftStorePwaProvenance(input) {
6
+ return {
7
+ appVersion: requireNonEmptyString(input.appVersion, 'PWA app version'),
8
+ buildId: requireNonEmptyString(input.buildId, 'PWA build ID'),
9
+ sourceGitSha: requireGitSha(input.sourceGitSha, 'PWA source Git SHA'),
10
+ kitGitSha: requireGitSha(input.kitGitSha, 'PWA kit Git SHA'),
11
+ };
12
+ }
13
+ export function createMicrosoftStorePwaRevision(input) {
14
+ const hash = createHash('sha256');
15
+ const release = {
16
+ appVersion: requireNonEmptyString(input.appVersion, 'PWA app version'),
17
+ buildId: requireNonEmptyString(input.buildId, 'PWA build ID'),
18
+ sourceGitSha: requireGitSha(input.sourceGitSha, 'PWA source Git SHA'),
19
+ kitGitSha: requireGitSha(input.kitGitSha, 'PWA kit Git SHA'),
20
+ };
21
+ updateRevisionField(hash, microsoftStorePwaCacheSchema);
22
+ updateRevisionField(hash, JSON.stringify(release));
23
+ for (const entry of normalizePrecacheEntries(input.precacheEntries)) {
24
+ updateRevisionField(hash, entry.url);
25
+ updateRevisionField(hash, entry.source);
26
+ }
27
+ return hash.digest('hex').slice(0, 16);
28
+ }
29
+ /**
30
+ * Recompute a release revision by streaming each precached file through the
31
+ * digest instead of materializing every payload in memory, for verifiers
32
+ * that only have the artifact root and the recorded URL list.
33
+ */
34
+ export function createMicrosoftStorePwaRevisionFromFiles(input) {
35
+ const hash = createHash('sha256');
36
+ const release = {
37
+ appVersion: requireNonEmptyString(input.appVersion, 'PWA app version'),
38
+ buildId: requireNonEmptyString(input.buildId, 'PWA build ID'),
39
+ sourceGitSha: requireGitSha(input.sourceGitSha, 'PWA source Git SHA'),
40
+ kitGitSha: requireGitSha(input.kitGitSha, 'PWA kit Git SHA'),
41
+ };
42
+ updateRevisionField(hash, microsoftStorePwaCacheSchema);
43
+ updateRevisionField(hash, JSON.stringify(release));
44
+ const urls = [...input.precacheUrls]
45
+ .filter((url) => url !== './pwa-release.json' && !url.endsWith('.map'))
46
+ .map(assertMicrosoftStorePwaPrecacheUrl)
47
+ .sort(compareCodeUnits);
48
+ for (const url of urls) {
49
+ const file = resolve(input.artifactRoot, url.replace(/^\.\//u, ''));
50
+ if (!existsSync(file)) {
51
+ throw new Error(`PWA precache URL has no artifact file: ${url}`);
52
+ }
53
+ updateRevisionField(hash, url);
54
+ hash.update(`${String(statSync(file).size)}:`);
55
+ hashStreamInto(hash, file);
56
+ }
57
+ return hash.digest('hex').slice(0, 16);
58
+ }
59
+ function hashStreamInto(hash, file) {
60
+ const handle = openSync(file, 'r');
61
+ const chunk = Buffer.allocUnsafe(1024 * 1024);
62
+ try {
63
+ let read = readSync(handle, chunk, 0, chunk.length, null);
64
+ while (read > 0) {
65
+ hash.update(chunk.subarray(0, read));
66
+ read = readSync(handle, chunk, 0, chunk.length, null);
67
+ }
68
+ }
69
+ finally {
70
+ closeSync(handle);
71
+ }
72
+ }
73
+ export function createMicrosoftStorePwaReleaseEvidence(input) {
74
+ const pwaId = requireGameSpecificPwaId(input.pwaId);
75
+ const revision = requireRevision(input.revision);
76
+ const cachePrefix = `mpgd-pwa-${createHash('sha256').update(pwaId).digest('hex').slice(0, 12)}-`;
77
+ return {
78
+ schemaVersion: 1,
79
+ cacheSchema: microsoftStorePwaCacheSchema,
80
+ pwaId,
81
+ appVersion: requireNonEmptyString(input.appVersion, 'PWA app version'),
82
+ buildId: requireNonEmptyString(input.buildId, 'PWA build ID'),
83
+ sourceGitSha: requireGitSha(input.sourceGitSha, 'PWA source Git SHA'),
84
+ kitGitSha: requireGitSha(input.kitGitSha, 'PWA kit Git SHA'),
85
+ configTarget: 'microsoft-store',
86
+ revision,
87
+ cachePrefix,
88
+ cacheNamePattern: `${cachePrefix}{scope}-${revision}`,
89
+ precacheUrls: normalizePrecacheUrls(input.precacheUrls),
90
+ };
91
+ }
92
+ export function createMicrosoftStorePwaServiceWorker(evidence) {
93
+ const revision = requireRevision(evidence.revision);
94
+ const cachePrefix = requireNonEmptyString(evidence.cachePrefix, 'PWA cache prefix');
95
+ const cacheNamePattern = requireNonEmptyString(evidence.cacheNamePattern, 'PWA cache name pattern');
96
+ const precacheUrls = normalizePrecacheUrls(evidence.precacheUrls);
97
+ if (cacheNamePattern !== `${cachePrefix}{scope}-${revision}`) {
98
+ throw new Error('PWA cache name pattern is inconsistent.');
99
+ }
100
+ return `'use strict';
101
+
102
+ const CACHE_NAMESPACE = ${JSON.stringify(cachePrefix)};
103
+ const CACHE_SCOPE = encodeURIComponent(self.registration.scope);
104
+ const CACHE_PREFIX = CACHE_NAMESPACE + CACHE_SCOPE + '-';
105
+ const CACHE_NAME = ${JSON.stringify(cacheNamePattern)}.replace('{scope}', CACHE_SCOPE);
106
+ const PRECACHE_URLS = ${JSON.stringify(precacheUrls, null, 2)};
107
+ const INDEX_URL = new URL('./index.html', self.registration.scope).href;
108
+ const APP_BASE_URL = self.registration.scope;
109
+
110
+ function escapeHtmlAttribute(value) {
111
+ return value
112
+ .replaceAll('&', '&')
113
+ .replaceAll('"', '"')
114
+ .replaceAll('<', '&lt;')
115
+ .replaceAll('>', '&gt;');
116
+ }
117
+
118
+ async function normalizeCachedNavigationResponse(response) {
119
+ if (!response.redirected) {
120
+ return response;
121
+ }
122
+
123
+ // Static hosts commonly canonicalize /index.html to /. A redirected response
124
+ // can be stored by Cache API, but Chromium rejects it when a service worker
125
+ // later returns it for a navigation whose URL is different. Reconstructing
126
+ // clears Response.url, so inject an explicit deployment-scope base before
127
+ // returning the same-origin app shell for a nested SPA route.
128
+ const html = await response.text();
129
+ const headPattern = /<head(?:\\s[^>]*)?>/iu;
130
+
131
+ if (!headPattern.test(html)) {
132
+ throw new Error('Cached PWA app shell is missing a head element.');
133
+ }
134
+
135
+ const headers = new Headers(response.headers);
136
+ headers.delete('content-encoding');
137
+ headers.delete('content-length');
138
+ const base = '<base href="' + escapeHtmlAttribute(APP_BASE_URL) + '">';
139
+ return new Response(html.replace(headPattern, (head) => head + base), {
140
+ headers,
141
+ status: response.status,
142
+ statusText: response.statusText,
143
+ });
144
+ }
145
+
146
+ self.addEventListener('install', (event) => {
147
+ event.waitUntil((async () => {
148
+ const cache = await caches.open(CACHE_NAME);
149
+
150
+ try {
151
+ const requests = PRECACHE_URLS.map((url) => new Request(
152
+ new URL(url, self.registration.scope),
153
+ { cache: 'reload' },
154
+ ));
155
+ await cache.addAll(requests);
156
+ } catch (error) {
157
+ try {
158
+ await caches.delete(CACHE_NAME);
159
+ } catch {
160
+ // Preserve the original installation error when cleanup also fails.
161
+ }
162
+ throw error;
163
+ }
164
+ })());
165
+ });
166
+
167
+ self.addEventListener('activate', (event) => {
168
+ event.waitUntil((async () => {
169
+ const cacheNames = await caches.keys();
170
+ await Promise.all(
171
+ cacheNames
172
+ .filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME)
173
+ .map((name) => caches.delete(name)),
174
+ );
175
+ await self.clients.claim();
176
+ })());
177
+ });
178
+
179
+ self.addEventListener('fetch', (event) => {
180
+ const request = event.request;
181
+
182
+ if (request.method !== 'GET') {
183
+ return;
184
+ }
185
+
186
+ const url = new URL(request.url);
187
+
188
+ if (url.origin !== self.location.origin) {
189
+ return;
190
+ }
191
+
192
+ if (request.mode === 'navigate') {
193
+ event.respondWith((async () => {
194
+ const cache = await caches.open(CACHE_NAME);
195
+ const cachedIndex = await cache.match(INDEX_URL);
196
+ return cachedIndex === undefined
197
+ ? fetch(request)
198
+ : normalizeCachedNavigationResponse(cachedIndex);
199
+ })());
200
+ return;
201
+ }
202
+
203
+ event.respondWith((async () => {
204
+ const cache = await caches.open(CACHE_NAME);
205
+ return await cache.match(request, { ignoreSearch: true }) ?? fetch(request);
206
+ })());
207
+ });
208
+ `;
209
+ }
210
+ export function readMicrosoftStorePwaReleaseEvidence(path) {
211
+ return assertMicrosoftStorePwaReleaseEvidence(readJsonFile(path));
212
+ }
213
+ /**
214
+ * Enumerate the precacheable files of a PWA artifact. Sourcemaps, the release
215
+ * evidence document, and the service worker itself are excluded because the
216
+ * release revision only covers the precached payload. Symbolic links are
217
+ * rejected so callers cannot be tricked into hashing files outside the root.
218
+ */
219
+ /** Enumerate only precache URLs without reading any payload bytes. */
220
+ export function listPrecacheUrls(artifactRoot) {
221
+ const files = [];
222
+ const pendingDirectories = [artifactRoot];
223
+ while (pendingDirectories.length > 0) {
224
+ const directory = pendingDirectories.pop();
225
+ if (directory === undefined) {
226
+ throw new Error('PWA artifact traversal lost its directory.');
227
+ }
228
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
229
+ const path = resolve(directory, entry.name);
230
+ if (entry.isSymbolicLink()) {
231
+ throw new Error(`PWA artifact must not contain symbolic links: ${path}`);
232
+ }
233
+ if (entry.isDirectory()) {
234
+ pendingDirectories.push(path);
235
+ }
236
+ else if (entry.isFile()) {
237
+ files.push(path);
238
+ }
239
+ }
240
+ }
241
+ return files
242
+ .filter((path) => !path.endsWith('.map')
243
+ && !path.endsWith(`${sep}pwa-release.json`)
244
+ && !path.endsWith(`${sep}service-worker.js`))
245
+ .map((path) => toPrecacheUrl(artifactRoot, path))
246
+ .sort(compareCodeUnits);
247
+ }
248
+ export function listPrecacheEntries(artifactRoot) {
249
+ const files = [];
250
+ const pendingDirectories = [artifactRoot];
251
+ while (pendingDirectories.length > 0) {
252
+ const directory = pendingDirectories.pop();
253
+ if (directory === undefined) {
254
+ throw new Error('PWA artifact traversal lost its directory.');
255
+ }
256
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
257
+ const path = resolve(directory, entry.name);
258
+ if (entry.isSymbolicLink()) {
259
+ throw new Error(`PWA artifact must not contain symbolic links: ${path}`);
260
+ }
261
+ if (entry.isDirectory()) {
262
+ pendingDirectories.push(path);
263
+ }
264
+ else if (entry.isFile()) {
265
+ files.push(path);
266
+ }
267
+ }
268
+ }
269
+ return files
270
+ .filter((path) => !path.endsWith('.map')
271
+ && !path.endsWith(`${sep}pwa-release.json`)
272
+ && !path.endsWith(`${sep}service-worker.js`))
273
+ .map((path) => ({
274
+ url: toPrecacheUrl(artifactRoot, path),
275
+ source: readFileSync(path),
276
+ }))
277
+ .sort((left, right) => compareCodeUnits(left.url, right.url));
278
+ }
279
+ function normalizePrecacheEntries(entries) {
280
+ const entriesByUrl = new Map();
281
+ for (const entry of entries) {
282
+ const url = assertMicrosoftStorePwaPrecacheUrl(entry.url);
283
+ if (entriesByUrl.has(url)) {
284
+ throw new Error(`Duplicate PWA precache URL: ${url}`);
285
+ }
286
+ if (typeof entry.source !== 'string' && !(entry.source instanceof Uint8Array)) {
287
+ throw new Error(`PWA precache source must be bytes or text: ${url}`);
288
+ }
289
+ entriesByUrl.set(url, entry.source);
290
+ }
291
+ return [...entriesByUrl.entries()]
292
+ .sort(([left], [right]) => compareCodeUnits(left, right))
293
+ .map(([url, source]) => ({ url, source }));
294
+ }
295
+ function normalizePrecacheUrls(urls) {
296
+ const normalized = urls.map(assertMicrosoftStorePwaPrecacheUrl);
297
+ if (new Set(normalized).size !== normalized.length) {
298
+ throw new Error('PWA precache URLs must be unique.');
299
+ }
300
+ return normalized.sort(compareCodeUnits);
301
+ }
302
+ function toPrecacheUrl(root, path) {
303
+ const portablePath = relative(root, path).split(sep).join('/');
304
+ return assertMicrosoftStorePwaPrecacheUrl(`./${portablePath}`);
305
+ }
306
+ export function requireGameSpecificPwaId(value) {
307
+ const pwaId = requireNonEmptyString(value, 'Microsoft Store PWA manifest id');
308
+ if (pwaId === '.' || pwaId === './' || pwaId === '/') {
309
+ throw new Error('Microsoft Store PWA manifest id must be game-specific.');
310
+ }
311
+ return pwaId;
312
+ }
313
+ function updateRevisionField(hash, value) {
314
+ const bytes = typeof value === 'string' ? Buffer.from(value, 'utf8') : value;
315
+ hash.update(`${String(bytes.byteLength)}:`);
316
+ hash.update(bytes);
317
+ }
318
+ function requireGitSha(value, label) {
319
+ const sha = requireNonEmptyString(value, label);
320
+ if (!/^[0-9a-f]{40}$/u.test(sha)) {
321
+ throw new Error(`${label} must be a full 40-character hexadecimal SHA.`);
322
+ }
323
+ return sha;
324
+ }
325
+ function requireRevision(value) {
326
+ const revision = requireNonEmptyString(value, 'PWA revision');
327
+ if (!/^[0-9a-f]{16}$/u.test(revision)) {
328
+ throw new Error('PWA revision must be a 16-character hexadecimal digest.');
329
+ }
330
+ return revision;
331
+ }
332
+ function requireNonEmptyString(value, label) {
333
+ if (typeof value !== 'string' || value.trim().length === 0) {
334
+ throw new Error(`${label} must be a non-empty string.`);
335
+ }
336
+ return value.trim();
337
+ }
338
+ function compareCodeUnits(left, right) {
339
+ return left < right ? -1 : left > right ? 1 : 0;
340
+ }
341
+ function readJsonFile(path) {
342
+ return JSON.parse(readFileSync(path, 'utf8'));
343
+ }