@mpgd/bridge 0.3.2 → 0.4.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.d.ts +42 -0
- package/dist/cloudflare-pages.js +252 -0
- package/package.json +6 -2
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type BridgeRequest, type BridgeResponse } from './index';
|
|
2
|
+
import { type BridgeRpcPrefix } from './orpc';
|
|
3
|
+
export declare const defaultCloudflarePagesBridgeEndpoint: '/api/mpgd/bridge';
|
|
4
|
+
export declare const defaultCloudflarePagesGameServicesPrefix: '/api/game-services';
|
|
5
|
+
export declare const defaultCloudflarePagesGameServicesForwardHeaders: readonly ['accept', 'authorization', 'content-type'];
|
|
6
|
+
export declare const defaultCloudflarePagesCorsHeaders: {
|
|
7
|
+
readonly 'access-control-allow-origin': '*';
|
|
8
|
+
readonly 'access-control-allow-methods': 'GET, POST, OPTIONS';
|
|
9
|
+
readonly 'access-control-allow-headers': 'content-type, authorization';
|
|
10
|
+
};
|
|
11
|
+
type CorsHeaders = Record<string, string>;
|
|
12
|
+
export interface CloudflarePagesAssetsBinding {
|
|
13
|
+
fetch(request: Request): Promise<Response>;
|
|
14
|
+
}
|
|
15
|
+
export interface CloudflarePagesServiceBinding {
|
|
16
|
+
fetch(request: Request): Promise<Response>;
|
|
17
|
+
}
|
|
18
|
+
export interface MpgdCloudflarePagesHostEnv {
|
|
19
|
+
readonly ASSETS: CloudflarePagesAssetsBinding;
|
|
20
|
+
readonly GAME_SERVICES?: CloudflarePagesServiceBinding;
|
|
21
|
+
}
|
|
22
|
+
export interface MpgdCloudflarePagesHostContext {
|
|
23
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
24
|
+
}
|
|
25
|
+
export type MpgdCloudflarePagesBridgeHandler<TEnv extends MpgdCloudflarePagesHostEnv = MpgdCloudflarePagesHostEnv> = (input: MpgdCloudflarePagesBridgeHandlerInput<TEnv>) => BridgeResponse | Promise<BridgeResponse>;
|
|
26
|
+
export interface MpgdCloudflarePagesBridgeHandlerInput<TEnv extends MpgdCloudflarePagesHostEnv = MpgdCloudflarePagesHostEnv> {
|
|
27
|
+
readonly request: Request;
|
|
28
|
+
readonly env: TEnv;
|
|
29
|
+
readonly ctx: MpgdCloudflarePagesHostContext | undefined;
|
|
30
|
+
readonly bridgeRequest: BridgeRequest;
|
|
31
|
+
}
|
|
32
|
+
export interface CreateMpgdCloudflarePagesHostFetchHandlerOptions<TEnv extends MpgdCloudflarePagesHostEnv = MpgdCloudflarePagesHostEnv> {
|
|
33
|
+
readonly bridgeEndpoint?: string;
|
|
34
|
+
readonly bridgeRpcEndpoint?: BridgeRpcPrefix;
|
|
35
|
+
readonly bridgeHandler?: MpgdCloudflarePagesBridgeHandler<TEnv>;
|
|
36
|
+
readonly gameServicesPrefix?: string;
|
|
37
|
+
readonly gameServicesForwardHeaders?: readonly string[] | 'all';
|
|
38
|
+
readonly corsHeaders?: CorsHeaders | false;
|
|
39
|
+
}
|
|
40
|
+
export declare function createMpgdCloudflarePagesHostFetchHandler<TEnv extends MpgdCloudflarePagesHostEnv = MpgdCloudflarePagesHostEnv>(options?: CreateMpgdCloudflarePagesHostFetchHandlerOptions<TEnv>): (request: Request, env: TEnv, ctx?: MpgdCloudflarePagesHostContext) => Promise<Response>;
|
|
41
|
+
export declare function createMpgdCloudflarePagesBridgeHandler<TEnv extends MpgdCloudflarePagesHostEnv = MpgdCloudflarePagesHostEnv>(): MpgdCloudflarePagesBridgeHandler<TEnv>;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { assertBridgeRequest, createBridgeError, } from './index';
|
|
2
|
+
import { createBridgeRpcFetchHandler, createBridgeRpcRouter, defaultBridgeRpcEndpoint, } from './orpc';
|
|
3
|
+
export const defaultCloudflarePagesBridgeEndpoint = '/api/mpgd/bridge';
|
|
4
|
+
export const defaultCloudflarePagesGameServicesPrefix = '/api/game-services';
|
|
5
|
+
export const defaultCloudflarePagesGameServicesForwardHeaders = [
|
|
6
|
+
'accept',
|
|
7
|
+
'authorization',
|
|
8
|
+
'content-type',
|
|
9
|
+
];
|
|
10
|
+
export const defaultCloudflarePagesCorsHeaders = {
|
|
11
|
+
'access-control-allow-origin': '*',
|
|
12
|
+
'access-control-allow-methods': 'GET, POST, OPTIONS',
|
|
13
|
+
'access-control-allow-headers': 'content-type, authorization',
|
|
14
|
+
};
|
|
15
|
+
export function createMpgdCloudflarePagesHostFetchHandler(options = {}) {
|
|
16
|
+
const bridgeEndpoint = options.bridgeEndpoint ?? defaultCloudflarePagesBridgeEndpoint;
|
|
17
|
+
const bridgeRpcEndpoint = options.bridgeRpcEndpoint ?? defaultBridgeRpcEndpoint;
|
|
18
|
+
const gameServicesPrefix = options.gameServicesPrefix ?? defaultCloudflarePagesGameServicesPrefix;
|
|
19
|
+
const gameServicesForwardHeaders = options.gameServicesForwardHeaders ?? defaultCloudflarePagesGameServicesForwardHeaders;
|
|
20
|
+
const bridgeHandler = options.bridgeHandler ?? createMpgdCloudflarePagesBridgeHandler();
|
|
21
|
+
const corsHeaders = options.corsHeaders === false
|
|
22
|
+
? undefined
|
|
23
|
+
: (options.corsHeaders ?? defaultCloudflarePagesCorsHeaders);
|
|
24
|
+
return async (request, env, ctx) => {
|
|
25
|
+
const url = new URL(request.url);
|
|
26
|
+
try {
|
|
27
|
+
if (request.method === 'OPTIONS' &&
|
|
28
|
+
corsHeaders !== undefined &&
|
|
29
|
+
(url.pathname === bridgeEndpoint ||
|
|
30
|
+
isPathUnderPrefix(url.pathname, bridgeRpcEndpoint) ||
|
|
31
|
+
isPathUnderPrefix(url.pathname, gameServicesPrefix))) {
|
|
32
|
+
return emptyResponse(204, corsHeaders);
|
|
33
|
+
}
|
|
34
|
+
if (url.pathname === bridgeEndpoint) {
|
|
35
|
+
return handleBridgeJsonRequest(request, env, ctx, bridgeHandler, corsHeaders);
|
|
36
|
+
}
|
|
37
|
+
if (isPathUnderPrefix(url.pathname, bridgeRpcEndpoint)) {
|
|
38
|
+
const rpcFetch = createBridgeRpcFetchHandler(createBridgeRpcRouter((bridgeRequest) => {
|
|
39
|
+
return bridgeHandler({
|
|
40
|
+
request,
|
|
41
|
+
env,
|
|
42
|
+
ctx,
|
|
43
|
+
bridgeRequest,
|
|
44
|
+
});
|
|
45
|
+
}), {
|
|
46
|
+
prefix: bridgeRpcEndpoint,
|
|
47
|
+
});
|
|
48
|
+
return withCors(await rpcFetch(request), corsHeaders);
|
|
49
|
+
}
|
|
50
|
+
if (isPathUnderPrefix(url.pathname, gameServicesPrefix)) {
|
|
51
|
+
return proxyGameServicesRequest(request, env, gameServicesPrefix, corsHeaders, gameServicesForwardHeaders);
|
|
52
|
+
}
|
|
53
|
+
return env.ASSETS.fetch(request);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
console.error(JSON.stringify({
|
|
57
|
+
message: 'mpgd Cloudflare Pages host request failed',
|
|
58
|
+
path: url.pathname,
|
|
59
|
+
error: errorMessage(error),
|
|
60
|
+
}));
|
|
61
|
+
return jsonResponse({ error: 'MPGD_PAGES_HOST_INTERNAL_ERROR' }, 500, corsHeaders);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function createMpgdCloudflarePagesBridgeHandler() {
|
|
66
|
+
return async (input) => {
|
|
67
|
+
switch (input.bridgeRequest.method) {
|
|
68
|
+
case 'runtime.getCapabilities':
|
|
69
|
+
return ok(input.bridgeRequest, {
|
|
70
|
+
nativeIap: false,
|
|
71
|
+
nativeAds: false,
|
|
72
|
+
rewardedAds: false,
|
|
73
|
+
interstitialAds: false,
|
|
74
|
+
nativeLeaderboard: false,
|
|
75
|
+
achievements: false,
|
|
76
|
+
cloudSave: false,
|
|
77
|
+
socialShare: true,
|
|
78
|
+
haptics: false,
|
|
79
|
+
localizedContent: true,
|
|
80
|
+
});
|
|
81
|
+
case 'identity.getPlayer': {
|
|
82
|
+
return ok(input.bridgeRequest, null);
|
|
83
|
+
}
|
|
84
|
+
case 'commerce.getProducts':
|
|
85
|
+
case 'commerce.getEntitlements':
|
|
86
|
+
return ok(input.bridgeRequest, []);
|
|
87
|
+
case 'commerce.purchase':
|
|
88
|
+
return ok(input.bridgeRequest, {
|
|
89
|
+
status: 'cancelled',
|
|
90
|
+
entitlementIds: [],
|
|
91
|
+
});
|
|
92
|
+
case 'commerce.restore':
|
|
93
|
+
return ok(input.bridgeRequest, {
|
|
94
|
+
restoredEntitlements: [],
|
|
95
|
+
});
|
|
96
|
+
case 'ads.preload':
|
|
97
|
+
return ok(input.bridgeRequest, {});
|
|
98
|
+
case 'ads.showRewarded':
|
|
99
|
+
return ok(input.bridgeRequest, {
|
|
100
|
+
status: 'unavailable',
|
|
101
|
+
rewardGranted: false,
|
|
102
|
+
});
|
|
103
|
+
case 'ads.showInterstitial':
|
|
104
|
+
return ok(input.bridgeRequest, {
|
|
105
|
+
status: 'unavailable',
|
|
106
|
+
});
|
|
107
|
+
case 'leaderboard.submitScore':
|
|
108
|
+
return ok(input.bridgeRequest, {
|
|
109
|
+
submitted: false,
|
|
110
|
+
});
|
|
111
|
+
case 'leaderboard.open':
|
|
112
|
+
return createBridgeError(input.bridgeRequest.id, 'CLOUDFLARE_PAGES_LEADERBOARD_OPEN_UNAVAILABLE', 'Cloudflare Pages host does not provide a native leaderboard UI.');
|
|
113
|
+
case 'storage.load':
|
|
114
|
+
return ok(input.bridgeRequest, null);
|
|
115
|
+
case 'storage.save':
|
|
116
|
+
return ok(input.bridgeRequest, {
|
|
117
|
+
saved: false,
|
|
118
|
+
});
|
|
119
|
+
default:
|
|
120
|
+
return createBridgeError(input.bridgeRequest.id, 'UNSUPPORTED_METHOD', `Unsupported Cloudflare Pages bridge method: ${input.bridgeRequest.method}`);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
async function handleBridgeJsonRequest(request, env, ctx, bridgeHandler, corsHeaders) {
|
|
125
|
+
if (request.method === 'OPTIONS') {
|
|
126
|
+
return emptyResponse(204, corsHeaders);
|
|
127
|
+
}
|
|
128
|
+
if (request.method !== 'POST') {
|
|
129
|
+
return jsonResponse({ error: 'METHOD_NOT_ALLOWED' }, 405, corsHeaders);
|
|
130
|
+
}
|
|
131
|
+
let bridgeRequest;
|
|
132
|
+
try {
|
|
133
|
+
bridgeRequest = assertBridgeRequest(await request.json());
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
return jsonResponse(createBridgeError('unknown', 'INVALID_BRIDGE_REQUEST', `Bridge request failed runtime validation: ${errorMessage(error)}`), 400, corsHeaders);
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
return jsonResponse(await bridgeHandler({
|
|
140
|
+
request,
|
|
141
|
+
env,
|
|
142
|
+
ctx,
|
|
143
|
+
bridgeRequest,
|
|
144
|
+
}), 200, corsHeaders);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
console.error(JSON.stringify({
|
|
148
|
+
message: 'Cloudflare Pages bridge handler failed',
|
|
149
|
+
bridgeRequestId: bridgeRequest.id,
|
|
150
|
+
error: errorMessage(error),
|
|
151
|
+
}));
|
|
152
|
+
return jsonResponse(createBridgeError(bridgeRequest.id, 'BRIDGE_HANDLER_ERROR', 'An internal error occurred while processing the bridge request.', true), 500, corsHeaders);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function proxyGameServicesRequest(request, env, prefix, corsHeaders, forwardHeaders) {
|
|
156
|
+
if (env.GAME_SERVICES === undefined) {
|
|
157
|
+
return jsonResponse({ error: 'GAME_SERVICES_BINDING_UNAVAILABLE' }, 503, corsHeaders);
|
|
158
|
+
}
|
|
159
|
+
const url = new URL(request.url);
|
|
160
|
+
const pathname = url.pathname.slice(prefix.length);
|
|
161
|
+
if (hasParentPathSegment(pathname)) {
|
|
162
|
+
return jsonResponse({ error: 'INVALID_GAME_SERVICES_PATH' }, 400, corsHeaders);
|
|
163
|
+
}
|
|
164
|
+
url.pathname = pathname.length === 0 ? '/' : pathname;
|
|
165
|
+
return withCors(await env.GAME_SERVICES.fetch(await createForwardedRequest(request, url, forwardHeaders)), corsHeaders);
|
|
166
|
+
}
|
|
167
|
+
function isPathUnderPrefix(pathname, prefix) {
|
|
168
|
+
return pathname === prefix || pathname.startsWith(`${prefix}/`);
|
|
169
|
+
}
|
|
170
|
+
function ok(input, data) {
|
|
171
|
+
return {
|
|
172
|
+
id: input.id,
|
|
173
|
+
ok: true,
|
|
174
|
+
data,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function hasParentPathSegment(pathname) {
|
|
178
|
+
return pathname.split('/').some((segment) => {
|
|
179
|
+
return decodePathSegment(segment).split('/').includes('..');
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function decodePathSegment(segment) {
|
|
183
|
+
let decoded = segment;
|
|
184
|
+
for (let index = 0; index < 64; index += 1) {
|
|
185
|
+
try {
|
|
186
|
+
const next = decodeURIComponent(decoded);
|
|
187
|
+
if (next === decoded) {
|
|
188
|
+
return decoded;
|
|
189
|
+
}
|
|
190
|
+
decoded = next;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return '..';
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return '..';
|
|
197
|
+
}
|
|
198
|
+
async function createForwardedRequest(request, url, forwardHeaders) {
|
|
199
|
+
const headers = forwardHeaders === 'all'
|
|
200
|
+
? request.headers
|
|
201
|
+
: createForwardedHeaders(request.headers, forwardHeaders);
|
|
202
|
+
const init = {
|
|
203
|
+
method: request.method,
|
|
204
|
+
headers,
|
|
205
|
+
};
|
|
206
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
207
|
+
init.body = await request.arrayBuffer();
|
|
208
|
+
}
|
|
209
|
+
return new Request(url.toString(), init);
|
|
210
|
+
}
|
|
211
|
+
function createForwardedHeaders(headers, forwardHeaders) {
|
|
212
|
+
const forwarded = new Headers();
|
|
213
|
+
for (const name of forwardHeaders) {
|
|
214
|
+
const value = headers.get(name);
|
|
215
|
+
if (value !== null) {
|
|
216
|
+
forwarded.set(name, value);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return forwarded;
|
|
220
|
+
}
|
|
221
|
+
function emptyResponse(status, corsHeaders) {
|
|
222
|
+
return new Response(null, {
|
|
223
|
+
status,
|
|
224
|
+
...(corsHeaders === undefined ? {} : { headers: corsHeaders }),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
function jsonResponse(body, status, corsHeaders) {
|
|
228
|
+
return new Response(JSON.stringify(body), {
|
|
229
|
+
status,
|
|
230
|
+
headers: {
|
|
231
|
+
'content-type': 'application/json',
|
|
232
|
+
...(corsHeaders ?? {}),
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
function withCors(response, corsHeaders) {
|
|
237
|
+
if (corsHeaders === undefined) {
|
|
238
|
+
return response;
|
|
239
|
+
}
|
|
240
|
+
const headers = new Headers(response.headers);
|
|
241
|
+
for (const [key, value] of Object.entries(corsHeaders)) {
|
|
242
|
+
headers.set(key, value);
|
|
243
|
+
}
|
|
244
|
+
return new Response(response.body, {
|
|
245
|
+
status: response.status,
|
|
246
|
+
statusText: response.statusText,
|
|
247
|
+
headers,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
function errorMessage(error) {
|
|
251
|
+
return error instanceof Error ? error.message : String(error);
|
|
252
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Typed native bridge protocol for mpgd platform adapters.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
"./orpc": {
|
|
33
33
|
"types": "./dist/orpc.d.ts",
|
|
34
34
|
"default": "./dist/orpc.js"
|
|
35
|
+
},
|
|
36
|
+
"./cloudflare-pages": {
|
|
37
|
+
"types": "./dist/cloudflare-pages.d.ts",
|
|
38
|
+
"default": "./dist/cloudflare-pages.js"
|
|
35
39
|
}
|
|
36
40
|
},
|
|
37
41
|
"files": [
|
|
@@ -57,6 +61,6 @@
|
|
|
57
61
|
"lint": "ttsc --noEmit",
|
|
58
62
|
"format": "ttsc format",
|
|
59
63
|
"fix": "ttsc fix",
|
|
60
|
-
"test": "cd ../.. && node tools/run-ttsx.mjs packages/bridge/src/bridge-protocol.test.ts && node tools/run-ttsx.mjs packages/bridge/src/orpc.test.ts"
|
|
64
|
+
"test": "cd ../.. && node tools/run-ttsx.mjs packages/bridge/src/bridge-protocol.test.ts && node tools/run-ttsx.mjs packages/bridge/src/orpc.test.ts && node tools/run-ttsx.mjs packages/bridge/src/cloudflare-pages.test.ts"
|
|
61
65
|
}
|
|
62
66
|
}
|