@vgai/editor 0.4.0 → 0.4.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/assets/{axe-BJjvBeOZ.js → axe-N4cNyzfs.js} +1 -1
- package/dist/assets/{browserAll-Dn0pgD87.js → browserAll-BIBT0Z2H.js} +1 -1
- package/dist/assets/{create-runtime-BF9OD-U8.js → create-runtime-DxLhdk4U.js} +1 -1
- package/dist/assets/{game-u6c2Patu.js → game-4NbNRROm.js} +1 -1
- package/dist/assets/{game-umrHIqFB.js → game-B3UH5LjS.js} +1 -1
- package/dist/assets/{game-BR0GBNgF.js → game-ByxRLJi1.js} +1 -1
- package/dist/assets/{game-DeTGdgYt.js → game-CJ5BqOYG.js} +1 -1
- package/dist/assets/{game-DQxhCYsZ.js → game-CSJm1kwB.js} +1 -1
- package/dist/assets/{game-CLNm2g0y.js → game-D9Mo4L3Q.js} +1 -1
- package/dist/assets/{game-B346oj_f.js → game-DJmU9BQF.js} +1 -1
- package/dist/assets/{game-BCNN63uV.js → game-ZqGLgxRj.js} +1 -1
- package/dist/assets/{game-loop-t5W_oqxz.js → game-loop-9YP76JB1.js} +1 -1
- package/dist/assets/{game-source-DzE9taML.js → game-source-DLKG9pmj.js} +1 -1
- package/dist/assets/{index-DbQzGLxv.js → index-8A4JLNIp.js} +902 -892
- package/dist/assets/{index-CgcH1RpF.js → index-Brzr17q0.js} +1 -1
- package/dist/assets/{index-D3tcNB2T.js → index-CnKtvrru.js} +1 -1
- package/dist/assets/{index-Fc8Ezr20.js → index-D-WR11Jw.js} +1 -1
- package/dist/assets/index-DiUacMXR.css +1 -0
- package/dist/assets/{ingest-siblings-CW_xbvrF.js → ingest-siblings-Cmx39OBK.js} +1 -1
- package/dist/assets/{mount-manifest-DgUscyaD.js → mount-manifest-kzdZgkkc.js} +1 -1
- package/dist/assets/{react-18-C4tVCii_.js → react-18-DvWsdwa_.js} +1 -1
- package/dist/assets/webworkerAll-C9GR-KrR.js +1 -0
- package/dist/assets/{world-documents-BSaA5YIF.js → world-documents-DM_aIMAW.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/packaged.mjs +27125 -1075
- package/package.json +2 -2
- package/server/asset-history-snapshots.ts +189 -0
- package/server/asset-library-routes.ts +404 -53
- package/server/catalog-import-recipe.ts +97 -0
- package/server/catalog-reimport.ts +141 -0
- package/server/cloud-asset-catalog.ts +71 -5
- package/server/dev.ts +1 -1
- package/server/editor-server.ts +106 -51
- package/server/editor-sse.ts +5 -0
- package/server/generative-execution-context.ts +45 -0
- package/server/model-import-conversion.ts +345 -0
- package/server/packaged.ts +1 -1
- package/server/project-hmr-files.ts +3 -5
- package/server/project-output-writer.ts +440 -36
- package/server/project-tools.ts +529 -0
- package/server/server-utils.ts +14 -11
- package/src/EditorContext.tsx +4 -4
- package/src/action-registry.ts +12 -0
- package/src/asset-editor-context.tsx +9 -0
- package/src/asset-events.ts +3 -15
- package/src/asset-selection.ts +3 -0
- package/src/asset-workflow/asset-capabilities.ts +40 -0
- package/src/asset-workflow/asset-import-jobs.ts +108 -0
- package/src/asset-workflow/asset-library-query.ts +8 -1
- package/src/asset-workflow/asset-workflow-quality.ts +2 -0
- package/src/asset-workflow/import-contract.ts +2 -0
- package/src/asset-workflow/model-inspection.ts +92 -1
- package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
- package/src/asset-workflow/project-asset-commands.ts +1 -0
- package/src/asset-workflow/project-asset-health.ts +3 -6
- package/src/asset-workflow/project-asset-operations.ts +119 -4
- package/src/command-listener.ts +37 -9
- package/src/components/ApplicationMenus.tsx +13 -15
- package/src/components/AssetBrowser.tsx +301 -26
- package/src/components/AssetImportDetails.tsx +207 -92
- package/src/components/AssetImportJobStatus.tsx +55 -0
- package/src/components/CenterDocuments.tsx +28 -1
- package/src/components/CommandPalette.tsx +2 -2
- package/src/components/DefaultEditorLayout.tsx +4 -0
- package/src/components/InspectorToolSection.tsx +12 -10
- package/src/components/OnlineAssetBrowser.tsx +197 -11
- package/src/components/ToolHost.tsx +8 -8
- package/src/components/ViewportOverlay.tsx +13 -26
- package/src/components/ViewportPanel.tsx +19 -14
- package/src/components/ViewportShadingMenu.tsx +103 -0
- package/src/components/WorkspaceDock.tsx +103 -17
- package/src/components/asset-documents.tsx +38 -11
- package/src/components/asset-editor-persistence.ts +300 -5
- package/src/components/asset-selection-section.tsx +83 -9
- package/src/components/asset-viewers/AudioViewer.tsx +4 -7
- package/src/components/asset-viewers/ModelViewer.tsx +168 -5
- package/src/components/asset-viewers/OnlineAssetDetail.tsx +52 -12
- package/src/components/asset-viewers/SourceAssetViewer.tsx +7 -26
- package/src/components/asset-workflow.css +30 -3
- package/src/components/primitives/editor-icons.ts +1 -0
- package/src/components/project-tool-documents.tsx +298 -0
- package/src/components/status-contributions.tsx +38 -0
- package/src/components/tool-documents.tsx +20 -17
- package/src/components/tool-schema-form.tsx +258 -0
- package/src/components/workspace-dock.css +16 -0
- package/src/editor-api.ts +159 -22
- package/src/editor-store.ts +6 -5
- package/src/editor-viewport.ts +12 -7
- package/src/game-viewport-state.ts +28 -0
- package/src/model-thumbnail.ts +97 -9
- package/src/play-mode.ts +30 -3
- package/src/project-tool-discovery.ts +5 -5
- package/src/{project-operations.ts → project-tools.ts} +39 -26
- package/src/scene-sync.ts +0 -67
- package/src/storage/handle-storage.ts +5 -1
- package/src/storage/http-storage.ts +7 -2
- package/src/storage/mem-storage.ts +7 -1
- package/src/storage/types.ts +2 -0
- package/src/theme-css.ts +17 -7
- package/src/tool-loader.ts +152 -241
- package/src/viewport-shading-boundary.ts +15 -0
- package/src/widgets/index.ts +2 -1
- package/src/workspace-dock-controller.ts +8 -0
- package/src/workspace-document-registry.ts +4 -4
- package/src/workspace-state-persistence.ts +36 -10
- package/template/.claude/skills/editor/SKILL.md +13 -3
- package/template/AGENTS.md +100 -83
- package/template/package.json +16 -0
- package/template/public/scenes/default.vscn.json +1 -2
- package/template/scripts/playtest.ts +7 -37
- package/template/server/rooms/game-room.ts +2 -2
- package/template/src/data/README.md +3 -2
- package/template/src/data/tuning.data.json +1 -3
- package/template/src/data/tuning.schema.json +0 -12
- package/template/src/data/tuning.schema.ts +2 -9
- package/template/src/data/tuning.ts +1 -1
- package/template/src/scripts/registry.ts +0 -3
- package/template/src/tools/README.md +151 -16
- package/template/src/tools/autoplay.tool.ts +31 -0
- package/template/src/tools/autoplay.ts +15 -0
- package/template/src/tools/tuning.document.tsx +107 -0
- package/template/src/tools/tuning.tool.ts +24 -0
- package/template/src/tools/tuning.ts +9 -0
- package/template/src/ui/Button.stories.tsx +1 -1
- package/template/tests/acceptance/example.spec.ts +4 -59
- package/template/vgai.game.json +1 -1
- package/dist/assets/index-bAGhEnnO.css +0 -1
- package/dist/assets/webworkerAll-BA4aq7xq.js +0 -1
- package/dist/project-scripts.bundle.js +0 -3917
- package/server/project-operations.ts +0 -343
- package/src/components/project-operation-documents.tsx +0 -226
- package/template/src/operations/README.md +0 -88
- package/template/src/scripts/components/data-spinner.ts +0 -86
- package/template/src/scripts/components/spin-lap-math.ts +0 -27
- package/template/src/tools/example.tool.tsx +0 -97
- package/template/src/tools/spin.tool.tsx +0 -101
- package/template/tests/logic/example.test.ts +0 -31
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
type AssetImportPlan,
|
|
6
|
+
assetImportPlanSchema,
|
|
7
|
+
importRecipePath,
|
|
8
|
+
} from '../src/asset-workflow/import-contract';
|
|
9
|
+
|
|
10
|
+
export interface CatalogRecipeSource {
|
|
11
|
+
stagedPath: string;
|
|
12
|
+
path: string;
|
|
13
|
+
url: string;
|
|
14
|
+
role: 'primary' | 'dependency' | 'material' | 'texture';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function writeCatalogImportRecipe(input: {
|
|
18
|
+
stagingDirectory: string;
|
|
19
|
+
source: string;
|
|
20
|
+
familyId: string;
|
|
21
|
+
variantId: string;
|
|
22
|
+
deliveryId: string;
|
|
23
|
+
displayName: string;
|
|
24
|
+
destination: string;
|
|
25
|
+
recipeRelativePath?: string;
|
|
26
|
+
sources: readonly CatalogRecipeSource[];
|
|
27
|
+
generatedFiles?: readonly string[];
|
|
28
|
+
contentKind?: AssetImportPlan['contentKind'];
|
|
29
|
+
targetSkeleton?: string;
|
|
30
|
+
runtimeReady: boolean;
|
|
31
|
+
backend: AssetImportPlan['backend'];
|
|
32
|
+
attribution: AssetImportPlan['attribution'];
|
|
33
|
+
settings?: Partial<AssetImportPlan['settings']>;
|
|
34
|
+
}): Promise<AssetImportPlan> {
|
|
35
|
+
const sourceFiles = await Promise.all(
|
|
36
|
+
input.sources.map(async (file) => {
|
|
37
|
+
const [bytes, info] = await Promise.all([readFile(file.stagedPath), stat(file.stagedPath)]);
|
|
38
|
+
return {
|
|
39
|
+
path: file.path,
|
|
40
|
+
url: file.url,
|
|
41
|
+
hash: createHash('sha256').update(bytes).digest('hex'),
|
|
42
|
+
size: info.size,
|
|
43
|
+
role: file.role,
|
|
44
|
+
};
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
const sourceHash = createHash('sha256')
|
|
48
|
+
.update(
|
|
49
|
+
JSON.stringify(
|
|
50
|
+
sourceFiles
|
|
51
|
+
.map(({ path, hash, size }) => ({ path, hash, size }))
|
|
52
|
+
.sort((left, right) => left.path.localeCompare(right.path)),
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
.digest('hex');
|
|
56
|
+
const plan = assetImportPlanSchema.parse({
|
|
57
|
+
version: 1,
|
|
58
|
+
id: randomUUID(),
|
|
59
|
+
identity: {
|
|
60
|
+
familyId: input.familyId,
|
|
61
|
+
variantId: input.variantId,
|
|
62
|
+
deliveryId: input.deliveryId,
|
|
63
|
+
source: input.source,
|
|
64
|
+
displayName: input.displayName,
|
|
65
|
+
},
|
|
66
|
+
sourceFiles,
|
|
67
|
+
generatedFiles: [...(input.generatedFiles ?? [])],
|
|
68
|
+
destination: input.destination,
|
|
69
|
+
contentKind: input.contentKind ?? 'model',
|
|
70
|
+
...(input.targetSkeleton ? { targetSkeleton: input.targetSkeleton } : {}),
|
|
71
|
+
settings: {
|
|
72
|
+
scale: 1,
|
|
73
|
+
sourceUnits: 'auto',
|
|
74
|
+
upAxis: 'auto',
|
|
75
|
+
forwardAxis: 'auto',
|
|
76
|
+
normals: 'preserve',
|
|
77
|
+
tangents: 'preserve',
|
|
78
|
+
materials: 'import',
|
|
79
|
+
textures: 'copy',
|
|
80
|
+
animations: [],
|
|
81
|
+
meshOptimization: 'safe',
|
|
82
|
+
retainSource: true,
|
|
83
|
+
...input.settings,
|
|
84
|
+
},
|
|
85
|
+
conflicts: [],
|
|
86
|
+
attribution: input.attribution,
|
|
87
|
+
sourceHash,
|
|
88
|
+
runtimeReady: input.runtimeReady,
|
|
89
|
+
backend: input.backend,
|
|
90
|
+
createdAt: new Date().toISOString(),
|
|
91
|
+
});
|
|
92
|
+
const recipeFile = input.recipeRelativePath ?? basename(importRecipePath(input.destination));
|
|
93
|
+
const recipePath = join(input.stagingDirectory, recipeFile);
|
|
94
|
+
await mkdir(dirname(recipePath), { recursive: true });
|
|
95
|
+
await writeFile(recipePath, `${JSON.stringify(plan, null, 2)}\n`);
|
|
96
|
+
return plan;
|
|
97
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { type AssetImportPlan, assetImportPlanSchema } from '../src/asset-workflow/import-contract';
|
|
4
|
+
import { type CatalogRecipeSource, writeCatalogImportRecipe } from './catalog-import-recipe';
|
|
5
|
+
import {
|
|
6
|
+
findLocalAsset,
|
|
7
|
+
findLocalAssetFamily,
|
|
8
|
+
getLocalAssetLibraryRoot,
|
|
9
|
+
resolveCatalogFile,
|
|
10
|
+
resolveLocalAssetThumbnail,
|
|
11
|
+
} from './local-asset-catalog';
|
|
12
|
+
import {
|
|
13
|
+
convertStagedModelToGlb,
|
|
14
|
+
SERVER_CONVERTIBLE_MODEL_FORMATS,
|
|
15
|
+
} from './model-import-conversion';
|
|
16
|
+
import { replaceStagedProjectDirectory } from './project-output-writer';
|
|
17
|
+
import { isPathInside } from './server-utils';
|
|
18
|
+
|
|
19
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: reacquisition, conversion, recipe regeneration, source-retention, and atomic replacement are one transaction boundary.
|
|
20
|
+
export async function reimportLocalCatalogAsset(
|
|
21
|
+
publicRoot: string,
|
|
22
|
+
assetPath: string,
|
|
23
|
+
rawPlan: AssetImportPlan,
|
|
24
|
+
): Promise<{ path: string; provenanceOperationId?: string }> {
|
|
25
|
+
const plan = assetImportPlanSchema.parse(rawPlan);
|
|
26
|
+
const normalizedAssetPath = assetPath.replace(/^\/+/, '');
|
|
27
|
+
if (normalizedAssetPath !== plan.destination || plan.identity.source !== 'local') {
|
|
28
|
+
throw new Error('Reimport identity does not match the selected local project asset.');
|
|
29
|
+
}
|
|
30
|
+
const family = await findLocalAssetFamily(plan.identity.familyId);
|
|
31
|
+
const variant = await findLocalAsset(plan.identity.variantId);
|
|
32
|
+
if (!family || !variant || variant.familyId !== family.id) {
|
|
33
|
+
throw new Error('The recorded catalog family/variant is no longer available.');
|
|
34
|
+
}
|
|
35
|
+
const sourceFile = variant.files[0];
|
|
36
|
+
if (!sourceFile) throw new Error('The recorded catalog variant has no source file.');
|
|
37
|
+
const destination = resolve(publicRoot, dirname(plan.destination));
|
|
38
|
+
if (!isPathInside(publicRoot, destination))
|
|
39
|
+
throw new Error('Reimport destination escapes public/.');
|
|
40
|
+
const staging = await mkdtemp(join(dirname(destination), `.${basename(destination)}.reimport-`));
|
|
41
|
+
try {
|
|
42
|
+
const libraryRoot = getLocalAssetLibraryRoot();
|
|
43
|
+
const primarySource = resolveCatalogFile(libraryRoot, sourceFile.path);
|
|
44
|
+
const primaryRelative = basename(primarySource);
|
|
45
|
+
const stagedPrimary = join(staging, primaryRelative);
|
|
46
|
+
await copyFile(primarySource, stagedPrimary);
|
|
47
|
+
const recipeSources: CatalogRecipeSource[] = [
|
|
48
|
+
{
|
|
49
|
+
stagedPath: stagedPrimary,
|
|
50
|
+
path: primaryRelative,
|
|
51
|
+
url: plan.sourceFiles[0]?.url ?? `vgai-local:${variant.id}:0`,
|
|
52
|
+
role: 'primary',
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
for (const dependency of sourceFile.dependencies ?? []) {
|
|
56
|
+
const dependencySource = resolveCatalogFile(libraryRoot, dependency);
|
|
57
|
+
const sourceRelative = relative(dirname(primarySource), dependencySource);
|
|
58
|
+
const dependencyRelative = sourceRelative.startsWith('..')
|
|
59
|
+
? basename(dependencySource)
|
|
60
|
+
: sourceRelative;
|
|
61
|
+
const destinationPath = join(staging, dependencyRelative);
|
|
62
|
+
await mkdir(dirname(destinationPath), { recursive: true });
|
|
63
|
+
await copyFile(dependencySource, destinationPath);
|
|
64
|
+
recipeSources.push({
|
|
65
|
+
stagedPath: destinationPath,
|
|
66
|
+
path: dependencyRelative.replaceAll('\\', '/'),
|
|
67
|
+
url: `catalog:${dependency}`,
|
|
68
|
+
role: dependencyRole(dependency),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const format = sourceFile.format.toLowerCase();
|
|
72
|
+
const resultFile = basename(plan.destination);
|
|
73
|
+
let backend: AssetImportPlan['backend'] = 'native';
|
|
74
|
+
if (SERVER_CONVERTIBLE_MODEL_FORMATS.has(format)) {
|
|
75
|
+
const output = await convertStagedModelToGlb(stagedPrimary, format, plan.settings);
|
|
76
|
+
await writeFile(join(staging, resultFile), output);
|
|
77
|
+
backend = 'threejs';
|
|
78
|
+
} else if (resultFile !== primaryRelative) {
|
|
79
|
+
await copyFile(stagedPrimary, join(staging, resultFile));
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const thumbnail = resolveLocalAssetThumbnail(libraryRoot, variant);
|
|
83
|
+
await copyFile(thumbnail, join(staging, '.vgai-thumbnail.webp'));
|
|
84
|
+
} catch {
|
|
85
|
+
// Thumbnails are a presentation cache and never invalidate a source reimport.
|
|
86
|
+
}
|
|
87
|
+
await writeCatalogImportRecipe({
|
|
88
|
+
stagingDirectory: staging,
|
|
89
|
+
source: 'local',
|
|
90
|
+
familyId: family.id,
|
|
91
|
+
variantId: variant.id,
|
|
92
|
+
deliveryId: plan.identity.deliveryId,
|
|
93
|
+
displayName: plan.identity.displayName ?? family.name,
|
|
94
|
+
destination: plan.destination,
|
|
95
|
+
sources: recipeSources,
|
|
96
|
+
contentKind: plan.contentKind,
|
|
97
|
+
...(plan.targetSkeleton ? { targetSkeleton: plan.targetSkeleton } : {}),
|
|
98
|
+
generatedFiles: backend === 'threejs' ? [plan.destination] : [],
|
|
99
|
+
runtimeReady: ['glb', 'gltf'].includes(format) || backend === 'threejs',
|
|
100
|
+
backend,
|
|
101
|
+
attribution: plan.attribution,
|
|
102
|
+
settings: plan.settings,
|
|
103
|
+
});
|
|
104
|
+
if (backend === 'threejs' && !plan.settings.retainSource) {
|
|
105
|
+
await Promise.all(recipeSources.map((source) => rm(source.stagedPath, { force: true })));
|
|
106
|
+
}
|
|
107
|
+
const result = await replaceStagedProjectDirectory({
|
|
108
|
+
projectRoot: dirname(publicRoot),
|
|
109
|
+
stagedDirectory: staging,
|
|
110
|
+
destination,
|
|
111
|
+
provenance: {
|
|
112
|
+
operationName: 'catalog.asset.reimport',
|
|
113
|
+
operationSource: 'vgai-catalog',
|
|
114
|
+
input: {
|
|
115
|
+
familyId: family.id,
|
|
116
|
+
variantId: variant.id,
|
|
117
|
+
sourceHash: plan.sourceHash,
|
|
118
|
+
settings: plan.settings,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
return {
|
|
123
|
+
path: plan.destination,
|
|
124
|
+
...(result.provenanceOperationId
|
|
125
|
+
? { provenanceOperationId: result.provenanceOperationId }
|
|
126
|
+
: {}),
|
|
127
|
+
};
|
|
128
|
+
} catch (error) {
|
|
129
|
+
await rm(staging, { recursive: true, force: true });
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function dependencyRole(path: string): 'dependency' | 'material' | 'texture' {
|
|
135
|
+
const extension = extname(path).toLowerCase();
|
|
136
|
+
if (extension === '.mtl') return 'material';
|
|
137
|
+
if (['.png', '.jpg', '.jpeg', '.webp', '.ktx', '.ktx2', '.dds', '.tga'].includes(extension)) {
|
|
138
|
+
return 'texture';
|
|
139
|
+
}
|
|
140
|
+
return 'dependency';
|
|
141
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const DEFAULT_CLOUD_ASSET_BASE_URL = 'https://vgai-asset-library.aaron-0ed.workers.dev';
|
|
2
2
|
|
|
3
3
|
export interface CloudAssetObject {
|
|
4
|
-
key
|
|
4
|
+
key?: string;
|
|
5
5
|
sha256: string;
|
|
6
6
|
sizeBytes: number;
|
|
7
7
|
contentType: string;
|
|
@@ -17,6 +17,8 @@ export interface CloudAssetFile {
|
|
|
17
17
|
|
|
18
18
|
export interface CloudAssetRecord {
|
|
19
19
|
id: string;
|
|
20
|
+
familyId?: string;
|
|
21
|
+
label?: string;
|
|
20
22
|
status: 'validated';
|
|
21
23
|
source: string;
|
|
22
24
|
name: string;
|
|
@@ -25,17 +27,24 @@ export interface CloudAssetRecord {
|
|
|
25
27
|
author: string;
|
|
26
28
|
sourceUrl: string;
|
|
27
29
|
attribution: string;
|
|
30
|
+
categories?: string[];
|
|
31
|
+
tags?: string[];
|
|
28
32
|
files: CloudAssetFile[];
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
interface CloudAssetManifest {
|
|
32
|
-
version: 1;
|
|
36
|
+
version: 1 | 2;
|
|
33
37
|
generatedAt: string;
|
|
34
38
|
assets: CloudAssetRecord[];
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
let cachedManifest: { value: CloudAssetManifest; url: string; loadedAt: number } | undefined;
|
|
38
42
|
const CACHE_MS = 5 * 60 * 1000;
|
|
43
|
+
const GRAPHQL_ID_BATCH = 500;
|
|
44
|
+
const graphqlAssetCache = new Map<
|
|
45
|
+
string,
|
|
46
|
+
{ baseUrl: string; value: CloudAssetRecord | null; loadedAt: number }
|
|
47
|
+
>();
|
|
39
48
|
|
|
40
49
|
export function getCloudAssetBaseUrl(): string {
|
|
41
50
|
return (process.env['VGAI_CLOUD_ASSET_URL'] ?? DEFAULT_CLOUD_ASSET_BASE_URL).replace(/\/+$/, '');
|
|
@@ -54,7 +63,7 @@ export async function loadCloudAssetManifest(): Promise<CloudAssetManifest | nul
|
|
|
54
63
|
const response = await fetch(`${baseUrl}/v1/manifest`, { signal: AbortSignal.timeout(5_000) });
|
|
55
64
|
if (!response.ok) return null;
|
|
56
65
|
const value = (await response.json()) as CloudAssetManifest;
|
|
57
|
-
if (value.version !== 1 || !Array.isArray(value.assets)) return null;
|
|
66
|
+
if ((value.version !== 1 && value.version !== 2) || !Array.isArray(value.assets)) return null;
|
|
58
67
|
cachedManifest = { value, url: baseUrl, loadedAt: Date.now() };
|
|
59
68
|
return value;
|
|
60
69
|
} catch {
|
|
@@ -63,8 +72,65 @@ export async function loadCloudAssetManifest(): Promise<CloudAssetManifest | nul
|
|
|
63
72
|
}
|
|
64
73
|
|
|
65
74
|
export async function findCloudAsset(id: string): Promise<CloudAssetRecord | null> {
|
|
66
|
-
|
|
67
|
-
|
|
75
|
+
return (await findCloudAssets([id]))[0] ?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function queryCloudAssets(ids: readonly string[]): Promise<CloudAssetRecord[] | null> {
|
|
79
|
+
if (ids.length === 0) return [];
|
|
80
|
+
const response = await fetch(`${getCloudAssetBaseUrl()}/graphql`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'Content-Type': 'application/json' },
|
|
83
|
+
body: JSON.stringify({
|
|
84
|
+
query: `query CloudAssets($ids: [ID!]!) {
|
|
85
|
+
assetsByIds(ids: $ids) {
|
|
86
|
+
id familyId label status source name type license author sourceUrl attribution categories tags
|
|
87
|
+
files: deliveries {
|
|
88
|
+
label format
|
|
89
|
+
main { sha256 sizeBytes contentType relativePath }
|
|
90
|
+
dependencies { sha256 sizeBytes contentType relativePath }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}`,
|
|
94
|
+
variables: { ids },
|
|
95
|
+
}),
|
|
96
|
+
signal: AbortSignal.timeout(5_000),
|
|
97
|
+
});
|
|
98
|
+
if (!response.ok) return null;
|
|
99
|
+
const body = (await response.json()) as {
|
|
100
|
+
data?: { assetsByIds?: CloudAssetRecord[] };
|
|
101
|
+
errors?: unknown[];
|
|
102
|
+
};
|
|
103
|
+
return body.errors || !Array.isArray(body.data?.assetsByIds) ? null : body.data.assetsByIds;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Resolve exact hosted variants through GraphQL; REST manifest is rollout fallback only. */
|
|
107
|
+
export async function findCloudAssets(ids: readonly string[]): Promise<CloudAssetRecord[]> {
|
|
108
|
+
const uniqueIds = [...new Set(ids)];
|
|
109
|
+
const baseUrl = getCloudAssetBaseUrl();
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
const unresolved = uniqueIds.filter((id) => {
|
|
112
|
+
const cached = graphqlAssetCache.get(id);
|
|
113
|
+
return !cached || cached.baseUrl !== baseUrl || now - cached.loadedAt >= CACHE_MS;
|
|
114
|
+
});
|
|
115
|
+
try {
|
|
116
|
+
for (let offset = 0; offset < unresolved.length; offset += GRAPHQL_ID_BATCH) {
|
|
117
|
+
const batch = unresolved.slice(offset, offset + GRAPHQL_ID_BATCH);
|
|
118
|
+
const found = await queryCloudAssets(batch);
|
|
119
|
+
if (!found) throw new Error('GraphQL catalog unavailable.');
|
|
120
|
+
const byId = new Map(found.map((asset) => [asset.id, asset]));
|
|
121
|
+
for (const id of batch) {
|
|
122
|
+
graphqlAssetCache.set(id, { baseUrl, value: byId.get(id) ?? null, loadedAt: now });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return uniqueIds.flatMap((id) => {
|
|
126
|
+
const asset = graphqlAssetCache.get(id)?.value;
|
|
127
|
+
return asset ? [asset] : [];
|
|
128
|
+
});
|
|
129
|
+
} catch {
|
|
130
|
+
const manifest = await loadCloudAssetManifest();
|
|
131
|
+
const requested = new Set(uniqueIds);
|
|
132
|
+
return manifest?.assets.filter((asset) => requested.has(asset.id)) ?? [];
|
|
133
|
+
}
|
|
68
134
|
}
|
|
69
135
|
|
|
70
136
|
export function cloudAssetObjectUrl(
|
package/server/dev.ts
CHANGED
|
@@ -209,7 +209,7 @@ async function main(): Promise<void> {
|
|
|
209
209
|
engineRoot,
|
|
210
210
|
projectPath,
|
|
211
211
|
watchEngineSource: true,
|
|
212
|
-
loadProjectModule: (moduleId) => vite.ssrLoadModule(moduleId),
|
|
212
|
+
loadProjectModule: (moduleId: string) => vite.ssrLoadModule(moduleId),
|
|
213
213
|
onProjectOpened(newProjectPath: string) {
|
|
214
214
|
// Dynamically allow Vite to serve /@fs/ paths from the new project directory
|
|
215
215
|
const allowed = vite.config.server.fs.allow;
|
package/server/editor-server.ts
CHANGED
|
@@ -19,6 +19,13 @@ import express from 'express';
|
|
|
19
19
|
import { GAME_MANIFEST_VERSION } from '../../engine/src/manifest/schema';
|
|
20
20
|
import { applyAssetRename } from '../../engine/src/scene/asset-paths';
|
|
21
21
|
import type { SceneFile } from '../../engine/src/scene/scene-types';
|
|
22
|
+
import {
|
|
23
|
+
deriveProjectAttributionReport,
|
|
24
|
+
emptyProjectProvenanceDocument,
|
|
25
|
+
formatProjectAttributionMarkdown,
|
|
26
|
+
PROJECT_PROVENANCE_PATH,
|
|
27
|
+
ProjectProvenanceDocumentSchema,
|
|
28
|
+
} from '../../vgai-sdk/src/project/provenance';
|
|
22
29
|
import {
|
|
23
30
|
assertEditorCompatibility,
|
|
24
31
|
assertProjectCompatibility,
|
|
@@ -39,10 +46,10 @@ import {
|
|
|
39
46
|
} from './editor-sse';
|
|
40
47
|
import { HarnessChatService } from './harness-chat-service';
|
|
41
48
|
import {
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
discoverProjectTools,
|
|
50
|
+
executeProjectTool,
|
|
44
51
|
type ProjectModuleLoader,
|
|
45
|
-
} from './project-
|
|
52
|
+
} from './project-tools';
|
|
46
53
|
import { classifyValidatableFile, validateProjectFile } from './project-validation';
|
|
47
54
|
import { readProjectView } from './project-view';
|
|
48
55
|
import type { CommandResult } from './server-utils';
|
|
@@ -70,6 +77,7 @@ interface AssetEntry {
|
|
|
70
77
|
name: string;
|
|
71
78
|
type: 'file' | 'directory';
|
|
72
79
|
size: number;
|
|
80
|
+
mtime: number;
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
interface RecentProject {
|
|
@@ -142,9 +150,26 @@ export interface EditorServerOptions {
|
|
|
142
150
|
/** Dev checkout only: mark this long-running process stale when server-loaded source changes. */
|
|
143
151
|
watchEngineSource?: boolean | undefined;
|
|
144
152
|
/** Node-side project-module loader. Vite's ssrLoadModule supplies this in
|
|
145
|
-
* dev and packaged editors so discoverable project
|
|
153
|
+
* dev and packaged editors so discoverable project tools can remain
|
|
146
154
|
* ordinary TypeScript using the project's own dependency graph. */
|
|
147
155
|
loadProjectModule?: ProjectModuleLoader | undefined;
|
|
156
|
+
/** Install a newly scaffolded project's dependencies before reporting it ready. */
|
|
157
|
+
installProjectDependencies?: ((projectPath: string) => Promise<void>) | undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function installProjectDependencies(projectPath: string): Promise<void> {
|
|
161
|
+
await new Promise<void>((resolveInstall, rejectInstall) => {
|
|
162
|
+
const child = spawn(
|
|
163
|
+
'npm',
|
|
164
|
+
['install', '--no-audit', '--no-fund', '--loglevel=error'],
|
|
165
|
+
{ cwd: projectPath, stdio: 'ignore', shell: true },
|
|
166
|
+
);
|
|
167
|
+
child.once('error', rejectInstall);
|
|
168
|
+
child.once('close', (code) => {
|
|
169
|
+
if (code === 0) resolveInstall();
|
|
170
|
+
else rejectInstall(new Error(`npm install exited with code ${code ?? 'unknown'}`));
|
|
171
|
+
});
|
|
172
|
+
});
|
|
148
173
|
}
|
|
149
174
|
|
|
150
175
|
function readRunningEngineVersion(engineRoot: string): string | null {
|
|
@@ -654,9 +679,8 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
|
|
|
654
679
|
// packaged/prod (this module is shared by both), and on drvfs
|
|
655
680
|
// (`/mnt/c`, no inotify) via the same `VGAI_WATCH_POLL` env var.
|
|
656
681
|
//
|
|
657
|
-
// Tool/data/story lists use add/unlink.
|
|
658
|
-
//
|
|
659
|
-
// implementation edits require a fresh catalog request.
|
|
682
|
+
// Tool/data/story lists use add/unlink. Registered tool modules also use
|
|
683
|
+
// change because their metadata and implementation are Node-loaded.
|
|
660
684
|
const srcDir = join(projectRoot, 'src');
|
|
661
685
|
if (projectRoot !== engineRoot && existsSync(srcDir)) {
|
|
662
686
|
// #131: same polling decision as the publicRoot watcher above (env
|
|
@@ -674,20 +698,18 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
|
|
|
674
698
|
tools: 'tool-files-changed',
|
|
675
699
|
data: 'data-files-changed',
|
|
676
700
|
stories: 'story-files-changed',
|
|
677
|
-
operations: 'operation-files-changed',
|
|
678
701
|
}[kind]
|
|
679
702
|
: undefined;
|
|
680
703
|
if (event) broadcastDebounced(event, { path: rel });
|
|
681
704
|
};
|
|
682
705
|
srcWatcher.on('add', onListChange);
|
|
683
706
|
srcWatcher.on('unlink', onListChange);
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
// read when metadata or implementation changes in place.
|
|
707
|
+
// Registered callable tool edits need a fresh Node-side catalog read;
|
|
708
|
+
// contribution modules still ride ordinary Vite HMR in the browser.
|
|
687
709
|
srcWatcher.on('change', (filePath) => {
|
|
688
710
|
const rel = relative(projectRoot, resolve(filePath)).split(sep).join('/');
|
|
689
|
-
if (classifyProjectSrcPath(rel) === '
|
|
690
|
-
broadcastDebounced('
|
|
711
|
+
if (classifyProjectSrcPath(rel) === 'tools') {
|
|
712
|
+
broadcastDebounced('tool-files-changed', { path: rel });
|
|
691
713
|
}
|
|
692
714
|
});
|
|
693
715
|
srcWatcher.on('add', (filePath) => scheduleFileValidation(resolve(filePath)));
|
|
@@ -852,6 +874,55 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
|
|
|
852
874
|
createAssetLibraryRouter(() => publicRoot),
|
|
853
875
|
);
|
|
854
876
|
|
|
877
|
+
// Derived credits view. The sole durable source is central project provenance.
|
|
878
|
+
router.get('/__editor/project-attribution', async (req: Request, res: Response) => {
|
|
879
|
+
if (projectRoot === engineRoot) {
|
|
880
|
+
res.status(400).json({ error: 'No project is open.' });
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
try {
|
|
884
|
+
const raw = await readFile(join(projectRoot, PROJECT_PROVENANCE_PATH), 'utf8').catch(
|
|
885
|
+
(error: NodeJS.ErrnoException) => {
|
|
886
|
+
if (error.code === 'ENOENT') return null;
|
|
887
|
+
throw error;
|
|
888
|
+
},
|
|
889
|
+
);
|
|
890
|
+
const provenance = raw
|
|
891
|
+
? ProjectProvenanceDocumentSchema.parse(JSON.parse(raw))
|
|
892
|
+
: emptyProjectProvenanceDocument();
|
|
893
|
+
const activeOperations = Object.fromEntries(
|
|
894
|
+
(
|
|
895
|
+
await Promise.all(
|
|
896
|
+
Object.entries(provenance.operations).map(async ([operationId, operation]) => {
|
|
897
|
+
const present = await Promise.all(
|
|
898
|
+
operation.outputs.map(async (output) => {
|
|
899
|
+
const absolute = resolve(projectRoot, output.path);
|
|
900
|
+
if (!isPathInside(projectRoot, absolute)) return false;
|
|
901
|
+
return stat(absolute).then(
|
|
902
|
+
(info) => info.isFile(),
|
|
903
|
+
() => false,
|
|
904
|
+
);
|
|
905
|
+
}),
|
|
906
|
+
);
|
|
907
|
+
return present.some(Boolean) ? ([operationId, operation] as const) : null;
|
|
908
|
+
}),
|
|
909
|
+
)
|
|
910
|
+
).filter((entry): entry is readonly [string, (typeof provenance.operations)[string]] =>
|
|
911
|
+
Boolean(entry),
|
|
912
|
+
),
|
|
913
|
+
);
|
|
914
|
+
const report = deriveProjectAttributionReport({ version: 1, operations: activeOperations });
|
|
915
|
+
const format = typeof req.query['format'] === 'string' ? req.query['format'] : 'json';
|
|
916
|
+
if (format === 'markdown' || req.accepts(['json', 'text/markdown']) === 'text/markdown') {
|
|
917
|
+
res.type('text/markdown').send(formatProjectAttributionMarkdown(report));
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
res.json(report);
|
|
921
|
+
} catch (error) {
|
|
922
|
+
res.status(500).json({ error: `Project attribution failed: ${error}` });
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
|
|
855
926
|
// ---- Project file serving middleware ----
|
|
856
927
|
// Serves the v2 manifest from root and static files from project's public/
|
|
857
928
|
router.use((req: Request, res: Response, next) => {
|
|
@@ -1253,11 +1324,11 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
|
|
|
1253
1324
|
for (const d of dirents) {
|
|
1254
1325
|
if (d.name.startsWith('.')) continue;
|
|
1255
1326
|
const entryPath = join(absDir, d.name);
|
|
1327
|
+
const s = await stat(entryPath);
|
|
1256
1328
|
if (d.isDirectory()) {
|
|
1257
|
-
entries.push({ name: d.name, type: 'directory', size: 0 });
|
|
1329
|
+
entries.push({ name: d.name, type: 'directory', size: 0, mtime: s.mtimeMs });
|
|
1258
1330
|
} else if (d.isFile()) {
|
|
1259
|
-
|
|
1260
|
-
entries.push({ name: d.name, type: 'file', size: s.size });
|
|
1331
|
+
entries.push({ name: d.name, type: 'file', size: s.size, mtime: s.mtimeMs });
|
|
1261
1332
|
}
|
|
1262
1333
|
}
|
|
1263
1334
|
|
|
@@ -1513,6 +1584,8 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
|
|
|
1513
1584
|
...(exampleId !== undefined ? { exampleId } : {}),
|
|
1514
1585
|
});
|
|
1515
1586
|
|
|
1587
|
+
await (options.installProjectDependencies ?? installProjectDependencies)(result.targetDir);
|
|
1588
|
+
|
|
1516
1589
|
// Ensure .vgai/ directory exists
|
|
1517
1590
|
await mkdir(join(projectDir, '.vgai'), { recursive: true });
|
|
1518
1591
|
|
|
@@ -2075,50 +2148,27 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
|
|
|
2075
2148
|
}
|
|
2076
2149
|
});
|
|
2077
2150
|
|
|
2078
|
-
// ----
|
|
2079
|
-
//
|
|
2080
|
-
// Discovery for the dock's project-tool tabs: every
|
|
2081
|
-
// `src/tools/**/*.tool.tsx` of the open project (folder scan, same D3
|
|
2082
|
-
// physics as `/__editor/data-files` above — no registry, no cached
|
|
2083
|
-
// manifest; §3.5 rule 2). Paths only: unlike data files, tool MODULES are
|
|
2084
|
-
// loaded client-side through Vite's `/@fs/` dynamic-import path (the
|
|
2085
|
-
// `script-loader.ts` pattern), so the code arrives transpiled with HMR —
|
|
2086
|
-
// never as raw text through this route.
|
|
2087
|
-
router.get('/__editor/tool-files', async (_req: Request, res: Response) => {
|
|
2088
|
-
if (projectRoot === engineRoot) {
|
|
2089
|
-
res.json({ files: [] });
|
|
2090
|
-
return;
|
|
2091
|
-
}
|
|
2092
|
-
const toolsDir = join(projectRoot, 'src', 'tools');
|
|
2093
|
-
const found = await findFiles(toolsDir, '.tool.tsx');
|
|
2094
|
-
res.json({
|
|
2095
|
-
files: found.sort().map((abs) => ({
|
|
2096
|
-
path: relative(projectRoot, abs).split('\\').join('/'),
|
|
2097
|
-
})),
|
|
2098
|
-
});
|
|
2099
|
-
});
|
|
2100
|
-
|
|
2101
|
-
// ---- Project operations (docs/PROJECT-OPERATIONS-DESIGN.md) ----
|
|
2151
|
+
// ---- Registered project tools (docs/PROJECT-TOOLS-DESIGN.md) ----
|
|
2102
2152
|
//
|
|
2103
|
-
//
|
|
2104
|
-
//
|
|
2153
|
+
// Callable modules are loaded on the NODE side. Merely listing a native
|
|
2154
|
+
// asset/map build script must never import it
|
|
2105
2155
|
// into the editor browser or make it reachable from a standalone game
|
|
2106
2156
|
// bundle. Vite's ssrLoadModule is injected by the owning server and gives
|
|
2107
2157
|
// us normal TS transform + project dependency resolution without a second
|
|
2108
2158
|
// script runtime.
|
|
2109
|
-
router.get('/__editor/project-
|
|
2159
|
+
router.get('/__editor/project-tools', async (_req: Request, res: Response) => {
|
|
2110
2160
|
if (projectRoot === engineRoot) {
|
|
2111
|
-
res.json({
|
|
2161
|
+
res.json({ tools: [], loadErrors: [] });
|
|
2112
2162
|
return;
|
|
2113
2163
|
}
|
|
2114
|
-
res.json(await
|
|
2164
|
+
res.json(await discoverProjectTools(projectRoot, options.loadProjectModule));
|
|
2115
2165
|
});
|
|
2116
2166
|
|
|
2117
|
-
router.post('/__editor/project-
|
|
2167
|
+
router.post('/__editor/project-tools/run', async (req: Request, res: Response) => {
|
|
2118
2168
|
if (projectRoot === engineRoot) {
|
|
2119
2169
|
res.status(400).json({
|
|
2120
2170
|
ok: false,
|
|
2121
|
-
error: { code: 'NO_PROJECT_OPEN', message: 'Open a project before running
|
|
2171
|
+
error: { code: 'NO_PROJECT_OPEN', message: 'Open a project before running a tool.' },
|
|
2122
2172
|
});
|
|
2123
2173
|
return;
|
|
2124
2174
|
}
|
|
@@ -2126,13 +2176,13 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
|
|
|
2126
2176
|
if (typeof body.name !== 'string' || body.name.trim() === '') {
|
|
2127
2177
|
res.status(400).json({
|
|
2128
2178
|
ok: false,
|
|
2129
|
-
error: { code: '
|
|
2179
|
+
error: { code: 'INVALID_TOOL_NAME', message: 'Expected a non-empty tool name.' },
|
|
2130
2180
|
});
|
|
2131
2181
|
return;
|
|
2132
2182
|
}
|
|
2133
2183
|
const controller = new AbortController();
|
|
2134
2184
|
req.once('aborted', () => controller.abort());
|
|
2135
|
-
const result = await
|
|
2185
|
+
const result = await executeProjectTool({
|
|
2136
2186
|
projectRoot,
|
|
2137
2187
|
loadModule: options.loadProjectModule,
|
|
2138
2188
|
name: body.name,
|
|
@@ -2251,6 +2301,7 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
|
|
|
2251
2301
|
// ---- Play-mode console log persistence ----
|
|
2252
2302
|
const MAX_LOG_FILES = 20;
|
|
2253
2303
|
let activeLogFile: string | null = null;
|
|
2304
|
+
let logSessionSequence = 0;
|
|
2254
2305
|
|
|
2255
2306
|
router.post('/__editor/log-session', async (req: Request, res: Response) => {
|
|
2256
2307
|
const body = req.body as { action: 'start' | 'end' };
|
|
@@ -2273,8 +2324,12 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
|
|
|
2273
2324
|
}
|
|
2274
2325
|
|
|
2275
2326
|
const now = new Date();
|
|
2276
|
-
|
|
2277
|
-
|
|
2327
|
+
// Milliseconds plus a process-local sequence make restart artifacts
|
|
2328
|
+
// distinct even when two sessions begin inside the same millisecond.
|
|
2329
|
+
// Lexicographic order remains chronological for pruning/tail tooling.
|
|
2330
|
+
const ts = now.toISOString().replace(/[:.]/g, '-');
|
|
2331
|
+
const sequence = String(++logSessionSequence).padStart(4, '0');
|
|
2332
|
+
const filename = `play-${ts}-${sequence}.jsonl`;
|
|
2278
2333
|
activeLogFile = join(logsDir, filename);
|
|
2279
2334
|
res.json({ ok: true, file: filename });
|
|
2280
2335
|
} else {
|
package/server/editor-sse.ts
CHANGED
|
@@ -119,6 +119,11 @@ export function updateClientControlHealth(clientId: string, health: ClientContro
|
|
|
119
119
|
clientControlHealth.set(clientId, health);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/** Clear project-specific health snapshots while keeping live SSE sockets. */
|
|
123
|
+
export function clearClientControlHealth(): void {
|
|
124
|
+
clientControlHealth.clear();
|
|
125
|
+
}
|
|
126
|
+
|
|
122
127
|
/** Number of currently-connected clients (exposed for tests). */
|
|
123
128
|
export function clientCount(): number {
|
|
124
129
|
return sseClients.size;
|