@aethervtt/shard-project 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/dist/reload.js ADDED
@@ -0,0 +1,247 @@
1
+ import { AssetServerResource } from "@aethervtt/shard-assets";
2
+ import {
3
+ allComponents,
4
+ beginRedefinition,
5
+ defineEvent,
6
+ endRedefinition,
7
+ findResource,
8
+ ShardError
9
+ } from "@aethervtt/shard-core";
10
+ import { ProcgenResource } from "@aethervtt/shard-procgen";
11
+ import { pathOfEntity } from "@aethervtt/shard-scene";
12
+ const ProjectReloaded = defineEvent("project/ProjectReloaded", {
13
+ description: "The project code was hot reloaded."
14
+ });
15
+ function sameLayout(a, b) {
16
+ if (a.version !== b.version || a.layout.length !== b.layout.length) return false;
17
+ for (let i = 0; i < a.layout.length; i++) {
18
+ const x = a.layout[i];
19
+ const y = b.layout[i];
20
+ if (x.name !== y.name || x.storage !== y.storage || x.stride !== y.stride || x.field.kind !== y.field.kind)
21
+ return false;
22
+ }
23
+ return true;
24
+ }
25
+ function asPlugin(mod) {
26
+ const plugin = mod.default;
27
+ if (!plugin || typeof plugin.build !== "function" || typeof plugin.name !== "string") {
28
+ throw new ShardError(
29
+ "project/entry-invalid",
30
+ "The project bundle has no plugin as its default export",
31
+ {
32
+ hint: "End the entry file with `export default project`, where project = defineProject({...})."
33
+ }
34
+ );
35
+ }
36
+ return plugin;
37
+ }
38
+ function createProjectReloader(app, options) {
39
+ let current = options.current;
40
+ const world = app.world;
41
+ const observerStops = /* @__PURE__ */ new Map();
42
+ const build = async (plugin) => {
43
+ const saved = /* @__PURE__ */ new Map();
44
+ for (const name of namespaceResources()) {
45
+ const def = findResource(name);
46
+ if (world.hasResource(def)) saved.set(def.id, [def, world.resource(def)]);
47
+ }
48
+ const stops = [];
49
+ const observe = world.observe.bind(world);
50
+ world.observe = (...args) => {
51
+ const stop = observe(...args);
52
+ stops.push(stop);
53
+ return stop;
54
+ };
55
+ try {
56
+ await app.loadPlugin(plugin);
57
+ } finally {
58
+ ;
59
+ world.observe = observe;
60
+ observerStops.set(plugin.name, stops);
61
+ for (const [id, [oldDef, value]] of saved) {
62
+ const def = findResource(oldDef.name) ?? oldDef;
63
+ if (def.id === id && def.reload !== "replace") world.insertResource(def, value);
64
+ }
65
+ }
66
+ };
67
+ const unload = (plugin) => {
68
+ for (const stop of observerStops.get(plugin.name) ?? []) stop();
69
+ observerStops.delete(plugin.name);
70
+ return app.unloadPlugin(plugin.name);
71
+ };
72
+ const namespaceResources = () => {
73
+ const out = [];
74
+ for (const def of world.registry.describe().resources) {
75
+ if (def.name.startsWith(`${options.namespace}/`)) out.push(def.name);
76
+ }
77
+ return out;
78
+ };
79
+ const plan = (changes) => {
80
+ const plans = [];
81
+ for (const change of changes) {
82
+ if (change.kind !== "component") continue;
83
+ const previous = change.previous;
84
+ const def = change.next;
85
+ if (sameLayout(previous, def)) {
86
+ plans.push({ def, previous });
87
+ continue;
88
+ }
89
+ const before = /* @__PURE__ */ new Map();
90
+ const after = /* @__PURE__ */ new Map();
91
+ const fields = new Set(def.layout.map((c) => c.name));
92
+ for (const table of world.allTables()) {
93
+ if (!table.hasId(def.id)) continue;
94
+ for (let row = 0; row < table.count; row++) {
95
+ const entity = table.entities[row];
96
+ const value = table.readComponent(previous, row);
97
+ let json = previous.serialize(value);
98
+ if (def.version > previous.version) json = def.upgrade(json, previous.version);
99
+ if (json && typeof json === "object" && !Array.isArray(json)) {
100
+ json = Object.fromEntries(Object.entries(json).filter(([k]) => fields.has(k)));
101
+ }
102
+ const errors = def.validate(json);
103
+ if (errors.length > 0) {
104
+ const where = pathOfEntity(world, entity) ?? `entity ${entity}`;
105
+ throw new ShardError(
106
+ "project/migration-failed",
107
+ `Can't migrate ${def.name} on ${where}: ${errors[0].message}`,
108
+ {
109
+ path: errors[0].path,
110
+ hint: "Bump the component version and convert the old value in `migrate`, or change the field back.",
111
+ details: errors
112
+ }
113
+ );
114
+ }
115
+ before.set(entity, value);
116
+ after.set(entity, def.deserialize(json));
117
+ }
118
+ }
119
+ plans.push({ def, previous, values: { before, after } });
120
+ }
121
+ return plans;
122
+ };
123
+ const apply = (plans, direction) => {
124
+ for (const p of plans) {
125
+ const def = direction === "forward" ? p.def : p.previous;
126
+ const values = p.values && (direction === "forward" ? p.values.after : p.values.before);
127
+ world.redefine(def, values && ((_, entity) => values.get(entity)));
128
+ }
129
+ };
130
+ const redefineOthers = (changes, direction) => {
131
+ for (const c of changes) {
132
+ if (c.kind === "component") continue;
133
+ world.redefine(direction === "forward" ? c.next : c.previous);
134
+ }
135
+ };
136
+ const systemSources = (plugin) => {
137
+ const out = /* @__PURE__ */ new Map();
138
+ for (const name of app.systemsOf(plugin.name)) {
139
+ const run = app.systemRun(name);
140
+ out.set(name, run ? run.toString() : "");
141
+ }
142
+ return out;
143
+ };
144
+ return {
145
+ get current() {
146
+ return current;
147
+ },
148
+ async reload(load) {
149
+ const start = performance.now();
150
+ const report = {
151
+ ok: false,
152
+ ms: 0,
153
+ migrated: [],
154
+ orphaned: [],
155
+ systems: { added: [], removed: [], changed: [] },
156
+ assets: { imported: [], failed: [] },
157
+ procgen: { regenerated: [] }
158
+ };
159
+ const finish = (error) => {
160
+ report.ms = performance.now() - start;
161
+ if (error !== void 0) {
162
+ const e = error instanceof ShardError ? error : new ShardError(
163
+ "project/reload-failed",
164
+ String(error?.message ?? error),
165
+ {
166
+ cause: error
167
+ }
168
+ );
169
+ report.error = {
170
+ code: e.code,
171
+ message: e.message,
172
+ ...e.path ? { path: e.path } : {},
173
+ ...e.hint ? { hint: e.hint } : {},
174
+ ...sourceOf(error) ? { source: sourceOf(error) } : {}
175
+ };
176
+ return report;
177
+ }
178
+ report.ok = true;
179
+ world.send(ProjectReloaded, report);
180
+ return report;
181
+ };
182
+ const before = new Set(
183
+ allComponents().filter((d) => d.name.startsWith(`${options.namespace}/`)).map((d) => d.name)
184
+ );
185
+ let changes;
186
+ let next;
187
+ beginRedefinition(options.namespace);
188
+ try {
189
+ next = asPlugin(await load());
190
+ } catch (err) {
191
+ for (const c of endRedefinition()) c.undo();
192
+ return finish(err);
193
+ }
194
+ changes = endRedefinition();
195
+ const redefined = new Set(changes.map((c) => c.name));
196
+ report.orphaned = [...before].filter((n) => !redefined.has(n)).sort();
197
+ let plans;
198
+ try {
199
+ plans = plan(changes);
200
+ } catch (err) {
201
+ for (const c of changes) c.undo();
202
+ return finish(err);
203
+ }
204
+ const oldSources = systemSources(current);
205
+ apply(plans, "forward");
206
+ redefineOthers(changes, "forward");
207
+ unload(current);
208
+ try {
209
+ await build(next);
210
+ } catch (err) {
211
+ unload(next);
212
+ apply(plans, "back");
213
+ redefineOthers(changes, "back");
214
+ for (const c of changes) c.undo();
215
+ await build(current);
216
+ return finish(err);
217
+ }
218
+ const newSources = systemSources(next);
219
+ current = next;
220
+ report.migrated = plans.filter((p) => p.values).map((p) => p.def.name);
221
+ for (const [name, src] of newSources) {
222
+ if (!oldSources.has(name)) report.systems.added.push(name);
223
+ else if (oldSources.get(name) !== src) report.systems.changed.push(name);
224
+ }
225
+ for (const name of oldSources.keys())
226
+ if (!newSources.has(name)) report.systems.removed.push(name);
227
+ const assets = world.tryResource(AssetServerResource);
228
+ if (assets) {
229
+ const scan = await assets.scan();
230
+ report.assets.imported = scan.imported;
231
+ report.assets.failed = scan.failed.map((f) => f.path);
232
+ report.procgen.regenerated = scan.imported.filter((p) => p.endsWith(".gen.json"));
233
+ }
234
+ const runtime = world.tryResource(ProcgenResource);
235
+ if (runtime) report.procgen.regenerated.push(...runtime.regenerateStale());
236
+ return finish();
237
+ }
238
+ };
239
+ }
240
+ function sourceOf(err) {
241
+ const source = err?.source;
242
+ return typeof source === "string" ? source : void 0;
243
+ }
244
+ export {
245
+ ProjectReloaded,
246
+ createProjectReloader
247
+ };
@@ -0,0 +1,73 @@
1
+ import type { MethodDef } from '@aethervtt/shard-protocol';
2
+ import { type App, type Plugin } from '@aethervtt/shard-runtime';
3
+ import { createProjectReloader, type ReloadReport } from './reload.js';
4
+ type ModuleLoader = () => Promise<{
5
+ default?: unknown;
6
+ }>;
7
+ /** Parameter schemas of the `project.*` protocol methods (tools read them before a session exists). */
8
+ export declare const ProjectMethodParams: {
9
+ 'project.status': import("@aethervtt/shard-core").ComponentDef<{}>;
10
+ 'project.reload': import("@aethervtt/shard-core").ComponentDef<{
11
+ readonly url: import("@aethervtt/shard-core").FieldType<string, "object">;
12
+ readonly error: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").JsonValue, "object">;
13
+ }>;
14
+ };
15
+ export interface ProjectStatus {
16
+ /** The current bundle's content hash and build time. */
17
+ bundle?: {
18
+ hash: string;
19
+ ms: number;
20
+ };
21
+ /** How many reloads have succeeded. */
22
+ reloads: number;
23
+ lastReload?: ReloadReport;
24
+ /** The last build or reload failure, if the latest attempt failed. */
25
+ error?: {
26
+ code: string;
27
+ message: string;
28
+ path?: string;
29
+ hint?: string;
30
+ source?: string;
31
+ };
32
+ /** Components the current code no longer defines (kept in the world). */
33
+ orphaned: string[];
34
+ watching: boolean;
35
+ }
36
+ /**
37
+ * One running project's code: the reloader plus what happened last (bundle, reload, error), served
38
+ * to tools as `project.status` / `project.reload`. Hosts feed it bundles; it never builds itself.
39
+ */
40
+ export declare class ProjectSession {
41
+ readonly app: App;
42
+ readonly reloader: ReturnType<typeof createProjectReloader>;
43
+ private state;
44
+ private readonly listeners;
45
+ /** Set by the host: rebuilds and returns a loader for the new bundle. */
46
+ rebuild: (() => Promise<{
47
+ load: ModuleLoader;
48
+ hash: string;
49
+ ms: number;
50
+ }>) | undefined;
51
+ constructor(app: App, options: {
52
+ namespace: string;
53
+ current: Plugin;
54
+ bundle?: {
55
+ hash: string;
56
+ ms: number;
57
+ };
58
+ });
59
+ status(): ProjectStatus;
60
+ set watching(on: boolean);
61
+ onChange(listener: (status: ProjectStatus) => void): () => void;
62
+ /** Reloads from a freshly built bundle. Failures are logged and reported, never thrown. */
63
+ reload(load: ModuleLoader, bundle?: {
64
+ hash: string;
65
+ ms: number;
66
+ }): Promise<ReloadReport>;
67
+ /** Records a bundle that failed to build (the running code is unchanged). */
68
+ buildFailed(error: unknown): void;
69
+ private emit;
70
+ /** `project.status` and `project.reload`, for the protocol server. */
71
+ methods(): MethodDef[];
72
+ }
73
+ export {};
@@ -0,0 +1,144 @@
1
+ import { defineSchema, ShardError, t } from "@aethervtt/shard-core";
2
+ import { AppControlResource, LogResource } from "@aethervtt/shard-runtime";
3
+ import { createProjectReloader } from "./reload.js";
4
+ const ProjectMethodParams = {
5
+ "project.status": defineSchema("protocol/ProjectStatusParams", {}),
6
+ "project.reload": defineSchema("protocol/ProjectReloadParams", {
7
+ url: t.string({ description: "Load this bundle URL instead of rebuilding (dev servers)." }),
8
+ error: t.json({
9
+ description: "A dev server reporting that the bundle failed to build: { code, message, source }."
10
+ })
11
+ })
12
+ };
13
+ class ProjectSession {
14
+ app;
15
+ reloader;
16
+ state = { reloads: 0, orphaned: [], watching: false };
17
+ listeners = /* @__PURE__ */ new Set();
18
+ /** Set by the host: rebuilds and returns a loader for the new bundle. */
19
+ rebuild;
20
+ constructor(app, options) {
21
+ this.app = app;
22
+ this.reloader = createProjectReloader(app, options);
23
+ if (options.bundle) this.state.bundle = options.bundle;
24
+ }
25
+ status() {
26
+ return structuredClone(this.state);
27
+ }
28
+ set watching(on) {
29
+ this.state.watching = on;
30
+ }
31
+ onChange(listener) {
32
+ this.listeners.add(listener);
33
+ return () => this.listeners.delete(listener);
34
+ }
35
+ /** Reloads from a freshly built bundle. Failures are logged and reported, never thrown. */
36
+ async reload(load, bundle) {
37
+ const report = await this.reloader.reload(load);
38
+ this.state.lastReload = report;
39
+ if (report.ok) {
40
+ this.state.reloads++;
41
+ const control = this.app.world.resource(AppControlResource);
42
+ if (control.pausedByError) {
43
+ control.paused = false;
44
+ control.pausedByError = false;
45
+ }
46
+ this.state.orphaned = report.orphaned;
47
+ delete this.state.error;
48
+ if (bundle) this.state.bundle = bundle;
49
+ this.app.world.resource(LogResource).info(
50
+ `Project reloaded in ${Math.round(report.ms)} ms${report.migrated.length ? ` (migrated ${report.migrated.join(", ")})` : ""}`
51
+ );
52
+ } else {
53
+ this.state.error = report.error;
54
+ this.app.world.resource(LogResource).error(
55
+ Object.assign(
56
+ new ShardError(report.error.code, report.error.message, {
57
+ path: report.error.path,
58
+ hint: report.error.hint
59
+ }),
60
+ { source: report.error.source }
61
+ )
62
+ );
63
+ }
64
+ this.emit();
65
+ return report;
66
+ }
67
+ /** Records a bundle that failed to build (the running code is unchanged). */
68
+ buildFailed(error) {
69
+ const e = error instanceof ShardError ? error : new ShardError("project/bundle-failed", String(error));
70
+ const source = error?.source;
71
+ this.state.error = {
72
+ code: e.code,
73
+ message: e.message,
74
+ ...e.path ? { path: e.path } : {},
75
+ ...e.hint ? { hint: e.hint } : {},
76
+ ...typeof source === "string" ? { source } : {}
77
+ };
78
+ this.app.world.resource(LogResource).error(error);
79
+ this.emit();
80
+ }
81
+ emit() {
82
+ const status = this.status();
83
+ for (const l of this.listeners) l(status);
84
+ }
85
+ /** `project.status` and `project.reload`, for the protocol server. */
86
+ methods() {
87
+ return [
88
+ {
89
+ name: "project.status",
90
+ description: "The project code: current bundle, reload count, the last reload report (migrated and orphaned components, systems added/removed/changed), and the last build or reload error with its source location.",
91
+ params: ProjectMethodParams["project.status"],
92
+ handler: () => this.status()
93
+ },
94
+ {
95
+ name: "project.reload",
96
+ description: "Rebuilds the project bundle and hot reloads it now (normally this happens on save). Returns the reload report.",
97
+ params: ProjectMethodParams["project.reload"],
98
+ handler: async (_ctx, p) => {
99
+ if (p.error) {
100
+ const e = p.error;
101
+ this.buildFailed(
102
+ Object.assign(
103
+ new ShardError(
104
+ e.code ?? "project/bundle-failed",
105
+ e.message ?? "The bundle failed to build",
106
+ {
107
+ path: e.path,
108
+ hint: e.hint
109
+ }
110
+ ),
111
+ { source: e.source }
112
+ )
113
+ );
114
+ return this.status();
115
+ }
116
+ if (p.url) {
117
+ const url = p.url;
118
+ return this.reload(() => import(
119
+ /* @vite-ignore */
120
+ url
121
+ ), { hash: url, ms: 0 });
122
+ }
123
+ if (!this.rebuild) {
124
+ throw new ShardError("project/no-bundler", "This host has no bundler to rebuild with", {
125
+ hint: 'Pass "url" with a built bundle.'
126
+ });
127
+ }
128
+ let built;
129
+ try {
130
+ built = await this.rebuild();
131
+ } catch (err) {
132
+ this.buildFailed(err);
133
+ throw err;
134
+ }
135
+ return this.reload(built.load, { hash: built.hash, ms: built.ms });
136
+ }
137
+ }
138
+ ];
139
+ }
140
+ }
141
+ export {
142
+ ProjectMethodParams,
143
+ ProjectSession
144
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Just enough source-map reading to turn a bundle stack frame back into `scripts/x.ts:line:col`.
3
+ * Works on every host (no Node APIs), so the browser runner and the CLI report errors the same way.
4
+ */
5
+ export interface SourceMapData {
6
+ version: number;
7
+ sources: string[];
8
+ mappings: string;
9
+ sourceRoot?: string;
10
+ }
11
+ export interface SourcePosition {
12
+ source: string;
13
+ /** 1-based. */
14
+ line: number;
15
+ /** 1-based. */
16
+ column: number;
17
+ }
18
+ /** Decoded mappings: `lookup(line, column)` with 1-based generated positions. */
19
+ export declare class SourceMap {
20
+ readonly sources: string[];
21
+ private readonly lines;
22
+ constructor(data: SourceMapData);
23
+ lookup(line: number, column: number): SourcePosition | undefined;
24
+ }
25
+ /** The inline source map in a bundle's text (`//# sourceMappingURL=data:...;base64,...`). */
26
+ export declare function inlineSourceMap(code: string): SourceMap | undefined;
27
+ /**
28
+ * The first stack frame of `error` inside the bundle at `bundleUrl`, mapped to project source and
29
+ * made relative (`scripts/main.ts:42:7`). Frames in dependencies (node_modules) are skipped.
30
+ */
31
+ export declare function locateInBundle(error: unknown, bundleUrl: string, map: SourceMap, normalize?: (source: string) => string): string | undefined;
@@ -0,0 +1,91 @@
1
+ const B64 = new Map(
2
+ [..."ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"].map((c, i) => [c, i])
3
+ );
4
+ class SourceMap {
5
+ sources;
6
+ lines = [];
7
+ constructor(data) {
8
+ this.sources = data.sources.map((s) => data.sourceRoot ? `${data.sourceRoot}${s}` : s);
9
+ let source = 0;
10
+ let line = 0;
11
+ let sourceColumn = 0;
12
+ for (const text of data.mappings.split(";")) {
13
+ const segments = [];
14
+ let column = 0;
15
+ for (const segment of text.split(",")) {
16
+ if (segment === "") continue;
17
+ const values = decodeVlq(segment);
18
+ column += values[0];
19
+ if (values.length >= 4) {
20
+ source += values[1];
21
+ line += values[2];
22
+ sourceColumn += values[3];
23
+ segments.push({ column, source, line, sourceColumn });
24
+ }
25
+ }
26
+ this.lines.push(segments);
27
+ }
28
+ }
29
+ lookup(line, column) {
30
+ const segments = this.lines[line - 1];
31
+ if (!segments || segments.length === 0) return void 0;
32
+ let best;
33
+ for (const s of segments) {
34
+ if (s.column > column - 1) break;
35
+ best = s;
36
+ }
37
+ best ??= segments[0];
38
+ return {
39
+ source: this.sources[best.source],
40
+ line: best.line + 1,
41
+ column: best.sourceColumn + 1
42
+ };
43
+ }
44
+ }
45
+ function decodeVlq(text) {
46
+ const out = [];
47
+ let value = 0;
48
+ let shift = 0;
49
+ for (const char of text) {
50
+ const digit = B64.get(char);
51
+ value += (digit & 31) << shift;
52
+ if (digit & 32) {
53
+ shift += 5;
54
+ } else {
55
+ const negative = value & 1;
56
+ value >>>= 1;
57
+ out.push(negative ? -value : value);
58
+ value = 0;
59
+ shift = 0;
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+ function inlineSourceMap(code) {
65
+ const m = /\/\/# sourceMappingURL=data:application\/json;(?:charset=utf-8;)?base64,([A-Za-z0-9+/=]+)\s*$/.exec(
66
+ code
67
+ );
68
+ if (!m) return void 0;
69
+ const json = new TextDecoder().decode(Uint8Array.from(atob(m[1]), (c) => c.charCodeAt(0)));
70
+ return new SourceMap(JSON.parse(json));
71
+ }
72
+ function locateInBundle(error, bundleUrl, map, normalize = (s) => s) {
73
+ const stack = error?.stack;
74
+ if (typeof stack !== "string") return void 0;
75
+ const file = bundleUrl.replace(/[?#].*$/, "");
76
+ for (const line of stack.split("\n")) {
77
+ const at = line.lastIndexOf(file);
78
+ if (at === -1) continue;
79
+ const m = /:(\d+):(\d+)/.exec(line.slice(at + file.length));
80
+ if (!m) continue;
81
+ const pos = map.lookup(Number(m[1]), Number(m[2]));
82
+ if (!pos || pos.source.includes("node_modules")) continue;
83
+ return `${normalize(pos.source)}:${pos.line}:${pos.column}`;
84
+ }
85
+ return void 0;
86
+ }
87
+ export {
88
+ SourceMap,
89
+ inlineSourceMap,
90
+ locateInBundle
91
+ };
@@ -0,0 +1,9 @@
1
+ export type TemplateName = 'empty' | 'explorer';
2
+ export interface TemplateOptions {
3
+ name: string;
4
+ template: TemplateName;
5
+ /** Version spec for `@aethervtt/shard-*` dependencies: `workspace:*` inside the Shard repo, a range otherwise. */
6
+ engineSpec: string;
7
+ }
8
+ /** Files for a new project, by project-relative path. */
9
+ export declare function projectTemplate(options: TemplateOptions): Record<string, string>;