@mpgd/cli 0.28.0 → 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/cloudflare-pages-static.d.ts +53 -0
- package/dist/cloudflare-pages-static.js +236 -0
- package/dist/hosted-pwa-deployment.d.ts +42 -0
- package/dist/hosted-pwa-deployment.js +1087 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +212 -1
- package/dist/microsoft-store-pwa-release.d.ts +58 -0
- package/dist/microsoft-store-pwa-release.js +343 -0
- package/package.json +6 -6
- package/templates/phaser-game/agent/game-manifest.json +4 -0
- package/templates/phaser-game/mpgd.game.json +3 -0
- package/templates/phaser-game/src/entry.ts +10 -0
- package/templates/phaser-game/src/gameEntry.ts +10 -0
- package/templates/phaser-game/src/platform/textSelection.ts +121 -0
- package/templates/phaser-game/vite.shared.ts +14 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsing and evaluation for the Cloudflare Pages `_headers` and `_redirects`
|
|
3
|
+
* static configuration files.
|
|
4
|
+
*
|
|
5
|
+
* The supported syntax subset follows the official Pages documentation: path
|
|
6
|
+
* blocks with indented directives, `!` removal lines, single-splat and
|
|
7
|
+
* placeholder patterns, `#` comments, and CRLF or LF line endings. Constructs
|
|
8
|
+
* outside that subset produce explicit unsupported diagnostics instead of
|
|
9
|
+
* being silently ignored.
|
|
10
|
+
*/
|
|
11
|
+
export interface CloudflarePagesHeaderAddDirective {
|
|
12
|
+
readonly kind: 'add';
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly value: string;
|
|
15
|
+
}
|
|
16
|
+
export interface CloudflarePagesHeaderRemoveDirective {
|
|
17
|
+
readonly kind: 'remove';
|
|
18
|
+
readonly name: string;
|
|
19
|
+
}
|
|
20
|
+
export type CloudflarePagesHeaderDirective = CloudflarePagesHeaderAddDirective | CloudflarePagesHeaderRemoveDirective;
|
|
21
|
+
export interface CloudflarePagesHeaderBlock {
|
|
22
|
+
readonly path: string;
|
|
23
|
+
readonly directives: readonly CloudflarePagesHeaderDirective[];
|
|
24
|
+
}
|
|
25
|
+
export interface CloudflarePagesRedirectRule {
|
|
26
|
+
readonly source: string;
|
|
27
|
+
readonly destination: string;
|
|
28
|
+
readonly code: number | undefined;
|
|
29
|
+
}
|
|
30
|
+
export interface EffectiveHeaderResult {
|
|
31
|
+
/** Final value the sequential Cloudflare evaluation model produces. */
|
|
32
|
+
readonly value: string | undefined;
|
|
33
|
+
/** Values added by matching blocks, in file order. */
|
|
34
|
+
readonly additions: readonly string[];
|
|
35
|
+
/** True when a matching block removed the header. */
|
|
36
|
+
readonly removed: boolean;
|
|
37
|
+
}
|
|
38
|
+
export declare function parseCloudflarePagesHeaders(input: string): readonly CloudflarePagesHeaderBlock[];
|
|
39
|
+
export declare function parseCloudflarePagesRedirects(input: string): readonly CloudflarePagesRedirectRule[];
|
|
40
|
+
/**
|
|
41
|
+
* Evaluate the effective header for a request path. Cloudflare applies every
|
|
42
|
+
* matching block in file order: `name: value` lines accumulate (duplicate
|
|
43
|
+
* headers are joined with commas by the platform) and `! name` lines remove
|
|
44
|
+
* everything accumulated for that header so far.
|
|
45
|
+
*/
|
|
46
|
+
export declare function evaluateCloudflarePagesHeader(blocks: readonly CloudflarePagesHeaderBlock[], requestPath: string, headerName: string): EffectiveHeaderResult;
|
|
47
|
+
/**
|
|
48
|
+
* Normalize a comma-separated directive value for semantic comparison:
|
|
49
|
+
* whitespace-trimmed, lower-cased, empty segments dropped, order-insensitive.
|
|
50
|
+
*/
|
|
51
|
+
export declare function normalizeHeaderDirectiveValue(value: string): string;
|
|
52
|
+
/** Match a supported `_headers` path pattern against a request path. */
|
|
53
|
+
export declare function cloudflarePagesPathMatches(pattern: string, requestPath: string): boolean;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsing and evaluation for the Cloudflare Pages `_headers` and `_redirects`
|
|
3
|
+
* static configuration files.
|
|
4
|
+
*
|
|
5
|
+
* The supported syntax subset follows the official Pages documentation: path
|
|
6
|
+
* blocks with indented directives, `!` removal lines, single-splat and
|
|
7
|
+
* placeholder patterns, `#` comments, and CRLF or LF line endings. Constructs
|
|
8
|
+
* outside that subset produce explicit unsupported diagnostics instead of
|
|
9
|
+
* being silently ignored.
|
|
10
|
+
*/
|
|
11
|
+
const maximumHeaderBlocks = 100;
|
|
12
|
+
const maximumLineLength = 2000;
|
|
13
|
+
const supportedRedirectCodes = new Set([301, 302, 303, 307, 308]);
|
|
14
|
+
export function parseCloudflarePagesHeaders(input) {
|
|
15
|
+
const lines = input.replaceAll('\r\n', '\n').replaceAll('\r', '\n').split('\n');
|
|
16
|
+
const blocks = [];
|
|
17
|
+
for (const line of lines) {
|
|
18
|
+
if (line.length > maximumLineLength) {
|
|
19
|
+
throw new Error(`Cloudflare Pages _headers line exceeds the ${maximumLineLength}-character limit.`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
let currentPath;
|
|
23
|
+
let currentDirectives = [];
|
|
24
|
+
const flushBlock = () => {
|
|
25
|
+
if (currentPath !== undefined && currentDirectives.length > 0) {
|
|
26
|
+
blocks.push({ path: currentPath, directives: currentDirectives });
|
|
27
|
+
}
|
|
28
|
+
currentPath = undefined;
|
|
29
|
+
currentDirectives = [];
|
|
30
|
+
};
|
|
31
|
+
for (const line of lines) {
|
|
32
|
+
const trimmed = line.trim();
|
|
33
|
+
if (trimmed.length === 0 || trimmed.startsWith('#')) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const indented = /^[ \t]/u.test(line);
|
|
37
|
+
if (!indented) {
|
|
38
|
+
flushBlock();
|
|
39
|
+
assertSupportedHeaderPathPattern(trimmed);
|
|
40
|
+
assertSupportedPlaceholderShape(trimmed);
|
|
41
|
+
currentPath = trimmed;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (currentPath === undefined) {
|
|
45
|
+
throw new Error(`Cloudflare Pages _headers directive appears before any path block: ${trimmed}`);
|
|
46
|
+
}
|
|
47
|
+
currentDirectives.push(parseHeaderDirective(trimmed));
|
|
48
|
+
}
|
|
49
|
+
flushBlock();
|
|
50
|
+
if (blocks.length > maximumHeaderBlocks) {
|
|
51
|
+
throw new Error(`Cloudflare Pages _headers declares ${blocks.length} blocks; `
|
|
52
|
+
+ `the documented limit is ${maximumHeaderBlocks}.`);
|
|
53
|
+
}
|
|
54
|
+
return blocks;
|
|
55
|
+
}
|
|
56
|
+
export function parseCloudflarePagesRedirects(input) {
|
|
57
|
+
const lines = input.replaceAll('\r\n', '\n').replaceAll('\r', '\n').split('\n');
|
|
58
|
+
const rules = [];
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
const trimmed = line.trim();
|
|
61
|
+
if (trimmed.length === 0 || trimmed.startsWith('#')) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const parts = trimmed.split(/\s+/u);
|
|
65
|
+
if (parts.length < 2 || parts.length > 3 || parts.some((part) => part.length === 0)) {
|
|
66
|
+
throw new Error(`Cloudflare Pages _redirects line is malformed: ${trimmed}`);
|
|
67
|
+
}
|
|
68
|
+
const [source, destination, codeText] = parts;
|
|
69
|
+
assertSupportedRedirectPattern(source, destination);
|
|
70
|
+
assertSupportedPlaceholderShape(source);
|
|
71
|
+
let code;
|
|
72
|
+
if (codeText !== undefined) {
|
|
73
|
+
const parsed = /^\d{3}$/u.exec(codeText);
|
|
74
|
+
if (parsed === null || !supportedRedirectCodes.has(Number(codeText))) {
|
|
75
|
+
throw new Error(`Cloudflare Pages _redirects status code is unsupported: ${codeText}`);
|
|
76
|
+
}
|
|
77
|
+
code = Number(codeText);
|
|
78
|
+
}
|
|
79
|
+
rules.push({ source, destination, code });
|
|
80
|
+
}
|
|
81
|
+
return rules;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Evaluate the effective header for a request path. Cloudflare applies every
|
|
85
|
+
* matching block in file order: `name: value` lines accumulate (duplicate
|
|
86
|
+
* headers are joined with commas by the platform) and `! name` lines remove
|
|
87
|
+
* everything accumulated for that header so far.
|
|
88
|
+
*/
|
|
89
|
+
export function evaluateCloudflarePagesHeader(blocks, requestPath, headerName) {
|
|
90
|
+
const normalizedHeader = headerName.toLowerCase();
|
|
91
|
+
const additions = [];
|
|
92
|
+
let removed = false;
|
|
93
|
+
for (const block of blocks) {
|
|
94
|
+
if (!cloudflarePagesPathMatches(block.path, requestPath)) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
for (const directive of block.directives) {
|
|
98
|
+
if (directive.name.toLowerCase() !== normalizedHeader) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (directive.kind === 'remove') {
|
|
102
|
+
additions.length = 0;
|
|
103
|
+
removed = true;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
additions.push(directive.value);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
value: additions.length === 0 ? undefined : additions.join(', '),
|
|
112
|
+
additions: [...additions],
|
|
113
|
+
removed,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Normalize a comma-separated directive value for semantic comparison:
|
|
118
|
+
* whitespace-trimmed, lower-cased, empty segments dropped, order-insensitive.
|
|
119
|
+
*/
|
|
120
|
+
export function normalizeHeaderDirectiveValue(value) {
|
|
121
|
+
return value
|
|
122
|
+
.split(',')
|
|
123
|
+
.map((directive) => directive.trim().toLowerCase())
|
|
124
|
+
.filter((directive) => directive.length > 0)
|
|
125
|
+
.sort()
|
|
126
|
+
.join(', ');
|
|
127
|
+
}
|
|
128
|
+
/** Match a supported `_headers` path pattern against a request path. */
|
|
129
|
+
export function cloudflarePagesPathMatches(pattern, requestPath) {
|
|
130
|
+
// Segments are compared without dropping empties: '/x/' and '/x' are
|
|
131
|
+
// distinct request paths, and only the root '/' is the empty pair.
|
|
132
|
+
const patternParts = pattern.split('/');
|
|
133
|
+
const pathParts = requestPath.split('/');
|
|
134
|
+
if (patternParts[0] === '' && pathParts[0] === '') {
|
|
135
|
+
return matchesFrom(patternParts.slice(1), pathParts.slice(1));
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
function matchesFrom(patternParts, pathParts) {
|
|
140
|
+
if (patternParts.length === 0) {
|
|
141
|
+
return pathParts.length === 0;
|
|
142
|
+
}
|
|
143
|
+
const [firstPart, ...rest] = patternParts;
|
|
144
|
+
const head = firstPart ?? '';
|
|
145
|
+
if (head === '*') {
|
|
146
|
+
// A standalone splat carries its preceding slash, so it must consume at
|
|
147
|
+
// least one segment: /service-worker.js/* does not match /service-worker.js.
|
|
148
|
+
for (let skip = 1; skip <= pathParts.length; skip += 1) {
|
|
149
|
+
if (matchesFrom(rest, pathParts.slice(skip))) {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
if (pathParts.length === 0) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
if (!partMatches(head, pathParts[0] ?? '')) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
return matchesFrom(rest, pathParts.slice(1));
|
|
162
|
+
}
|
|
163
|
+
function partMatches(pattern, value) {
|
|
164
|
+
if (!pattern.includes(':') && !pattern.includes('*')) {
|
|
165
|
+
return pattern === value;
|
|
166
|
+
}
|
|
167
|
+
assertSupportedPlaceholderShape(pattern);
|
|
168
|
+
// Placeholders match one non-separator run; a splat is greedy inside its
|
|
169
|
+
// segment, so /assets/*.js covers /assets/app.js like the Pages router.
|
|
170
|
+
const placeholderJoined = pattern
|
|
171
|
+
.split(/:[A-Za-z]\w*/gu)
|
|
172
|
+
.map(escapeRegExp)
|
|
173
|
+
.join('[^/]+');
|
|
174
|
+
return new RegExp(`^${placeholderJoined.replaceAll('*', '.*')}$`, 'u').test(value);
|
|
175
|
+
}
|
|
176
|
+
function escapeRegExp(text) {
|
|
177
|
+
return text.replace(/[.+?^${}()|[\]\\]/gu, '\\$&');
|
|
178
|
+
}
|
|
179
|
+
/** Reject placeholder shapes that would compile into ambiguous backtracking. */
|
|
180
|
+
function assertSupportedPlaceholderShape(path) {
|
|
181
|
+
for (const segment of path.split('/')) {
|
|
182
|
+
const placeholders = [...segment.matchAll(/:[A-Za-z]\w*/gu)];
|
|
183
|
+
if (placeholders.length > 1) {
|
|
184
|
+
throw new Error('Cloudflare Pages path segments must contain at most one placeholder: '
|
|
185
|
+
+ `${path} (${segment})`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function assertSupportedHeaderPathPattern(path) {
|
|
190
|
+
if (/^https?:\/\//iu.test(path)) {
|
|
191
|
+
throw new Error(`Cloudflare Pages _headers absolute-URL patterns are unsupported: ${path}`);
|
|
192
|
+
}
|
|
193
|
+
if (!path.startsWith('/')) {
|
|
194
|
+
throw new Error(`Cloudflare Pages _headers path must start with '/': ${path}`);
|
|
195
|
+
}
|
|
196
|
+
const splatCount = [...path.matchAll(/\*/gu)].length;
|
|
197
|
+
if (splatCount > 1) {
|
|
198
|
+
throw new Error(`Cloudflare Pages _headers supports at most one splat per pattern: ${path}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function assertSupportedRedirectPattern(source, destination) {
|
|
202
|
+
if (!source.startsWith('/')) {
|
|
203
|
+
throw new Error(`Cloudflare Pages _redirects source must start with '/': ${source}`);
|
|
204
|
+
}
|
|
205
|
+
if ([...source.matchAll(/\*/gu)].length > 1) {
|
|
206
|
+
throw new Error(`Cloudflare Pages _redirects supports at most one splat per source: ${source}`);
|
|
207
|
+
}
|
|
208
|
+
if (/^https?:\/\//iu.test(destination) && destination.includes(':splat')) {
|
|
209
|
+
throw new Error('Cloudflare Pages _redirects :splat interpolation into absolute URLs is unsupported.');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function parseHeaderDirective(line) {
|
|
213
|
+
if (line.startsWith('!')) {
|
|
214
|
+
const name = line.slice(1).trim();
|
|
215
|
+
if (!/^[A-Za-z0-9-]+$/u.test(name)) {
|
|
216
|
+
throw new Error(`Cloudflare Pages _headers removal directive is malformed: ${line}`);
|
|
217
|
+
}
|
|
218
|
+
return { kind: 'remove', name };
|
|
219
|
+
}
|
|
220
|
+
const separator = line.indexOf(':');
|
|
221
|
+
if (separator <= 0) {
|
|
222
|
+
throw new Error(`Cloudflare Pages _headers directive is malformed: ${line}`);
|
|
223
|
+
}
|
|
224
|
+
const name = line.slice(0, separator).trim();
|
|
225
|
+
if (!/^[A-Za-z0-9-]+$/u.test(name)) {
|
|
226
|
+
throw new Error(`Cloudflare Pages _headers directive name is malformed: ${line}`);
|
|
227
|
+
}
|
|
228
|
+
const value = line.slice(separator + 1).trim();
|
|
229
|
+
if (value.length === 0) {
|
|
230
|
+
throw new Error(`Cloudflare Pages _headers directive value is empty: ${line}`);
|
|
231
|
+
}
|
|
232
|
+
if (value.includes(':splat')) {
|
|
233
|
+
throw new Error(`Cloudflare Pages _headers :splat interpolation is unsupported: ${line}`);
|
|
234
|
+
}
|
|
235
|
+
return { kind: 'add', name, value };
|
|
236
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only verification that a hosted Cloudflare Pages deployment directory
|
|
3
|
+
* serves exactly the files of an already-verified PWA artifact, with routing
|
|
4
|
+
* and cache headers that keep the PWA entry points reachable and fresh.
|
|
5
|
+
*
|
|
6
|
+
* The verifier knows nothing about individual games or workspace layouts: it
|
|
7
|
+
* compares an explicit source artifact directory against an explicit
|
|
8
|
+
* deployment directory under one of the reviewed Pages host profiles.
|
|
9
|
+
*/
|
|
10
|
+
export declare const hostedPwaDeploymentHosts: readonly ['cloudflare-pages'];
|
|
11
|
+
export type HostedPwaDeploymentHost = (typeof hostedPwaDeploymentHosts)[number];
|
|
12
|
+
export declare const cloudflarePagesDeploymentProfiles: readonly ['api-only', 'api-canonical-index'];
|
|
13
|
+
export type CloudflarePagesDeploymentProfile = (typeof cloudflarePagesDeploymentProfiles)[number];
|
|
14
|
+
export interface VerifyHostedPwaDeploymentInput {
|
|
15
|
+
/** Directory of the already-verified source PWA artifact. */
|
|
16
|
+
readonly sourceArtifactRoot: string;
|
|
17
|
+
/** Directory that the host deploys (game files merged with host files). */
|
|
18
|
+
readonly deploymentRoot: string;
|
|
19
|
+
readonly host: HostedPwaDeploymentHost;
|
|
20
|
+
readonly profile: CloudflarePagesDeploymentProfile;
|
|
21
|
+
}
|
|
22
|
+
export interface HostedPwaDeploymentVerification {
|
|
23
|
+
readonly host: HostedPwaDeploymentHost;
|
|
24
|
+
readonly profile: CloudflarePagesDeploymentProfile;
|
|
25
|
+
readonly sourceArtifactRoot: string;
|
|
26
|
+
readonly deploymentRoot: string;
|
|
27
|
+
readonly appVersion: string;
|
|
28
|
+
readonly buildId: string;
|
|
29
|
+
readonly revision: string;
|
|
30
|
+
readonly verifiedGameFileCount: number;
|
|
31
|
+
readonly hostFileCount: number;
|
|
32
|
+
readonly workerRoutes: {
|
|
33
|
+
readonly include: readonly string[];
|
|
34
|
+
readonly exclude: readonly string[];
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export declare function isSupportedHostedPwaProfile(host: string, profile: string): boolean;
|
|
38
|
+
export declare function verifyHostedPwaDeployment(input: VerifyHostedPwaDeploymentInput): HostedPwaDeploymentVerification;
|
|
39
|
+
export interface ArtifactFile {
|
|
40
|
+
readonly path: string;
|
|
41
|
+
readonly sha256: string;
|
|
42
|
+
}
|