@driftengine/package 3.63.0 → 4.1.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/NOTICE +20 -0
- package/README.md +58 -1
- package/android/.gradle/9.2.0/fileHashes/fileHashes.bin +0 -0
- package/android/.gradle/9.2.0/fileHashes/fileHashes.lock +0 -0
- package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/android/.gradle/buildOutputCleanup/cache.properties +1 -1
- package/dist/bootstrap.js +7 -1
- package/dist/build.js +23 -10
- package/dist/cli.js +50 -7
- package/dist/flags.js +16 -0
- package/dist/main/ipc.js +5 -19
- package/dist/main/storeFile.d.ts +30 -0
- package/dist/main/storeFile.js +104 -0
- package/dist/main/window.js +6 -3
- package/dist/manifest.d.ts +8 -1
- package/dist/manifest.js +8 -0
- package/dist/native.d.ts +67 -0
- package/dist/native.js +212 -0
- package/dist/nativeBundle.d.ts +53 -0
- package/dist/nativeBundle.js +177 -0
- package/dist/nativeCopy.d.ts +39 -0
- package/dist/nativeCopy.js +110 -0
- package/dist/nativeLicenses.d.ts +42 -0
- package/dist/nativeLicenses.js +131 -0
- package/dist/signing.js +2 -2
- package/dist/toolchain.js +3 -0
- package/package.json +12 -2
- package/src/bootstrap.ts +11 -1
- package/src/build.ts +25 -13
- package/src/cli.ts +54 -7
- package/src/flags.ts +18 -0
- package/src/main/ipc.ts +6 -17
- package/src/main/storeFile.ts +113 -0
- package/src/main/window.ts +6 -3
- package/src/manifest.ts +19 -1
- package/src/native.ts +263 -0
- package/src/nativeBundle.ts +215 -0
- package/src/nativeCopy.ts +133 -0
- package/src/nativeLicenses.ts +163 -0
- package/src/signing.ts +2 -2
- package/src/toolchain.ts +3 -0
package/src/manifest.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
import { checkPermission, fullPermissionName } from './mobile/androidManifest.ts';
|
|
14
14
|
|
|
15
15
|
export type WebGpuPolicy = 'prefer' | 'require' | 'off';
|
|
16
|
-
export type Target =
|
|
16
|
+
export type Target =
|
|
17
|
+
'win-x64' | 'mac-arm64' | 'mac-x64' | 'linux-x64' | 'native-linux-x64' | 'android' | 'ios';
|
|
17
18
|
|
|
18
19
|
/** The two that are a phone rather than a desktop, and differ in almost every way that matters. */
|
|
19
20
|
export const MOBILE_TARGETS: readonly Target[] = ['android', 'ios'];
|
|
@@ -24,6 +25,7 @@ const TARGETS: readonly Target[] = [
|
|
|
24
25
|
'mac-arm64',
|
|
25
26
|
'mac-x64',
|
|
26
27
|
'linux-x64',
|
|
28
|
+
'native-linux-x64',
|
|
27
29
|
'android',
|
|
28
30
|
'ios',
|
|
29
31
|
];
|
|
@@ -54,6 +56,11 @@ export interface PackageManifest {
|
|
|
54
56
|
readonly backend: { readonly webgpu: WebGpuPolicy; readonly allowSoftwareRenderer: boolean };
|
|
55
57
|
readonly features: { readonly clipExport: boolean; readonly gamepad: boolean };
|
|
56
58
|
readonly targets: readonly Target[];
|
|
59
|
+
/**
|
|
60
|
+
* The module a native target mounts: it exports `mount(canvas)`, which the native host calls with
|
|
61
|
+
* its canvas, since there is no page there to find one in. Null unless a native target is built.
|
|
62
|
+
*/
|
|
63
|
+
readonly native: { readonly entry: string } | null;
|
|
57
64
|
readonly steam: { readonly appId: number | null };
|
|
58
65
|
readonly splash: { readonly show: boolean; readonly minMs: number };
|
|
59
66
|
/**
|
|
@@ -133,6 +140,16 @@ export function parseManifest(raw: unknown): PackageManifest {
|
|
|
133
140
|
}
|
|
134
141
|
}
|
|
135
142
|
|
|
143
|
+
const nativeRaw = (source.native ?? {}) as Record<string, unknown>;
|
|
144
|
+
const nativeEntry =
|
|
145
|
+
typeof nativeRaw.entry === 'string' && nativeRaw.entry.length > 0 ? nativeRaw.entry : null;
|
|
146
|
+
if ((targetsRaw as unknown[]).includes('native-linux-x64') && nativeEntry === null) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
'drift.package.json: the native-linux-x64 target needs "native.entry", the module whose ' +
|
|
149
|
+
'`mount(canvas)` the native host calls — there is no page there to find a canvas in',
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
136
153
|
const windowRaw = (source.window ?? {}) as Record<string, unknown>;
|
|
137
154
|
const featuresRaw = (source.features ?? {}) as Record<string, unknown>;
|
|
138
155
|
const steamRaw = (source.steam ?? {}) as Record<string, unknown>;
|
|
@@ -175,6 +192,7 @@ export function parseManifest(raw: unknown): PackageManifest {
|
|
|
175
192
|
gamepad: featuresRaw.gamepad !== false,
|
|
176
193
|
},
|
|
177
194
|
targets: targetsRaw as readonly Target[],
|
|
195
|
+
native: nativeEntry === null ? null : { entry: nativeEntry },
|
|
178
196
|
steam: { appId: typeof appId === 'number' ? appId : null },
|
|
179
197
|
publisher:
|
|
180
198
|
typeof source.publisher === 'string' && source.publisher.length > 0
|
package/src/native.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A game packaged for the native host: a Node, the game bundled with the host and the engine, the
|
|
3
|
+
* modules that cannot be bundled, the game's own files, a launcher and the licences, in one archive.
|
|
4
|
+
*
|
|
5
|
+
* <executable>/ what the archive holds, and what a Steam depot uploads
|
|
6
|
+
* <executable> the launcher: the Node beside it, running the game
|
|
7
|
+
* runtime/node the Node that ran this build
|
|
8
|
+
* app/game.mjs the game, the host and the engine, bundled for Node
|
|
9
|
+
* app/modules/ each module the bundle starts by URL: workers, the thread bootstrap
|
|
10
|
+
* app/node_modules/ the modules that cannot be bundled, what they load, and the
|
|
11
|
+
* libraries a licence keeps replaceable
|
|
12
|
+
* app/public/ the game's web build, which its `fetch` of a relative path reads
|
|
13
|
+
* licenses/ Node's licence and every package's, with INDEX.txt
|
|
14
|
+
* <name>-<version>-linux-x64.tar.gz
|
|
15
|
+
*
|
|
16
|
+
* **The runtime is the Node running this build**, as the desktop target ships the Electron that is
|
|
17
|
+
* installed: the artifact is made of what this machine has, so it is built on the machine it is for
|
|
18
|
+
* — Linux on x64 — and the binaries beside the host are this machine's too. **The host comes from
|
|
19
|
+
* the game's own tree**, at the packager's own version, because the host depends on the packager and
|
|
20
|
+
* a range back would be a build cycle; a game that never builds this target never downloads a
|
|
21
|
+
* native binary.
|
|
22
|
+
*
|
|
23
|
+
* **The entry is generated, not written by the game.** A game's native entry exports
|
|
24
|
+
* `mount(canvas)`; the generated one starts the host in the manifest's window, hands the game its
|
|
25
|
+
* files and its store — the file the desktop shell keeps under the same name, so a player moving
|
|
26
|
+
* between the two builds keeps their saves — and prints when the first frame reached the screen
|
|
27
|
+
* under `DRIFT_STARTUP_REPORT=1`, which is how the target's start is measured.
|
|
28
|
+
*
|
|
29
|
+
* **No copyleft library is bundled.** One under the LGPL stays a file of its own, as the Opus
|
|
30
|
+
* decoder's Ogg parser does (`LINKED_LIBRARIES`), with the GPL's text beside it, which that licence
|
|
31
|
+
* asks for; any other that would land in the bundle stops the build, naming it.
|
|
32
|
+
*
|
|
33
|
+
* What it gives up: the game's whole web build is copied to `public/`, its page and scripts
|
|
34
|
+
* included, where the game reads only its assets; and the archive is `.tar.gz`, made by the
|
|
35
|
+
* system's `tar`, with no installer and no desktop entry.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { spawn } from 'node:child_process';
|
|
39
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
40
|
+
import { chmod, copyFile, cp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
41
|
+
import { dirname, join, resolve } from 'node:path';
|
|
42
|
+
import { fileURLToPath } from 'node:url';
|
|
43
|
+
|
|
44
|
+
import { refuseLyingFilesystem } from './identity.ts';
|
|
45
|
+
import type { PackageManifest } from './manifest.ts';
|
|
46
|
+
import { bundleNative, NATIVE_MODULES } from './nativeBundle.ts';
|
|
47
|
+
import { copyPackages, findPackage, packageDirOf, runtimeClosure } from './nativeCopy.ts';
|
|
48
|
+
import { copyleftOf, type FetchText, nodeLicense, writeLicenses } from './nativeLicenses.ts';
|
|
49
|
+
import { consumerVersion } from './stage.ts';
|
|
50
|
+
|
|
51
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
52
|
+
const HOST = '@driftengine/native-host';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What each copied module declares and does not load: the two bindings' install scripts download
|
|
56
|
+
* or build their binary with these, and at run time they load that binary and nothing else.
|
|
57
|
+
*/
|
|
58
|
+
const INSTALL_ONLY: Readonly<Record<string, readonly string[]>> = {
|
|
59
|
+
'@kmamal/gpu': ['node-addon-api', 'node-gyp', 'tar'],
|
|
60
|
+
'@kmamal/sdl': ['tar'],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const LAUNCHER = `#!/bin/sh
|
|
64
|
+
# Starts this game on DriftEngine's native host: the Node in runtime/ runs app/game.mjs.
|
|
65
|
+
here=$(dirname "$(readlink -f "$0")")
|
|
66
|
+
exec "$here/runtime/node" "$here/app/game.mjs" "$@"
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
export interface NativeOptions {
|
|
70
|
+
/** The Node the artifact ships, and its version; the one running this build by default. */
|
|
71
|
+
readonly node?: { readonly path: string; readonly version: string };
|
|
72
|
+
/** How Node's licence is fetched when no copy is on this machine. */
|
|
73
|
+
readonly fetchText?: FetchText;
|
|
74
|
+
/** Package conditions resolved before `default`: `drift-source` bundles the engine's source. */
|
|
75
|
+
readonly conditions?: readonly string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Why this machine cannot build the native target, or null. */
|
|
79
|
+
export function nativeMachineRefusal(platform: string, arch: string): string | null {
|
|
80
|
+
if (platform === 'linux' && arch === 'x64') return null;
|
|
81
|
+
const which = platform === 'linux' ? `on ${arch}` : `on ${platform}`;
|
|
82
|
+
return (
|
|
83
|
+
`native-linux-x64 ships the Node that builds it and the binaries installed beside the host, so ` +
|
|
84
|
+
`it is built on Linux on x64, and this is ${which}.`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The version of this packager, which the host has to match. */
|
|
89
|
+
export function packagerVersion(): string {
|
|
90
|
+
const manifest = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')) as {
|
|
91
|
+
version: string;
|
|
92
|
+
};
|
|
93
|
+
return manifest.version;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The host's directory in the game's tree, at `version`, or a refusal naming the install. */
|
|
97
|
+
export function nativeHost(cwd: string, version: string): string {
|
|
98
|
+
const dir = findPackage(HOST, cwd);
|
|
99
|
+
if (dir === null) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`native-linux-x64 runs the game on ${HOST}, and ${cwd} does not have it. ` +
|
|
102
|
+
`\`npm install ${HOST}@${version}\` there. It is kept out of the packager so that a game ` +
|
|
103
|
+
'that never builds this target never downloads a native binary.',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const found = (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { version: string })
|
|
107
|
+
.version;
|
|
108
|
+
if (found !== version) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`${HOST} is ${found} in ${cwd} and this packager is ${version}; the host is the engine's ` +
|
|
111
|
+
`own code and has to be the same release. \`npm install ${HOST}@${version}\` there.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return dir;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The module the bundle starts from: the host, started on the game at `game`. */
|
|
118
|
+
export function nativeEntrySource(manifest: PackageManifest, host: string, game: string): string {
|
|
119
|
+
const text = JSON.stringify;
|
|
120
|
+
return `import { dirname, join } from 'node:path';
|
|
121
|
+
import { fileURLToPath } from 'node:url';
|
|
122
|
+
import { configDirectory, runGame } from ${text(host)};
|
|
123
|
+
|
|
124
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
125
|
+
await runGame({
|
|
126
|
+
title: ${text(manifest.name)},
|
|
127
|
+
width: ${text(manifest.window.width)},
|
|
128
|
+
height: ${text(manifest.window.height)},
|
|
129
|
+
mode: ${text(manifest.window.mode)},
|
|
130
|
+
resizable: ${text(manifest.window.resizable)},
|
|
131
|
+
publicDir: join(here, 'public'),
|
|
132
|
+
storePath: join(configDirectory(), ${text(manifest.name)}, 'store.json'),
|
|
133
|
+
startupReport: process.env.DRIFT_STARTUP_REPORT === '1',
|
|
134
|
+
load: () => import(${text(game)}),
|
|
135
|
+
});
|
|
136
|
+
`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** What the desktop shell does for this manifest that the native host does not. */
|
|
140
|
+
export function nativeNotes(manifest: PackageManifest): string[] {
|
|
141
|
+
const notes: string[] = [];
|
|
142
|
+
if (manifest.splash.show) {
|
|
143
|
+
notes.push(
|
|
144
|
+
'no engine badge: the desktop shell shows it in a window of its own and a page draws it as ' +
|
|
145
|
+
'markup, and the host draws only the game',
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (manifest.steam.appId !== null) {
|
|
149
|
+
notes.push(
|
|
150
|
+
`steam.appId is ${manifest.steam.appId} and the host has no store: achievements, presence ` +
|
|
151
|
+
'and cloud saves are absent from this build',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return notes;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The launcher's name, and the archive's directory: the id's last label, which is also the name the
|
|
159
|
+
* desktop target's Linux build gives its executable (`stage.ts`). Reverse-DNS keeps it to letters,
|
|
160
|
+
* digits and hyphens.
|
|
161
|
+
*/
|
|
162
|
+
export function executableName(manifest: PackageManifest): string {
|
|
163
|
+
return manifest.id.split('.').pop() ?? 'app';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function fetchText(url: string): Promise<string> {
|
|
167
|
+
const response = await fetch(url);
|
|
168
|
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
169
|
+
return response.text();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The archive, owned by nobody in particular: `--owner=0 --group=0` so it does not carry the
|
|
174
|
+
* builder's account name to every player's machine.
|
|
175
|
+
*/
|
|
176
|
+
function tarGz(dir: string, entry: string, archive: string): Promise<void> {
|
|
177
|
+
const args = ['--owner=0', '--group=0', '--numeric-owner', '-czf', archive, '-C', dir, entry];
|
|
178
|
+
return new Promise((done, fail) => {
|
|
179
|
+
const child = spawn('tar', args, { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
180
|
+
child.on('error', (cause) => fail(new Error(`tar could not be run: ${String(cause)}`)));
|
|
181
|
+
child.on('close', (code) => {
|
|
182
|
+
if (code === 0) done();
|
|
183
|
+
else fail(new Error(`tar ${args.join(' ')} exited ${String(code)}`));
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Build the native artifact for `manifest` into `outDir`, and answer the archive's path. */
|
|
189
|
+
export async function buildNative(
|
|
190
|
+
manifest: PackageManifest,
|
|
191
|
+
cwd: string,
|
|
192
|
+
outDir: string,
|
|
193
|
+
options: NativeOptions = {},
|
|
194
|
+
): Promise<string> {
|
|
195
|
+
const refusal = nativeMachineRefusal(process.platform, process.arch);
|
|
196
|
+
if (refusal !== null) throw new Error(refusal);
|
|
197
|
+
if (manifest.native === null) {
|
|
198
|
+
throw new Error('native-linux-x64 needs "native.entry" in drift.package.json');
|
|
199
|
+
}
|
|
200
|
+
/* Everything that can refuse, before anything is written — the copy's own refusal first, as
|
|
201
|
+
`stageApp` makes it (see `identity.ts`). */
|
|
202
|
+
refuseLyingFilesystem(cwd, outDir);
|
|
203
|
+
const hostDir = nativeHost(cwd, packagerVersion());
|
|
204
|
+
const hosted = NATIVE_MODULES.map((name) => ({ name, from: hostDir }));
|
|
205
|
+
runtimeClosure(hosted, INSTALL_ONLY);
|
|
206
|
+
const node = options.node ?? { path: process.execPath, version: process.version };
|
|
207
|
+
const licence = await nodeLicense(node.path, node.version, options.fetchText ?? fetchText);
|
|
208
|
+
for (const note of nativeNotes(manifest))
|
|
209
|
+
console.warn(`[drift-package] native-linux-x64: ${note}`);
|
|
210
|
+
|
|
211
|
+
const executable = executableName(manifest);
|
|
212
|
+
const root = join(outDir, executable);
|
|
213
|
+
const app = join(root, 'app');
|
|
214
|
+
await rm(root, { recursive: true, force: true });
|
|
215
|
+
await mkdir(app, { recursive: true });
|
|
216
|
+
|
|
217
|
+
const game = resolve(cwd, manifest.native.entry);
|
|
218
|
+
const bundle = await bundleNative(
|
|
219
|
+
nativeEntrySource(manifest, HOST, game),
|
|
220
|
+
app,
|
|
221
|
+
cwd,
|
|
222
|
+
options.conditions,
|
|
223
|
+
);
|
|
224
|
+
/* The game's own package is the game's to license, and is left out of both. */
|
|
225
|
+
const own = realpathSync(cwd);
|
|
226
|
+
const bundled = new Set<string>();
|
|
227
|
+
for (const file of bundle.inputs) {
|
|
228
|
+
const dir = packageDirOf(file);
|
|
229
|
+
if (dir !== null && dir !== own) bundled.add(dir);
|
|
230
|
+
}
|
|
231
|
+
const held = copyleftOf(bundled);
|
|
232
|
+
if (held.length > 0) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`native-linux-x64 would bundle ${held.join(', ')} into one file with the game. A library ` +
|
|
235
|
+
'under such a licence has to stay a file a person can replace, which the native build does ' +
|
|
236
|
+
'for the libraries in LINKED_LIBRARIES (nativeBundle.ts); this one is not among them.',
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
const modules = runtimeClosure([...hosted, ...bundle.linked], INSTALL_ONLY);
|
|
240
|
+
await copyPackages(modules, join(app, 'node_modules'), { platform: 'linux', arch: 'x64' });
|
|
241
|
+
await cp(resolve(cwd, dirname(manifest.entry)), join(app, 'public'), { recursive: true });
|
|
242
|
+
await mkdir(join(root, 'runtime'));
|
|
243
|
+
await copyFile(node.path, join(root, 'runtime', 'node'));
|
|
244
|
+
await writeFile(join(root, executable), LAUNCHER);
|
|
245
|
+
/* After writing rather than as a write option, which the umask would narrow. */
|
|
246
|
+
await chmod(join(root, 'runtime', 'node'), 0o755);
|
|
247
|
+
await chmod(join(root, executable), 0o755);
|
|
248
|
+
|
|
249
|
+
/* The host carries the GPL's text, because the LGPL library it brings in asks for it. */
|
|
250
|
+
const gpl = readFileSync(join(hostDir, 'licenses', 'GPL-3.0.txt'), 'utf8');
|
|
251
|
+
await writeLicenses(
|
|
252
|
+
[...bundled, ...modules.map((found) => found.dir)],
|
|
253
|
+
{ version: node.version, text: licence },
|
|
254
|
+
join(root, 'licenses'),
|
|
255
|
+
gpl,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const archive = join(outDir, `${manifest.name}-${await consumerVersion(cwd)}-linux-x64.tar.gz`);
|
|
259
|
+
await rm(archive, { force: true });
|
|
260
|
+
await tarGz(outDir, executable, archive);
|
|
261
|
+
console.log(`[drift-package] native-linux-x64: ${archive}`);
|
|
262
|
+
return archive;
|
|
263
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A game, the native host and the engine as one module for Node, with every module one of them
|
|
3
|
+
* names by URL bundled beside it.
|
|
4
|
+
*
|
|
5
|
+
* **esbuild, for the reason `stage.ts` gives**: it is already a dependency here and it bundles
|
|
6
|
+
* TypeScript in one call. What it does not do is what a browser bundler does for a page:
|
|
7
|
+
* `new Worker(new URL('./islandWorker.ts', import.meta.url))` is left pointing at a source file that
|
|
8
|
+
* a shipped game does not have. So every `new URL('./x.ts', import.meta.url)` in the game's modules
|
|
9
|
+
* becomes an entry of its own, bundled into `modules/` beside the game, and the reference is
|
|
10
|
+
* rewritten to it — the engine's physics islands and splat sorting, and the host's own thread
|
|
11
|
+
* bootstrap, are three. **A DriftScript module is compiled on the way in**, by the language's own
|
|
12
|
+
* transform resolved from the game's tree, as the Vite plugin compiles it for the browser.
|
|
13
|
+
*
|
|
14
|
+
* **Four modules are left out and copied beside it instead** (`NATIVE_MODULES`): three carry a
|
|
15
|
+
* compiled binary, which cannot be bundled, and the JPEG codec reads its WebAssembly from its own
|
|
16
|
+
* directory. **So is a library whose licence asks that it stay replaceable** (`LINKED_LIBRARIES`),
|
|
17
|
+
* left an import and reported with the directory that imports it, where it is found.
|
|
18
|
+
*
|
|
19
|
+
* What it gives up: a module named by a URL that is not a literal is left alone, and fails where it
|
|
20
|
+
* is loaded — the same limit a browser bundler has.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { type BuildOptions, build as esbuild, type Metafile, type Plugin } from 'esbuild';
|
|
24
|
+
import { realpathSync } from 'node:fs';
|
|
25
|
+
import { readFile } from 'node:fs/promises';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Libraries left outside the bundle for their licence, and copied as files of their own: under the
|
|
31
|
+
* LGPL a work of any licence may use the library so long as a person can put a modified copy in its
|
|
32
|
+
* place, which one bundled file with the game in it does not allow. `codec-parser`, which the Opus
|
|
33
|
+
* decoder reads Ogg pages with, is LGPL-3.0-or-later. Each is found from the package that imports
|
|
34
|
+
* it, as Node will find it.
|
|
35
|
+
*/
|
|
36
|
+
export const LINKED_LIBRARIES: readonly string[] = ['codec-parser'];
|
|
37
|
+
|
|
38
|
+
/** What a native bundle was made from, and what it imports from outside itself by licence. */
|
|
39
|
+
export interface NativeBundle {
|
|
40
|
+
/** Every file the bundles were made from. */
|
|
41
|
+
readonly inputs: Set<string>;
|
|
42
|
+
/** Each linked library, and the directory of the module that imports it. */
|
|
43
|
+
readonly linked: Set<{ readonly name: string; readonly from: string }>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What stays outside the bundle and is copied beside it. */
|
|
47
|
+
export const NATIVE_MODULES = [
|
|
48
|
+
'@kmamal/gpu',
|
|
49
|
+
'@kmamal/sdl',
|
|
50
|
+
'node-web-audio-api',
|
|
51
|
+
'@jsquash/jpeg',
|
|
52
|
+
] as const;
|
|
53
|
+
|
|
54
|
+
const MODULE_URL =
|
|
55
|
+
/new URL\(\s*(['"])(\.{1,2}\/[^'"]+?\.(?:ts|mts|js|mjs))\1\s*,\s*import\.meta\.url\s*\)/g;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Each module `code` names by a URL relative to `file`, renamed to an entry under `prefix` and
|
|
59
|
+
* recorded in `found`, source path to entry name. One module keeps one name however many name it.
|
|
60
|
+
*/
|
|
61
|
+
export function rewriteModuleUrls(
|
|
62
|
+
code: string,
|
|
63
|
+
file: string,
|
|
64
|
+
prefix: string,
|
|
65
|
+
found: Map<string, string>,
|
|
66
|
+
): string {
|
|
67
|
+
return code.replace(MODULE_URL, (_match, _quote: string, relative: string) => {
|
|
68
|
+
const target = resolve(dirname(file), relative);
|
|
69
|
+
let name = found.get(target);
|
|
70
|
+
if (name === undefined) {
|
|
71
|
+
name = `${basename(relative, extname(relative))}-${found.size + 1}.mjs`;
|
|
72
|
+
found.set(target, name);
|
|
73
|
+
}
|
|
74
|
+
return `new URL('${prefix}${name}', import.meta.url)`;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/*
|
|
79
|
+
* A bundle of ESM for Node still carries CommonJS inside it, and esbuild's shim for a `require` of a
|
|
80
|
+
* builtin needs one in scope. The host's own `createRequire(import.meta.url)` calls then resolve the
|
|
81
|
+
* copied native modules from beside the bundle.
|
|
82
|
+
*
|
|
83
|
+
* **Imported under a name nothing bundled uses.** The banner is text esbuild does not read, so it
|
|
84
|
+
* cannot rename around it, and the host imports `createRequire` itself: two declarations of one
|
|
85
|
+
* name, and a bundle Node refused to parse.
|
|
86
|
+
*/
|
|
87
|
+
const REQUIRE =
|
|
88
|
+
"import { createRequire as __driftCreateRequire } from 'node:module';\n" +
|
|
89
|
+
'const require = __driftCreateRequire(import.meta.url);';
|
|
90
|
+
|
|
91
|
+
type Transform = (code: string, path: string) => { code: string } | null;
|
|
92
|
+
|
|
93
|
+
/** The language's own transform, from the game's tree, or a refusal naming the module that asked. */
|
|
94
|
+
async function driftScript(projectDir: string, asker: string): Promise<Transform> {
|
|
95
|
+
let resolved: string;
|
|
96
|
+
try {
|
|
97
|
+
resolved = createRequire(join(projectDir, 'package.json')).resolve('driftscript/vite');
|
|
98
|
+
} catch {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`${asker} is a DriftScript module and driftscript is not installed in ${projectDir}; ` +
|
|
101
|
+
'`npm install driftscript` there, at the version the engine pins',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const { driftScript: plugin } = (await import(resolved)) as {
|
|
105
|
+
driftScript: () => {
|
|
106
|
+
transform: (this: object, code: string, path: string) => { code: string } | null;
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
const made = plugin();
|
|
110
|
+
return (code, path) => made.transform.call({ addWatchFile() {} }, code, path);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const LINKED = new RegExp(`^(${LINKED_LIBRARIES.join('|')})$`);
|
|
114
|
+
|
|
115
|
+
function plugin(
|
|
116
|
+
prefix: string,
|
|
117
|
+
found: Map<string, string>,
|
|
118
|
+
projectDir: string,
|
|
119
|
+
linked: Map<string, { readonly name: string; readonly from: string }>,
|
|
120
|
+
): Plugin {
|
|
121
|
+
let compile: Transform | null = null;
|
|
122
|
+
return {
|
|
123
|
+
name: 'drift-native',
|
|
124
|
+
setup(build) {
|
|
125
|
+
build.onResolve({ filter: LINKED }, (args) => {
|
|
126
|
+
const from = realpathSync(args.resolveDir);
|
|
127
|
+
linked.set(`${args.path}\0${from}`, { name: args.path, from });
|
|
128
|
+
return { path: args.path, external: true };
|
|
129
|
+
});
|
|
130
|
+
build.onLoad({ filter: /\.(ts|mts|js|mjs)$/ }, async (args) => {
|
|
131
|
+
const code = await readFile(args.path, 'utf8');
|
|
132
|
+
if (!code.includes('import.meta.url')) return undefined;
|
|
133
|
+
const contents = rewriteModuleUrls(code, args.path, prefix, found);
|
|
134
|
+
if (contents === code) return undefined;
|
|
135
|
+
const loader = /\.m?ts$/.test(args.path) ? 'ts' : 'js';
|
|
136
|
+
return { contents, loader, resolveDir: dirname(args.path) };
|
|
137
|
+
});
|
|
138
|
+
build.onLoad({ filter: /\.drs$/ }, async (args) => {
|
|
139
|
+
compile ??= await driftScript(projectDir, args.path);
|
|
140
|
+
const compiled = compile(await readFile(args.path, 'utf8'), args.path);
|
|
141
|
+
if (compiled === null) throw new Error(`${args.path} did not compile`);
|
|
142
|
+
return { contents: compiled.code, loader: 'js', resolveDir: dirname(args.path) };
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Where esbuild says an input came from, as a path, or null for one that is not a file. */
|
|
149
|
+
function inputPath(projectDir: string, input: string): string | null {
|
|
150
|
+
return /^[a-z-]+:|^<stdin>$/.test(input) ? null : resolve(projectDir, input);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Bundle the module `source` into `appDir/game.mjs`, and each module it names into
|
|
155
|
+
* `appDir/modules/`. `source` resolves its imports from `projectDir`, the game's own tree, and so
|
|
156
|
+
* does everything it imports; `conditions` are the package conditions resolved before `default`.
|
|
157
|
+
* Answers every file the bundles were made from.
|
|
158
|
+
*/
|
|
159
|
+
export async function bundleNative(
|
|
160
|
+
source: string,
|
|
161
|
+
appDir: string,
|
|
162
|
+
projectDir: string,
|
|
163
|
+
conditions: readonly string[] = [],
|
|
164
|
+
): Promise<NativeBundle> {
|
|
165
|
+
const found = new Map<string, string>();
|
|
166
|
+
const inputs = new Set<string>();
|
|
167
|
+
const linked = new Map<string, { readonly name: string; readonly from: string }>();
|
|
168
|
+
const common: BuildOptions = {
|
|
169
|
+
bundle: true,
|
|
170
|
+
platform: 'node',
|
|
171
|
+
format: 'esm',
|
|
172
|
+
target: 'node22',
|
|
173
|
+
external: [...NATIVE_MODULES],
|
|
174
|
+
conditions: [...conditions],
|
|
175
|
+
banner: { js: REQUIRE },
|
|
176
|
+
absWorkingDir: projectDir,
|
|
177
|
+
metafile: true,
|
|
178
|
+
logLevel: 'warning',
|
|
179
|
+
};
|
|
180
|
+
const record = (metafile: Metafile | undefined): void => {
|
|
181
|
+
for (const input of Object.keys(metafile?.inputs ?? {})) {
|
|
182
|
+
const path = inputPath(projectDir, input);
|
|
183
|
+
if (path !== null) inputs.add(path);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
record(
|
|
187
|
+
(
|
|
188
|
+
await esbuild({
|
|
189
|
+
...common,
|
|
190
|
+
stdin: { contents: source, resolveDir: projectDir, sourcefile: 'entry.mjs', loader: 'js' },
|
|
191
|
+
outfile: join(appDir, 'game.mjs'),
|
|
192
|
+
plugins: [plugin('./modules/', found, projectDir, linked)],
|
|
193
|
+
})
|
|
194
|
+
).metafile,
|
|
195
|
+
);
|
|
196
|
+
/* A module may name others in turn, so this runs until a pass finds nothing new. */
|
|
197
|
+
const built = new Set<string>();
|
|
198
|
+
while (built.size < found.size) {
|
|
199
|
+
for (const [module, name] of [...found]) {
|
|
200
|
+
if (built.has(module)) continue;
|
|
201
|
+
built.add(module);
|
|
202
|
+
record(
|
|
203
|
+
(
|
|
204
|
+
await esbuild({
|
|
205
|
+
...common,
|
|
206
|
+
entryPoints: [module],
|
|
207
|
+
outfile: join(appDir, 'modules', name),
|
|
208
|
+
plugins: [plugin('./', found, projectDir, linked)],
|
|
209
|
+
})
|
|
210
|
+
).metafile,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { inputs, linked: new Set(linked.values()) };
|
|
215
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The modules a native build copies rather than bundles, found as Node would find them and copied
|
|
3
|
+
* with what they load when they run.
|
|
4
|
+
*
|
|
5
|
+
* **Found by walking `node_modules` upwards, not by `require.resolve`**, because a package's
|
|
6
|
+
* directory is what is copied and most of these do not export their `package.json`. That walk is
|
|
7
|
+
* Node's own lookup for a bare name, so a module found here is the module the game would load, and
|
|
8
|
+
* it starts from the real path of whoever asks, as Node does through a workspace link.
|
|
9
|
+
*
|
|
10
|
+
* **Every declared dependency is followed but the ones a module only installs with**, which are
|
|
11
|
+
* named rather than inferred (`installOnly`): the two bindings declare `tar`, `node-gyp` and
|
|
12
|
+
* `node-addon-api` for the script that downloads or builds their binary, and load nothing at run
|
|
13
|
+
* time but that binary. Named rather than skipped wholesale, so a run-time dependency a later version
|
|
14
|
+
* adds is copied, not lost.
|
|
15
|
+
*
|
|
16
|
+
* What it gives up: one directory per name. Two versions of one package in the copy are refused
|
|
17
|
+
* rather than nested, which none of these four has needed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
21
|
+
import { cp } from 'node:fs/promises';
|
|
22
|
+
import { basename, dirname, join, relative, sep } from 'node:path';
|
|
23
|
+
|
|
24
|
+
export interface FoundPackage {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly version: string;
|
|
27
|
+
readonly dir: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function manifestOf(dir: string): Record<string, unknown> | null {
|
|
31
|
+
const path = join(dir, 'package.json');
|
|
32
|
+
if (!existsSync(path)) return null;
|
|
33
|
+
return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The directory of the package `name` as a module in `from` would load it, or null. */
|
|
37
|
+
export function findPackage(name: string, from: string): string | null {
|
|
38
|
+
let dir = existsSync(from) ? realpathSync(from) : from;
|
|
39
|
+
for (;;) {
|
|
40
|
+
const candidate = join(dir, 'node_modules', name);
|
|
41
|
+
if (manifestOf(candidate) !== null) return realpathSync(candidate);
|
|
42
|
+
const up = dirname(dir);
|
|
43
|
+
if (up === dir) return null;
|
|
44
|
+
dir = up;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The package `file` belongs to: the nearest directory above it whose manifest has a name. */
|
|
49
|
+
export function packageDirOf(file: string): string | null {
|
|
50
|
+
let dir = dirname(file);
|
|
51
|
+
for (;;) {
|
|
52
|
+
const manifest = manifestOf(dir);
|
|
53
|
+
if (manifest !== null && typeof manifest.name === 'string') return dir;
|
|
54
|
+
const up = dirname(dir);
|
|
55
|
+
if (up === dir) return null;
|
|
56
|
+
dir = up;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A package to copy, and the directory of the module that loads it, which is where it is found. */
|
|
61
|
+
export interface CopyRequest {
|
|
62
|
+
readonly name: string;
|
|
63
|
+
readonly from: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Each requested package as its importer would load it, and every package they load in turn, once. */
|
|
67
|
+
export function runtimeClosure(
|
|
68
|
+
requests: readonly CopyRequest[],
|
|
69
|
+
installOnly: Readonly<Record<string, readonly string[]>>,
|
|
70
|
+
): FoundPackage[] {
|
|
71
|
+
const found: FoundPackage[] = [];
|
|
72
|
+
const byName = new Map<string, FoundPackage>();
|
|
73
|
+
const visit = (name: string, asker: string, askerDir: string): void => {
|
|
74
|
+
const dir = findPackage(name, askerDir);
|
|
75
|
+
if (dir === null) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`the native build needs "${name}", which ${asker} loads, and it is not installed where ` +
|
|
78
|
+
`${asker} would find it. \`npm install\` in the game, and check it finished.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const manifest = manifestOf(dir) ?? {};
|
|
82
|
+
const version = typeof manifest.version === 'string' ? manifest.version : '0.0.0';
|
|
83
|
+
const seen = byName.get(name);
|
|
84
|
+
if (seen !== undefined) {
|
|
85
|
+
if (seen.dir === dir) return;
|
|
86
|
+
throw new Error(
|
|
87
|
+
`the native build would copy ${name} ${seen.version} and ${version} into one ` +
|
|
88
|
+
`node_modules (${seen.dir} and ${dir}); only one can be there. Align the two ranges.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const entry = { name, version, dir };
|
|
92
|
+
byName.set(name, entry);
|
|
93
|
+
found.push(entry);
|
|
94
|
+
const skip = installOnly[name] ?? [];
|
|
95
|
+
const dependencies = (manifest.dependencies ?? {}) as Record<string, string>;
|
|
96
|
+
for (const dependency of Object.keys(dependencies)) {
|
|
97
|
+
if (!skip.includes(dependency)) visit(dependency, name, dir);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
for (const request of requests) visit(request.name, 'the bundle', request.from);
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A compiled binary's platform, from the name napi-rs and its kind give it:
|
|
106
|
+
* `name.linux-x64-gnu.node`, `name.darwin-arm64.node`. A binary with no such suffix is taken to be
|
|
107
|
+
* this machine's, which is what a binding that builds or downloads its own writes.
|
|
108
|
+
*/
|
|
109
|
+
const TAGGED =
|
|
110
|
+
/\.(darwin|win32|linux|freebsd|android|openbsd|sunos)-([a-z0-9]+)(?:-[a-z0-9]+)?\.node$/;
|
|
111
|
+
|
|
112
|
+
function keeps(path: string, host: { platform: string; arch: string }): boolean {
|
|
113
|
+
const match = TAGGED.exec(basename(path));
|
|
114
|
+
return match === null || (match[1] === host.platform && match[2] === host.arch);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Copy each package into `into/<name>`, leaving its own `node_modules` and other machines' binaries. */
|
|
118
|
+
export async function copyPackages(
|
|
119
|
+
packages: readonly FoundPackage[],
|
|
120
|
+
into: string,
|
|
121
|
+
host: { readonly platform: string; readonly arch: string },
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
for (const found of packages) {
|
|
124
|
+
await cp(found.dir, join(into, found.name), {
|
|
125
|
+
recursive: true,
|
|
126
|
+
filter: (source) => {
|
|
127
|
+
const inside = relative(found.dir, source);
|
|
128
|
+
if (inside.split(sep)[0] === 'node_modules') return false;
|
|
129
|
+
return keeps(source, host);
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|