@driftengine/package 3.63.0 → 4.0.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.
@@ -0,0 +1,67 @@
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
+ import type { PackageManifest } from './manifest.ts';
38
+ import { type FetchText } from './nativeLicenses.ts';
39
+ export interface NativeOptions {
40
+ /** The Node the artifact ships, and its version; the one running this build by default. */
41
+ readonly node?: {
42
+ readonly path: string;
43
+ readonly version: string;
44
+ };
45
+ /** How Node's licence is fetched when no copy is on this machine. */
46
+ readonly fetchText?: FetchText;
47
+ /** Package conditions resolved before `default`: `drift-source` bundles the engine's source. */
48
+ readonly conditions?: readonly string[];
49
+ }
50
+ /** Why this machine cannot build the native target, or null. */
51
+ export declare function nativeMachineRefusal(platform: string, arch: string): string | null;
52
+ /** The version of this packager, which the host has to match. */
53
+ export declare function packagerVersion(): string;
54
+ /** The host's directory in the game's tree, at `version`, or a refusal naming the install. */
55
+ export declare function nativeHost(cwd: string, version: string): string;
56
+ /** The module the bundle starts from: the host, started on the game at `game`. */
57
+ export declare function nativeEntrySource(manifest: PackageManifest, host: string, game: string): string;
58
+ /** What the desktop shell does for this manifest that the native host does not. */
59
+ export declare function nativeNotes(manifest: PackageManifest): string[];
60
+ /**
61
+ * The launcher's name, and the archive's directory: the id's last label, which is also the name the
62
+ * desktop target's Linux build gives its executable (`stage.ts`). Reverse-DNS keeps it to letters,
63
+ * digits and hyphens.
64
+ */
65
+ export declare function executableName(manifest: PackageManifest): string;
66
+ /** Build the native artifact for `manifest` into `outDir`, and answer the archive's path. */
67
+ export declare function buildNative(manifest: PackageManifest, cwd: string, outDir: string, options?: NativeOptions): Promise<string>;
package/dist/native.js ADDED
@@ -0,0 +1,212 @@
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
+ import { spawn } from 'node:child_process';
38
+ import { readFileSync, realpathSync } from 'node:fs';
39
+ import { chmod, copyFile, cp, mkdir, rm, writeFile } from 'node:fs/promises';
40
+ import { dirname, join, resolve } from 'node:path';
41
+ import { fileURLToPath } from 'node:url';
42
+ import { refuseLyingFilesystem } from './identity.js';
43
+ import { bundleNative, NATIVE_MODULES } from './nativeBundle.js';
44
+ import { copyPackages, findPackage, packageDirOf, runtimeClosure } from './nativeCopy.js';
45
+ import { copyleftOf, nodeLicense, writeLicenses } from './nativeLicenses.js';
46
+ import { consumerVersion } from './stage.js';
47
+ const HERE = dirname(fileURLToPath(import.meta.url));
48
+ const HOST = '@driftengine/native-host';
49
+ /**
50
+ * What each copied module declares and does not load: the two bindings' install scripts download
51
+ * or build their binary with these, and at run time they load that binary and nothing else.
52
+ */
53
+ const INSTALL_ONLY = {
54
+ '@kmamal/gpu': ['node-addon-api', 'node-gyp', 'tar'],
55
+ '@kmamal/sdl': ['tar'],
56
+ };
57
+ const LAUNCHER = `#!/bin/sh
58
+ # Starts this game on DriftEngine's native host: the Node in runtime/ runs app/game.mjs.
59
+ here=$(dirname "$(readlink -f "$0")")
60
+ exec "$here/runtime/node" "$here/app/game.mjs" "$@"
61
+ `;
62
+ /** Why this machine cannot build the native target, or null. */
63
+ export function nativeMachineRefusal(platform, arch) {
64
+ if (platform === 'linux' && arch === 'x64')
65
+ return null;
66
+ const which = platform === 'linux' ? `on ${arch}` : `on ${platform}`;
67
+ return (`native-linux-x64 ships the Node that builds it and the binaries installed beside the host, so ` +
68
+ `it is built on Linux on x64, and this is ${which}.`);
69
+ }
70
+ /** The version of this packager, which the host has to match. */
71
+ export function packagerVersion() {
72
+ const manifest = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8'));
73
+ return manifest.version;
74
+ }
75
+ /** The host's directory in the game's tree, at `version`, or a refusal naming the install. */
76
+ export function nativeHost(cwd, version) {
77
+ const dir = findPackage(HOST, cwd);
78
+ if (dir === null) {
79
+ throw new Error(`native-linux-x64 runs the game on ${HOST}, and ${cwd} does not have it. ` +
80
+ `\`npm install ${HOST}@${version}\` there. It is kept out of the packager so that a game ` +
81
+ 'that never builds this target never downloads a native binary.');
82
+ }
83
+ const found = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
84
+ .version;
85
+ if (found !== version) {
86
+ throw new Error(`${HOST} is ${found} in ${cwd} and this packager is ${version}; the host is the engine's ` +
87
+ `own code and has to be the same release. \`npm install ${HOST}@${version}\` there.`);
88
+ }
89
+ return dir;
90
+ }
91
+ /** The module the bundle starts from: the host, started on the game at `game`. */
92
+ export function nativeEntrySource(manifest, host, game) {
93
+ const text = JSON.stringify;
94
+ return `import { dirname, join } from 'node:path';
95
+ import { fileURLToPath } from 'node:url';
96
+ import { configDirectory, runGame } from ${text(host)};
97
+
98
+ const here = dirname(fileURLToPath(import.meta.url));
99
+ await runGame({
100
+ title: ${text(manifest.name)},
101
+ width: ${text(manifest.window.width)},
102
+ height: ${text(manifest.window.height)},
103
+ mode: ${text(manifest.window.mode)},
104
+ resizable: ${text(manifest.window.resizable)},
105
+ publicDir: join(here, 'public'),
106
+ storePath: join(configDirectory(), ${text(manifest.name)}, 'store.json'),
107
+ startupReport: process.env.DRIFT_STARTUP_REPORT === '1',
108
+ load: () => import(${text(game)}),
109
+ });
110
+ `;
111
+ }
112
+ /** What the desktop shell does for this manifest that the native host does not. */
113
+ export function nativeNotes(manifest) {
114
+ const notes = [];
115
+ if (manifest.splash.show) {
116
+ notes.push('no engine badge: the desktop shell shows it in a window of its own and a page draws it as ' +
117
+ 'markup, and the host draws only the game');
118
+ }
119
+ if (manifest.steam.appId !== null) {
120
+ notes.push(`steam.appId is ${manifest.steam.appId} and the host has no store: achievements, presence ` +
121
+ 'and cloud saves are absent from this build');
122
+ }
123
+ return notes;
124
+ }
125
+ /**
126
+ * The launcher's name, and the archive's directory: the id's last label, which is also the name the
127
+ * desktop target's Linux build gives its executable (`stage.ts`). Reverse-DNS keeps it to letters,
128
+ * digits and hyphens.
129
+ */
130
+ export function executableName(manifest) {
131
+ return manifest.id.split('.').pop() ?? 'app';
132
+ }
133
+ async function fetchText(url) {
134
+ const response = await fetch(url);
135
+ if (!response.ok)
136
+ throw new Error(`${response.status} ${response.statusText}`);
137
+ return response.text();
138
+ }
139
+ /**
140
+ * The archive, owned by nobody in particular: `--owner=0 --group=0` so it does not carry the
141
+ * builder's account name to every player's machine.
142
+ */
143
+ function tarGz(dir, entry, archive) {
144
+ const args = ['--owner=0', '--group=0', '--numeric-owner', '-czf', archive, '-C', dir, entry];
145
+ return new Promise((done, fail) => {
146
+ const child = spawn('tar', args, { stdio: ['ignore', 'inherit', 'inherit'] });
147
+ child.on('error', (cause) => fail(new Error(`tar could not be run: ${String(cause)}`)));
148
+ child.on('close', (code) => {
149
+ if (code === 0)
150
+ done();
151
+ else
152
+ fail(new Error(`tar ${args.join(' ')} exited ${String(code)}`));
153
+ });
154
+ });
155
+ }
156
+ /** Build the native artifact for `manifest` into `outDir`, and answer the archive's path. */
157
+ export async function buildNative(manifest, cwd, outDir, options = {}) {
158
+ const refusal = nativeMachineRefusal(process.platform, process.arch);
159
+ if (refusal !== null)
160
+ throw new Error(refusal);
161
+ if (manifest.native === null) {
162
+ throw new Error('native-linux-x64 needs "native.entry" in drift.package.json');
163
+ }
164
+ /* Everything that can refuse, before anything is written — the copy's own refusal first, as
165
+ `stageApp` makes it (see `identity.ts`). */
166
+ refuseLyingFilesystem(cwd, outDir);
167
+ const hostDir = nativeHost(cwd, packagerVersion());
168
+ const hosted = NATIVE_MODULES.map((name) => ({ name, from: hostDir }));
169
+ runtimeClosure(hosted, INSTALL_ONLY);
170
+ const node = options.node ?? { path: process.execPath, version: process.version };
171
+ const licence = await nodeLicense(node.path, node.version, options.fetchText ?? fetchText);
172
+ for (const note of nativeNotes(manifest))
173
+ console.warn(`[drift-package] native-linux-x64: ${note}`);
174
+ const executable = executableName(manifest);
175
+ const root = join(outDir, executable);
176
+ const app = join(root, 'app');
177
+ await rm(root, { recursive: true, force: true });
178
+ await mkdir(app, { recursive: true });
179
+ const game = resolve(cwd, manifest.native.entry);
180
+ const bundle = await bundleNative(nativeEntrySource(manifest, HOST, game), app, cwd, options.conditions);
181
+ /* The game's own package is the game's to license, and is left out of both. */
182
+ const own = realpathSync(cwd);
183
+ const bundled = new Set();
184
+ for (const file of bundle.inputs) {
185
+ const dir = packageDirOf(file);
186
+ if (dir !== null && dir !== own)
187
+ bundled.add(dir);
188
+ }
189
+ const held = copyleftOf(bundled);
190
+ if (held.length > 0) {
191
+ throw new Error(`native-linux-x64 would bundle ${held.join(', ')} into one file with the game. A library ` +
192
+ 'under such a licence has to stay a file a person can replace, which the native build does ' +
193
+ 'for the libraries in LINKED_LIBRARIES (nativeBundle.ts); this one is not among them.');
194
+ }
195
+ const modules = runtimeClosure([...hosted, ...bundle.linked], INSTALL_ONLY);
196
+ await copyPackages(modules, join(app, 'node_modules'), { platform: 'linux', arch: 'x64' });
197
+ await cp(resolve(cwd, dirname(manifest.entry)), join(app, 'public'), { recursive: true });
198
+ await mkdir(join(root, 'runtime'));
199
+ await copyFile(node.path, join(root, 'runtime', 'node'));
200
+ await writeFile(join(root, executable), LAUNCHER);
201
+ /* After writing rather than as a write option, which the umask would narrow. */
202
+ await chmod(join(root, 'runtime', 'node'), 0o755);
203
+ await chmod(join(root, executable), 0o755);
204
+ /* The host carries the GPL's text, because the LGPL library it brings in asks for it. */
205
+ const gpl = readFileSync(join(hostDir, 'licenses', 'GPL-3.0.txt'), 'utf8');
206
+ await writeLicenses([...bundled, ...modules.map((found) => found.dir)], { version: node.version, text: licence }, join(root, 'licenses'), gpl);
207
+ const archive = join(outDir, `${manifest.name}-${await consumerVersion(cwd)}-linux-x64.tar.gz`);
208
+ await rm(archive, { force: true });
209
+ await tarGz(outDir, executable, archive);
210
+ console.log(`[drift-package] native-linux-x64: ${archive}`);
211
+ return archive;
212
+ }
@@ -0,0 +1,53 @@
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
+ * Libraries left outside the bundle for their licence, and copied as files of their own: under the
24
+ * LGPL a work of any licence may use the library so long as a person can put a modified copy in its
25
+ * place, which one bundled file with the game in it does not allow. `codec-parser`, which the Opus
26
+ * decoder reads Ogg pages with, is LGPL-3.0-or-later. Each is found from the package that imports
27
+ * it, as Node will find it.
28
+ */
29
+ export declare const LINKED_LIBRARIES: readonly string[];
30
+ /** What a native bundle was made from, and what it imports from outside itself by licence. */
31
+ export interface NativeBundle {
32
+ /** Every file the bundles were made from. */
33
+ readonly inputs: Set<string>;
34
+ /** Each linked library, and the directory of the module that imports it. */
35
+ readonly linked: Set<{
36
+ readonly name: string;
37
+ readonly from: string;
38
+ }>;
39
+ }
40
+ /** What stays outside the bundle and is copied beside it. */
41
+ export declare const NATIVE_MODULES: readonly ['@kmamal/gpu', '@kmamal/sdl', 'node-web-audio-api', '@jsquash/jpeg'];
42
+ /**
43
+ * Each module `code` names by a URL relative to `file`, renamed to an entry under `prefix` and
44
+ * recorded in `found`, source path to entry name. One module keeps one name however many name it.
45
+ */
46
+ export declare function rewriteModuleUrls(code: string, file: string, prefix: string, found: Map<string, string>): string;
47
+ /**
48
+ * Bundle the module `source` into `appDir/game.mjs`, and each module it names into
49
+ * `appDir/modules/`. `source` resolves its imports from `projectDir`, the game's own tree, and so
50
+ * does everything it imports; `conditions` are the package conditions resolved before `default`.
51
+ * Answers every file the bundles were made from.
52
+ */
53
+ export declare function bundleNative(source: string, appDir: string, projectDir: string, conditions?: readonly string[]): Promise<NativeBundle>;
@@ -0,0 +1,177 @@
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
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
23
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
24
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
25
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
26
+ });
27
+ }
28
+ return path;
29
+ };
30
+ import { build as esbuild } from 'esbuild';
31
+ import { realpathSync } from 'node:fs';
32
+ import { readFile } from 'node:fs/promises';
33
+ import { createRequire } from 'node:module';
34
+ import { basename, dirname, extname, join, resolve } from 'node:path';
35
+ /**
36
+ * Libraries left outside the bundle for their licence, and copied as files of their own: under the
37
+ * LGPL a work of any licence may use the library so long as a person can put a modified copy in its
38
+ * place, which one bundled file with the game in it does not allow. `codec-parser`, which the Opus
39
+ * decoder reads Ogg pages with, is LGPL-3.0-or-later. Each is found from the package that imports
40
+ * it, as Node will find it.
41
+ */
42
+ export const LINKED_LIBRARIES = ['codec-parser'];
43
+ /** What stays outside the bundle and is copied beside it. */
44
+ export const NATIVE_MODULES = [
45
+ '@kmamal/gpu',
46
+ '@kmamal/sdl',
47
+ 'node-web-audio-api',
48
+ '@jsquash/jpeg',
49
+ ];
50
+ const MODULE_URL = /new URL\(\s*(['"])(\.{1,2}\/[^'"]+?\.(?:ts|mts|js|mjs))\1\s*,\s*import\.meta\.url\s*\)/g;
51
+ /**
52
+ * Each module `code` names by a URL relative to `file`, renamed to an entry under `prefix` and
53
+ * recorded in `found`, source path to entry name. One module keeps one name however many name it.
54
+ */
55
+ export function rewriteModuleUrls(code, file, prefix, found) {
56
+ return code.replace(MODULE_URL, (_match, _quote, relative) => {
57
+ const target = resolve(dirname(file), relative);
58
+ let name = found.get(target);
59
+ if (name === undefined) {
60
+ name = `${basename(relative, extname(relative))}-${found.size + 1}.mjs`;
61
+ found.set(target, name);
62
+ }
63
+ return `new URL('${prefix}${name}', import.meta.url)`;
64
+ });
65
+ }
66
+ /*
67
+ * A bundle of ESM for Node still carries CommonJS inside it, and esbuild's shim for a `require` of a
68
+ * builtin needs one in scope. The host's own `createRequire(import.meta.url)` calls then resolve the
69
+ * copied native modules from beside the bundle.
70
+ *
71
+ * **Imported under a name nothing bundled uses.** The banner is text esbuild does not read, so it
72
+ * cannot rename around it, and the host imports `createRequire` itself: two declarations of one
73
+ * name, and a bundle Node refused to parse.
74
+ */
75
+ const REQUIRE = "import { createRequire as __driftCreateRequire } from 'node:module';\n" +
76
+ 'const require = __driftCreateRequire(import.meta.url);';
77
+ /** The language's own transform, from the game's tree, or a refusal naming the module that asked. */
78
+ async function driftScript(projectDir, asker) {
79
+ let resolved;
80
+ try {
81
+ resolved = createRequire(join(projectDir, 'package.json')).resolve('driftscript/vite');
82
+ }
83
+ catch {
84
+ throw new Error(`${asker} is a DriftScript module and driftscript is not installed in ${projectDir}; ` +
85
+ '`npm install driftscript` there, at the version the engine pins');
86
+ }
87
+ const { driftScript: plugin } = (await import(__rewriteRelativeImportExtension(resolved)));
88
+ const made = plugin();
89
+ return (code, path) => made.transform.call({ addWatchFile() { } }, code, path);
90
+ }
91
+ const LINKED = new RegExp(`^(${LINKED_LIBRARIES.join('|')})$`);
92
+ function plugin(prefix, found, projectDir, linked) {
93
+ let compile = null;
94
+ return {
95
+ name: 'drift-native',
96
+ setup(build) {
97
+ build.onResolve({ filter: LINKED }, (args) => {
98
+ const from = realpathSync(args.resolveDir);
99
+ linked.set(`${args.path}\0${from}`, { name: args.path, from });
100
+ return { path: args.path, external: true };
101
+ });
102
+ build.onLoad({ filter: /\.(ts|mts|js|mjs)$/ }, async (args) => {
103
+ const code = await readFile(args.path, 'utf8');
104
+ if (!code.includes('import.meta.url'))
105
+ return undefined;
106
+ const contents = rewriteModuleUrls(code, args.path, prefix, found);
107
+ if (contents === code)
108
+ return undefined;
109
+ const loader = /\.m?ts$/.test(args.path) ? 'ts' : 'js';
110
+ return { contents, loader, resolveDir: dirname(args.path) };
111
+ });
112
+ build.onLoad({ filter: /\.drs$/ }, async (args) => {
113
+ compile ??= await driftScript(projectDir, args.path);
114
+ const compiled = compile(await readFile(args.path, 'utf8'), args.path);
115
+ if (compiled === null)
116
+ throw new Error(`${args.path} did not compile`);
117
+ return { contents: compiled.code, loader: 'js', resolveDir: dirname(args.path) };
118
+ });
119
+ },
120
+ };
121
+ }
122
+ /** Where esbuild says an input came from, as a path, or null for one that is not a file. */
123
+ function inputPath(projectDir, input) {
124
+ return /^[a-z-]+:|^<stdin>$/.test(input) ? null : resolve(projectDir, input);
125
+ }
126
+ /**
127
+ * Bundle the module `source` into `appDir/game.mjs`, and each module it names into
128
+ * `appDir/modules/`. `source` resolves its imports from `projectDir`, the game's own tree, and so
129
+ * does everything it imports; `conditions` are the package conditions resolved before `default`.
130
+ * Answers every file the bundles were made from.
131
+ */
132
+ export async function bundleNative(source, appDir, projectDir, conditions = []) {
133
+ const found = new Map();
134
+ const inputs = new Set();
135
+ const linked = new Map();
136
+ const common = {
137
+ bundle: true,
138
+ platform: 'node',
139
+ format: 'esm',
140
+ target: 'node22',
141
+ external: [...NATIVE_MODULES],
142
+ conditions: [...conditions],
143
+ banner: { js: REQUIRE },
144
+ absWorkingDir: projectDir,
145
+ metafile: true,
146
+ logLevel: 'warning',
147
+ };
148
+ const record = (metafile) => {
149
+ for (const input of Object.keys(metafile?.inputs ?? {})) {
150
+ const path = inputPath(projectDir, input);
151
+ if (path !== null)
152
+ inputs.add(path);
153
+ }
154
+ };
155
+ record((await esbuild({
156
+ ...common,
157
+ stdin: { contents: source, resolveDir: projectDir, sourcefile: 'entry.mjs', loader: 'js' },
158
+ outfile: join(appDir, 'game.mjs'),
159
+ plugins: [plugin('./modules/', found, projectDir, linked)],
160
+ })).metafile);
161
+ /* A module may name others in turn, so this runs until a pass finds nothing new. */
162
+ const built = new Set();
163
+ while (built.size < found.size) {
164
+ for (const [module, name] of [...found]) {
165
+ if (built.has(module))
166
+ continue;
167
+ built.add(module);
168
+ record((await esbuild({
169
+ ...common,
170
+ entryPoints: [module],
171
+ outfile: join(appDir, 'modules', name),
172
+ plugins: [plugin('./', found, projectDir, linked)],
173
+ })).metafile);
174
+ }
175
+ }
176
+ return { inputs, linked: new Set(linked.values()) };
177
+ }
@@ -0,0 +1,39 @@
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
+ export interface FoundPackage {
20
+ readonly name: string;
21
+ readonly version: string;
22
+ readonly dir: string;
23
+ }
24
+ /** The directory of the package `name` as a module in `from` would load it, or null. */
25
+ export declare function findPackage(name: string, from: string): string | null;
26
+ /** The package `file` belongs to: the nearest directory above it whose manifest has a name. */
27
+ export declare function packageDirOf(file: string): string | null;
28
+ /** A package to copy, and the directory of the module that loads it, which is where it is found. */
29
+ export interface CopyRequest {
30
+ readonly name: string;
31
+ readonly from: string;
32
+ }
33
+ /** Each requested package as its importer would load it, and every package they load in turn, once. */
34
+ export declare function runtimeClosure(requests: readonly CopyRequest[], installOnly: Readonly<Record<string, readonly string[]>>): FoundPackage[];
35
+ /** Copy each package into `into/<name>`, leaving its own `node_modules` and other machines' binaries. */
36
+ export declare function copyPackages(packages: readonly FoundPackage[], into: string, host: {
37
+ readonly platform: string;
38
+ readonly arch: string;
39
+ }): Promise<void>;
@@ -0,0 +1,110 @@
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
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
20
+ import { cp } from 'node:fs/promises';
21
+ import { basename, dirname, join, relative, sep } from 'node:path';
22
+ function manifestOf(dir) {
23
+ const path = join(dir, 'package.json');
24
+ if (!existsSync(path))
25
+ return null;
26
+ return JSON.parse(readFileSync(path, 'utf8'));
27
+ }
28
+ /** The directory of the package `name` as a module in `from` would load it, or null. */
29
+ export function findPackage(name, from) {
30
+ let dir = existsSync(from) ? realpathSync(from) : from;
31
+ for (;;) {
32
+ const candidate = join(dir, 'node_modules', name);
33
+ if (manifestOf(candidate) !== null)
34
+ return realpathSync(candidate);
35
+ const up = dirname(dir);
36
+ if (up === dir)
37
+ return null;
38
+ dir = up;
39
+ }
40
+ }
41
+ /** The package `file` belongs to: the nearest directory above it whose manifest has a name. */
42
+ export function packageDirOf(file) {
43
+ let dir = dirname(file);
44
+ for (;;) {
45
+ const manifest = manifestOf(dir);
46
+ if (manifest !== null && typeof manifest.name === 'string')
47
+ return dir;
48
+ const up = dirname(dir);
49
+ if (up === dir)
50
+ return null;
51
+ dir = up;
52
+ }
53
+ }
54
+ /** Each requested package as its importer would load it, and every package they load in turn, once. */
55
+ export function runtimeClosure(requests, installOnly) {
56
+ const found = [];
57
+ const byName = new Map();
58
+ const visit = (name, asker, askerDir) => {
59
+ const dir = findPackage(name, askerDir);
60
+ if (dir === null) {
61
+ throw new Error(`the native build needs "${name}", which ${asker} loads, and it is not installed where ` +
62
+ `${asker} would find it. \`npm install\` in the game, and check it finished.`);
63
+ }
64
+ const manifest = manifestOf(dir) ?? {};
65
+ const version = typeof manifest.version === 'string' ? manifest.version : '0.0.0';
66
+ const seen = byName.get(name);
67
+ if (seen !== undefined) {
68
+ if (seen.dir === dir)
69
+ return;
70
+ throw new Error(`the native build would copy ${name} ${seen.version} and ${version} into one ` +
71
+ `node_modules (${seen.dir} and ${dir}); only one can be there. Align the two ranges.`);
72
+ }
73
+ const entry = { name, version, dir };
74
+ byName.set(name, entry);
75
+ found.push(entry);
76
+ const skip = installOnly[name] ?? [];
77
+ const dependencies = (manifest.dependencies ?? {});
78
+ for (const dependency of Object.keys(dependencies)) {
79
+ if (!skip.includes(dependency))
80
+ visit(dependency, name, dir);
81
+ }
82
+ };
83
+ for (const request of requests)
84
+ visit(request.name, 'the bundle', request.from);
85
+ return found;
86
+ }
87
+ /**
88
+ * A compiled binary's platform, from the name napi-rs and its kind give it:
89
+ * `name.linux-x64-gnu.node`, `name.darwin-arm64.node`. A binary with no such suffix is taken to be
90
+ * this machine's, which is what a binding that builds or downloads its own writes.
91
+ */
92
+ const TAGGED = /\.(darwin|win32|linux|freebsd|android|openbsd|sunos)-([a-z0-9]+)(?:-[a-z0-9]+)?\.node$/;
93
+ function keeps(path, host) {
94
+ const match = TAGGED.exec(basename(path));
95
+ return match === null || (match[1] === host.platform && match[2] === host.arch);
96
+ }
97
+ /** Copy each package into `into/<name>`, leaving its own `node_modules` and other machines' binaries. */
98
+ export async function copyPackages(packages, into, host) {
99
+ for (const found of packages) {
100
+ await cp(found.dir, join(into, found.name), {
101
+ recursive: true,
102
+ filter: (source) => {
103
+ const inside = relative(found.dir, source);
104
+ if (inside.split(sep)[0] === 'node_modules')
105
+ return false;
106
+ return keeps(source, host);
107
+ },
108
+ });
109
+ }
110
+ }