thrunium 0.0.0-stage → 1.2.0-rc.2
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 +198 -2
- package/dist/cli.mjs +1150 -0
- package/dist/thrunium-global.mjs +20 -0
- package/dist/thrunium.js +2241 -0
- package/dist/thrunium.min.js +3 -0
- package/dist/thrunium.min.js.map +7 -0
- package/dist/thrunium.mjs +2 -0
- package/dist/types/ajax.d.ts +47 -0
- package/dist/types/collider.d.ts +130 -0
- package/dist/types/elem.d.ts +241 -0
- package/dist/types/env.d.ts +78 -0
- package/dist/types/game.d.ts +702 -0
- package/dist/types/namespace.d.ts +40 -0
- package/dist/types/reference.d.ts +52 -0
- package/dist/types/sprite.d.ts +348 -0
- package/dist/types/storage-token.d.ts +20 -0
- package/dist/types/storage.d.ts +38 -0
- package/dist/types/vector.d.ts +295 -0
- package/package.json +62 -4
- package/templates/js/game.js +17 -0
- package/templates/js/game.json +1 -0
- package/templates/js/gitignore +2 -0
- package/templates/js/package.json +13 -0
- package/templates/pnpm/pnpm-workspace.yaml +4 -0
- package/templates/ts/game.json +1 -0
- package/templates/ts/game.ts +17 -0
- package/templates/ts/gitignore +2 -0
- package/templates/ts/package.json +14 -0
- package/templates/ts/tsconfig.json +12 -0
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,1150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res, err) => function __init() {
|
|
5
|
+
if (err) throw err[0];
|
|
6
|
+
try {
|
|
7
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
8
|
+
} catch (e) {
|
|
9
|
+
throw err = [e], e;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var __export = (target, all) => {
|
|
13
|
+
for (var name in all)
|
|
14
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// cli/errors.ts
|
|
18
|
+
var UserError;
|
|
19
|
+
var init_errors = __esm({
|
|
20
|
+
"cli/errors.ts"() {
|
|
21
|
+
"use strict";
|
|
22
|
+
UserError = class extends Error {
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// cli/engine.ts
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
30
|
+
import { dirname, join } from "node:path";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
function cdnBase() {
|
|
33
|
+
return process.env.THRUNIUM_CDN_URL || DEFAULT_CDN;
|
|
34
|
+
}
|
|
35
|
+
function packageRoot() {
|
|
36
|
+
if (cachedRoot) return cachedRoot;
|
|
37
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
for (; ; ) {
|
|
39
|
+
const manifest = join(dir, "package.json");
|
|
40
|
+
if (existsSync(manifest)) {
|
|
41
|
+
try {
|
|
42
|
+
if (JSON.parse(readFileSync(manifest, "utf8")).name === "thrunium") {
|
|
43
|
+
return cachedRoot = dir;
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const parent = dirname(dir);
|
|
49
|
+
if (parent === dir) throw new Error("Could not locate the thrunium package root.");
|
|
50
|
+
dir = parent;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function engineVersion() {
|
|
54
|
+
const manifest = JSON.parse(readFileSync(join(packageRoot(), "package.json"), "utf8"));
|
|
55
|
+
return manifest.version;
|
|
56
|
+
}
|
|
57
|
+
function engineFile(name) {
|
|
58
|
+
return join(packageRoot(), "dist", name);
|
|
59
|
+
}
|
|
60
|
+
function globalShimFile() {
|
|
61
|
+
return join(packageRoot(), "dist", "thrunium-global.mjs");
|
|
62
|
+
}
|
|
63
|
+
function sriHash(file) {
|
|
64
|
+
return "sha384-" + createHash("sha384").update(readFileSync(file)).digest("base64");
|
|
65
|
+
}
|
|
66
|
+
function cdnEngineUrl(version) {
|
|
67
|
+
return `${cdnBase()}/v${version}/thrunium.min.js`;
|
|
68
|
+
}
|
|
69
|
+
function cdnScriptTag(version, integrity) {
|
|
70
|
+
return `<script src="${cdnEngineUrl(version)}" integrity="${integrity}" crossorigin="anonymous"></script>`;
|
|
71
|
+
}
|
|
72
|
+
async function assertOnCdn(version, integrity, fetchImpl = fetch) {
|
|
73
|
+
const fail = (reason) => new UserError(
|
|
74
|
+
`Thrunium ${version} could not be confirmed on the CDN (${reason}). Check your connection or use --engine bundle to include the engine in your build.`
|
|
75
|
+
);
|
|
76
|
+
const get = async (url) => {
|
|
77
|
+
let res;
|
|
78
|
+
try {
|
|
79
|
+
res = await fetchImpl(url, { signal: AbortSignal.timeout(CDN_TIMEOUT_MS) });
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw fail(`${url} is unreachable: ${err instanceof Error ? err.message : String(err)}`);
|
|
82
|
+
}
|
|
83
|
+
if (!res.ok) throw fail(`${url} returned ${res.status}`);
|
|
84
|
+
return res;
|
|
85
|
+
};
|
|
86
|
+
const read = async (url, res, parse) => {
|
|
87
|
+
try {
|
|
88
|
+
return await parse(res);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err instanceof SyntaxError) throw fail(`${url} returned an invalid response`);
|
|
91
|
+
throw fail(`${url} is unreachable: ${err instanceof Error ? err.message : String(err)}`);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const listUrl = `${cdnBase()}/versions.json`;
|
|
95
|
+
const body = await read(listUrl, await get(listUrl), (r) => r.json());
|
|
96
|
+
const versions = body !== null && typeof body === "object" ? body.versions : void 0;
|
|
97
|
+
if (!Array.isArray(versions)) throw fail(`${listUrl} returned an invalid response`);
|
|
98
|
+
if (!versions.some((v) => v !== null && typeof v === "object" && v.version === version)) {
|
|
99
|
+
throw fail("version not published");
|
|
100
|
+
}
|
|
101
|
+
const engineUrl = cdnEngineUrl(version);
|
|
102
|
+
const bytes = await read(engineUrl, await get(engineUrl), (r) => r.arrayBuffer());
|
|
103
|
+
const cdnIntegrity = "sha384-" + createHash("sha384").update(new Uint8Array(bytes)).digest("base64");
|
|
104
|
+
if (cdnIntegrity !== integrity) {
|
|
105
|
+
throw new UserError(
|
|
106
|
+
`The CDN copy of Thrunium ${version} differs from the installed one (e.g. npm link or local changes). Use --engine bundle to include the installed engine in your build.`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
var DEFAULT_CDN, cachedRoot, CDN_TIMEOUT_MS;
|
|
111
|
+
var init_engine = __esm({
|
|
112
|
+
"cli/engine.ts"() {
|
|
113
|
+
"use strict";
|
|
114
|
+
init_errors();
|
|
115
|
+
DEFAULT_CDN = "https://cdn.thrungames.com/thrunium";
|
|
116
|
+
CDN_TIMEOUT_MS = 1e4;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// cli/html.ts
|
|
121
|
+
function parseAttributes(tag) {
|
|
122
|
+
const attrs = /* @__PURE__ */ new Map();
|
|
123
|
+
const body = tag.slice("<script".length);
|
|
124
|
+
for (const m of body.matchAll(ATTRIBUTE)) {
|
|
125
|
+
const name = m[1].toLowerCase();
|
|
126
|
+
if (!attrs.has(name)) attrs.set(name, m[2] ?? m[3] ?? m[4] ?? "");
|
|
127
|
+
}
|
|
128
|
+
return attrs;
|
|
129
|
+
}
|
|
130
|
+
function scriptTags(html) {
|
|
131
|
+
return [...html.matchAll(SCRIPT_TAG)].map((m) => ({
|
|
132
|
+
index: m.index,
|
|
133
|
+
text: m[0],
|
|
134
|
+
attrs: parseAttributes(m[0])
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
function isModuleWithSrc(tag) {
|
|
138
|
+
return tag.attrs.get("type")?.toLowerCase() === "module" && tag.attrs.has("src");
|
|
139
|
+
}
|
|
140
|
+
function isRemote(src) {
|
|
141
|
+
return /^(https?:|\/\/|data:)/i.test(src.trim());
|
|
142
|
+
}
|
|
143
|
+
function normaliseEntry(src) {
|
|
144
|
+
return src.trim().replace(/[?#].*$/, "").replace(/^(\.\/|\/)+/, "");
|
|
145
|
+
}
|
|
146
|
+
function localModuleTags(html) {
|
|
147
|
+
return scriptTags(html).filter((t) => isModuleWithSrc(t) && !isRemote(t.attrs.get("src")));
|
|
148
|
+
}
|
|
149
|
+
function findModuleEntries(html) {
|
|
150
|
+
return localModuleTags(html).map((t) => normaliseEntry(t.attrs.get("src")));
|
|
151
|
+
}
|
|
152
|
+
function hasEngineTag(html) {
|
|
153
|
+
return scriptTags(html).some((t) => ENGINE_SRC.test(t.attrs.get("src") ?? ""));
|
|
154
|
+
}
|
|
155
|
+
function escapeHtml(text) {
|
|
156
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
157
|
+
}
|
|
158
|
+
function generatePage(title, entry) {
|
|
159
|
+
return `<!DOCTYPE html>
|
|
160
|
+
<html lang="en">
|
|
161
|
+
<head>
|
|
162
|
+
<meta charset="utf-8">
|
|
163
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
164
|
+
<title>${escapeHtml(title)}</title>
|
|
165
|
+
</head>
|
|
166
|
+
<body style="margin: 0; padding: 0;">
|
|
167
|
+
<script type="module" src="${escapeHtml(entry)}"></script>
|
|
168
|
+
</body>
|
|
169
|
+
</html>
|
|
170
|
+
`;
|
|
171
|
+
}
|
|
172
|
+
function transformHtml(html, opts) {
|
|
173
|
+
let out = html;
|
|
174
|
+
const hasHead = /<\/head\s*>/i.test(html);
|
|
175
|
+
const styleLinks = opts.styles.map((href) => `<link rel="stylesheet" href="${escapeHtml(href)}">`).join("");
|
|
176
|
+
const tags = localModuleTags(html);
|
|
177
|
+
for (let i = tags.length - 1; i >= 0; i--) {
|
|
178
|
+
const tag = tags[i];
|
|
179
|
+
const srcValue = tag.attrs.get("src");
|
|
180
|
+
const mapped = opts.entryMap.get(normaliseEntry(srcValue));
|
|
181
|
+
let text = tag.text;
|
|
182
|
+
if (mapped !== void 0) {
|
|
183
|
+
text = text.replace(/(\ssrc\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/i, (_m, pre) => `${pre}"${mapped}"`);
|
|
184
|
+
}
|
|
185
|
+
if (i === 0) {
|
|
186
|
+
if (opts.engineTag !== null) text = opts.engineTag + text;
|
|
187
|
+
if (!hasHead) text = styleLinks + text;
|
|
188
|
+
}
|
|
189
|
+
out = out.slice(0, tag.index) + text + out.slice(tag.index + tag.text.length);
|
|
190
|
+
}
|
|
191
|
+
if (hasHead && styleLinks !== "") {
|
|
192
|
+
const at = /<\/head\s*>/i.exec(out).index;
|
|
193
|
+
out = out.slice(0, at) + styleLinks + out.slice(at);
|
|
194
|
+
}
|
|
195
|
+
if (opts.inject !== null) {
|
|
196
|
+
const at = [/<\/body\s*>/i, /<\/html\s*>/i].map((re) => re.exec(out)?.index).find((i) => i !== void 0);
|
|
197
|
+
out = at === void 0 ? out + opts.inject : out.slice(0, at) + opts.inject + out.slice(at);
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
var SCRIPT_TAG, ATTRIBUTE, ENGINE_SRC;
|
|
202
|
+
var init_html = __esm({
|
|
203
|
+
"cli/html.ts"() {
|
|
204
|
+
"use strict";
|
|
205
|
+
SCRIPT_TAG = /<script\b[^>]*>/gi;
|
|
206
|
+
ATTRIBUTE = /([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
207
|
+
ENGINE_SRC = /(^|\/)thrunium(\.min)?\.js([?#]|$)/;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// cli/project.ts
|
|
212
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "node:fs";
|
|
213
|
+
import { basename, isAbsolute, join as join2, normalize, resolve } from "node:path";
|
|
214
|
+
function resolveEngineMode(root, flag) {
|
|
215
|
+
if (flag) return flag;
|
|
216
|
+
const manifest = join2(root, "package.json");
|
|
217
|
+
if (!existsSync2(manifest)) return "cdn";
|
|
218
|
+
let pkg;
|
|
219
|
+
try {
|
|
220
|
+
pkg = JSON.parse(readFileSync2(manifest, "utf8"));
|
|
221
|
+
} catch {
|
|
222
|
+
throw new UserError("package.json is not valid JSON.");
|
|
223
|
+
}
|
|
224
|
+
const value = pkg.thrunium?.engine;
|
|
225
|
+
if (value === void 0) return "cdn";
|
|
226
|
+
if (value === "cdn" || value === "bundle") return value;
|
|
227
|
+
throw new UserError(`Invalid "thrunium.engine" in package.json: ${JSON.stringify(value)}. Use "cdn" or "bundle".`);
|
|
228
|
+
}
|
|
229
|
+
function isFile(path) {
|
|
230
|
+
return existsSync2(path) && statSync(path).isFile();
|
|
231
|
+
}
|
|
232
|
+
function readGameJson(root) {
|
|
233
|
+
const file = join2(root, "game.json");
|
|
234
|
+
if (!existsSync2(file)) return null;
|
|
235
|
+
let json;
|
|
236
|
+
try {
|
|
237
|
+
json = JSON.parse(readFileSync2(file, "utf8"));
|
|
238
|
+
} catch {
|
|
239
|
+
throw new UserError("game.json is not valid JSON.");
|
|
240
|
+
}
|
|
241
|
+
if (typeof json !== "object" || json === null || Array.isArray(json)) {
|
|
242
|
+
throw new UserError("game.json must contain a JSON object.");
|
|
243
|
+
}
|
|
244
|
+
const { name, entry } = json;
|
|
245
|
+
if (name !== void 0 && typeof name !== "string") throw new UserError('"name" in game.json must be a string.');
|
|
246
|
+
if (entry !== void 0 && typeof entry !== "string") throw new UserError('"entry" in game.json must be a string.');
|
|
247
|
+
return { name, entry };
|
|
248
|
+
}
|
|
249
|
+
function validateEntry(root, entry) {
|
|
250
|
+
const inside = !isAbsolute(entry) && !normalize(entry).split(/[\\/]/).includes("..");
|
|
251
|
+
if (!inside) throw new UserError(`"entry" in game.json must be a relative path inside the project: ${entry}`);
|
|
252
|
+
if (!ENTRY_EXTENSIONS.test(entry)) {
|
|
253
|
+
throw new UserError(`"entry" in game.json must end in .ts, .js, .mts or .mjs: ${entry}`);
|
|
254
|
+
}
|
|
255
|
+
if (!isFile(join2(root, entry))) throw new UserError(`"entry" in game.json points to ${entry}, which does not exist.`);
|
|
256
|
+
}
|
|
257
|
+
function hasEntryKey(file) {
|
|
258
|
+
try {
|
|
259
|
+
const json = JSON.parse(readFileSync2(file, "utf8"));
|
|
260
|
+
return typeof json === "object" && json !== null && !Array.isArray(json) && "entry" in json;
|
|
261
|
+
} catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function resolvePage(root) {
|
|
266
|
+
const indexPath = join2(root, "index.html");
|
|
267
|
+
if (existsSync2(indexPath)) {
|
|
268
|
+
if (hasEntryKey(join2(root, "game.json"))) {
|
|
269
|
+
throw new UserError('game.json has "entry", but index.html also exists and is used instead. Remove one of them.');
|
|
270
|
+
}
|
|
271
|
+
return { html: readFileSync2(indexPath, "utf8"), htmlSource: "file" };
|
|
272
|
+
}
|
|
273
|
+
const game = readGameJson(root);
|
|
274
|
+
let entry = game?.entry;
|
|
275
|
+
if (entry !== void 0) validateEntry(root, entry);
|
|
276
|
+
else entry = DEFAULT_ENTRIES.find((candidate) => isFile(join2(root, candidate)));
|
|
277
|
+
if (entry === void 0) {
|
|
278
|
+
throw new UserError(
|
|
279
|
+
`No game found: add game.ts or game.js (or set "entry" in game.json), or an index.html. Looked for: ${DEFAULT_ENTRIES.join(", ")}.`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return { html: generatePage(game?.name ?? basename(resolve(root)), entry), htmlSource: "generated" };
|
|
283
|
+
}
|
|
284
|
+
function loadProject(root, engineFlag) {
|
|
285
|
+
const { html, htmlSource } = resolvePage(root);
|
|
286
|
+
const entries = findModuleEntries(html);
|
|
287
|
+
for (const entry of entries) {
|
|
288
|
+
if (!existsSync2(join2(root, entry))) {
|
|
289
|
+
throw new UserError(`Entry file ${entry} (from ${htmlSource === "file" ? "index.html" : "game.json"}) does not exist.`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const usesModules = entries.length > 0;
|
|
293
|
+
if (usesModules && hasEngineTag(html)) {
|
|
294
|
+
throw new UserError("Remove the Thrunium <script> tag from index.html; thrunium adds it for you.");
|
|
295
|
+
}
|
|
296
|
+
return { root, html, htmlSource, entries, usesModules, engineMode: resolveEngineMode(root, engineFlag) };
|
|
297
|
+
}
|
|
298
|
+
function thruniumPlugin() {
|
|
299
|
+
return {
|
|
300
|
+
name: "thrunium",
|
|
301
|
+
setup(build2) {
|
|
302
|
+
build2.onResolve({ filter: /^thrunium$/ }, () => ({ path: globalShimFile() }));
|
|
303
|
+
const assetFilter = new RegExp(`(${ASSET_EXTENSIONS.map((e) => e.replace(".", "\\.")).join("|")})$`, "i");
|
|
304
|
+
build2.onResolve({ filter: assetFilter }, (args) => {
|
|
305
|
+
if (args.kind === "url-token" || args.kind === "import-rule") return void 0;
|
|
306
|
+
return {
|
|
307
|
+
errors: [
|
|
308
|
+
{
|
|
309
|
+
text: `Cannot import ${args.path} from game code. Load the file by path instead, for example with game.assets.`
|
|
310
|
+
}
|
|
311
|
+
]
|
|
312
|
+
};
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function baseBuildOptions(root) {
|
|
318
|
+
return {
|
|
319
|
+
absWorkingDir: root,
|
|
320
|
+
bundle: true,
|
|
321
|
+
format: "esm",
|
|
322
|
+
target: "es2017",
|
|
323
|
+
plugins: [thruniumPlugin()],
|
|
324
|
+
// CSS imported from game code is bundled; fonts and images it references
|
|
325
|
+
// are emitted as files (hashed names in build) rather than inlined.
|
|
326
|
+
loader: Object.fromEntries(ASSET_EXTENSIONS.map((ext) => [ext, "file"])),
|
|
327
|
+
logLevel: "silent"
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
var DEFAULT_ENTRIES, ENTRY_EXTENSIONS, ASSET_EXTENSIONS;
|
|
331
|
+
var init_project = __esm({
|
|
332
|
+
"cli/project.ts"() {
|
|
333
|
+
"use strict";
|
|
334
|
+
init_engine();
|
|
335
|
+
init_errors();
|
|
336
|
+
init_html();
|
|
337
|
+
DEFAULT_ENTRIES = ["game.ts", "game.js", "src/game.ts", "src/game.js"];
|
|
338
|
+
ENTRY_EXTENSIONS = /\.(ts|js|mts|mjs)$/;
|
|
339
|
+
ASSET_EXTENSIONS = [".woff", ".woff2", ".ttf", ".otf", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"];
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// cli/reload-client.ts
|
|
344
|
+
var RELOAD_CLIENT;
|
|
345
|
+
var init_reload_client = __esm({
|
|
346
|
+
"cli/reload-client.ts"() {
|
|
347
|
+
"use strict";
|
|
348
|
+
RELOAD_CLIENT = `<script>
|
|
349
|
+
(function () {
|
|
350
|
+
var overlay = null;
|
|
351
|
+
function clearOverlay() {
|
|
352
|
+
if (overlay) { overlay.remove(); overlay = null; }
|
|
353
|
+
}
|
|
354
|
+
function showError(message) {
|
|
355
|
+
clearOverlay();
|
|
356
|
+
overlay = document.createElement('div');
|
|
357
|
+
overlay.setAttribute('data-thrunium-overlay', '');
|
|
358
|
+
overlay.style.cssText = 'position:fixed;left:0;top:0;right:0;z-index:2147483647;max-height:100vh;overflow:auto;' +
|
|
359
|
+
'box-sizing:border-box;padding:16px;background:#2a0f12;color:#ffb4b4;border-bottom:3px solid #ff5555;';
|
|
360
|
+
var pre = document.createElement('pre');
|
|
361
|
+
pre.style.cssText = 'margin:0;font:13px/1.4 ui-monospace,Menlo,Consolas,monospace;white-space:pre-wrap;';
|
|
362
|
+
pre.textContent = message; // textContent: never interpreted as HTML
|
|
363
|
+
overlay.appendChild(pre);
|
|
364
|
+
document.body.appendChild(overlay);
|
|
365
|
+
}
|
|
366
|
+
var source = new EventSource('/__thrunium/events');
|
|
367
|
+
source.addEventListener('reload', function () { clearOverlay(); location.reload(); });
|
|
368
|
+
source.addEventListener('error', function (e) {
|
|
369
|
+
// The browser also fires a bare 'error' when the connection drops; only SSE messages carry data.
|
|
370
|
+
if (typeof e.data === 'string') showError(JSON.parse(e.data));
|
|
371
|
+
});
|
|
372
|
+
})();
|
|
373
|
+
</script>`;
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// cli/dev.ts
|
|
378
|
+
var dev_exports = {};
|
|
379
|
+
__export(dev_exports, {
|
|
380
|
+
dev: () => dev,
|
|
381
|
+
startDev: () => startDev
|
|
382
|
+
});
|
|
383
|
+
import { spawn } from "node:child_process";
|
|
384
|
+
import { existsSync as existsSync3, readdirSync, realpathSync, statSync as statSync2, watch } from "node:fs";
|
|
385
|
+
import { readFile } from "node:fs/promises";
|
|
386
|
+
import { createServer } from "node:http";
|
|
387
|
+
import { isIP } from "node:net";
|
|
388
|
+
import { basename as basename2, extname, isAbsolute as isAbsolute2, join as join3, normalize as normalize2, relative, resolve as resolve2, sep } from "node:path";
|
|
389
|
+
import { parseArgs } from "node:util";
|
|
390
|
+
import { context as esbuildContext, formatMessages } from "esbuild";
|
|
391
|
+
function openBrowser(url) {
|
|
392
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
393
|
+
try {
|
|
394
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
395
|
+
child.on("error", () => {
|
|
396
|
+
});
|
|
397
|
+
child.unref();
|
|
398
|
+
} catch {
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async function listen(server, port, host) {
|
|
402
|
+
const last = Math.min(port + PORT_ATTEMPTS - 1, 65535);
|
|
403
|
+
for (let candidate = port; candidate <= last; candidate++) {
|
|
404
|
+
try {
|
|
405
|
+
await new Promise((resolveListen, reject) => {
|
|
406
|
+
const onError = (err) => reject(err);
|
|
407
|
+
server.once("error", onError);
|
|
408
|
+
server.listen(candidate, host, () => {
|
|
409
|
+
server.off("error", onError);
|
|
410
|
+
resolveListen();
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
const address = server.address();
|
|
414
|
+
return typeof address === "object" && address ? address.port : candidate;
|
|
415
|
+
} catch (err) {
|
|
416
|
+
const code = err.code;
|
|
417
|
+
if (code === "EADDRINUSE" && port !== 0) continue;
|
|
418
|
+
throw new UserError(`Cannot listen on ${host}:${candidate}: ${err instanceof Error ? err.message : String(err)}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
throw new UserError(`Ports ${port}-${last} are all in use. Pass a different --port.`);
|
|
422
|
+
}
|
|
423
|
+
async function startDev(opts) {
|
|
424
|
+
const root = resolve2(opts.root);
|
|
425
|
+
const host = opts.host ?? "localhost";
|
|
426
|
+
const outDir = join3(root, "dist");
|
|
427
|
+
const realRoot = realpathSync(root);
|
|
428
|
+
const outTmp = outDir + ".tmp";
|
|
429
|
+
const virtualOut = join3(root, ".thrunium-dev");
|
|
430
|
+
loadProject(root);
|
|
431
|
+
const clients = /* @__PURE__ */ new Set();
|
|
432
|
+
let lastError = null;
|
|
433
|
+
let buildError = null;
|
|
434
|
+
const send = (res, event, data) => res.write(`event: ${event}
|
|
435
|
+
data: ${JSON.stringify(data)}
|
|
436
|
+
|
|
437
|
+
`);
|
|
438
|
+
const broadcastReload = () => {
|
|
439
|
+
lastError = null;
|
|
440
|
+
for (const res of clients) send(res, "reload", "");
|
|
441
|
+
};
|
|
442
|
+
const broadcastError = (message) => {
|
|
443
|
+
lastError = message;
|
|
444
|
+
for (const res of clients) send(res, "error", message);
|
|
445
|
+
};
|
|
446
|
+
const bundles = /* @__PURE__ */ new Map();
|
|
447
|
+
let owned = /* @__PURE__ */ new Set();
|
|
448
|
+
let entries = [];
|
|
449
|
+
let ctx = null;
|
|
450
|
+
const formatErrors = async (errors) => (await formatMessages(errors, { kind: "error", color: false })).join("\n").trim();
|
|
451
|
+
async function startBundler(newEntries) {
|
|
452
|
+
await ctx?.dispose();
|
|
453
|
+
ctx = null;
|
|
454
|
+
entries = newEntries;
|
|
455
|
+
buildError = null;
|
|
456
|
+
bundles.clear();
|
|
457
|
+
owned = new Set(entries.map((e) => resolve2(root, e)));
|
|
458
|
+
if (entries.length === 0) return;
|
|
459
|
+
let first = true;
|
|
460
|
+
let firstDone;
|
|
461
|
+
const firstBuild = new Promise((r) => firstDone = r);
|
|
462
|
+
const base = baseBuildOptions(root);
|
|
463
|
+
const created = await esbuildContext({
|
|
464
|
+
...base,
|
|
465
|
+
entryPoints: entries.map((entry, i) => ({ in: entry, out: String(i) })),
|
|
466
|
+
sourcemap: "inline",
|
|
467
|
+
// inline maps: a bundle is a single file, there is no .js.map to serve
|
|
468
|
+
outdir: virtualOut,
|
|
469
|
+
metafile: true,
|
|
470
|
+
write: false,
|
|
471
|
+
plugins: [
|
|
472
|
+
...base.plugins,
|
|
473
|
+
{
|
|
474
|
+
name: "thrunium-dev-events",
|
|
475
|
+
setup(build2) {
|
|
476
|
+
build2.onEnd(async (result) => {
|
|
477
|
+
const wasFirst = first;
|
|
478
|
+
first = false;
|
|
479
|
+
if (result.errors.length > 0) {
|
|
480
|
+
buildError = await formatErrors(result.errors);
|
|
481
|
+
broadcastError(buildError);
|
|
482
|
+
} else {
|
|
483
|
+
buildError = null;
|
|
484
|
+
const next = /* @__PURE__ */ new Map();
|
|
485
|
+
for (const f of result.outputFiles ?? []) next.set(f.path.slice(virtualOut.length + 1).split(sep).join("/"), f.contents);
|
|
486
|
+
const changed = next.size !== bundles.size || [...next].some(([k, v]) => !Buffer.from(v).equals(bundles.get(k) ?? new Uint8Array()));
|
|
487
|
+
bundles.clear();
|
|
488
|
+
for (const [k, v] of next) bundles.set(k, v);
|
|
489
|
+
owned = new Set(Object.keys(result.metafile?.inputs ?? {}).map((p) => resolve2(root, p)));
|
|
490
|
+
for (const e of entries) owned.add(resolve2(root, e));
|
|
491
|
+
if (!wasFirst && (changed || lastError !== null)) broadcastReload();
|
|
492
|
+
}
|
|
493
|
+
if (wasFirst) firstDone();
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
]
|
|
498
|
+
});
|
|
499
|
+
ctx = created;
|
|
500
|
+
await created.watch();
|
|
501
|
+
await firstBuild;
|
|
502
|
+
}
|
|
503
|
+
await startBundler(findModuleEntries(loadProject(root).html));
|
|
504
|
+
const server = createServer((req, res) => {
|
|
505
|
+
handle(req.url ?? "/", req.headers.host, res).catch((err) => {
|
|
506
|
+
if (res.headersSent) return res.end();
|
|
507
|
+
const message = err instanceof UserError ? err.message : "Internal server error";
|
|
508
|
+
res.writeHead(500, { "content-type": "text/plain" }).end(message);
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
const allowedHosts = /* @__PURE__ */ new Set(["localhost", host.toLowerCase(), `[${host.toLowerCase()}]`]);
|
|
512
|
+
const hostAllowed = (header) => {
|
|
513
|
+
if (header === void 0) return true;
|
|
514
|
+
const name = header.toLowerCase().replace(/:\d*$/, "");
|
|
515
|
+
if (allowedHosts.has(name)) return true;
|
|
516
|
+
if (isIP(name) === 4) return true;
|
|
517
|
+
return name.startsWith("[") && name.endsWith("]") && isIP(name.slice(1, -1)) === 6;
|
|
518
|
+
};
|
|
519
|
+
async function handle(rawUrl, hostHeader, res) {
|
|
520
|
+
if (!hostAllowed(hostHeader)) {
|
|
521
|
+
res.writeHead(403, { "content-type": "text/plain" }).end("Forbidden: unexpected Host header");
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
let pathname;
|
|
525
|
+
try {
|
|
526
|
+
pathname = decodeURIComponent(rawUrl.split(/[?#]/)[0]);
|
|
527
|
+
} catch {
|
|
528
|
+
res.writeHead(400).end();
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const noCache = { "cache-control": "no-store" };
|
|
532
|
+
if (pathname === "/__thrunium/events") {
|
|
533
|
+
res.writeHead(200, { ...noCache, "content-type": "text/event-stream", connection: "keep-alive" });
|
|
534
|
+
res.write(": connected\n\n");
|
|
535
|
+
clients.add(res);
|
|
536
|
+
res.on("close", () => clients.delete(res));
|
|
537
|
+
if (lastError !== null) send(res, "error", lastError);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (pathname === "/" || pathname === "/index.html") {
|
|
541
|
+
const project = loadProject(root);
|
|
542
|
+
const entryMap = new Map(project.entries.map((e, i) => [e, `${BUNDLE_PREFIX}${i}.js`]));
|
|
543
|
+
const styles = project.entries.flatMap((_, i) => bundles.has(`${i}.css`) ? [`${BUNDLE_PREFIX}${i}.css`] : []);
|
|
544
|
+
const html = transformHtml(project.html, {
|
|
545
|
+
engineTag: project.usesModules ? ENGINE_TAG : null,
|
|
546
|
+
entryMap,
|
|
547
|
+
inject: RELOAD_CLIENT,
|
|
548
|
+
styles
|
|
549
|
+
});
|
|
550
|
+
res.writeHead(200, { ...noCache, "content-type": MIME[".html"] }).end(html);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (pathname === "/__thrunium/engine/thrunium.js") {
|
|
554
|
+
res.writeHead(200, { ...noCache, "content-type": MIME[".js"] }).end(await readFile(engineFile("thrunium.js")));
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (pathname.startsWith(BUNDLE_PREFIX)) {
|
|
558
|
+
const name = pathname.slice(BUNDLE_PREFIX.length);
|
|
559
|
+
const contents = bundles.get(name);
|
|
560
|
+
if (!contents) {
|
|
561
|
+
res.writeHead(404).end();
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const type = MIME[extname(name).toLowerCase()] ?? "application/octet-stream";
|
|
565
|
+
res.writeHead(200, { ...noCache, "content-type": type }).end(contents);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const file = normalize2(join3(root, pathname));
|
|
569
|
+
const rel = relative(root, file);
|
|
570
|
+
if (/[\\\0]/.test(pathname) || // backslashes act as separators on Windows; NUL is never valid
|
|
571
|
+
rel.split(/[\\/]/).some((part) => part.startsWith(".")) || rel === "" || rel === ".." || rel.startsWith(".." + sep) || isAbsolute2(rel) || !existsSync3(file) || !statSync2(file).isFile()) {
|
|
572
|
+
res.writeHead(404).end();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const realRel = relative(realRoot, realpathSync(file));
|
|
576
|
+
if (realRel === ".." || realRel.startsWith(".." + sep) || isAbsolute2(realRel)) {
|
|
577
|
+
res.writeHead(404).end();
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
res.writeHead(200, { ...noCache, "content-type": MIME[extname(file).toLowerCase()] ?? "application/octet-stream" }).end(await readFile(file));
|
|
581
|
+
}
|
|
582
|
+
const ignored = (abs) => {
|
|
583
|
+
const rel = relative(root, abs);
|
|
584
|
+
if (rel.split(sep).some((part) => part === "node_modules" || part.startsWith("."))) return true;
|
|
585
|
+
for (const dir of [outDir, outTmp]) if (abs === dir || abs.startsWith(dir + sep)) return true;
|
|
586
|
+
if (owned.has(abs)) return true;
|
|
587
|
+
try {
|
|
588
|
+
return statSync2(abs).isDirectory();
|
|
589
|
+
} catch {
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
let timer;
|
|
594
|
+
let closed = false;
|
|
595
|
+
let pending = Promise.resolve();
|
|
596
|
+
let indexChanged = false;
|
|
597
|
+
const flush = async () => {
|
|
598
|
+
const checkEntries = indexChanged;
|
|
599
|
+
indexChanged = false;
|
|
600
|
+
try {
|
|
601
|
+
if (checkEntries) {
|
|
602
|
+
const next = findModuleEntries(loadProject(root).html);
|
|
603
|
+
if (next.join("\n") !== entries.join("\n")) await startBundler(next);
|
|
604
|
+
}
|
|
605
|
+
} catch (err) {
|
|
606
|
+
broadcastError(err instanceof Error ? err.message : String(err));
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (buildError !== null) {
|
|
610
|
+
if (lastError !== buildError) broadcastError(buildError);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
broadcastReload();
|
|
614
|
+
};
|
|
615
|
+
const schedule = () => {
|
|
616
|
+
clearTimeout(timer);
|
|
617
|
+
timer = setTimeout(() => {
|
|
618
|
+
if (!closed) pending = pending.then(flush);
|
|
619
|
+
}, WATCH_DEBOUNCE_MS);
|
|
620
|
+
};
|
|
621
|
+
const watchers = /* @__PURE__ */ new Map();
|
|
622
|
+
const skippedTop = (name) => {
|
|
623
|
+
const abs = join3(root, name);
|
|
624
|
+
return name === "node_modules" || name.startsWith(".") || abs === outDir || abs === outTmp;
|
|
625
|
+
};
|
|
626
|
+
const onWatchError = (path) => (err) => {
|
|
627
|
+
watchers.get(path)?.close();
|
|
628
|
+
watchers.delete(path);
|
|
629
|
+
if (existsSync3(path)) console.error(`thrunium: file watching stopped for ${path}: ${err.message}`);
|
|
630
|
+
};
|
|
631
|
+
const rescan = () => {
|
|
632
|
+
if (closed) return;
|
|
633
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
634
|
+
try {
|
|
635
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
636
|
+
if (entry.isDirectory() && !skippedTop(entry.name)) dirs.add(join3(root, entry.name));
|
|
637
|
+
}
|
|
638
|
+
} catch {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
for (const [path, w] of watchers) {
|
|
642
|
+
if (path !== root && !dirs.has(path)) {
|
|
643
|
+
w.close();
|
|
644
|
+
watchers.delete(path);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
for (const dir of dirs) {
|
|
648
|
+
if (watchers.has(dir)) continue;
|
|
649
|
+
try {
|
|
650
|
+
const w = watch(dir, { recursive: true }, (_event, filename) => {
|
|
651
|
+
if (!filename) return schedule();
|
|
652
|
+
const name = filename.toString();
|
|
653
|
+
if (name === basename2(dir) && !existsSync3(join3(dir, name))) return;
|
|
654
|
+
if (!ignored(resolve2(dir, name))) schedule();
|
|
655
|
+
});
|
|
656
|
+
w.on("error", onWatchError(dir));
|
|
657
|
+
watchers.set(dir, w);
|
|
658
|
+
} catch {
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
const rootWatcher = watch(root, (event, filename) => {
|
|
663
|
+
if (event === "rename") rescan();
|
|
664
|
+
const abs = filename ? resolve2(root, filename.toString()) : root;
|
|
665
|
+
const pageInput = !filename || abs === join3(root, "index.html") || abs === join3(root, "game.json");
|
|
666
|
+
if (!pageInput && ignored(abs)) return;
|
|
667
|
+
if (pageInput) indexChanged = true;
|
|
668
|
+
schedule();
|
|
669
|
+
});
|
|
670
|
+
rootWatcher.on("error", onWatchError(root));
|
|
671
|
+
watchers.set(root, rootWatcher);
|
|
672
|
+
rescan();
|
|
673
|
+
const closeWatchers = () => {
|
|
674
|
+
for (const w of watchers.values()) w.close();
|
|
675
|
+
watchers.clear();
|
|
676
|
+
};
|
|
677
|
+
let port;
|
|
678
|
+
try {
|
|
679
|
+
port = await listen(server, opts.port ?? 3e3, host);
|
|
680
|
+
} catch (err) {
|
|
681
|
+
closeWatchers();
|
|
682
|
+
await ctx?.dispose();
|
|
683
|
+
throw err;
|
|
684
|
+
}
|
|
685
|
+
const url = `http://${host}:${port}`;
|
|
686
|
+
console.log(` \u25B2 Thrunium dev ${url} (engine ${engineVersion()})`);
|
|
687
|
+
if (opts.open ?? true) openBrowser(url);
|
|
688
|
+
return {
|
|
689
|
+
url,
|
|
690
|
+
async close() {
|
|
691
|
+
closed = true;
|
|
692
|
+
clearTimeout(timer);
|
|
693
|
+
closeWatchers();
|
|
694
|
+
for (const res of clients) res.end();
|
|
695
|
+
clients.clear();
|
|
696
|
+
await pending;
|
|
697
|
+
await ctx?.dispose();
|
|
698
|
+
await new Promise((r) => {
|
|
699
|
+
server.close(() => r());
|
|
700
|
+
server.closeAllConnections();
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
async function dev(args) {
|
|
706
|
+
let values;
|
|
707
|
+
try {
|
|
708
|
+
({ values } = parseArgs({
|
|
709
|
+
args,
|
|
710
|
+
options: {
|
|
711
|
+
port: { type: "string" },
|
|
712
|
+
host: { type: "string" },
|
|
713
|
+
"no-open": { type: "boolean" },
|
|
714
|
+
help: { type: "boolean", short: "h" }
|
|
715
|
+
},
|
|
716
|
+
allowPositionals: false
|
|
717
|
+
}));
|
|
718
|
+
} catch (err) {
|
|
719
|
+
throw new UserError(`${err instanceof Error ? err.message : String(err)}
|
|
720
|
+
|
|
721
|
+
${USAGE}`);
|
|
722
|
+
}
|
|
723
|
+
if (values.help) {
|
|
724
|
+
console.log(USAGE);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
let port;
|
|
728
|
+
if (values.port !== void 0) {
|
|
729
|
+
port = Number(values.port);
|
|
730
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new UserError(`Invalid --port "${values.port}".`);
|
|
731
|
+
}
|
|
732
|
+
const server = await startDev({ root: process.cwd(), port, host: values.host, open: !values["no-open"] });
|
|
733
|
+
await new Promise((done) => {
|
|
734
|
+
const stop = () => {
|
|
735
|
+
process.off("SIGINT", stop);
|
|
736
|
+
process.off("SIGTERM", stop);
|
|
737
|
+
done();
|
|
738
|
+
};
|
|
739
|
+
process.on("SIGINT", stop);
|
|
740
|
+
process.on("SIGTERM", stop);
|
|
741
|
+
});
|
|
742
|
+
await server.close();
|
|
743
|
+
}
|
|
744
|
+
var USAGE, MIME, ENGINE_TAG, BUNDLE_PREFIX, WATCH_DEBOUNCE_MS, PORT_ATTEMPTS;
|
|
745
|
+
var init_dev = __esm({
|
|
746
|
+
"cli/dev.ts"() {
|
|
747
|
+
"use strict";
|
|
748
|
+
init_engine();
|
|
749
|
+
init_errors();
|
|
750
|
+
init_html();
|
|
751
|
+
init_project();
|
|
752
|
+
init_reload_client();
|
|
753
|
+
USAGE = `Usage: thrunium dev [options]
|
|
754
|
+
|
|
755
|
+
Options:
|
|
756
|
+
--port <n> Port to listen on (default: 3000; the next free one is used if busy)
|
|
757
|
+
--host <name> Host to listen on (default: localhost)
|
|
758
|
+
--no-open Do not open the browser
|
|
759
|
+
--help, -h Show this help
|
|
760
|
+
`;
|
|
761
|
+
MIME = {
|
|
762
|
+
".html": "text/html",
|
|
763
|
+
".js": "text/javascript",
|
|
764
|
+
".xml": "text/xml",
|
|
765
|
+
".json": "application/json",
|
|
766
|
+
".png": "image/png",
|
|
767
|
+
".jpg": "image/jpeg",
|
|
768
|
+
".gif": "image/gif",
|
|
769
|
+
".svg": "image/svg+xml",
|
|
770
|
+
".mp3": "audio/mpeg",
|
|
771
|
+
".wav": "audio/wav",
|
|
772
|
+
".ogg": "audio/ogg",
|
|
773
|
+
".css": "text/css",
|
|
774
|
+
".ts": "text/javascript",
|
|
775
|
+
".wasm": "application/wasm",
|
|
776
|
+
".woff": "font/woff",
|
|
777
|
+
".woff2": "font/woff2",
|
|
778
|
+
".ttf": "font/ttf",
|
|
779
|
+
".otf": "font/otf",
|
|
780
|
+
".webp": "image/webp"
|
|
781
|
+
};
|
|
782
|
+
ENGINE_TAG = '<script src="/__thrunium/engine/thrunium.js"></script>';
|
|
783
|
+
BUNDLE_PREFIX = "/__thrunium/bundle/";
|
|
784
|
+
WATCH_DEBOUNCE_MS = 50;
|
|
785
|
+
PORT_ATTEMPTS = 11;
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
// cli/build.ts
|
|
790
|
+
var build_exports = {};
|
|
791
|
+
__export(build_exports, {
|
|
792
|
+
build: () => build,
|
|
793
|
+
runBuild: () => runBuild
|
|
794
|
+
});
|
|
795
|
+
import { existsSync as existsSync4, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync3, renameSync, rmSync, statSync as statSync3, writeFileSync, copyFileSync } from "node:fs";
|
|
796
|
+
import { dirname as dirname2, isAbsolute as isAbsolute3, join as join4, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path";
|
|
797
|
+
import { parseArgs as parseArgs2 } from "node:util";
|
|
798
|
+
import { build as esbuild, formatMessages as formatMessages2 } from "esbuild";
|
|
799
|
+
function listFiles(root, dir, skipDirs) {
|
|
800
|
+
const files = [];
|
|
801
|
+
for (const entry of readdirSync2(dir, { withFileTypes: true })) {
|
|
802
|
+
const abs = join4(dir, entry.name);
|
|
803
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules" || skipDirs.has(abs)) continue;
|
|
804
|
+
if (entry.isDirectory()) files.push(...listFiles(root, abs, skipDirs));
|
|
805
|
+
else if (entry.isFile() && !SKIPPED_NAMES.test(entry.name)) files.push(relative2(root, abs));
|
|
806
|
+
}
|
|
807
|
+
return files;
|
|
808
|
+
}
|
|
809
|
+
async function runBuild(opts) {
|
|
810
|
+
const root = resolve3(opts.root);
|
|
811
|
+
const outDir = resolve3(root, opts.outDir ?? "dist");
|
|
812
|
+
const rel = relative2(root, outDir);
|
|
813
|
+
if (rel === "" || rel === ".." || rel.startsWith(".." + sep2) || isAbsolute3(rel)) {
|
|
814
|
+
throw new UserError(`The output folder must be inside the project and not the project itself: ${outDir}`);
|
|
815
|
+
}
|
|
816
|
+
const project = loadProject(root, opts.engine);
|
|
817
|
+
const bundled = project.engineMode === "bundle";
|
|
818
|
+
if (bundled && !project.usesModules) {
|
|
819
|
+
throw new UserError(
|
|
820
|
+
`Engine mode "bundle" needs a module entry: a <script type="module" src="..."> in index.html that imports 'thrunium'. A game that loads Thrunium with its own <script> tag keeps that tag; build copies it unchanged.`
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
const integrity = sriHash(engineFile("thrunium.min.js"));
|
|
824
|
+
if (project.usesModules && !bundled) await assertOnCdn(engineVersion(), integrity, opts.fetchImpl);
|
|
825
|
+
const written = /* @__PURE__ */ new Map();
|
|
826
|
+
const consumed = /* @__PURE__ */ new Set();
|
|
827
|
+
const entryMap = /* @__PURE__ */ new Map();
|
|
828
|
+
const styles = [];
|
|
829
|
+
if (project.usesModules) {
|
|
830
|
+
const outAbs = join4(root, ".thrunium-out");
|
|
831
|
+
const result = await esbuild({
|
|
832
|
+
...baseBuildOptions(root),
|
|
833
|
+
entryPoints: project.entries,
|
|
834
|
+
minify: true,
|
|
835
|
+
sourcemap: "linked",
|
|
836
|
+
outdir: outAbs,
|
|
837
|
+
entryNames: "assets/[name]-[hash]",
|
|
838
|
+
assetNames: "assets/[name]-[hash]",
|
|
839
|
+
// fonts/images referenced by CSS or code
|
|
840
|
+
metafile: true,
|
|
841
|
+
write: false
|
|
842
|
+
}).catch(async (err) => {
|
|
843
|
+
const failure = err;
|
|
844
|
+
if (!Array.isArray(failure.errors)) throw err;
|
|
845
|
+
const text = await formatMessages2(failure.errors, { kind: "error", color: false });
|
|
846
|
+
throw new UserError(text.join("\n").trim());
|
|
847
|
+
});
|
|
848
|
+
for (const f of result.outputFiles) written.set(relative2(outAbs, f.path).split(sep2).join("/"), f.contents);
|
|
849
|
+
for (const [file, meta] of Object.entries(result.metafile.outputs)) {
|
|
850
|
+
if (meta.entryPoint) entryMap.set(meta.entryPoint, relative2(outAbs, resolve3(root, file)).split(sep2).join("/"));
|
|
851
|
+
}
|
|
852
|
+
for (const entry of project.entries) {
|
|
853
|
+
const output = Object.entries(result.metafile.outputs).find(([, meta]) => meta.entryPoint === entry);
|
|
854
|
+
const cssBundle = output?.[1].cssBundle;
|
|
855
|
+
if (cssBundle) styles.push(relative2(outAbs, resolve3(root, cssBundle)).split(sep2).join("/"));
|
|
856
|
+
}
|
|
857
|
+
for (const input of Object.keys(result.metafile.inputs)) {
|
|
858
|
+
if (!ASSET_EXTENSIONS.some((ext) => input.toLowerCase().endsWith(ext))) consumed.add(resolve3(root, input));
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
let engineTag = null;
|
|
862
|
+
if (project.usesModules) {
|
|
863
|
+
if (bundled) {
|
|
864
|
+
engineTag = '<script src="thrunium.min.js"></script>';
|
|
865
|
+
written.set("thrunium.min.js", readFileSync3(engineFile("thrunium.min.js")));
|
|
866
|
+
const map = engineFile("thrunium.min.js") + ".map";
|
|
867
|
+
if (existsSync4(map)) written.set("thrunium.min.js.map", readFileSync3(map));
|
|
868
|
+
} else {
|
|
869
|
+
engineTag = cdnScriptTag(engineVersion(), integrity);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
const tmp = outDir + ".tmp";
|
|
873
|
+
written.set("index.html", transformHtml(project.html, { engineTag, entryMap, inject: null, styles }));
|
|
874
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
875
|
+
try {
|
|
876
|
+
const skipDirs = /* @__PURE__ */ new Set([outDir, tmp]);
|
|
877
|
+
for (const file of listFiles(root, root, skipDirs)) {
|
|
878
|
+
const key = file.split(sep2).join("/");
|
|
879
|
+
if (written.has(key) || consumed.has(resolve3(root, file))) continue;
|
|
880
|
+
const dest = join4(tmp, file);
|
|
881
|
+
mkdirSync(dirname2(dest), { recursive: true });
|
|
882
|
+
copyFileSync(join4(root, file), dest);
|
|
883
|
+
}
|
|
884
|
+
for (const [file, contents] of written) {
|
|
885
|
+
const dest = join4(tmp, file);
|
|
886
|
+
mkdirSync(dirname2(dest), { recursive: true });
|
|
887
|
+
writeFileSync(dest, contents);
|
|
888
|
+
}
|
|
889
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
890
|
+
renameSync(tmp, outDir);
|
|
891
|
+
} catch (err) {
|
|
892
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
893
|
+
throw err;
|
|
894
|
+
}
|
|
895
|
+
const files = listAll(outDir, outDir).sort();
|
|
896
|
+
for (const f of files) console.log(` ${f} ${formatSize(statSync3(join4(outDir, f)).size)}`);
|
|
897
|
+
return { outDir, files };
|
|
898
|
+
}
|
|
899
|
+
function listAll(base, dir) {
|
|
900
|
+
return readdirSync2(dir, { withFileTypes: true }).flatMap(
|
|
901
|
+
(e) => e.isDirectory() ? listAll(base, join4(dir, e.name)) : [relative2(base, join4(dir, e.name)).split(sep2).join("/")]
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
function formatSize(bytes) {
|
|
905
|
+
return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} kB`;
|
|
906
|
+
}
|
|
907
|
+
async function build(args) {
|
|
908
|
+
let values;
|
|
909
|
+
try {
|
|
910
|
+
({ values } = parseArgs2({
|
|
911
|
+
args,
|
|
912
|
+
options: {
|
|
913
|
+
out: { type: "string" },
|
|
914
|
+
engine: { type: "string" },
|
|
915
|
+
help: { type: "boolean", short: "h" }
|
|
916
|
+
},
|
|
917
|
+
allowPositionals: false
|
|
918
|
+
}));
|
|
919
|
+
} catch (err) {
|
|
920
|
+
throw new UserError(`${err instanceof Error ? err.message : String(err)}
|
|
921
|
+
|
|
922
|
+
${USAGE2}`);
|
|
923
|
+
}
|
|
924
|
+
if (values.help) {
|
|
925
|
+
console.log(USAGE2);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (values.engine !== void 0 && values.engine !== "cdn" && values.engine !== "bundle") {
|
|
929
|
+
throw new UserError(`Invalid --engine "${values.engine}". Use "cdn" or "bundle".`);
|
|
930
|
+
}
|
|
931
|
+
await runBuild({ root: process.cwd(), outDir: values.out, engine: values.engine });
|
|
932
|
+
}
|
|
933
|
+
var USAGE2, SKIPPED_NAMES;
|
|
934
|
+
var init_build = __esm({
|
|
935
|
+
"cli/build.ts"() {
|
|
936
|
+
"use strict";
|
|
937
|
+
init_engine();
|
|
938
|
+
init_errors();
|
|
939
|
+
init_html();
|
|
940
|
+
init_project();
|
|
941
|
+
USAGE2 = `Usage: thrunium build [options]
|
|
942
|
+
|
|
943
|
+
Options:
|
|
944
|
+
--out <dir> Output folder inside the project (default: dist)
|
|
945
|
+
--engine cdn|bundle Load the engine from the CDN (default) or ship it in the build
|
|
946
|
+
--help, -h Show this help
|
|
947
|
+
`;
|
|
948
|
+
SKIPPED_NAMES = /^(package\.json|package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|yarn\.lock|bun\.lockb?|bunfig\.toml|tsconfig.*\.json|.*\.ts)$/;
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
// cli/init.ts
|
|
953
|
+
var init_exports = {};
|
|
954
|
+
__export(init_exports, {
|
|
955
|
+
detectPackageManager: () => detectPackageManager,
|
|
956
|
+
devCommand: () => devCommand,
|
|
957
|
+
init: () => init,
|
|
958
|
+
installCommand: () => installCommand,
|
|
959
|
+
runInit: () => runInit,
|
|
960
|
+
spawnInstall: () => spawnInstall
|
|
961
|
+
});
|
|
962
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
963
|
+
import { cpSync, existsSync as existsSync5, mkdirSync as mkdirSync2, readdirSync as readdirSync3, readFileSync as readFileSync4, renameSync as renameSync2, statSync as statSync4, writeFileSync as writeFileSync2 } from "node:fs";
|
|
964
|
+
import { basename as basename3, join as join5, resolve as resolve4 } from "node:path";
|
|
965
|
+
import { parseArgs as parseArgs3 } from "node:util";
|
|
966
|
+
function detectPackageManager(userAgent) {
|
|
967
|
+
const [name, version] = (userAgent?.split(" ")[0] ?? "").split("/");
|
|
968
|
+
if (!PACKAGE_MANAGERS.includes(name)) return { name: "npm", version: null };
|
|
969
|
+
return { name, version: version && version !== "?" ? version : null };
|
|
970
|
+
}
|
|
971
|
+
function installCommand(pm) {
|
|
972
|
+
return { cmd: pm, args: pm === "yarn" ? [] : ["install"] };
|
|
973
|
+
}
|
|
974
|
+
function devCommand(pm) {
|
|
975
|
+
return pm === "npm" || pm === "bun" ? `${pm} run dev` : `${pm} dev`;
|
|
976
|
+
}
|
|
977
|
+
function spawnInstall(cmd, args, cwd) {
|
|
978
|
+
return new Promise((done) => {
|
|
979
|
+
const child = process.platform === "win32" ? spawn2([cmd, ...args].join(" "), { cwd, stdio: "inherit", shell: true }) : spawn2(cmd, args, { cwd, stdio: "inherit" });
|
|
980
|
+
child.on("error", () => done(false));
|
|
981
|
+
child.on("close", (code) => done(code === 0));
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
async function runInit(opts) {
|
|
985
|
+
const dir = resolve4(opts.dir);
|
|
986
|
+
if (existsSync5(dir)) {
|
|
987
|
+
if (!statSync4(dir).isDirectory() || readdirSync3(dir).length > 0) {
|
|
988
|
+
throw new UserError(`${dir} already exists and is not an empty folder.`);
|
|
989
|
+
}
|
|
990
|
+
} else {
|
|
991
|
+
mkdirSync2(dir, { recursive: true });
|
|
992
|
+
}
|
|
993
|
+
cpSync(join5(packageRoot(), "templates", opts.template), dir, { recursive: true });
|
|
994
|
+
if (opts.pm.name === "pnpm") cpSync(join5(packageRoot(), "templates", "pnpm"), dir, { recursive: true });
|
|
995
|
+
renameSync2(join5(dir, "gitignore"), join5(dir, ".gitignore"));
|
|
996
|
+
const name = basename3(dir).toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
997
|
+
for (const file of TEXT_FILES) {
|
|
998
|
+
const path = join5(dir, file);
|
|
999
|
+
const text = readFileSync4(path, "utf8").replace(/\{\{name\}\}/g, name).replace(/\{\{version\}\}/g, engineVersion());
|
|
1000
|
+
writeFileSync2(path, text);
|
|
1001
|
+
}
|
|
1002
|
+
const pinPnpm = opts.pm.name === "pnpm" && opts.pm.version;
|
|
1003
|
+
if (pinPnpm || opts.pm.name === "npm") {
|
|
1004
|
+
const path = join5(dir, "package.json");
|
|
1005
|
+
const pkg = JSON.parse(readFileSync4(path, "utf8"));
|
|
1006
|
+
if (pinPnpm) pkg.packageManager = `pnpm@${opts.pm.version}`;
|
|
1007
|
+
if (opts.pm.name === "npm") pkg.allowScripts = { esbuild: true };
|
|
1008
|
+
writeFileSync2(path, `${JSON.stringify(pkg, null, 2)}
|
|
1009
|
+
`);
|
|
1010
|
+
}
|
|
1011
|
+
if (!opts.install) return;
|
|
1012
|
+
const { cmd, args } = installCommand(opts.pm.name);
|
|
1013
|
+
const ok = await (opts.runInstall ?? spawnInstall)(cmd, args, dir);
|
|
1014
|
+
if (!ok) {
|
|
1015
|
+
throw new UserError(`The project was created, but "${[cmd, ...args].join(" ")}" failed. Run it in the project folder to finish.`);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
async function init(args) {
|
|
1019
|
+
let parsed;
|
|
1020
|
+
try {
|
|
1021
|
+
parsed = parseArgs3({
|
|
1022
|
+
args,
|
|
1023
|
+
options: {
|
|
1024
|
+
js: { type: "boolean" },
|
|
1025
|
+
pm: { type: "string" },
|
|
1026
|
+
install: { type: "boolean", default: true },
|
|
1027
|
+
help: { type: "boolean", short: "h" }
|
|
1028
|
+
},
|
|
1029
|
+
allowPositionals: true,
|
|
1030
|
+
allowNegative: true
|
|
1031
|
+
});
|
|
1032
|
+
} catch (err) {
|
|
1033
|
+
throw new UserError(`${err instanceof Error ? err.message : String(err)}
|
|
1034
|
+
|
|
1035
|
+
${USAGE3}`);
|
|
1036
|
+
}
|
|
1037
|
+
const { values, positionals } = parsed;
|
|
1038
|
+
if (values.help) {
|
|
1039
|
+
console.log(USAGE3);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
if (positionals.length > 1) throw new UserError(`Expected at most one folder name.
|
|
1043
|
+
|
|
1044
|
+
${USAGE3}`);
|
|
1045
|
+
const dir = positionals[0] ?? ".";
|
|
1046
|
+
let pm = detectPackageManager(process.env.npm_config_user_agent);
|
|
1047
|
+
if (values.pm !== void 0) {
|
|
1048
|
+
if (!PACKAGE_MANAGERS.includes(values.pm)) {
|
|
1049
|
+
throw new UserError(`Unknown package manager "${values.pm}". Use one of: ${PACKAGE_MANAGERS.join(", ")}.
|
|
1050
|
+
|
|
1051
|
+
${USAGE3}`);
|
|
1052
|
+
}
|
|
1053
|
+
pm = values.pm === pm.name ? pm : { name: values.pm, version: null };
|
|
1054
|
+
}
|
|
1055
|
+
await runInit({ dir, template: values.js ? "js" : "ts", pm, install: values.install });
|
|
1056
|
+
console.log(`Created ${dir === "." ? "the project" : dir}. Next:
|
|
1057
|
+
|
|
1058
|
+
${dir === "." ? "" : ` cd ${dir}
|
|
1059
|
+
`} ${devCommand(pm.name)}`);
|
|
1060
|
+
}
|
|
1061
|
+
var USAGE3, PACKAGE_MANAGERS, TEXT_FILES;
|
|
1062
|
+
var init_init = __esm({
|
|
1063
|
+
"cli/init.ts"() {
|
|
1064
|
+
"use strict";
|
|
1065
|
+
init_engine();
|
|
1066
|
+
init_errors();
|
|
1067
|
+
USAGE3 = `Usage: thrunium init [dir] [options]
|
|
1068
|
+
|
|
1069
|
+
Creates a new game project in <dir> (default: the current folder, which must be empty)
|
|
1070
|
+
and installs its dependencies.
|
|
1071
|
+
|
|
1072
|
+
Options:
|
|
1073
|
+
--js Use JavaScript instead of TypeScript
|
|
1074
|
+
--pm <name> Package manager: npm, pnpm, yarn or bun
|
|
1075
|
+
(default: the one that ran this command, else npm)
|
|
1076
|
+
--no-install Do not install dependencies
|
|
1077
|
+
--help, -h Show this help
|
|
1078
|
+
`;
|
|
1079
|
+
PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
|
|
1080
|
+
TEXT_FILES = ["package.json", "game.json"];
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
// cli/main.ts
|
|
1085
|
+
init_errors();
|
|
1086
|
+
import { readFileSync as readFileSync5 } from "node:fs";
|
|
1087
|
+
import { parseArgs as parseArgs4 } from "node:util";
|
|
1088
|
+
var USAGE4 = `Usage: thrunium <command>
|
|
1089
|
+
|
|
1090
|
+
Commands:
|
|
1091
|
+
dev Start the development server
|
|
1092
|
+
build Build the game for upload
|
|
1093
|
+
init Create a new game project
|
|
1094
|
+
|
|
1095
|
+
Options:
|
|
1096
|
+
--help, -h Show this help
|
|
1097
|
+
--version, -v Show the thrunium version
|
|
1098
|
+
`;
|
|
1099
|
+
function packageVersion() {
|
|
1100
|
+
const url = new URL("../package.json", import.meta.url);
|
|
1101
|
+
return JSON.parse(readFileSync5(url, "utf8")).version;
|
|
1102
|
+
}
|
|
1103
|
+
async function main(argv) {
|
|
1104
|
+
try {
|
|
1105
|
+
const commandIndex = argv.findIndex((a) => !a.startsWith("-"));
|
|
1106
|
+
const globalArgs = commandIndex === -1 ? argv : argv.slice(0, commandIndex);
|
|
1107
|
+
const command = commandIndex === -1 ? void 0 : argv[commandIndex];
|
|
1108
|
+
const { values } = parseArgs4({
|
|
1109
|
+
args: globalArgs,
|
|
1110
|
+
options: { help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "v" } },
|
|
1111
|
+
strict: false
|
|
1112
|
+
});
|
|
1113
|
+
if (values.version) {
|
|
1114
|
+
console.log(packageVersion());
|
|
1115
|
+
return 0;
|
|
1116
|
+
}
|
|
1117
|
+
if (values.help || command === void 0) {
|
|
1118
|
+
(command === void 0 && !values.help ? console.error : console.log)(USAGE4);
|
|
1119
|
+
return values.help ? 0 : 1;
|
|
1120
|
+
}
|
|
1121
|
+
const commandArgs = argv.slice(commandIndex + 1);
|
|
1122
|
+
switch (command) {
|
|
1123
|
+
case "dev":
|
|
1124
|
+
await (await Promise.resolve().then(() => (init_dev(), dev_exports))).dev(commandArgs);
|
|
1125
|
+
return 0;
|
|
1126
|
+
case "build":
|
|
1127
|
+
await (await Promise.resolve().then(() => (init_build(), build_exports))).build(commandArgs);
|
|
1128
|
+
return 0;
|
|
1129
|
+
case "init":
|
|
1130
|
+
await (await Promise.resolve().then(() => (init_init(), init_exports))).init(commandArgs);
|
|
1131
|
+
return 0;
|
|
1132
|
+
default:
|
|
1133
|
+
console.error(`thrunium: unknown command "${command}"
|
|
1134
|
+
|
|
1135
|
+
${USAGE4}`);
|
|
1136
|
+
return 1;
|
|
1137
|
+
}
|
|
1138
|
+
} catch (err) {
|
|
1139
|
+
if (err instanceof UserError) {
|
|
1140
|
+
console.error(`thrunium: ${err.message}`);
|
|
1141
|
+
return 1;
|
|
1142
|
+
}
|
|
1143
|
+
throw err;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// cli-entry.ts
|
|
1148
|
+
main(process.argv.slice(2)).then((code) => {
|
|
1149
|
+
process.exitCode = code;
|
|
1150
|
+
});
|