@rpgjs/server 5.0.0-alpha.41 → 5.0.0-alpha.43
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.js +24 -1
- package/dist/index.js.map +1 -1
- package/dist/map-D4T2_hc-.js +265 -0
- package/dist/map-D4T2_hc-.js.map +1 -0
- package/dist/node/connection.d.ts +51 -0
- package/dist/node/index.d.ts +5 -0
- package/dist/node/index.js +693 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/map.d.ts +16 -0
- package/dist/node/room.d.ts +21 -0
- package/dist/node/transport.d.ts +26 -0
- package/dist/node/types.d.ts +47 -0
- package/dist/rooms/map.d.ts +1 -1
- package/package.json +15 -4
- package/src/node/connection.ts +254 -0
- package/src/node/index.ts +22 -0
- package/src/node/map.ts +328 -0
- package/src/node/room.ts +63 -0
- package/src/node/transport.ts +516 -0
- package/src/node/types.ts +61 -0
- package/src/rooms/map.ts +23 -0
- package/tests/node-transport.spec.ts +223 -0
- package/vite.config.ts +20 -3
package/src/node/map.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import type { RpgTransportServer } from "./types";
|
|
2
|
+
|
|
3
|
+
interface ResolveMapOptions {
|
|
4
|
+
host?: string;
|
|
5
|
+
headers?: Headers;
|
|
6
|
+
mapUpdateToken?: string;
|
|
7
|
+
tiledBasePaths?: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const MAP_UPDATE_TOKEN_HEADER = "x-rpgjs-map-update-token";
|
|
11
|
+
export const MAP_UPDATE_TOKEN_ENV = "RPGJS_MAP_UPDATE_TOKEN";
|
|
12
|
+
|
|
13
|
+
type RuntimeProcess = {
|
|
14
|
+
cwd?: () => string;
|
|
15
|
+
env?: Record<string, string | undefined>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function getRuntimeProcess(): RuntimeProcess | undefined {
|
|
19
|
+
return (globalThis as { process?: RuntimeProcess }).process;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readEnvVariable(name: string): string | undefined {
|
|
23
|
+
const value = getRuntimeProcess()?.env?.[name];
|
|
24
|
+
return typeof value === "string" ? value : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getWorkingDirectory(): string | undefined {
|
|
28
|
+
const cwd = getRuntimeProcess()?.cwd;
|
|
29
|
+
if (typeof cwd !== "function") {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
return cwd();
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeRoomMapId(roomId: string): string {
|
|
41
|
+
return roomId.startsWith("map-") ? roomId.slice(4) : roomId;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toBasePathPrefix(basePath: string): string {
|
|
45
|
+
const trimmed = basePath.trim();
|
|
46
|
+
if (!trimmed) {
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function extractFileLikeMapDefinition(maps: any[], mapId: string): any | null {
|
|
53
|
+
for (const mapDef of maps) {
|
|
54
|
+
if (typeof mapDef === "object" && mapDef) {
|
|
55
|
+
const candidateId = typeof mapDef.id === "string" ? mapDef.id.replace(/^map-/, "") : "";
|
|
56
|
+
if (candidateId === mapId) {
|
|
57
|
+
return mapDef;
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (typeof mapDef === "string") {
|
|
63
|
+
const fileName = mapDef.split("/").pop()?.replace(/\.tmx$/i, "");
|
|
64
|
+
if (fileName === mapId) {
|
|
65
|
+
return { id: mapId, file: mapDef };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function fetchTextByUrl(url: string): Promise<string | null> {
|
|
74
|
+
try {
|
|
75
|
+
const response = await fetch(url);
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return await response.text();
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function readTextByFilePath(pathLike: string): Promise<string | null> {
|
|
86
|
+
try {
|
|
87
|
+
const { readFile } = await import("node:fs/promises");
|
|
88
|
+
const { isAbsolute, join } = await import("node:path");
|
|
89
|
+
|
|
90
|
+
const cwd = getWorkingDirectory();
|
|
91
|
+
const candidates = isAbsolute(pathLike) || !cwd ? [pathLike] : [pathLike, join(cwd, pathLike)];
|
|
92
|
+
|
|
93
|
+
for (const candidate of candidates) {
|
|
94
|
+
try {
|
|
95
|
+
return await readFile(candidate, "utf8");
|
|
96
|
+
} catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getTiledBasePaths(paths?: string[]): string[] {
|
|
108
|
+
const values = [
|
|
109
|
+
...(paths || []),
|
|
110
|
+
readEnvVariable("RPGJS_TILED_BASE_PATH"),
|
|
111
|
+
"map",
|
|
112
|
+
"data",
|
|
113
|
+
"assets/data",
|
|
114
|
+
"assets/map",
|
|
115
|
+
].filter((value): value is string => !!value);
|
|
116
|
+
|
|
117
|
+
return Array.from(new Set(values));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function resolveMapUpdateToken(explicitToken?: string): string {
|
|
121
|
+
return explicitToken ?? readEnvVariable(MAP_UPDATE_TOKEN_ENV) ?? "";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function createMapUpdateHeaders(
|
|
125
|
+
token?: string,
|
|
126
|
+
init?: HeadersInit,
|
|
127
|
+
): Headers {
|
|
128
|
+
const headers = new Headers(init);
|
|
129
|
+
if (!headers.has("content-type")) {
|
|
130
|
+
headers.set("content-type", "application/json");
|
|
131
|
+
}
|
|
132
|
+
const resolvedToken = resolveMapUpdateToken(token);
|
|
133
|
+
if (resolvedToken) {
|
|
134
|
+
headers.set(MAP_UPDATE_TOKEN_HEADER, resolvedToken);
|
|
135
|
+
}
|
|
136
|
+
return headers;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function readMapUpdateToken(headers: Headers): string {
|
|
140
|
+
const directToken = headers.get(MAP_UPDATE_TOKEN_HEADER);
|
|
141
|
+
if (directToken) {
|
|
142
|
+
return directToken;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const authorization = headers.get("authorization");
|
|
146
|
+
if (!authorization) {
|
|
147
|
+
return "";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const [scheme, value] = authorization.split(/\s+/, 2);
|
|
151
|
+
if (scheme?.toLowerCase() !== "bearer" || !value) {
|
|
152
|
+
return "";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return value.trim();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function isMapUpdateAuthorized(headers: Headers, expectedToken?: string): boolean {
|
|
159
|
+
const requiredToken = resolveMapUpdateToken(expectedToken);
|
|
160
|
+
if (!requiredToken) {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
return readMapUpdateToken(headers) === requiredToken;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function resolveMapDocument(
|
|
167
|
+
mapId: string,
|
|
168
|
+
mapDefinition: any,
|
|
169
|
+
options: ResolveMapOptions,
|
|
170
|
+
): Promise<{ xml: string; sourceUrl?: string }> {
|
|
171
|
+
if (typeof mapDefinition?.data === "string" && mapDefinition.data.includes("<map")) {
|
|
172
|
+
return { xml: mapDefinition.data };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (typeof mapDefinition?.file === "string") {
|
|
176
|
+
const file = mapDefinition.file.trim();
|
|
177
|
+
if (file.includes("<map")) {
|
|
178
|
+
return { xml: file };
|
|
179
|
+
}
|
|
180
|
+
if (/^https?:\/\//i.test(file)) {
|
|
181
|
+
const xml = await fetchTextByUrl(file);
|
|
182
|
+
if (xml) {
|
|
183
|
+
return { xml, sourceUrl: file };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (file.startsWith("/") && options.host) {
|
|
187
|
+
const sourceUrl = `http://${options.host}${file}`;
|
|
188
|
+
const xml = await fetchTextByUrl(sourceUrl);
|
|
189
|
+
if (xml) {
|
|
190
|
+
return { xml, sourceUrl };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const xmlFromFile = await readTextByFilePath(file);
|
|
194
|
+
if (xmlFromFile) {
|
|
195
|
+
return { xml: xmlFromFile };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (options.host) {
|
|
200
|
+
for (const basePath of getTiledBasePaths(options.tiledBasePaths)) {
|
|
201
|
+
const prefix = toBasePathPrefix(basePath);
|
|
202
|
+
const sourceUrl = `http://${options.host}${prefix}/${mapId}.tmx`;
|
|
203
|
+
const xml = await fetchTextByUrl(sourceUrl);
|
|
204
|
+
if (xml) {
|
|
205
|
+
return { xml, sourceUrl };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { xml: "" };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function enrichMapWithParsedTiledData(payload: any, options: ResolveMapOptions = {}): Promise<void> {
|
|
214
|
+
if (payload?.parsedMap || typeof payload?.id !== "string") {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const maps = Array.isArray(payload.__maps) ? payload.__maps : [];
|
|
219
|
+
const mapDefinition = extractFileLikeMapDefinition(maps, payload.id);
|
|
220
|
+
const mapDoc = await resolveMapDocument(payload.id, mapDefinition, options);
|
|
221
|
+
if (!mapDoc.xml) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const tiledModuleName = "@canvasengine/tiled";
|
|
227
|
+
const tiledModule = await import(/* @vite-ignore */ tiledModuleName);
|
|
228
|
+
const TiledParser = tiledModule?.TiledParser;
|
|
229
|
+
if (!TiledParser) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const mapParser = new TiledParser(mapDoc.xml);
|
|
234
|
+
const parsedMap = mapParser.parseMap();
|
|
235
|
+
const tilesets = Array.isArray(parsedMap?.tilesets) ? parsedMap.tilesets : [];
|
|
236
|
+
const mergedTilesets: any[] = [];
|
|
237
|
+
|
|
238
|
+
for (const tileset of tilesets) {
|
|
239
|
+
if (!tileset?.source) {
|
|
240
|
+
mergedTilesets.push(tileset);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let tilesetUrl: string | undefined;
|
|
245
|
+
if (mapDoc.sourceUrl) {
|
|
246
|
+
try {
|
|
247
|
+
tilesetUrl = new URL(tileset.source, mapDoc.sourceUrl).toString();
|
|
248
|
+
} catch {
|
|
249
|
+
tilesetUrl = undefined;
|
|
250
|
+
}
|
|
251
|
+
} else if (options.host) {
|
|
252
|
+
const prefix = toBasePathPrefix(getTiledBasePaths(options.tiledBasePaths)[0] || "map");
|
|
253
|
+
const candidatePath = tileset.source.startsWith("/")
|
|
254
|
+
? tileset.source
|
|
255
|
+
: `${prefix}/${tileset.source}`.replace(/\/{2,}/g, "/");
|
|
256
|
+
tilesetUrl = `http://${options.host}${candidatePath.startsWith("/") ? candidatePath : `/${candidatePath}`}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const tilesetRaw = tilesetUrl
|
|
260
|
+
? await fetchTextByUrl(tilesetUrl)
|
|
261
|
+
: await readTextByFilePath(tileset.source);
|
|
262
|
+
|
|
263
|
+
if (!tilesetRaw) {
|
|
264
|
+
mergedTilesets.push(tileset);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const tilesetParser = new TiledParser(tilesetRaw);
|
|
270
|
+
const parsedTileset = tilesetParser.parseTileset();
|
|
271
|
+
mergedTilesets.push({
|
|
272
|
+
...tileset,
|
|
273
|
+
...parsedTileset,
|
|
274
|
+
});
|
|
275
|
+
} catch {
|
|
276
|
+
mergedTilesets.push(tileset);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
parsedMap.tilesets = mergedTilesets;
|
|
281
|
+
payload.data = mapDoc.xml;
|
|
282
|
+
payload.parsedMap = parsedMap;
|
|
283
|
+
|
|
284
|
+
if (typeof parsedMap?.width === "number" && typeof parsedMap?.tilewidth === "number") {
|
|
285
|
+
payload.width = parsedMap.width * parsedMap.tilewidth;
|
|
286
|
+
}
|
|
287
|
+
if (typeof parsedMap?.height === "number" && typeof parsedMap?.tileheight === "number") {
|
|
288
|
+
payload.height = parsedMap.height * parsedMap.tileheight;
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function updateMap(roomId: string, rpgServer: RpgTransportServer, options: ResolveMapOptions = {}): Promise<void> {
|
|
296
|
+
if (!roomId.startsWith("map-")) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const mapId = normalizeRoomMapId(roomId);
|
|
302
|
+
const serverMaps = Array.isArray(rpgServer.maps) ? rpgServer.maps : [];
|
|
303
|
+
const defaultMapPayload: any = {
|
|
304
|
+
id: mapId,
|
|
305
|
+
width: 0,
|
|
306
|
+
height: 0,
|
|
307
|
+
events: [],
|
|
308
|
+
__maps: serverMaps,
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
await enrichMapWithParsedTiledData(defaultMapPayload, options);
|
|
312
|
+
delete defaultMapPayload.__maps;
|
|
313
|
+
|
|
314
|
+
const headers = createMapUpdateHeaders(options.mapUpdateToken, options.headers);
|
|
315
|
+
|
|
316
|
+
await rpgServer.onRequest?.({
|
|
317
|
+
url: `http://localhost/parties/main/${roomId}/map/update`,
|
|
318
|
+
method: "POST",
|
|
319
|
+
headers,
|
|
320
|
+
json: async () => defaultMapPayload,
|
|
321
|
+
text: async () => JSON.stringify(defaultMapPayload),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
console.log(`Initialized map for room ${roomId} via POST /map/update`);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
console.warn(`Failed initializing map for room ${roomId}:`, error);
|
|
327
|
+
}
|
|
328
|
+
}
|
package/src/node/room.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { PartyConnection } from "./connection";
|
|
2
|
+
|
|
3
|
+
export class PartyRoom {
|
|
4
|
+
public id: string;
|
|
5
|
+
public internalID: string;
|
|
6
|
+
public env: Record<string, any> = {};
|
|
7
|
+
public context: any = {};
|
|
8
|
+
|
|
9
|
+
private connections = new Map<string, PartyConnection>();
|
|
10
|
+
private storageData = new Map<string, any>();
|
|
11
|
+
|
|
12
|
+
constructor(id: string) {
|
|
13
|
+
this.id = id;
|
|
14
|
+
this.internalID = `internal_${id}_${Date.now()}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async broadcast(message: any, except: string[] = []): Promise<void> {
|
|
18
|
+
const data = typeof message === "string" ? message : JSON.stringify(message);
|
|
19
|
+
const sendPromises: Promise<void>[] = [];
|
|
20
|
+
|
|
21
|
+
for (const [connectionId, connection] of this.connections) {
|
|
22
|
+
if (!except.includes(connectionId)) {
|
|
23
|
+
sendPromises.push(connection.send(data));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
await Promise.all(sendPromises);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getConnection(id: string): PartyConnection | undefined {
|
|
31
|
+
return this.connections.get(id);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
getConnections(tag?: string): IterableIterator<PartyConnection> {
|
|
35
|
+
void tag;
|
|
36
|
+
return this.connections.values();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
addConnection(connection: PartyConnection): void {
|
|
40
|
+
this.connections.set(connection.id, connection);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
removeConnection(connectionId: string): void {
|
|
44
|
+
this.connections.delete(connectionId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get storage() {
|
|
48
|
+
return {
|
|
49
|
+
put: async (key: string, value: any) => {
|
|
50
|
+
this.storageData.set(key, value);
|
|
51
|
+
},
|
|
52
|
+
get: async <T = any>(key: string): Promise<T | undefined> => {
|
|
53
|
+
return this.storageData.get(key) as T;
|
|
54
|
+
},
|
|
55
|
+
delete: async (key: string) => {
|
|
56
|
+
this.storageData.delete(key);
|
|
57
|
+
},
|
|
58
|
+
list: async () => {
|
|
59
|
+
return Array.from(this.storageData.entries());
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|