@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/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: false;
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: true;
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: true;
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
- await new ProjectWriter(createWriterFileSystem(input.fileSystem, input.writtenPaths, input.failedPaths)).write(input.document, input.fairyPath, {
165
- staleSourceFiles: input.staleSourceFiles,
166
- staleResourceFolders: input.staleResourceFolders,
167
- staleBranchDirectories: input.staleBranchDirectories
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: true
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: true
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 `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
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: false,
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
- return this.runtimeService.openSession(input);
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
- return fs.readdir(dirPath);
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
- try {
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
- const handle = await fs.open(filePath, "wx");
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
- await handle.writeFile(content, "utf-8");
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.unlink(filePath).catch((error) => {
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.0",
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/OpenFairYGUI/issues"
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/functions": "0.3.0",
56
- "@openfairygui/core": "0.3.0"
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.2.0-alpha.10"
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,