@aethervtt/shard-node 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Dias
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # @aethervtt/shard-node
2
+
3
+ Part of [Shard](https://github.com/aethervtt/shard), a WebGPU-first 2D/3D game engine in TypeScript. See the repository for documentation.
@@ -0,0 +1,53 @@
1
+ import { ShardError } from '@aethervtt/shard-core';
2
+ import { type SourceMap } from '@aethervtt/shard-project';
3
+ export interface BuiltBundle {
4
+ /** Content hash of the bundle. */
5
+ hash: string;
6
+ /** Absolute path of the written file. */
7
+ file: string;
8
+ /** `file://` URL, for `import()`. */
9
+ url: string;
10
+ code: string;
11
+ map: SourceMap | undefined;
12
+ ms: number;
13
+ /** Inputs and their imports (esbuild's metafile): generators' code hashes come from it. */
14
+ graph: {
15
+ inputs: Record<string, {
16
+ imports: {
17
+ path: string;
18
+ external?: boolean;
19
+ }[];
20
+ }>;
21
+ };
22
+ }
23
+ export interface BundlerOptions {
24
+ /** Project folder. */
25
+ root: string;
26
+ /** Entry module, project-relative (the manifest's `entry`). */
27
+ entry: string;
28
+ /** Where bundles are written. Default `.shard/build`. */
29
+ outDir?: string;
30
+ }
31
+ /**
32
+ * Bundles a project's entry into one ESM file with `@aethervtt/shard-*` left external, so the engine's module
33
+ * instances are shared. Keeps an incremental esbuild context for fast rebuilds.
34
+ */
35
+ export declare function createBundler(options: BundlerOptions): Promise<{
36
+ root: string;
37
+ readonly last: BuiltBundle | undefined;
38
+ /**
39
+ * A URL for importing a bundle that no module cache has seen. Identical code rebuilds to the same
40
+ * file (content hash), and without this a revert would import a stale, earlier module instance.
41
+ */
42
+ importUrl(bundle: BuiltBundle): string;
43
+ build: () => Promise<BuiltBundle>;
44
+ /**
45
+ * Rebuilds (debounced) when anything under the entry's folder changes. `onBuild` gets the bundle
46
+ * or the error. Returns a stop function.
47
+ */
48
+ watch(onBuild: (result: BuiltBundle | ShardError) => void, debounceMs?: number): () => void;
49
+ /** Removes bundles other than the latest from the output folder. */
50
+ prune(): Promise<void>;
51
+ dispose: () => Promise<void>;
52
+ }>;
53
+ export type Bundler = Awaited<ReturnType<typeof createBundler>>;
package/dist/bundle.js ADDED
@@ -0,0 +1,137 @@
1
+ import { createHash } from "node:crypto";
2
+ import { watch as fsWatch } from "node:fs";
3
+ import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
4
+ import { builtinModules } from "node:module";
5
+ import { dirname, join, relative, resolve } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import { ShardError } from "@aethervtt/shard-core";
8
+ import { inlineSourceMap } from "@aethervtt/shard-project";
9
+ import * as esbuild from "esbuild";
10
+ const BUILTINS = new Set(builtinModules.flatMap((m) => [m, `node:${m}`]));
11
+ const noNodeBuiltins = {
12
+ name: "shard-no-node-builtins",
13
+ setup(build) {
14
+ build.onResolve({ filter: /^[a-z:/_]+$/ }, (args) => {
15
+ if (!BUILTINS.has(args.path)) return void 0;
16
+ return {
17
+ errors: [
18
+ {
19
+ text: `"${args.path}" is a Node built-in; project code must run in browsers too`,
20
+ detail: "project/node-builtin"
21
+ }
22
+ ]
23
+ };
24
+ });
25
+ }
26
+ };
27
+ function bundleError(root, failure) {
28
+ const first = failure.errors?.[0];
29
+ if (!first) {
30
+ return new ShardError("project/bundle-failed", failure.message, { cause: failure });
31
+ }
32
+ const code = first.detail === "project/node-builtin" ? "project/node-builtin" : "project/bundle-failed";
33
+ const loc = first.location;
34
+ const file = loc ? relative(root, resolve(root, loc.file)).split("\\").join("/") : void 0;
35
+ const source = loc ? `${file}:${loc.line}:${loc.column + 1}` : void 0;
36
+ const err = new ShardError(code, source ? `${source}: ${first.text}` : first.text, {
37
+ path: file,
38
+ hint: code === "project/node-builtin" ? "Use @aethervtt/shard-platform services (files, storage) instead of Node APIs." : "Fix the error and save; the last good code keeps running.",
39
+ cause: failure
40
+ });
41
+ return Object.assign(err, { source });
42
+ }
43
+ async function createBundler(options) {
44
+ const root = resolve(options.root);
45
+ const outDir = resolve(root, options.outDir ?? ".shard/build");
46
+ let last;
47
+ const context = await esbuild.context({
48
+ absWorkingDir: root,
49
+ entryPoints: [resolve(root, options.entry)],
50
+ // Only names the output so source-map paths are relative to it; nothing is written by esbuild.
51
+ outfile: join(outDir, "main.mjs"),
52
+ bundle: true,
53
+ format: "esm",
54
+ platform: "browser",
55
+ target: "es2023",
56
+ sourcemap: "inline",
57
+ sourcesContent: false,
58
+ external: ["@aethervtt/shard-*"],
59
+ write: false,
60
+ metafile: true,
61
+ logLevel: "silent",
62
+ plugins: [noNodeBuiltins]
63
+ });
64
+ const build = async () => {
65
+ const start = performance.now();
66
+ let result;
67
+ try {
68
+ result = await context.rebuild();
69
+ } catch (err) {
70
+ throw bundleError(root, err);
71
+ }
72
+ const code = result.outputFiles[0].text;
73
+ const hash = createHash("sha256").update(code).digest("hex").slice(0, 16);
74
+ const file = join(outDir, `main.${hash}.mjs`);
75
+ if (last?.hash !== hash) {
76
+ await mkdir(outDir, { recursive: true });
77
+ await writeFile(file, code);
78
+ }
79
+ last = {
80
+ hash,
81
+ file,
82
+ url: pathToFileURL(file).href,
83
+ code,
84
+ map: inlineSourceMap(code),
85
+ ms: performance.now() - start,
86
+ graph: result.metafile
87
+ };
88
+ return last;
89
+ };
90
+ let imports = 0;
91
+ return {
92
+ root,
93
+ get last() {
94
+ return last;
95
+ },
96
+ /**
97
+ * A URL for importing a bundle that no module cache has seen. Identical code rebuilds to the same
98
+ * file (content hash), and without this a revert would import a stale, earlier module instance.
99
+ */
100
+ importUrl(bundle) {
101
+ return `${bundle.url}?r=${++imports}`;
102
+ },
103
+ build,
104
+ /**
105
+ * Rebuilds (debounced) when anything under the entry's folder changes. `onBuild` gets the bundle
106
+ * or the error. Returns a stop function.
107
+ */
108
+ watch(onBuild, debounceMs = 50) {
109
+ const dir = dirname(resolve(root, options.entry));
110
+ let timer;
111
+ const watcher = fsWatch(dir, { recursive: true }, () => {
112
+ if (timer) clearTimeout(timer);
113
+ timer = setTimeout(() => {
114
+ timer = void 0;
115
+ build().then(onBuild, (err) => onBuild(err));
116
+ }, debounceMs);
117
+ });
118
+ return () => {
119
+ if (timer) clearTimeout(timer);
120
+ watcher.close();
121
+ };
122
+ },
123
+ /** Removes bundles other than the latest from the output folder. */
124
+ async prune() {
125
+ const files = await readdir(outDir).catch(() => []);
126
+ await Promise.all(
127
+ files.filter(
128
+ (f) => f.startsWith("main.") && f.endsWith(".mjs") && join(outDir, f) !== last?.file
129
+ ).map((f) => rm(join(outDir, f), { force: true }))
130
+ );
131
+ },
132
+ dispose: () => context.dispose()
133
+ };
134
+ }
135
+ export {
136
+ createBundler
137
+ };
@@ -0,0 +1,53 @@
1
+ import type { Workers } from '@aethervtt/shard-platform';
2
+ /** What an esbuild metafile says about inputs: each file and what it imports. */
3
+ export interface ModuleGraph {
4
+ inputs: Record<string, {
5
+ imports: readonly {
6
+ path: string;
7
+ external?: boolean;
8
+ }[];
9
+ }>;
10
+ }
11
+ /**
12
+ * Each generator's code hash: the hash of the module that defines it and every project module it
13
+ * imports (transitively), from the bundler's module graph. The entry and the module that calls
14
+ * `defineProject` are left out (with what only they import), so editing game systems there never
15
+ * regenerates outputs. Keys are generator names (`<namespace>/<Name>`).
16
+ */
17
+ export declare function generatorCodeHashes(root: string, namespace: string, entry: string, graph: ModuleGraph): Promise<Record<string, string>>;
18
+ /** The module graph of a project's code (engine packages external), without writing anything. */
19
+ export declare function projectModuleGraph(root: string, entry: string): Promise<ModuleGraph>;
20
+ export interface GeneratorWorkerBundle {
21
+ /** `file://` URL of the module, for `configureProcgenHost({ workerModule })`. */
22
+ url: string;
23
+ file: string;
24
+ hash: string;
25
+ ms: number;
26
+ }
27
+ /**
28
+ * Bundles the project's code with `@aethervtt/shard-procgen/worker` into one self-contained module (the
29
+ * engine inlined, so a worker thread loads it with no resolver or loader): what generator jobs run
30
+ * on the worker pool. Written to `.shard/build/procgen-worker.<hash>.mjs`.
31
+ */
32
+ export declare function buildGeneratorWorker(options: {
33
+ root: string;
34
+ entry: string;
35
+ outDir?: string;
36
+ }): Promise<GeneratorWorkerBundle>;
37
+ export declare function prepareGenerators(options: {
38
+ root: string;
39
+ namespace: string;
40
+ entry: string;
41
+ workers?: Workers;
42
+ graph?: ModuleGraph;
43
+ /** Build the worker bundle (default true); false runs jobs inline. */
44
+ worker?: boolean;
45
+ /**
46
+ * Resolve once the code hashes are set, building the worker bundle behind (hot reloads): jobs
47
+ * wait for the new bundle, and `worker` in the result is a promise.
48
+ */
49
+ background?: boolean;
50
+ }): Promise<{
51
+ codeHashes: Record<string, string>;
52
+ worker: GeneratorWorkerBundle | Promise<GeneratorWorkerBundle> | undefined;
53
+ }>;
@@ -0,0 +1,161 @@
1
+ import { createHash } from "node:crypto";
2
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { ShardError } from "@aethervtt/shard-core";
7
+ import { configureProcgenHost, setGeneratorCodeHashes } from "@aethervtt/shard-procgen";
8
+ import * as esbuild from "esbuild";
9
+ const PROJECT_GENERATOR = /\.generator\(\s*['"]([A-Za-z][A-Za-z0-9]*)['"]/g;
10
+ const ENGINE_GENERATOR = /\bdefineGenerator\(\s*['"]([a-z][a-z0-9-]*\/[A-Za-z][A-Za-z0-9]*)['"]/g;
11
+ async function generatorCodeHashes(root, namespace, entry, graph) {
12
+ const scriptsDir = relative(root, dirname(resolve(root, entry))).split("\\").join("/");
13
+ const own = (p) => !p.includes("node_modules/") && !p.startsWith("..") && (scriptsDir === "" || p.startsWith(`${scriptsDir}/`));
14
+ const files = Object.keys(graph.inputs).filter(own);
15
+ const text = /* @__PURE__ */ new Map();
16
+ await Promise.all(files.map(async (f) => text.set(f, await readFile(resolve(root, f), "utf8"))));
17
+ const entryPath = relative(root, resolve(root, entry)).split("\\").join("/");
18
+ const excluded = new Set(
19
+ files.filter((f) => f === entryPath || /\bdefineProject\s*\(/.test(text.get(f)))
20
+ );
21
+ const out = {};
22
+ for (const file of files) {
23
+ const source = text.get(file);
24
+ const names = [];
25
+ for (const m of source.matchAll(PROJECT_GENERATOR)) names.push(`${namespace}/${m[1]}`);
26
+ for (const m of source.matchAll(ENGINE_GENERATOR)) names.push(m[1]);
27
+ if (names.length === 0) continue;
28
+ const seen = /* @__PURE__ */ new Set([file]);
29
+ const stack = [file];
30
+ while (stack.length > 0) {
31
+ const at = stack.pop();
32
+ for (const imp of graph.inputs[at]?.imports ?? []) {
33
+ if (imp.external || !own(imp.path) || seen.has(imp.path) || excluded.has(imp.path)) continue;
34
+ seen.add(imp.path);
35
+ stack.push(imp.path);
36
+ }
37
+ }
38
+ const hash = createHash("sha256");
39
+ for (const f of [...seen].sort()) hash.update(`${f}
40
+ ${text.get(f)}
41
+ `);
42
+ const digest = hash.digest("hex").slice(0, 16);
43
+ for (const name of names) out[name] = digest;
44
+ }
45
+ return out;
46
+ }
47
+ async function projectModuleGraph(root, entry) {
48
+ const result = await esbuild.build({
49
+ absWorkingDir: resolve(root),
50
+ entryPoints: [resolve(root, entry)],
51
+ bundle: true,
52
+ format: "esm",
53
+ platform: "browser",
54
+ external: ["@aethervtt/shard-*", "node:*"],
55
+ write: false,
56
+ metafile: true,
57
+ logLevel: "silent"
58
+ });
59
+ return result.metafile;
60
+ }
61
+ async function buildGeneratorWorker(options) {
62
+ const start = performance.now();
63
+ const root = resolve(options.root);
64
+ const require2 = createRequire(import.meta.url);
65
+ const worker = require2.resolve("@aethervtt/shard-procgen/worker");
66
+ const entry = resolve(root, options.entry);
67
+ let result;
68
+ try {
69
+ result = await esbuild.build({
70
+ absWorkingDir: root,
71
+ stdin: {
72
+ contents: `import ${JSON.stringify(entry)}
73
+ export { runGeneratorJob, warmUp } from ${JSON.stringify(worker)}
74
+ `,
75
+ resolveDir: root,
76
+ sourcefile: "procgen-worker.ts",
77
+ loader: "ts"
78
+ },
79
+ bundle: true,
80
+ format: "esm",
81
+ platform: "browser",
82
+ target: "es2023",
83
+ // Host-only fallbacks (reading .wasm files from disk) stay dynamic imports that never run here.
84
+ external: ["node:*"],
85
+ // Keep every engine module, ignoring `sideEffects: false`. A worker runs no plugins, so their
86
+ // `provides` can't keep definitions alive, and a job may load any asset type (a noise graph,
87
+ // a mesh, a data asset) whose definition registers on import.
88
+ ignoreAnnotations: true,
89
+ write: false,
90
+ logLevel: "silent"
91
+ });
92
+ } catch (err) {
93
+ const first = err.errors?.[0];
94
+ throw new ShardError(
95
+ "procgen/worker-bundle-failed",
96
+ `Couldn't bundle the generator worker: ${first?.text ?? err.message}`,
97
+ { hint: "The project code has to bundle for browsers (no Node built-ins).", cause: err }
98
+ );
99
+ }
100
+ const code = result.outputFiles[0].contents;
101
+ const hash = createHash("sha256").update(code).digest("hex").slice(0, 16);
102
+ const outDir = resolve(root, options.outDir ?? ".shard/build");
103
+ const file = join(outDir, `procgen-worker.${hash}.mjs`);
104
+ const exists = await access(file).then(
105
+ () => true,
106
+ () => false
107
+ );
108
+ if (!exists) {
109
+ await mkdir(outDir, { recursive: true });
110
+ await writeFile(file, code);
111
+ }
112
+ return { url: pathToFileURL(file).href, file, hash, ms: performance.now() - start };
113
+ }
114
+ let lastWorker;
115
+ async function prepareGenerators(options) {
116
+ const graph = options.graph ?? await projectModuleGraph(options.root, options.entry);
117
+ const codeHashes = await generatorCodeHashes(
118
+ options.root,
119
+ options.namespace,
120
+ options.entry,
121
+ graph
122
+ );
123
+ setGeneratorCodeHashes(codeHashes);
124
+ let worker;
125
+ if (options.worker !== false && options.workers && options.workers.size > 0) {
126
+ const key = `${resolve(options.root)}
127
+ ${JSON.stringify(codeHashes)}`;
128
+ if (lastWorker?.key !== key) {
129
+ const previous = lastWorker?.building;
130
+ lastWorker = {
131
+ key,
132
+ building: (previous ?? Promise.resolve()).catch(() => {
133
+ }).then(() => buildGeneratorWorker({ root: options.root, entry: options.entry }))
134
+ };
135
+ }
136
+ const building = lastWorker.building;
137
+ const mine = lastWorker;
138
+ building.catch(() => {
139
+ if (lastWorker === mine) lastWorker = void 0;
140
+ });
141
+ if (options.background) {
142
+ worker = building;
143
+ const url = building.then((b) => b.url);
144
+ url.catch(() => {
145
+ });
146
+ configureProcgenHost({ workers: options.workers, workerModule: url });
147
+ } else {
148
+ worker = await building;
149
+ configureProcgenHost({ workers: options.workers, workerModule: worker.url });
150
+ }
151
+ } else {
152
+ configureProcgenHost({ workers: void 0, workerModule: void 0 });
153
+ }
154
+ return { codeHashes, worker };
155
+ }
156
+ export {
157
+ buildGeneratorWorker,
158
+ generatorCodeHashes,
159
+ prepareGenerators,
160
+ projectModuleGraph
161
+ };
@@ -0,0 +1,82 @@
1
+ import { type AssetServer, type ScanReport } from '@aethervtt/shard-assets';
2
+ import { type World } from '@aethervtt/shard-core';
3
+ import type { GpuContext } from '@aethervtt/shard-gpu';
4
+ import { type Platform } from '@aethervtt/shard-platform';
5
+ import { type ErrorCode, type ManifestValue, ProjectSession } from '@aethervtt/shard-project';
6
+ import { type ProtocolServer } from '@aethervtt/shard-protocol';
7
+ import { OffscreenTarget } from '@aethervtt/shard-render';
8
+ import { type App, type Plugin } from '@aethervtt/shard-runtime';
9
+ import { type LoadedSceneHandle } from '@aethervtt/shard-scene';
10
+ import { type Bundler } from './bundle.js';
11
+ export { createNodePlatform } from '@aethervtt/shard-platform-node';
12
+ export interface OpenProjectOptions {
13
+ /** Project folder (with shard.json). */
14
+ root: string;
15
+ /** Headless render size; defaults to the manifest's window. */
16
+ width?: number;
17
+ height?: number;
18
+ /** Load the manifest's start scene (default true). */
19
+ loadStartScene?: boolean;
20
+ /** Share a GPU context across projects (tests); otherwise one is created. */
21
+ gpu?: GpuContext;
22
+ /** Overrides the manifest's `seed`. */
23
+ seed?: number;
24
+ /** Hot reload assets and project code when files change (default false). */
25
+ watch?: boolean;
26
+ /**
27
+ * 'bundle' (default): esbuild bundles `entry` and hot reload works. 'source': imports `entry`
28
+ * directly (gameplay tests, where test files import the same modules).
29
+ */
30
+ code?: 'bundle' | 'source';
31
+ /**
32
+ * Run generator jobs on worker threads (default true): the project's code is bundled with the
33
+ * engine into `.shard/build/procgen-worker.<hash>.mjs`. False runs them inline.
34
+ */
35
+ generatorWorkers?: boolean;
36
+ /**
37
+ * Where saves and settings go. 'memory' (default): gone when the process exits, so runs, tests,
38
+ * and screenshots never depend on what an earlier session saved. 'files': `.shard/user` in the
39
+ * project, kept across sessions (`shard serve`, `shard mcp`).
40
+ */
41
+ userData?: 'memory' | 'files';
42
+ }
43
+ export interface HeadlessProject {
44
+ root: string;
45
+ manifest: ManifestValue;
46
+ platform: Platform;
47
+ app: App;
48
+ server: ProtocolServer;
49
+ gpu: GpuContext;
50
+ target: OffscreenTarget;
51
+ scene: LoadedSceneHandle | undefined;
52
+ assets: AssetServer;
53
+ /** What the startup scan imported, skipped, and failed. */
54
+ imports: ScanReport;
55
+ /** The project's code: reloads, status, errors. */
56
+ session: ProjectSession;
57
+ /** The bundler, in 'bundle' mode. */
58
+ bundler: Bundler | undefined;
59
+ close(): void;
60
+ }
61
+ /** Imports the project plugin (default export of `entry`). */
62
+ export declare function importProjectPlugin(root: string, manifest: ManifestValue): Promise<Plugin>;
63
+ /** Opens a project headless: manifest, project code, Dawn GPU, offscreen target, protocol server. */
64
+ export declare function openProject(options: OpenProjectOptions): Promise<HeadlessProject>;
65
+ /** The first stack frame in project source (not node_modules or .shard), as `path:line:col`. */
66
+ export declare function locateInProject(error: unknown, root: string): string | undefined;
67
+ /** Scene files under `scenes/`, as project-relative paths. */
68
+ export declare function listScenes(root: string): Promise<string[]>;
69
+ /**
70
+ * A stable hash of every entity's serializable components (and parents). Equal worlds hash equal,
71
+ * so determinism checks compare one string. `core/Derived` entities (rebuilt from other state, like
72
+ * terrain chunks) are left out, and entity ids are numbered by rank among the rest, so a run that
73
+ * spawns render-only chunks (a GPU) hashes like one that doesn't (headless).
74
+ */
75
+ export declare function worldHash(world: World): string;
76
+ /**
77
+ * Error codes the engine can raise, scanned from the engine packages' sources (with the hint written
78
+ * next to each). Feeds `.agents/errors.md`.
79
+ */
80
+ export declare function collectErrorCodes(): Promise<ErrorCode[]>;
81
+ export { type BuiltBundle, type Bundler, createBundler } from './bundle.js';
82
+ export { buildGeneratorWorker, type GeneratorWorkerBundle, generatorCodeHashes, type ModuleGraph, prepareGenerators, projectModuleGraph, } from './generators.js';
package/dist/index.js ADDED
@@ -0,0 +1,331 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { assetServer } from "@aethervtt/shard-assets";
7
+ import {
8
+ ChildOf,
9
+ Derived,
10
+ ShardError
11
+ } from "@aethervtt/shard-core";
12
+ import { createNodeGpuContext } from "@aethervtt/shard-gpu/node";
13
+ import { createMemoryStorage } from "@aethervtt/shard-platform";
14
+ import { createNodePlatform } from "@aethervtt/shard-platform-node";
15
+ import { configureProcgenHost, procgenHost } from "@aethervtt/shard-procgen";
16
+ import {
17
+ buildApp,
18
+ loadProject,
19
+ loadProjectNavCache,
20
+ loadProjectShaders,
21
+ loadProjectStrings,
22
+ locateInBundle,
23
+ ProjectSession
24
+ } from "@aethervtt/shard-project";
25
+ import { createProtocolServer } from "@aethervtt/shard-protocol";
26
+ import { OffscreenTarget } from "@aethervtt/shard-render";
27
+ import { LogResource } from "@aethervtt/shard-runtime";
28
+ import { loadScene, whenSceneReady } from "@aethervtt/shard-scene";
29
+ import { createBundler } from "./bundle.js";
30
+ import { prepareGenerators } from "./generators.js";
31
+ import { createNodePlatform as createNodePlatform2 } from "@aethervtt/shard-platform-node";
32
+ async function importProjectPlugin(root, manifest) {
33
+ const file = resolve(root, manifest.entry);
34
+ let mod;
35
+ try {
36
+ mod = await import(pathToFileURL(file).href);
37
+ } catch (cause) {
38
+ throw new ShardError(
39
+ "project/entry-failed",
40
+ `Couldn't load ${manifest.entry}: ${cause.message}`,
41
+ {
42
+ path: manifest.entry,
43
+ hint: "The entry must be a module whose default export is defineProject({...}).",
44
+ cause
45
+ }
46
+ );
47
+ }
48
+ const plugin = mod.default;
49
+ if (!plugin || typeof plugin.build !== "function" || typeof plugin.name !== "string") {
50
+ throw new ShardError(
51
+ "project/entry-invalid",
52
+ `${manifest.entry} has no project plugin as its default export`,
53
+ {
54
+ path: manifest.entry,
55
+ hint: "End the file with `export default project`, where project = defineProject({...})."
56
+ }
57
+ );
58
+ }
59
+ return plugin;
60
+ }
61
+ async function openProject(options) {
62
+ const root = resolve(options.root);
63
+ const files = createNodePlatform({ root });
64
+ const platform = options.userData === "files" ? files : { ...files, storage: createMemoryStorage() };
65
+ const loaded = (await loadProject(platform)).manifest;
66
+ const manifest = options.seed === void 0 ? loaded : { ...loaded, seed: options.seed };
67
+ const code = options.code ?? "bundle";
68
+ let bundler;
69
+ let project;
70
+ if (code === "bundle") {
71
+ bundler = await createBundler({ root, entry: manifest.entry });
72
+ let built;
73
+ try {
74
+ built = await bundler.build();
75
+ } catch (err) {
76
+ await bundler.dispose();
77
+ throw err;
78
+ }
79
+ project = await importBundle(built, manifest);
80
+ } else {
81
+ project = await importProjectPlugin(root, manifest);
82
+ }
83
+ const generators = {
84
+ root,
85
+ namespace: manifest.name,
86
+ entry: manifest.entry,
87
+ workers: platform.workers,
88
+ worker: options.generatorWorkers ?? true
89
+ };
90
+ await prepareGenerators({ ...generators, graph: bundler?.last?.graph });
91
+ const ownGpu = !options.gpu;
92
+ const gpu = options.gpu ?? await createNodeGpuContext({ features: ["timestamp-query"] });
93
+ const target = new OffscreenTarget(gpu, {
94
+ label: "headless",
95
+ width: options.width ?? manifest.window.width,
96
+ height: options.height ?? manifest.window.height
97
+ });
98
+ const app = buildApp({ manifest, project, gpu, target, platform });
99
+ await app.init();
100
+ await loadProjectNavCache(app, platform);
101
+ const stopShaders = await loadProjectShaders(app, platform, { watch: options.watch });
102
+ const assets = assetServer(app.world).configure({ platform, roots: manifest.assetRoots });
103
+ const imports = await assets.scan();
104
+ await loadProjectStrings(app);
105
+ const stopWatching = options.watch ? await assets.watch() : () => {
106
+ };
107
+ let scene;
108
+ if (options.loadStartScene ?? true) {
109
+ const json = JSON.parse(await platform.fs.readText(manifest.startScene));
110
+ scene = loadScene(app.world, json, { id: manifest.startScene });
111
+ await whenSceneReady(app.world, manifest.startScene);
112
+ }
113
+ const log = app.world.resource(LogResource);
114
+ log.annotate = (err) => {
115
+ const last = bundler?.last;
116
+ for (let e = err, depth = 0; e && depth < 5; e = e.cause, depth++) {
117
+ const mapped = last?.map && locateInBundle(
118
+ e,
119
+ last.url,
120
+ last.map,
121
+ (s) => relative(root, resolve(dirname(last.file), s)).split("\\").join("/")
122
+ );
123
+ const source = mapped || locateInProject(e, root);
124
+ if (source) return { source };
125
+ }
126
+ return void 0;
127
+ };
128
+ const session = new ProjectSession(app, {
129
+ namespace: manifest.name,
130
+ current: project,
131
+ ...bundler?.last ? { bundle: { hash: bundler.last.hash, ms: bundler.last.ms } } : {}
132
+ });
133
+ if (bundler) {
134
+ const b = bundler;
135
+ session.rebuild = async () => {
136
+ const built = await b.build();
137
+ await prepareGenerators({ ...generators, graph: built.graph, background: true });
138
+ return { load: () => import(b.importUrl(built)), hash: built.hash, ms: built.ms };
139
+ };
140
+ }
141
+ const server = createProtocolServer(app, {
142
+ frames: "manual",
143
+ platform,
144
+ methods: session.methods()
145
+ });
146
+ session.onChange((status) => server.publish("project", status));
147
+ let stopCode = () => {
148
+ };
149
+ if (options.watch && bundler) {
150
+ session.watching = true;
151
+ stopCode = bundler.watch((result) => {
152
+ if (result instanceof Error) session.buildFailed(result);
153
+ else
154
+ void prepareGenerators({ ...generators, graph: result.graph, background: true }).then(
155
+ () => session.reload(() => import(bundler.importUrl(result)), {
156
+ hash: result.hash,
157
+ ms: result.ms
158
+ })
159
+ ).catch((err) => session.buildFailed(err));
160
+ });
161
+ }
162
+ return {
163
+ root,
164
+ manifest,
165
+ platform,
166
+ app,
167
+ server,
168
+ gpu,
169
+ target,
170
+ scene,
171
+ assets,
172
+ imports,
173
+ session,
174
+ bundler,
175
+ close() {
176
+ stopWatching();
177
+ stopShaders();
178
+ stopCode();
179
+ void bundler?.dispose();
180
+ server.close();
181
+ target.destroy();
182
+ if (ownGpu) gpu.destroy();
183
+ if (procgenHost().workers === platform.workers)
184
+ configureProcgenHost({ workers: void 0, workerModule: void 0 });
185
+ platform.workers?.dispose();
186
+ }
187
+ };
188
+ }
189
+ async function importBundle(built, manifest) {
190
+ let mod;
191
+ try {
192
+ mod = await import(built.url);
193
+ } catch (cause) {
194
+ const source = built.map ? locateInBundle(cause, built.url, built.map) : void 0;
195
+ throw Object.assign(
196
+ new ShardError(
197
+ "project/entry-failed",
198
+ `Couldn't start ${manifest.entry}: ${cause.message}`,
199
+ {
200
+ path: manifest.entry,
201
+ hint: 'The error is in the project code; see "source" for where.',
202
+ cause
203
+ }
204
+ ),
205
+ { source }
206
+ );
207
+ }
208
+ const plugin = mod.default;
209
+ if (!plugin || typeof plugin.build !== "function" || typeof plugin.name !== "string") {
210
+ throw new ShardError(
211
+ "project/entry-invalid",
212
+ `${manifest.entry} has no project plugin as its default export`,
213
+ {
214
+ path: manifest.entry,
215
+ hint: "End the file with `export default project`, where project = defineProject({...})."
216
+ }
217
+ );
218
+ }
219
+ return plugin;
220
+ }
221
+ function locateInProject(error, root) {
222
+ const stack = error?.stack;
223
+ if (typeof stack !== "string") return void 0;
224
+ const prefix = resolve(root);
225
+ for (const line of stack.split("\n")) {
226
+ const m = /(?:file:\/\/)?(\/[^\s():]+):(\d+):(\d+)/.exec(line);
227
+ if (!m) continue;
228
+ const file = decodeURIComponent(m[1]);
229
+ if (!file.startsWith(`${prefix}/`)) continue;
230
+ const rel = relative(prefix, file).split("\\").join("/");
231
+ if (rel.startsWith(".shard/") || rel.includes("node_modules/")) continue;
232
+ return `${rel}:${m[2]}:${m[3]}`;
233
+ }
234
+ return void 0;
235
+ }
236
+ async function listScenes(root) {
237
+ const dir = join(resolve(root), "scenes");
238
+ const entries = await readdir(dir, { recursive: true }).catch(() => []);
239
+ return entries.filter((f) => f.endsWith(".scene.json")).map((f) => relative(resolve(root), join(dir, f)).split("\\").join("/")).sort();
240
+ }
241
+ function worldHash(world) {
242
+ const kept = [];
243
+ for (const table of world.allTables()) {
244
+ if (table.has(Derived)) continue;
245
+ for (let row = 0; row < table.count; row++) kept.push(table.entities[row]);
246
+ }
247
+ kept.sort((a, b) => a - b);
248
+ const rank = /* @__PURE__ */ new Map();
249
+ for (let i = 0; i < kept.length; i++) rank.set(kept[i], i);
250
+ const id = (e) => typeof e === "number" && e >= 0 ? rank.get(e) ?? -1 : e;
251
+ const remap = (field, value) => {
252
+ if (value === null || value === void 0) return value;
253
+ if (field.kind === "entity") return id(value);
254
+ if (field.kind === "list" && field.item && Array.isArray(value))
255
+ return value.map((v) => remap(field.item, v));
256
+ if (field.kind === "struct" && field.fields && typeof value === "object")
257
+ return remapFields(field.fields, value);
258
+ return value;
259
+ };
260
+ const remapFields = (fields, value) => {
261
+ const out = { ...value };
262
+ for (const key in fields) if (key in out) out[key] = remap(fields[key], out[key]);
263
+ return out;
264
+ };
265
+ const rows = [];
266
+ for (const table of world.allTables()) {
267
+ if (table.has(Derived)) continue;
268
+ const components = table.components.filter((c) => c.serializable || c === ChildOf);
269
+ for (let row = 0; row < table.count; row++) {
270
+ const entity = table.entities[row];
271
+ const values = components.map((c) => [
272
+ c.name,
273
+ remapFields(c.fields, c.serialize(table.readComponent(c, row)))
274
+ ]);
275
+ rows.push(JSON.stringify([rank.get(entity), values]));
276
+ }
277
+ }
278
+ return createHash("sha256").update(rows.sort().join("\n")).digest("hex");
279
+ }
280
+ async function collectErrorCodes() {
281
+ const require2 = createRequire(import.meta.url);
282
+ const packagesDir = dirname(dirname(dirname(require2.resolve("@aethervtt/shard-core"))));
283
+ const found = /* @__PURE__ */ new Map();
284
+ const pkgs = await readdir(packagesDir).catch(() => []);
285
+ for (const pkg of pkgs) {
286
+ const src = join(packagesDir, pkg, "src");
287
+ const files = await readdir(src, { recursive: true }).catch(() => []);
288
+ for (const file of files) {
289
+ if (!file.endsWith(".ts") || file.endsWith(".test.ts")) continue;
290
+ const text = await readFile(join(src, file), "utf8");
291
+ const re = /new ShardError\(\s*['"]([a-z0-9-]+\/[a-z0-9-]+)['"]/g;
292
+ for (let m = re.exec(text); m; m = re.exec(text)) {
293
+ const code = m[1];
294
+ const window = text.slice(m.index, m.index + 700);
295
+ const next = window.indexOf("new ShardError", 10);
296
+ const hint = /hint:\s*(['"`])((?:(?!\1).)+)\1/.exec(
297
+ next === -1 ? window : window.slice(0, next)
298
+ )?.[2];
299
+ if (!found.has(code) || !found.get(code).hint && hint) {
300
+ found.set(code, {
301
+ code,
302
+ source: `@aethervtt/shard-${pkg}`,
303
+ ...hint && !hint.includes("${") ? { hint } : {}
304
+ });
305
+ }
306
+ }
307
+ }
308
+ }
309
+ return [...found.values()];
310
+ }
311
+ import { createBundler as createBundler2 } from "./bundle.js";
312
+ import {
313
+ buildGeneratorWorker,
314
+ generatorCodeHashes,
315
+ prepareGenerators as prepareGenerators2,
316
+ projectModuleGraph
317
+ } from "./generators.js";
318
+ export {
319
+ buildGeneratorWorker,
320
+ collectErrorCodes,
321
+ createBundler2 as createBundler,
322
+ createNodePlatform2 as createNodePlatform,
323
+ generatorCodeHashes,
324
+ importProjectPlugin,
325
+ listScenes,
326
+ locateInProject,
327
+ openProject,
328
+ prepareGenerators2 as prepareGenerators,
329
+ projectModuleGraph,
330
+ worldHash
331
+ };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@aethervtt/shard-node",
3
+ "version": "0.0.1",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "LICENSE",
16
+ "README.md"
17
+ ],
18
+ "dependencies": {
19
+ "@aethervtt/shard-assets": "0.0.1",
20
+ "@aethervtt/shard-core": "0.0.1",
21
+ "@aethervtt/shard-gpu": "0.0.1",
22
+ "@aethervtt/shard-platform": "0.0.1",
23
+ "@aethervtt/shard-platform-node": "0.0.1",
24
+ "@aethervtt/shard-procgen": "0.0.1",
25
+ "@aethervtt/shard-project": "0.0.1",
26
+ "@aethervtt/shard-protocol": "0.0.1",
27
+ "@aethervtt/shard-render": "0.0.1",
28
+ "@aethervtt/shard-runtime": "0.0.1",
29
+ "@aethervtt/shard-scene": "0.0.1",
30
+ "esbuild": "^0.28.2",
31
+ "webgpu": "0.6.1"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/aethervtt/shard.git",
36
+ "directory": "packages/node"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public",
40
+ "provenance": true
41
+ },
42
+ "shard": {
43
+ "buildSha": "d6c5c6aef4675575c878b02cf2c8f738b4190229"
44
+ }
45
+ }