@openfairygui/backend 0.3.0 → 0.3.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/README.md +4 -2
- package/dist/index.cjs +89 -29
- package/dist/index.d.cts +30 -7
- package/dist/index.d.mts +30 -7
- package/dist/index.mjs +89 -29
- package/dist/node.cjs +277 -44
- package/dist/node.d.cts +29 -6
- package/dist/node.d.mts +29 -6
- package/dist/node.mjs +276 -44
- package/package.json +8 -5
- package/src/index.ts +3 -0
- package/src/node.ts +213 -14
- package/src/path-policy.ts +18 -0
- package/src/runtime/capabilities.ts +2 -2
- package/src/runtime/contracts.ts +35 -3
- package/src/runtime.ts +27 -8
- package/src/services/authoring-service.ts +3 -3
- package/src/services/context.ts +1 -0
- package/src/services/runtime-service.ts +53 -14
- package/src/services/session-project-writer.ts +31 -13
package/dist/node.d.mts
CHANGED
|
@@ -72,6 +72,12 @@ interface BackendFileSystem {
|
|
|
72
72
|
recursive?: boolean;
|
|
73
73
|
}): Promise<void>;
|
|
74
74
|
resolvePath(filePath: string): Promise<string>;
|
|
75
|
+
/** Optional host validation before a project is read. Node rejects links anywhere in the project tree. */
|
|
76
|
+
validateProjectRoot?(projectRoot: string): Promise<void>;
|
|
77
|
+
/** Optional host-specific lock location. Node keeps it beside the project so directory swaps do not move it. */
|
|
78
|
+
getSessionLockPath?(canonicalProjectPath: string): string;
|
|
79
|
+
/** Runs project writes against a staged copy and commits them as one directory swap. */
|
|
80
|
+
runProjectWriteTransaction?(projectRoot: string, write: (stagedFileSystem: BackendFileSystem) => Promise<void>): Promise<void>;
|
|
75
81
|
acquireSessionLock(lockPath: string): Promise<BackendSessionLock>;
|
|
76
82
|
unlink(filePath: string): Promise<void>;
|
|
77
83
|
rmdir(dirPath: string): Promise<void>;
|
|
@@ -163,7 +169,7 @@ interface BackendCapabilities {
|
|
|
163
169
|
sessionRuntime: true;
|
|
164
170
|
advisoryLocking: true;
|
|
165
171
|
coordinatedSave: true;
|
|
166
|
-
atomicSave:
|
|
172
|
+
atomicSave: boolean;
|
|
167
173
|
staleRevisionProtection: true;
|
|
168
174
|
pathPolicy: {
|
|
169
175
|
canonicalization: 'realpath+normalized-casefold';
|
|
@@ -297,6 +303,11 @@ interface AdvisoryLockConflictError {
|
|
|
297
303
|
holderSessionId?: string;
|
|
298
304
|
lockFilePath: string;
|
|
299
305
|
}
|
|
306
|
+
interface SessionIdConflictError {
|
|
307
|
+
code: 'session_id_conflict';
|
|
308
|
+
message: string;
|
|
309
|
+
sessionId: string;
|
|
310
|
+
}
|
|
300
311
|
interface SavePartialFailureError {
|
|
301
312
|
code: 'save_partial_failure';
|
|
302
313
|
message: string;
|
|
@@ -306,7 +317,7 @@ interface SavePartialFailureError {
|
|
|
306
317
|
lastSavedRevision: number;
|
|
307
318
|
committedPaths: string[];
|
|
308
319
|
failedPaths: string[];
|
|
309
|
-
diskMayBePartiallyUpdated:
|
|
320
|
+
diskMayBePartiallyUpdated: boolean;
|
|
310
321
|
}
|
|
311
322
|
interface UamFidelityUnsupportedError {
|
|
312
323
|
code: 'uam_fidelity_unsupported';
|
|
@@ -333,7 +344,7 @@ interface MaterializeWriteFailedError {
|
|
|
333
344
|
failedPaths: string[];
|
|
334
345
|
skippedPaths: string[];
|
|
335
346
|
diagnostics: BackendDiagnostic[];
|
|
336
|
-
diskMayBePartiallyUpdated:
|
|
347
|
+
diskMayBePartiallyUpdated: boolean;
|
|
337
348
|
}
|
|
338
349
|
type BackendEventKind = 'session.opened' | 'transaction.applied' | 'transaction.rejected' | 'save.started' | 'save.completed' | 'save.failed' | 'session.closeRequested' | 'session.closed' | 'cache.invalidated' | 'cache.updated' | 'job.created' | 'job.started' | 'job.progress' | 'job.cancelRequested' | 'job.cancelled' | 'job.completed' | 'job.failed';
|
|
339
350
|
interface BackendEvent {
|
|
@@ -465,7 +476,17 @@ interface RefreshCacheInput {
|
|
|
465
476
|
sessionId: string;
|
|
466
477
|
reason?: 'manual' | 'session_open' | 'after_save';
|
|
467
478
|
}
|
|
468
|
-
type BackendError = SessionNotFoundError | SessionStaleWriteError | InProcessLockConflictError | AdvisoryLockConflictError | SavePartialFailureError | UamFidelityUnsupportedError | MaterializeValidationFailedError | MaterializeWriteFailedError | PathPolicyViolationError | EventCursorInvalidError | BackendJobNotFoundError | BackendJobNotCancellableError | BackendJobCancelledError | CacheRefreshFailedError | BackendCapabilityUnavailableError | ApplyUamTransactionAppError;
|
|
479
|
+
type BackendError = SessionNotFoundError | SessionIdConflictError | SessionStaleWriteError | InProcessLockConflictError | AdvisoryLockConflictError | SavePartialFailureError | UamFidelityUnsupportedError | MaterializeValidationFailedError | MaterializeWriteFailedError | PathPolicyViolationError | EventCursorInvalidError | BackendJobNotFoundError | BackendJobNotCancellableError | BackendJobCancelledError | CacheRefreshFailedError | BackendCapabilityUnavailableError | ProjectRootNotAllowedError | ProjectOpenFailedError | ApplyUamTransactionAppError;
|
|
480
|
+
interface ProjectRootNotAllowedError {
|
|
481
|
+
code: 'project_root_not_allowed';
|
|
482
|
+
message: string;
|
|
483
|
+
projectPath: string;
|
|
484
|
+
}
|
|
485
|
+
interface ProjectOpenFailedError {
|
|
486
|
+
code: 'project_open_failed';
|
|
487
|
+
message: string;
|
|
488
|
+
projectPath: string;
|
|
489
|
+
}
|
|
469
490
|
interface ApplySessionTransactionInput {
|
|
470
491
|
sessionId: string;
|
|
471
492
|
expectedRevision: number;
|
|
@@ -512,6 +533,8 @@ interface MaterializeSessionInput {
|
|
|
512
533
|
interface BackendRuntimeOptions {
|
|
513
534
|
fileSystem?: BackendFileSystem;
|
|
514
535
|
host?: BackendHostAdapter;
|
|
536
|
+
/** Canonical filesystem roots available to file-backed sessions. Omit for unrestricted library use. */
|
|
537
|
+
allowedProjectRoots?: readonly string[];
|
|
515
538
|
}
|
|
516
539
|
//#endregion
|
|
517
540
|
//#region src/runtime.d.ts
|
|
@@ -535,8 +558,8 @@ declare class BackendRuntime {
|
|
|
535
558
|
getCapabilities(): BackendSuccess<BackendCapabilities>;
|
|
536
559
|
openSession(input: {
|
|
537
560
|
projectPath: string;
|
|
538
|
-
}): Promise<BackendResult<BackendSessionSnapshot, InProcessLockConflictError | AdvisoryLockConflictError | BackendCapabilityUnavailableError>>;
|
|
539
|
-
openProjectSession(input: OpenProjectSessionInput): BackendResult<BackendSessionSnapshot>;
|
|
561
|
+
}): Promise<BackendResult<BackendSessionSnapshot, InProcessLockConflictError | AdvisoryLockConflictError | BackendCapabilityUnavailableError | ProjectRootNotAllowedError | ProjectOpenFailedError>>;
|
|
562
|
+
openProjectSession(input: OpenProjectSessionInput): BackendResult<BackendSessionSnapshot, InProcessLockConflictError | SessionIdConflictError>;
|
|
540
563
|
getSession(input: {
|
|
541
564
|
sessionId: string;
|
|
542
565
|
}): BackendResult<BackendSessionSnapshot, SessionNotFoundError>;
|
package/dist/node.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
3
5
|
import { UAM_SUPPORTED_MATERIALIZATION_SCOPE, UAM_SUPPORTED_TRANSACTION_SCOPE, commitUamProjectSourcePaths, liftDocumentToUamProject, materializeUamProject, normalizeUamProject, staleBranchDirectories, staleResourceFolders, staleSourceFiles, validateUamProject } from "@openfairygui/core/uam";
|
|
4
6
|
import { applyUamTransactionAppAsync } from "@openfairygui/functions/uam";
|
|
5
7
|
import { ProjectReader, ProjectWriter } from "@openfairygui/core/project-io";
|
|
@@ -34,6 +36,16 @@ function createRuntimePathPolicy() {
|
|
|
34
36
|
workspaceBoundary: "project-root-only"
|
|
35
37
|
};
|
|
36
38
|
}
|
|
39
|
+
async function assertProjectPathContained(fileSystem, projectRoot, targetPath) {
|
|
40
|
+
const [resolvedRoot, resolvedTarget] = await Promise.all([fileSystem.resolvePath(projectRoot), fileSystem.resolvePath(targetPath)]);
|
|
41
|
+
const root = normalizeComparablePath(resolvedRoot);
|
|
42
|
+
const target = normalizeComparablePath(resolvedTarget);
|
|
43
|
+
if (root === "." && !target.startsWith("/") && !/^[a-z]:\//i.test(target)) return;
|
|
44
|
+
if (target === root || target.startsWith(`${root}/`)) return;
|
|
45
|
+
const error = /* @__PURE__ */ new Error(`Project path escapes the opened root: ${targetPath}`);
|
|
46
|
+
error.code = "EACCES";
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
37
49
|
async function resolveFairyPath(fileSystem, input) {
|
|
38
50
|
const resolvedInput = fileSystem.resolve(input);
|
|
39
51
|
const stat = await fileSystem.stat(resolvedInput);
|
|
@@ -122,10 +134,14 @@ function failure(stage, startedAt, error, session, options) {
|
|
|
122
134
|
}
|
|
123
135
|
//#endregion
|
|
124
136
|
//#region src/services/session-project-writer.ts
|
|
125
|
-
function createWriterFileSystem(fileSystem, writtenPaths, failedPaths) {
|
|
137
|
+
function createWriterFileSystem(fileSystem, projectRoot, writtenPaths, failedPaths) {
|
|
138
|
+
const contained = async (targetPath, operation) => {
|
|
139
|
+
await assertProjectPathContained(fileSystem, projectRoot, targetPath);
|
|
140
|
+
return operation();
|
|
141
|
+
};
|
|
126
142
|
async function trackWrite(targetPath, write) {
|
|
127
143
|
try {
|
|
128
|
-
const result = await write
|
|
144
|
+
const result = await contained(targetPath, write);
|
|
129
145
|
writtenPaths.push(targetPath);
|
|
130
146
|
return result;
|
|
131
147
|
} catch (error) {
|
|
@@ -134,8 +150,8 @@ function createWriterFileSystem(fileSystem, writtenPaths, failedPaths) {
|
|
|
134
150
|
}
|
|
135
151
|
}
|
|
136
152
|
return {
|
|
137
|
-
readFile: (path) => fileSystem.readFile(path),
|
|
138
|
-
readFileRaw: (path) => fileSystem.readFileRaw(path),
|
|
153
|
+
readFile: (path) => contained(path, () => fileSystem.readFile(path)),
|
|
154
|
+
readFileRaw: (path) => contained(path, () => fileSystem.readFileRaw(path)),
|
|
139
155
|
writeFile: (path, content) => trackWrite(path, async () => {
|
|
140
156
|
await fileSystem.mkdir(fileSystem.dirname(path), { recursive: true });
|
|
141
157
|
await fileSystem.writeFile(path, content);
|
|
@@ -144,9 +160,10 @@ function createWriterFileSystem(fileSystem, writtenPaths, failedPaths) {
|
|
|
144
160
|
await fileSystem.mkdir(fileSystem.dirname(path), { recursive: true });
|
|
145
161
|
await fileSystem.writeFileRaw(path, data);
|
|
146
162
|
}),
|
|
147
|
-
mkdir: (path) => fileSystem.mkdir(path, { recursive: true }),
|
|
148
|
-
readdir: (path) => fileSystem.readdir(path),
|
|
163
|
+
mkdir: (path) => contained(path, () => fileSystem.mkdir(path, { recursive: true })),
|
|
164
|
+
readdir: (path) => contained(path, () => fileSystem.readdir(path)),
|
|
149
165
|
async exists(path) {
|
|
166
|
+
await assertProjectPathContained(fileSystem, projectRoot, path);
|
|
150
167
|
try {
|
|
151
168
|
await fileSystem.stat(path);
|
|
152
169
|
return true;
|
|
@@ -161,11 +178,21 @@ function createWriterFileSystem(fileSystem, writtenPaths, failedPaths) {
|
|
|
161
178
|
};
|
|
162
179
|
}
|
|
163
180
|
async function writeSessionProject(input) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
181
|
+
const projectRoot = input.fileSystem.dirname(input.fairyPath);
|
|
182
|
+
const write = async (fileSystem) => {
|
|
183
|
+
await new ProjectWriter(createWriterFileSystem(fileSystem, projectRoot, input.writtenPaths, input.failedPaths)).write(input.document, input.fairyPath, {
|
|
184
|
+
staleSourceFiles: input.staleSourceFiles,
|
|
185
|
+
staleResourceFolders: input.staleResourceFolders,
|
|
186
|
+
staleBranchDirectories: input.staleBranchDirectories
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
if (!input.fileSystem.runProjectWriteTransaction) return write(input.fileSystem);
|
|
190
|
+
try {
|
|
191
|
+
await input.fileSystem.runProjectWriteTransaction(projectRoot, write);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
input.writtenPaths.length = 0;
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
169
196
|
}
|
|
170
197
|
//#endregion
|
|
171
198
|
//#region src/services/session-utils.ts
|
|
@@ -479,7 +506,7 @@ var AuthoringService = class {
|
|
|
479
506
|
lastSavedRevision: session.lastSavedRevision,
|
|
480
507
|
committedPaths,
|
|
481
508
|
failedPaths,
|
|
482
|
-
diskMayBePartiallyUpdated:
|
|
509
|
+
diskMayBePartiallyUpdated: !fileSystem.runProjectWriteTransaction
|
|
483
510
|
}, toSessionSnapshot(session, this.context.capabilities), {
|
|
484
511
|
sessionId: session.sessionId,
|
|
485
512
|
revision: session.revision
|
|
@@ -654,7 +681,7 @@ var AuthoringService = class {
|
|
|
654
681
|
failedPaths,
|
|
655
682
|
skippedPaths,
|
|
656
683
|
diagnostics: diagnosticsFromError,
|
|
657
|
-
diskMayBePartiallyUpdated:
|
|
684
|
+
diskMayBePartiallyUpdated: !fileSystem.runProjectWriteTransaction
|
|
658
685
|
}, toSessionSnapshot(session, this.context.capabilities), {
|
|
659
686
|
sessionId: session.sessionId,
|
|
660
687
|
revision: session.revision,
|
|
@@ -1143,7 +1170,7 @@ var ReadService = class {
|
|
|
1143
1170
|
//#endregion
|
|
1144
1171
|
//#region src/services/runtime-service.ts
|
|
1145
1172
|
function randomId() {
|
|
1146
|
-
return
|
|
1173
|
+
return crypto.randomUUID();
|
|
1147
1174
|
}
|
|
1148
1175
|
function createCapabilityUnavailableError(capability) {
|
|
1149
1176
|
const artifactCapability = capability === "artifact.publish" || capability === "artifact.restore";
|
|
@@ -1156,29 +1183,33 @@ function createCapabilityUnavailableError(capability) {
|
|
|
1156
1183
|
bridgeBoundary: artifactCapability ? "external-bridge" : void 0
|
|
1157
1184
|
};
|
|
1158
1185
|
}
|
|
1159
|
-
function createProjectReaderFileSystem(fileSystem) {
|
|
1186
|
+
function createProjectReaderFileSystem(fileSystem, projectRoot) {
|
|
1187
|
+
const contained = async (filePath, operation) => {
|
|
1188
|
+
await assertProjectPathContained(fileSystem, projectRoot, filePath);
|
|
1189
|
+
return operation();
|
|
1190
|
+
};
|
|
1160
1191
|
return {
|
|
1161
1192
|
readFile(filePath) {
|
|
1162
|
-
return fileSystem.readFile(filePath);
|
|
1193
|
+
return contained(filePath, () => fileSystem.readFile(filePath));
|
|
1163
1194
|
},
|
|
1164
1195
|
readFileRaw(filePath) {
|
|
1165
|
-
return fileSystem.readFileRaw(filePath);
|
|
1196
|
+
return contained(filePath, () => fileSystem.readFileRaw(filePath));
|
|
1166
1197
|
},
|
|
1167
1198
|
writeFile(filePath, content) {
|
|
1168
|
-
return fileSystem.writeFile(filePath, content);
|
|
1199
|
+
return contained(filePath, () => fileSystem.writeFile(filePath, content));
|
|
1169
1200
|
},
|
|
1170
1201
|
writeFileRaw(filePath, data) {
|
|
1171
|
-
return fileSystem.writeFileRaw(filePath, data);
|
|
1202
|
+
return contained(filePath, () => fileSystem.writeFileRaw(filePath, data));
|
|
1172
1203
|
},
|
|
1173
1204
|
async mkdir(dirPath) {
|
|
1174
|
-
await fileSystem.mkdir(dirPath, { recursive: true });
|
|
1205
|
+
await contained(dirPath, () => fileSystem.mkdir(dirPath, { recursive: true }));
|
|
1175
1206
|
},
|
|
1176
1207
|
readdir(dirPath) {
|
|
1177
|
-
return fileSystem.readdir(dirPath);
|
|
1208
|
+
return contained(dirPath, () => fileSystem.readdir(dirPath));
|
|
1178
1209
|
},
|
|
1179
1210
|
async exists(filePath) {
|
|
1180
1211
|
try {
|
|
1181
|
-
await fileSystem.stat(filePath);
|
|
1212
|
+
await contained(filePath, () => fileSystem.stat(filePath));
|
|
1182
1213
|
return true;
|
|
1183
1214
|
} catch {
|
|
1184
1215
|
return false;
|
|
@@ -1272,9 +1303,25 @@ var RuntimeService = class {
|
|
|
1272
1303
|
const startedAt = Date.now();
|
|
1273
1304
|
if (!this.context.fileSystem) return failure("runtime", startedAt, createCapabilityUnavailableError("fileSystem"));
|
|
1274
1305
|
const fileSystem = this.context.fileSystem;
|
|
1306
|
+
if (this.context.allowedProjectRoots?.length) {
|
|
1307
|
+
let allowed = false;
|
|
1308
|
+
for (const root of this.context.allowedProjectRoots) try {
|
|
1309
|
+
await assertProjectPathContained(fileSystem, root, input.projectPath);
|
|
1310
|
+
allowed = true;
|
|
1311
|
+
break;
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
if (error.code !== "EACCES") throw error;
|
|
1314
|
+
}
|
|
1315
|
+
if (!allowed) return failure("runtime", startedAt, {
|
|
1316
|
+
code: "project_root_not_allowed",
|
|
1317
|
+
message: "Project path is outside the configured allowed roots.",
|
|
1318
|
+
projectPath: input.projectPath
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1275
1321
|
const { fairyPath, canonicalProjectPath, canonicalPathKey } = await resolveCanonicalProjectRoot(fileSystem, input.projectPath);
|
|
1322
|
+
await fileSystem.validateProjectRoot?.(canonicalProjectPath);
|
|
1276
1323
|
const existingSessionId = this.context.sessionsByPath.get(canonicalPathKey);
|
|
1277
|
-
const lockFilePath = fileSystem.join(canonicalProjectPath, ".openfairygui.backend.lock");
|
|
1324
|
+
const lockFilePath = fileSystem.getSessionLockPath?.(canonicalProjectPath) ?? fileSystem.join(canonicalProjectPath, ".openfairygui.backend.lock");
|
|
1278
1325
|
if (existingSessionId) return failure("runtime", startedAt, {
|
|
1279
1326
|
code: "lock_conflict",
|
|
1280
1327
|
kind: "in_process_session_exists",
|
|
@@ -1294,7 +1341,7 @@ var RuntimeService = class {
|
|
|
1294
1341
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1295
1342
|
canonicalPathKey
|
|
1296
1343
|
}));
|
|
1297
|
-
const read = await new ProjectReader(createProjectReaderFileSystem(fileSystem)).readDetailed(fairyPath, { hydrateResourceBytes: true });
|
|
1344
|
+
const read = await new ProjectReader(createProjectReaderFileSystem(fileSystem, canonicalProjectPath)).readDetailed(fairyPath, { hydrateResourceBytes: true });
|
|
1298
1345
|
if (!read.document) throw new Error(read.diagnostics[0]?.message ?? `Unable to read project: ${fairyPath}`);
|
|
1299
1346
|
const document = read.document;
|
|
1300
1347
|
const project = liftDocumentToUamProject(document);
|
|
@@ -1352,6 +1399,11 @@ var RuntimeService = class {
|
|
|
1352
1399
|
openProjectSession(input) {
|
|
1353
1400
|
const startedAt = Date.now();
|
|
1354
1401
|
const sessionId = input.sessionId ?? randomId();
|
|
1402
|
+
if (this.context.sessions.has(sessionId)) return failure("runtime", startedAt, {
|
|
1403
|
+
code: "session_id_conflict",
|
|
1404
|
+
message: `Session id is already in use: ${sessionId}`,
|
|
1405
|
+
sessionId
|
|
1406
|
+
});
|
|
1355
1407
|
const storage = input.storage;
|
|
1356
1408
|
const memoryProjectPath = `memory://${sessionId}`;
|
|
1357
1409
|
const canonicalProjectPath = storage?.canonicalProjectPath ?? input.canonicalProjectPath ?? (storage ? storage.fileSystem.dirname(storage.fairyPath) || "." : memoryProjectPath);
|
|
@@ -1478,7 +1530,7 @@ const ARTIFACT_BRIDGE_CAPABILITY = {
|
|
|
1478
1530
|
bridgeEntrypoint: "@openfairygui/backend/node",
|
|
1479
1531
|
reason: "publish/restore require explicit Node-hosted filesystem and artifact execution."
|
|
1480
1532
|
};
|
|
1481
|
-
function createCapabilities() {
|
|
1533
|
+
function createCapabilities(atomicSave = false) {
|
|
1482
1534
|
return {
|
|
1483
1535
|
contractVersion: BACKEND_CONTRACT_VERSION,
|
|
1484
1536
|
capabilitySchemaVersion: 3,
|
|
@@ -1550,7 +1602,7 @@ function createCapabilities() {
|
|
|
1550
1602
|
sessionRuntime: true,
|
|
1551
1603
|
advisoryLocking: true,
|
|
1552
1604
|
coordinatedSave: true,
|
|
1553
|
-
atomicSave
|
|
1605
|
+
atomicSave,
|
|
1554
1606
|
staleRevisionProtection: true,
|
|
1555
1607
|
pathPolicy: createRuntimePathPolicy(),
|
|
1556
1608
|
events: {
|
|
@@ -1596,10 +1648,11 @@ var BackendRuntime = class {
|
|
|
1596
1648
|
jobService;
|
|
1597
1649
|
constructor(options = {}) {
|
|
1598
1650
|
this.fileSystem = options.fileSystem;
|
|
1599
|
-
this.capabilities = createCapabilities();
|
|
1651
|
+
this.capabilities = createCapabilities(Boolean(options.fileSystem?.runProjectWriteTransaction));
|
|
1600
1652
|
this.context = {
|
|
1601
1653
|
fileSystem: this.fileSystem,
|
|
1602
1654
|
host: options.host,
|
|
1655
|
+
allowedProjectRoots: options.allowedProjectRoots,
|
|
1603
1656
|
capabilities: this.capabilities,
|
|
1604
1657
|
sessions: this.sessions,
|
|
1605
1658
|
sessionsByPath: this.sessionsByPath,
|
|
@@ -1622,7 +1675,16 @@ var BackendRuntime = class {
|
|
|
1622
1675
|
return this.readService.getCapabilities();
|
|
1623
1676
|
}
|
|
1624
1677
|
async openSession(input) {
|
|
1625
|
-
|
|
1678
|
+
const startedAt = Date.now();
|
|
1679
|
+
try {
|
|
1680
|
+
return await this.runtimeService.openSession(input);
|
|
1681
|
+
} catch {
|
|
1682
|
+
return failure("runtime", startedAt, {
|
|
1683
|
+
code: "project_open_failed",
|
|
1684
|
+
message: "Unable to open project.",
|
|
1685
|
+
projectPath: input.projectPath
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1626
1688
|
}
|
|
1627
1689
|
openProjectSession(input) {
|
|
1628
1690
|
return this.runtimeService.openProjectSession(input);
|
|
@@ -1646,7 +1708,7 @@ var BackendRuntime = class {
|
|
|
1646
1708
|
return this.authoringService.materializeSession(input);
|
|
1647
1709
|
}
|
|
1648
1710
|
async closeSession(input) {
|
|
1649
|
-
return this.runtimeService.closeSession(input);
|
|
1711
|
+
return this.authoringService.runSessionExclusive(input.sessionId, () => this.runtimeService.closeSession(input));
|
|
1650
1712
|
}
|
|
1651
1713
|
getEvents(input) {
|
|
1652
1714
|
return this.eventService.getEvents(input);
|
|
@@ -1669,13 +1731,161 @@ var BackendRuntime = class {
|
|
|
1669
1731
|
};
|
|
1670
1732
|
//#endregion
|
|
1671
1733
|
//#region src/node.ts
|
|
1734
|
+
const PROCESS_START_TIME = Math.trunc(Date.now() - process.uptime() * 1e3);
|
|
1735
|
+
function parseLockMetadata(content) {
|
|
1736
|
+
try {
|
|
1737
|
+
const value = JSON.parse(content);
|
|
1738
|
+
if (value.schemaVersion !== 1 || !Number.isSafeInteger(value.pid) || !Number.isFinite(value.processStartTime) || typeof value.hostname !== "string" || typeof value.token !== "string") return null;
|
|
1739
|
+
return value;
|
|
1740
|
+
} catch {
|
|
1741
|
+
return null;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
function isProcessAlive(metadata) {
|
|
1745
|
+
if (metadata.hostname !== os.hostname()) return true;
|
|
1746
|
+
if (metadata.pid === process.pid) return Math.abs(metadata.processStartTime - PROCESS_START_TIME) < 1e3;
|
|
1747
|
+
try {
|
|
1748
|
+
process.kill(metadata.pid, 0);
|
|
1749
|
+
return true;
|
|
1750
|
+
} catch (error) {
|
|
1751
|
+
return error.code !== "ESRCH";
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
async function recoverStaleLock(filePath) {
|
|
1755
|
+
let before;
|
|
1756
|
+
try {
|
|
1757
|
+
before = await fs.readFile(filePath, "utf-8");
|
|
1758
|
+
} catch {
|
|
1759
|
+
return false;
|
|
1760
|
+
}
|
|
1761
|
+
const metadata = parseLockMetadata(before);
|
|
1762
|
+
if (!metadata || isProcessAlive(metadata)) return false;
|
|
1763
|
+
const current = parseLockMetadata(await fs.readFile(filePath, "utf-8").catch(() => ""));
|
|
1764
|
+
if (!current || current.token !== metadata.token) return false;
|
|
1765
|
+
await fs.unlink(filePath);
|
|
1766
|
+
return true;
|
|
1767
|
+
}
|
|
1768
|
+
async function resolvePathThroughExistingAncestor(filePath) {
|
|
1769
|
+
const missing = [];
|
|
1770
|
+
let candidate = path.resolve(filePath);
|
|
1771
|
+
for (;;) try {
|
|
1772
|
+
const resolved = await fs.realpath(candidate);
|
|
1773
|
+
return path.join(resolved, ...missing);
|
|
1774
|
+
} catch (error) {
|
|
1775
|
+
const code = error.code;
|
|
1776
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
|
|
1777
|
+
const parent = path.dirname(candidate);
|
|
1778
|
+
if (parent === candidate) return path.resolve(filePath);
|
|
1779
|
+
missing.unshift(path.basename(candidate));
|
|
1780
|
+
candidate = parent;
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
async function pathExists(filePath) {
|
|
1784
|
+
return fs.stat(filePath).then(() => true, (error) => {
|
|
1785
|
+
if (error.code === "ENOENT") return false;
|
|
1786
|
+
throw error;
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
async function assertNoSymlinks(dirPath) {
|
|
1790
|
+
for (const entry of await fs.readdir(dirPath, { withFileTypes: true })) {
|
|
1791
|
+
const entryPath = path.join(dirPath, entry.name);
|
|
1792
|
+
if (entry.isSymbolicLink()) throw new Error(`Symbolic links are not supported in project directories: ${entryPath}`);
|
|
1793
|
+
if (entry.isDirectory()) await assertNoSymlinks(entryPath);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
function createStagedNodeFileSystem(projectRoot, stagingRoot) {
|
|
1797
|
+
const { runProjectWriteTransaction: _, ...base } = createNodeBackendFileSystem();
|
|
1798
|
+
const translate = (filePath) => {
|
|
1799
|
+
const relative = path.relative(projectRoot, path.resolve(filePath));
|
|
1800
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
1801
|
+
const error = /* @__PURE__ */ new Error(`Project path escapes the staged root: ${filePath}`);
|
|
1802
|
+
error.code = "EACCES";
|
|
1803
|
+
throw error;
|
|
1804
|
+
}
|
|
1805
|
+
return path.join(stagingRoot, relative);
|
|
1806
|
+
};
|
|
1807
|
+
return {
|
|
1808
|
+
...base,
|
|
1809
|
+
stat: (filePath) => fs.stat(translate(filePath)),
|
|
1810
|
+
async readdir(dirPath) {
|
|
1811
|
+
const entries = await fs.readdir(translate(dirPath), { withFileTypes: true });
|
|
1812
|
+
const symlink = entries.find((entry) => entry.isSymbolicLink());
|
|
1813
|
+
if (symlink) throw new Error(`Symbolic links are not supported in project directories: ${path.join(dirPath, symlink.name)}`);
|
|
1814
|
+
return entries.map((entry) => entry.name);
|
|
1815
|
+
},
|
|
1816
|
+
readFile: (filePath) => fs.readFile(translate(filePath), "utf-8"),
|
|
1817
|
+
async readFileRaw(filePath) {
|
|
1818
|
+
const buffer = await fs.readFile(translate(filePath));
|
|
1819
|
+
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
1820
|
+
},
|
|
1821
|
+
writeFile: (filePath, content) => fs.writeFile(translate(filePath), content, "utf-8"),
|
|
1822
|
+
writeFileRaw: (filePath, data) => fs.writeFile(translate(filePath), data),
|
|
1823
|
+
async mkdir(dirPath, options) {
|
|
1824
|
+
await fs.mkdir(translate(dirPath), { recursive: options?.recursive ?? false });
|
|
1825
|
+
},
|
|
1826
|
+
resolvePath: (filePath) => resolvePathThroughExistingAncestor(translate(filePath)),
|
|
1827
|
+
unlink: (filePath) => fs.unlink(translate(filePath)),
|
|
1828
|
+
rmdir: (dirPath) => fs.rmdir(translate(dirPath))
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
async function runNodeProjectWriteTransaction(projectRoot, write) {
|
|
1832
|
+
const root = path.resolve(projectRoot);
|
|
1833
|
+
const parent = path.dirname(root);
|
|
1834
|
+
const name = path.basename(root);
|
|
1835
|
+
const staging = path.join(parent, `.${name}.save-${randomUUID()}`);
|
|
1836
|
+
const backup = path.join(parent, `.${name}.save-backup-${randomUUID()}`);
|
|
1837
|
+
const existed = await pathExists(root);
|
|
1838
|
+
if (existed) {
|
|
1839
|
+
await assertNoSymlinks(root);
|
|
1840
|
+
await fs.cp(root, staging, {
|
|
1841
|
+
recursive: true,
|
|
1842
|
+
errorOnExist: true,
|
|
1843
|
+
force: false
|
|
1844
|
+
});
|
|
1845
|
+
} else await fs.mkdir(staging, { recursive: true });
|
|
1846
|
+
try {
|
|
1847
|
+
await write(createStagedNodeFileSystem(root, staging));
|
|
1848
|
+
} catch (error) {
|
|
1849
|
+
await fs.rm(staging, {
|
|
1850
|
+
recursive: true,
|
|
1851
|
+
force: true
|
|
1852
|
+
});
|
|
1853
|
+
throw error;
|
|
1854
|
+
}
|
|
1855
|
+
if (!existed) {
|
|
1856
|
+
await fs.rename(staging, root);
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
await fs.rename(root, backup);
|
|
1860
|
+
try {
|
|
1861
|
+
await fs.rename(staging, root);
|
|
1862
|
+
} catch (error) {
|
|
1863
|
+
await fs.rename(backup, root);
|
|
1864
|
+
await fs.rm(staging, {
|
|
1865
|
+
recursive: true,
|
|
1866
|
+
force: true
|
|
1867
|
+
});
|
|
1868
|
+
throw error;
|
|
1869
|
+
}
|
|
1870
|
+
await fs.rm(backup, {
|
|
1871
|
+
recursive: true,
|
|
1872
|
+
force: true
|
|
1873
|
+
}).catch(() => void 0);
|
|
1874
|
+
}
|
|
1672
1875
|
function createNodeBackendFileSystem() {
|
|
1673
1876
|
return {
|
|
1674
1877
|
stat(filePath) {
|
|
1675
1878
|
return fs.stat(filePath);
|
|
1676
1879
|
},
|
|
1677
|
-
readdir(dirPath) {
|
|
1678
|
-
|
|
1880
|
+
async readdir(dirPath) {
|
|
1881
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
1882
|
+
const symlink = entries.find((entry) => entry.isSymbolicLink());
|
|
1883
|
+
if (symlink) {
|
|
1884
|
+
const error = /* @__PURE__ */ new Error(`Symbolic links are not supported in project directories: ${path.join(dirPath, symlink.name)}`);
|
|
1885
|
+
error.code = "ELOOP";
|
|
1886
|
+
throw error;
|
|
1887
|
+
}
|
|
1888
|
+
return entries.map((entry) => entry.name);
|
|
1679
1889
|
},
|
|
1680
1890
|
readFile(filePath) {
|
|
1681
1891
|
return fs.readFile(filePath, "utf-8");
|
|
@@ -1694,16 +1904,32 @@ function createNodeBackendFileSystem() {
|
|
|
1694
1904
|
await fs.mkdir(dirPath, { recursive: options?.recursive ?? false });
|
|
1695
1905
|
},
|
|
1696
1906
|
async resolvePath(filePath) {
|
|
1697
|
-
|
|
1698
|
-
return await fs.realpath(filePath);
|
|
1699
|
-
} catch {
|
|
1700
|
-
return path.resolve(filePath);
|
|
1701
|
-
}
|
|
1907
|
+
return resolvePathThroughExistingAncestor(filePath);
|
|
1702
1908
|
},
|
|
1909
|
+
validateProjectRoot: assertNoSymlinks,
|
|
1910
|
+
getSessionLockPath(canonicalProjectPath) {
|
|
1911
|
+
return path.join(path.dirname(canonicalProjectPath), `.${path.basename(canonicalProjectPath)}.openfairygui.backend.lock`);
|
|
1912
|
+
},
|
|
1913
|
+
runProjectWriteTransaction: runNodeProjectWriteTransaction,
|
|
1703
1914
|
async acquireSessionLock(filePath) {
|
|
1704
|
-
|
|
1915
|
+
let handle;
|
|
1916
|
+
try {
|
|
1917
|
+
handle = await fs.open(filePath, "wx");
|
|
1918
|
+
} catch (error) {
|
|
1919
|
+
if (error.code !== "EEXIST" || !await recoverStaleLock(filePath)) throw error;
|
|
1920
|
+
handle = await fs.open(filePath, "wx");
|
|
1921
|
+
}
|
|
1922
|
+
const owner = {
|
|
1923
|
+
schemaVersion: 1,
|
|
1924
|
+
pid: process.pid,
|
|
1925
|
+
processStartTime: PROCESS_START_TIME,
|
|
1926
|
+
hostname: os.hostname(),
|
|
1927
|
+
token: randomUUID(),
|
|
1928
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1929
|
+
};
|
|
1705
1930
|
let closed = false;
|
|
1706
1931
|
let released = false;
|
|
1932
|
+
let metadataWritten = false;
|
|
1707
1933
|
const closeHandle = async () => {
|
|
1708
1934
|
if (closed) return;
|
|
1709
1935
|
await handle.close();
|
|
@@ -1711,13 +1937,23 @@ function createNodeBackendFileSystem() {
|
|
|
1711
1937
|
};
|
|
1712
1938
|
return {
|
|
1713
1939
|
async writeMetadata(content) {
|
|
1714
|
-
|
|
1940
|
+
let supplied = {};
|
|
1941
|
+
try {
|
|
1942
|
+
const parsed = JSON.parse(content);
|
|
1943
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) supplied = parsed;
|
|
1944
|
+
} catch {}
|
|
1945
|
+
await handle.writeFile(JSON.stringify({
|
|
1946
|
+
...supplied,
|
|
1947
|
+
...owner
|
|
1948
|
+
}, null, 2), "utf-8");
|
|
1949
|
+
metadataWritten = true;
|
|
1715
1950
|
await closeHandle();
|
|
1716
1951
|
},
|
|
1717
1952
|
async release() {
|
|
1718
1953
|
if (released) return;
|
|
1719
1954
|
await closeHandle();
|
|
1720
|
-
await fs.
|
|
1955
|
+
const current = metadataWritten ? parseLockMetadata(await fs.readFile(filePath, "utf-8").catch(() => "")) : null;
|
|
1956
|
+
if (!metadataWritten || current?.token === owner.token) await fs.unlink(filePath).catch((error) => {
|
|
1721
1957
|
if (error.code !== "ENOENT") throw error;
|
|
1722
1958
|
});
|
|
1723
1959
|
released = true;
|
|
@@ -1743,11 +1979,7 @@ function createNodeBackendFileSystem() {
|
|
|
1743
1979
|
}
|
|
1744
1980
|
function createNodeBackendHostAdapter() {
|
|
1745
1981
|
return { lockMetadata(input) {
|
|
1746
|
-
return {
|
|
1747
|
-
pid: process.pid,
|
|
1748
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1749
|
-
canonicalPathKey: input.canonicalPathKey
|
|
1750
|
-
};
|
|
1982
|
+
return { canonicalPathKey: input.canonicalPathKey };
|
|
1751
1983
|
} };
|
|
1752
1984
|
}
|
|
1753
1985
|
function createNodeBackendRuntime(options = {}) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openfairygui/backend",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "FairyGUI Headless Authoring SDK — stateful backend runtime and session services.",
|
|
5
5
|
"author": "OpenFairyGUI Contributors",
|
|
6
6
|
"license": "MIT",
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
},
|
|
12
12
|
"homepage": "https://github.com/OpenFairyGUI/OpenFairyGUI#readme",
|
|
13
13
|
"bugs": {
|
|
14
|
-
"url": "https://github.com/OpenFairyGUI/
|
|
14
|
+
"url": "https://github.com/OpenFairyGUI/OpenFairyGUI/issues"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
15
18
|
},
|
|
16
19
|
"type": "module",
|
|
17
20
|
"sideEffects": false,
|
|
@@ -52,13 +55,13 @@
|
|
|
52
55
|
"runtime"
|
|
53
56
|
],
|
|
54
57
|
"dependencies": {
|
|
55
|
-
"@openfairygui/
|
|
56
|
-
"@openfairygui/
|
|
58
|
+
"@openfairygui/core": "0.3.1",
|
|
59
|
+
"@openfairygui/functions": "0.3.1"
|
|
57
60
|
},
|
|
58
61
|
"devDependencies": {
|
|
59
62
|
"ava": "^7.0.0",
|
|
60
63
|
"tsx": "^4.0.0",
|
|
61
|
-
"@openfairygui/test-utils": "0.
|
|
64
|
+
"@openfairygui/test-utils": "0.3.0"
|
|
62
65
|
},
|
|
63
66
|
"scripts": {
|
|
64
67
|
"build": "tsdown",
|
package/src/index.ts
CHANGED
|
@@ -57,9 +57,12 @@ export {
|
|
|
57
57
|
type MaterializeValidationFailedError,
|
|
58
58
|
type MaterializeWriteFailedError,
|
|
59
59
|
type OpenProjectSessionInput,
|
|
60
|
+
type ProjectOpenFailedError,
|
|
61
|
+
type ProjectRootNotAllowedError,
|
|
60
62
|
type RefreshCacheInput,
|
|
61
63
|
type SavePartialFailureError,
|
|
62
64
|
type SaveSessionInput,
|
|
65
|
+
type SessionIdConflictError,
|
|
63
66
|
type SessionNotFoundError,
|
|
64
67
|
type SessionStaleWriteError,
|
|
65
68
|
type UamFidelityUnsupportedError,
|