@superdoc/sdk 2.7.0 → 2.8.0

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.
@@ -29333,6 +29333,7 @@ export interface DocOpenParams {
29333
29333
  url: string;
29334
29334
  documentId?: string;
29335
29335
  tokenEnv?: string;
29336
+ headersEnv?: string;
29336
29337
  params?: Record<string, unknown>;
29337
29338
  syncTimeoutMs?: number;
29338
29339
  roomMode?: "join" | "create";
@@ -296902,6 +296902,10 @@ const CONTRACT = {
296902
296902
  "type": "string",
296903
296903
  "description": "Environment variable name containing the auth token."
296904
296904
  },
296905
+ "headersEnv": {
296906
+ "type": "string",
296907
+ "description": "Environment variable name containing JSON WebSocket headers. Supported by y-websocket."
296908
+ },
296905
296909
  "params": {
296906
296910
  "type": "object",
296907
296911
  "description": "Custom query parameters appended to the WebSocket URL. Values must be strings. Reserved keys: token.",
@@ -297149,6 +297153,10 @@ const CONTRACT = {
297149
297153
  "type": "string",
297150
297154
  "description": "Environment variable name containing the auth token."
297151
297155
  },
297156
+ "headersEnv": {
297157
+ "type": "string",
297158
+ "description": "Environment variable name containing JSON WebSocket headers. Supported by y-websocket."
297159
+ },
297152
297160
  "params": {
297153
297161
  "type": "object",
297154
297162
  "description": "Custom query parameters appended to the WebSocket URL. Values must be strings. Reserved keys: token.",
@@ -299816,6 +299816,10 @@ export const CONTRACT = {
299816
299816
  "type": "string",
299817
299817
  "description": "Environment variable name containing the auth token."
299818
299818
  },
299819
+ "headersEnv": {
299820
+ "type": "string",
299821
+ "description": "Environment variable name containing JSON WebSocket headers. Supported by y-websocket."
299822
+ },
299819
299823
  "params": {
299820
299824
  "type": "object",
299821
299825
  "description": "Custom query parameters appended to the WebSocket URL. Values must be strings. Reserved keys: token.",
@@ -300063,6 +300067,10 @@ export const CONTRACT = {
300063
300067
  "type": "string",
300064
300068
  "description": "Environment variable name containing the auth token."
300065
300069
  },
300070
+ "headersEnv": {
300071
+ "type": "string",
300072
+ "description": "Environment variable name containing JSON WebSocket headers. Supported by y-websocket."
300073
+ },
300066
300074
  "params": {
300067
300075
  "type": "object",
300068
300076
  "description": "Custom query parameters appended to the WebSocket URL. Values must be strings. Reserved keys: token.",
package/dist/index.cjs CHANGED
@@ -153,11 +153,11 @@ class SuperDocClient {
153
153
  return introspection.describeContractOperation(params);
154
154
  }
155
155
  async dispose() {
156
+ await this.runtime.dispose();
156
157
  for (const handle of this.handles.values()) {
157
158
  handle.markClosed();
158
159
  }
159
160
  this.handles.clear();
160
- await this.runtime.dispose();
161
161
  }
162
162
  /** @internal */
163
163
  removeHandle(sessionId) {
package/dist/index.js CHANGED
@@ -145,11 +145,11 @@ export class SuperDocClient {
145
145
  return describeContractOperation(params);
146
146
  }
147
147
  async dispose() {
148
+ await this.runtime.dispose();
148
149
  for (const handle of this.handles.values()) {
149
150
  handle.markClosed();
150
151
  }
151
152
  this.handles.clear();
152
- await this.runtime.dispose();
153
153
  }
154
154
  /** @internal */
155
155
  removeHandle(sessionId) {
@@ -4,6 +4,7 @@ var node_fs = require('node:fs');
4
4
  var node_module = require('node:module');
5
5
  var path = require('node:path');
6
6
  var node_url = require('node:url');
7
+ var sdkVersion_generated = require('./sdk-version.generated.cjs');
7
8
 
8
9
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
9
10
  const require$1 = node_module.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('runtime/embedded-platform.cjs', document.baseURI).href)));
@@ -50,31 +51,36 @@ function resolveFromPackageLocal(target, binaryName) {
50
51
  * Resolve the platform package through Node, and reject a version that is not
51
52
  * this SDK's own.
52
53
  *
53
- * `require.resolve` falls back to NODE_PATH, which package managers set to a
54
- * flat store directory (pnpm does it for every binary shim). A host binary from
55
- * an unrelated SDK version installed anywhere in that store then satisfies this
56
- * lookup, and the SDK silently drives a foreign engine: seen in the wild as an
57
- * SDK 2.2.1 install executing a hoisted 2.0.0 host, which failed operations the
58
- * matching host performs correctly. optionalDependencies pin the platform
59
- * package to an exact version, so a mismatch here is never legitimate.
54
+ * Resolution starts from the installed SDK so pnpm's isolated optional
55
+ * dependency remains visible after the SDK itself is bundled. `require.resolve`
56
+ * can also fall back to NODE_PATH, so the version check prevents a host binary
57
+ * from an unrelated SDK version in a flat store from satisfying this lookup.
58
+ * optionalDependencies pin the platform package to an exact version, so a
59
+ * mismatch here is never legitimate.
60
60
  */
61
61
  function resolveFromPlatformPackage(target, binaryName) {
62
62
  const pkg = TARGET_TO_PACKAGE[target];
63
+ let sdkRequire;
64
+ try {
65
+ sdkRequire = node_module.createRequire(require$1.resolve('@superdoc/sdk'));
66
+ }
67
+ catch {
68
+ sdkRequire = require$1;
69
+ }
63
70
  let binaryPath;
64
71
  try {
65
- binaryPath = require$1.resolve(`${pkg}/bin/${binaryName}`);
72
+ binaryPath = sdkRequire.resolve(`${pkg}/bin/${binaryName}`);
66
73
  }
67
74
  catch {
68
75
  return null;
69
76
  }
70
77
  try {
71
- const platformVersion = require$1(require$1.resolve(`${pkg}/package.json`)).version;
72
- const ownVersion = require$1(node_url.fileURLToPath(new URL('../../package.json', (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('runtime/embedded-platform.cjs', document.baseURI).href))))).version;
73
- if (platformVersion !== ownVersion)
78
+ const platformVersion = sdkRequire(sdkRequire.resolve(`${pkg}/package.json`)).version;
79
+ if (platformVersion !== sdkVersion_generated.SDK_VERSION)
74
80
  return null;
75
81
  }
76
82
  catch {
77
- // Either manifest being unreadable is itself disqualifying: an unverifiable
83
+ // An unreadable platform manifest is itself disqualifying: an unverifiable
78
84
  // host is exactly the case this guard exists to reject.
79
85
  return null;
80
86
  }
@@ -2,6 +2,7 @@ import { chmodSync, existsSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { SDK_VERSION } from './sdk-version.generated.js';
5
6
  const require = createRequire(import.meta.url);
6
7
  const TARGET_TO_PACKAGE = {
7
8
  'darwin-arm64': '@superdoc/sdk-darwin-arm64',
@@ -46,31 +47,36 @@ function resolveFromPackageLocal(target, binaryName) {
46
47
  * Resolve the platform package through Node, and reject a version that is not
47
48
  * this SDK's own.
48
49
  *
49
- * `require.resolve` falls back to NODE_PATH, which package managers set to a
50
- * flat store directory (pnpm does it for every binary shim). A host binary from
51
- * an unrelated SDK version installed anywhere in that store then satisfies this
52
- * lookup, and the SDK silently drives a foreign engine: seen in the wild as an
53
- * SDK 2.2.1 install executing a hoisted 2.0.0 host, which failed operations the
54
- * matching host performs correctly. optionalDependencies pin the platform
55
- * package to an exact version, so a mismatch here is never legitimate.
50
+ * Resolution starts from the installed SDK so pnpm's isolated optional
51
+ * dependency remains visible after the SDK itself is bundled. `require.resolve`
52
+ * can also fall back to NODE_PATH, so the version check prevents a host binary
53
+ * from an unrelated SDK version in a flat store from satisfying this lookup.
54
+ * optionalDependencies pin the platform package to an exact version, so a
55
+ * mismatch here is never legitimate.
56
56
  */
57
57
  function resolveFromPlatformPackage(target, binaryName) {
58
58
  const pkg = TARGET_TO_PACKAGE[target];
59
+ let sdkRequire;
60
+ try {
61
+ sdkRequire = createRequire(require.resolve('@superdoc/sdk'));
62
+ }
63
+ catch {
64
+ sdkRequire = require;
65
+ }
59
66
  let binaryPath;
60
67
  try {
61
- binaryPath = require.resolve(`${pkg}/bin/${binaryName}`);
68
+ binaryPath = sdkRequire.resolve(`${pkg}/bin/${binaryName}`);
62
69
  }
63
70
  catch {
64
71
  return null;
65
72
  }
66
73
  try {
67
- const platformVersion = require(require.resolve(`${pkg}/package.json`)).version;
68
- const ownVersion = require(fileURLToPath(new URL('../../package.json', import.meta.url))).version;
69
- if (platformVersion !== ownVersion)
74
+ const platformVersion = sdkRequire(sdkRequire.resolve(`${pkg}/package.json`)).version;
75
+ if (platformVersion !== SDK_VERSION)
70
76
  return null;
71
77
  }
72
78
  catch {
73
- // Either manifest being unreadable is itself disqualifying: an unverifiable
79
+ // An unreadable platform manifest is itself disqualifying: an unverifiable
74
80
  // host is exactly the case this guard exists to reject.
75
81
  return null;
76
82
  }
@@ -98,9 +98,15 @@ class HostTransport {
98
98
  pending = new Map();
99
99
  nextRequestId = 1;
100
100
  connecting = null;
101
+ disposePromise = null;
102
+ terminalDisposalError = null;
101
103
  stopping = false;
104
+ activeCalls = new Set();
102
105
  hostFeatures = new Set();
103
106
  documentRpcSessions = new Set();
107
+ dirtyDocumentRpcSessions = new Set();
108
+ documentRpcMutationStates = new Map();
109
+ indeterminateDiscardCloses = new Set();
104
110
  constructor(options) {
105
111
  this.hostBin = options.hostBin;
106
112
  this.processMode = options.processMode ?? 'cli';
@@ -121,12 +127,43 @@ class HostTransport {
121
127
  this.documentRpcEnabled = this.processMode === 'document' || documentRpc.documentRpcEnabled(options.env);
122
128
  }
123
129
  async connect() {
124
- await this.ensureConnected();
130
+ await this.runWhileActive(() => this.ensureConnected());
125
131
  }
126
132
  async dispose() {
127
- if (!this.child)
133
+ if (this.disposePromise)
134
+ return this.disposePromise;
135
+ if (this.terminalDisposalError)
136
+ throw this.terminalDisposalError;
137
+ if (!this.child && !this.connecting && this.activeCalls.size === 0)
128
138
  return;
129
139
  this.stopping = true;
140
+ const disposal = this.disposeAfterActiveCalls();
141
+ this.disposePromise = disposal;
142
+ try {
143
+ await disposal;
144
+ }
145
+ finally {
146
+ if (this.disposePromise === disposal)
147
+ this.disposePromise = null;
148
+ this.stopping = false;
149
+ }
150
+ }
151
+ async disposeAfterActiveCalls() {
152
+ await Promise.allSettled(Array.from(this.activeCalls));
153
+ if (this.terminalDisposalError)
154
+ throw this.terminalDisposalError;
155
+ if (!this.child)
156
+ return;
157
+ try {
158
+ await this.reconcileIndeterminateDiscardCloses();
159
+ await this.persistDirtyDocumentRpcSessions();
160
+ }
161
+ catch (error) {
162
+ if (!this.child && error instanceof errors.SuperDocCliError) {
163
+ this.terminalDisposalError = error;
164
+ }
165
+ throw error;
166
+ }
130
167
  const child = this.child;
131
168
  try {
132
169
  await this.sendJsonRpcRequest('host.shutdown', {}, this.shutdownTimeoutMs);
@@ -145,9 +182,11 @@ class HostTransport {
145
182
  });
146
183
  });
147
184
  this.cleanupProcess(null);
148
- this.stopping = false;
149
185
  }
150
186
  async invoke(operation, params = {}, options = {}) {
187
+ return this.runWhileActive(() => this.invokeWhileActive(operation, params, options));
188
+ }
189
+ async invokeWhileActive(operation, params, options) {
151
190
  await this.ensureConnected();
152
191
  const shouldOpenThroughCliTimeoutFallback = this.processMode === 'cli' &&
153
192
  (this.requestTimeoutMs !== undefined ||
@@ -163,6 +202,7 @@ class HostTransport {
163
202
  const response = await this.sendJsonRpcRequest('document.open', opened.params, this.resolveWatchdogTimeout(options.timeoutMs), { requestTimeoutMs });
164
203
  const result = documentRpc.mapDocumentOpenResult(response, opened.sessionId, params.doc);
165
204
  this.documentRpcSessions.add(opened.sessionId);
205
+ this.indeterminateDiscardCloses.delete(opened.sessionId);
166
206
  return result;
167
207
  }
168
208
  const sessionId = typeof params.sessionId === 'string' ? params.sessionId : undefined;
@@ -172,10 +212,50 @@ class HostTransport {
172
212
  const requestTimeoutMs = supportsResponseTimeout
173
213
  ? this.resolveDocumentRequestTimeout(options.timeoutMs)
174
214
  : undefined;
175
- const response = await this.sendJsonRpcRequest(request.method, request.params, this.resolveWatchdogTimeout(options.timeoutMs), { requestTimeoutMs });
176
- const result = documentRpc.mapDocumentLifecycleResult(operation.operationId, response, sessionId, operation.operationId === 'doc.save' ? request.params.path === undefined : undefined);
177
- if (operation.operationId === 'doc.close')
215
+ const documentOperation = operation;
216
+ const requestOptions = typeof request.params.options === 'object' &&
217
+ request.params.options !== null &&
218
+ !Array.isArray(request.params.options)
219
+ ? request.params.options
220
+ : undefined;
221
+ const tracksMutation = request.method === 'document.invoke' && documentOperation.mutates === true && requestOptions?.dryRun !== true;
222
+ if (tracksMutation)
223
+ this.beginDocumentRpcMutation(sessionId);
224
+ let result;
225
+ try {
226
+ const response = await this.sendJsonRpcRequest(request.method, request.params, this.resolveWatchdogTimeout(options.timeoutMs), { requestTimeoutMs });
227
+ result = documentRpc.mapDocumentLifecycleResult(operation.operationId, response, sessionId, operation.operationId === 'doc.save' ? request.params.path === undefined : undefined);
228
+ }
229
+ catch (error) {
230
+ if (tracksMutation) {
231
+ const indeterminate = error instanceof errors.SuperDocCliError && ['TIMEOUT', 'HOST_TIMEOUT', 'HOST_DISCONNECTED'].includes(error.code);
232
+ this.settleDocumentRpcMutation(sessionId, indeterminate ? 'indeterminate' : 'not-applied');
233
+ }
234
+ if (operation.operationId === 'doc.close' &&
235
+ request.params.discard === true &&
236
+ error instanceof errors.SuperDocCliError &&
237
+ error.code === 'TIMEOUT') {
238
+ this.indeterminateDiscardCloses.add(sessionId);
239
+ }
240
+ throw error;
241
+ }
242
+ if (tracksMutation) {
243
+ const failedReceipt = typeof result === 'object' &&
244
+ result !== null &&
245
+ !Array.isArray(result) &&
246
+ result.success === false;
247
+ this.settleDocumentRpcMutation(sessionId, failedReceipt ? 'not-applied' : 'applied');
248
+ }
249
+ if (operation.operationId === 'doc.close') {
178
250
  this.documentRpcSessions.delete(sessionId);
251
+ this.clearDirtyDocumentRpcSession(sessionId);
252
+ this.indeterminateDiscardCloses.delete(sessionId);
253
+ }
254
+ else if (operation.operationId === 'doc.save') {
255
+ if (request.params.mode === undefined || request.params.mode === 'review-preserving') {
256
+ this.markDocumentRpcSessionSaved(sessionId);
257
+ }
258
+ }
179
259
  return result;
180
260
  }
181
261
  if (this.processMode === 'document') {
@@ -212,6 +292,20 @@ class HostTransport {
212
292
  const resultRecord = response;
213
293
  return mapCliInvocationResult(operation, resultRecord.data);
214
294
  }
295
+ runWhileActive(start) {
296
+ if (this.terminalDisposalError) {
297
+ return Promise.reject(this.terminalDisposalError);
298
+ }
299
+ if (this.stopping) {
300
+ return Promise.reject(new errors.SuperDocCliError('Host is disposing.', {
301
+ code: 'HOST_DISCONNECTED',
302
+ }));
303
+ }
304
+ const activeCall = start();
305
+ this.activeCalls.add(activeCall);
306
+ void activeCall.then(() => this.activeCalls.delete(activeCall), () => this.activeCalls.delete(activeCall));
307
+ return activeCall;
308
+ }
215
309
  async ensureConnected() {
216
310
  if (this.connecting) {
217
311
  await this.connecting;
@@ -264,7 +358,7 @@ class HostTransport {
264
358
  }));
265
359
  });
266
360
  child.on('close', (code, signal) => {
267
- if (this.stopping) {
361
+ if (this.stopping && this.dirtyDocumentRpcSessions.size === 0) {
268
362
  this.cleanupProcess(null);
269
363
  return;
270
364
  }
@@ -350,6 +444,65 @@ class HostTransport {
350
444
  details: { feature: documentRpc.DOCUMENT_RPC_REQUEST_TIMEOUT_FEATURE },
351
445
  });
352
446
  }
447
+ beginDocumentRpcMutation(sessionId) {
448
+ const state = this.documentRpcMutationStates.get(sessionId) ?? {
449
+ confirmedDirty: this.dirtyDocumentRpcSessions.has(sessionId),
450
+ pendingCount: 0,
451
+ };
452
+ state.pendingCount += 1;
453
+ this.documentRpcMutationStates.set(sessionId, state);
454
+ this.dirtyDocumentRpcSessions.add(sessionId);
455
+ }
456
+ settleDocumentRpcMutation(sessionId, outcome) {
457
+ const state = this.documentRpcMutationStates.get(sessionId);
458
+ if (!state)
459
+ return;
460
+ state.pendingCount = Math.max(0, state.pendingCount - 1);
461
+ if (outcome !== 'not-applied')
462
+ state.confirmedDirty = true;
463
+ if (state.confirmedDirty || state.pendingCount > 0) {
464
+ this.dirtyDocumentRpcSessions.add(sessionId);
465
+ return;
466
+ }
467
+ this.clearDirtyDocumentRpcSession(sessionId);
468
+ }
469
+ clearDirtyDocumentRpcSession(sessionId) {
470
+ this.dirtyDocumentRpcSessions.delete(sessionId);
471
+ this.documentRpcMutationStates.delete(sessionId);
472
+ }
473
+ markDocumentRpcSessionSaved(sessionId) {
474
+ const state = this.documentRpcMutationStates.get(sessionId);
475
+ if (!state || state.pendingCount === 0) {
476
+ this.clearDirtyDocumentRpcSession(sessionId);
477
+ return;
478
+ }
479
+ state.confirmedDirty = false;
480
+ this.dirtyDocumentRpcSessions.add(sessionId);
481
+ }
482
+ async persistDirtyDocumentRpcSessions() {
483
+ if (this.dirtyDocumentRpcSessions.size === 0)
484
+ return;
485
+ if (!this.hostFeatures.has(documentRpc.DOCUMENT_RPC_SOURCE_SAVE_FEATURE)) {
486
+ throw new errors.SuperDocCliError('Document host cannot persist dirty sessions during client disposal because it does not support source saves.', {
487
+ code: 'DOCUMENT_RPC_INPUT_UNSUPPORTED',
488
+ details: { feature: documentRpc.DOCUMENT_RPC_SOURCE_SAVE_FEATURE },
489
+ });
490
+ }
491
+ for (const sessionId of this.dirtyDocumentRpcSessions) {
492
+ const response = await this.sendJsonRpcRequest('document.save', { sessionId }, this.resolveWatchdogTimeout(undefined));
493
+ documentRpc.mapDocumentLifecycleResult('doc.save', response, sessionId, true);
494
+ this.markDocumentRpcSessionSaved(sessionId);
495
+ }
496
+ }
497
+ async reconcileIndeterminateDiscardCloses() {
498
+ for (const sessionId of this.indeterminateDiscardCloses) {
499
+ const response = await this.sendJsonRpcRequest('document.close', { sessionId, discard: true }, this.resolveWatchdogTimeout(undefined));
500
+ documentRpc.mapDocumentLifecycleResult('doc.close', response, sessionId);
501
+ this.documentRpcSessions.delete(sessionId);
502
+ this.clearDirtyDocumentRpcSession(sessionId);
503
+ this.indeterminateDiscardCloses.delete(sessionId);
504
+ }
505
+ }
353
506
  async sendJsonRpcRequest(method, params, watchdogTimeoutMs, metadata = {}) {
354
507
  const child = this.child;
355
508
  if (!child || !child.stdin.writable) {
@@ -481,8 +634,17 @@ class HostTransport {
481
634
  this.cleanupProcess(error);
482
635
  }
483
636
  cleanupProcess(error) {
637
+ if (error && this.dirtyDocumentRpcSessions.size > 0 && !this.terminalDisposalError) {
638
+ this.terminalDisposalError = new errors.SuperDocCliError('Document host disconnected before dirty sessions could be persisted.', {
639
+ code: 'HOST_DISCONNECTED',
640
+ details: { causeCode: error.code, causeDetails: error.details },
641
+ });
642
+ }
484
643
  this.hostFeatures.clear();
485
644
  this.documentRpcSessions.clear();
645
+ this.dirtyDocumentRpcSessions.clear();
646
+ this.documentRpcMutationStates.clear();
647
+ this.indeterminateDiscardCloses.clear();
486
648
  const child = this.child;
487
649
  if (child) {
488
650
  child.removeAllListeners();
@@ -46,21 +46,36 @@ export declare class HostTransport {
46
46
  private readonly pending;
47
47
  private nextRequestId;
48
48
  private connecting;
49
+ private disposePromise;
50
+ private terminalDisposalError;
49
51
  private stopping;
52
+ private readonly activeCalls;
50
53
  private readonly hostFeatures;
51
54
  private readonly documentRpcSessions;
55
+ private readonly dirtyDocumentRpcSessions;
56
+ private readonly documentRpcMutationStates;
57
+ private readonly indeterminateDiscardCloses;
52
58
  constructor(options: {
53
59
  hostBin: string;
54
60
  processMode?: 'cli' | 'document';
55
61
  } & SuperDocClientOptions);
56
62
  connect(): Promise<void>;
57
63
  dispose(): Promise<void>;
64
+ private disposeAfterActiveCalls;
58
65
  invoke<TData = unknown>(operation: OperationSpec, params?: Record<string, unknown>, options?: InvokeOptions): Promise<TData>;
66
+ private invokeWhileActive;
67
+ private runWhileActive;
59
68
  private ensureConnected;
60
69
  private startHostProcess;
61
70
  private assertCapabilities;
62
71
  private resolveWatchdogTimeout;
63
72
  private resolveDocumentRequestTimeout;
73
+ private beginDocumentRpcMutation;
74
+ private settleDocumentRpcMutation;
75
+ private clearDirtyDocumentRpcSession;
76
+ private markDocumentRpcSessionSaved;
77
+ private persistDirtyDocumentRpcSessions;
78
+ private reconcileIndeterminateDiscardCloses;
64
79
  private sendJsonRpcRequest;
65
80
  private onStdoutLine;
66
81
  private mapJsonRpcError;
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { createInterface } from 'node:readline';
3
3
  import { buildOperationArgv, resolveInvocation, } from './transport-common.js';
4
4
  import { SuperDocCliError } from './errors.js';
5
- import { DOCUMENT_RPC_FEATURES, DOCUMENT_RPC_DEFAULT_CHANGE_MODE_FEATURE, DOCUMENT_RPC_REQUEST_TIMEOUT_FEATURE, buildDocumentInvokeParams, buildDocumentOpenParams, canOpenWithDocumentRpc, documentRpcRequestSupportsResponseTimeout, documentRpcEnabled, mapDocumentLifecycleResult, mapDocumentOpenResult, supportsDocumentRpc, } from './document-rpc.js';
5
+ import { DOCUMENT_RPC_FEATURES, DOCUMENT_RPC_DEFAULT_CHANGE_MODE_FEATURE, DOCUMENT_RPC_REQUEST_TIMEOUT_FEATURE, DOCUMENT_RPC_SOURCE_SAVE_FEATURE, buildDocumentInvokeParams, buildDocumentOpenParams, canOpenWithDocumentRpc, documentRpcRequestSupportsResponseTimeout, documentRpcEnabled, mapDocumentLifecycleResult, mapDocumentOpenResult, supportsDocumentRpc, } from './document-rpc.js';
6
6
  const HOST_PROTOCOL_VERSION = '1.0';
7
7
  const CLI_HOST_REQUIRED_FEATURES = ['cli.invoke', 'host.shutdown'];
8
8
  const DOCUMENT_HOST_REQUIRED_FEATURES = [...DOCUMENT_RPC_FEATURES, 'host.shutdown'];
@@ -95,9 +95,15 @@ export class HostTransport {
95
95
  pending = new Map();
96
96
  nextRequestId = 1;
97
97
  connecting = null;
98
+ disposePromise = null;
99
+ terminalDisposalError = null;
98
100
  stopping = false;
101
+ activeCalls = new Set();
99
102
  hostFeatures = new Set();
100
103
  documentRpcSessions = new Set();
104
+ dirtyDocumentRpcSessions = new Set();
105
+ documentRpcMutationStates = new Map();
106
+ indeterminateDiscardCloses = new Set();
101
107
  constructor(options) {
102
108
  this.hostBin = options.hostBin;
103
109
  this.processMode = options.processMode ?? 'cli';
@@ -118,12 +124,43 @@ export class HostTransport {
118
124
  this.documentRpcEnabled = this.processMode === 'document' || documentRpcEnabled(options.env);
119
125
  }
120
126
  async connect() {
121
- await this.ensureConnected();
127
+ await this.runWhileActive(() => this.ensureConnected());
122
128
  }
123
129
  async dispose() {
124
- if (!this.child)
130
+ if (this.disposePromise)
131
+ return this.disposePromise;
132
+ if (this.terminalDisposalError)
133
+ throw this.terminalDisposalError;
134
+ if (!this.child && !this.connecting && this.activeCalls.size === 0)
125
135
  return;
126
136
  this.stopping = true;
137
+ const disposal = this.disposeAfterActiveCalls();
138
+ this.disposePromise = disposal;
139
+ try {
140
+ await disposal;
141
+ }
142
+ finally {
143
+ if (this.disposePromise === disposal)
144
+ this.disposePromise = null;
145
+ this.stopping = false;
146
+ }
147
+ }
148
+ async disposeAfterActiveCalls() {
149
+ await Promise.allSettled(Array.from(this.activeCalls));
150
+ if (this.terminalDisposalError)
151
+ throw this.terminalDisposalError;
152
+ if (!this.child)
153
+ return;
154
+ try {
155
+ await this.reconcileIndeterminateDiscardCloses();
156
+ await this.persistDirtyDocumentRpcSessions();
157
+ }
158
+ catch (error) {
159
+ if (!this.child && error instanceof SuperDocCliError) {
160
+ this.terminalDisposalError = error;
161
+ }
162
+ throw error;
163
+ }
127
164
  const child = this.child;
128
165
  try {
129
166
  await this.sendJsonRpcRequest('host.shutdown', {}, this.shutdownTimeoutMs);
@@ -142,9 +179,11 @@ export class HostTransport {
142
179
  });
143
180
  });
144
181
  this.cleanupProcess(null);
145
- this.stopping = false;
146
182
  }
147
183
  async invoke(operation, params = {}, options = {}) {
184
+ return this.runWhileActive(() => this.invokeWhileActive(operation, params, options));
185
+ }
186
+ async invokeWhileActive(operation, params, options) {
148
187
  await this.ensureConnected();
149
188
  const shouldOpenThroughCliTimeoutFallback = this.processMode === 'cli' &&
150
189
  (this.requestTimeoutMs !== undefined ||
@@ -160,6 +199,7 @@ export class HostTransport {
160
199
  const response = await this.sendJsonRpcRequest('document.open', opened.params, this.resolveWatchdogTimeout(options.timeoutMs), { requestTimeoutMs });
161
200
  const result = mapDocumentOpenResult(response, opened.sessionId, params.doc);
162
201
  this.documentRpcSessions.add(opened.sessionId);
202
+ this.indeterminateDiscardCloses.delete(opened.sessionId);
163
203
  return result;
164
204
  }
165
205
  const sessionId = typeof params.sessionId === 'string' ? params.sessionId : undefined;
@@ -169,10 +209,50 @@ export class HostTransport {
169
209
  const requestTimeoutMs = supportsResponseTimeout
170
210
  ? this.resolveDocumentRequestTimeout(options.timeoutMs)
171
211
  : undefined;
172
- const response = await this.sendJsonRpcRequest(request.method, request.params, this.resolveWatchdogTimeout(options.timeoutMs), { requestTimeoutMs });
173
- const result = mapDocumentLifecycleResult(operation.operationId, response, sessionId, operation.operationId === 'doc.save' ? request.params.path === undefined : undefined);
174
- if (operation.operationId === 'doc.close')
212
+ const documentOperation = operation;
213
+ const requestOptions = typeof request.params.options === 'object' &&
214
+ request.params.options !== null &&
215
+ !Array.isArray(request.params.options)
216
+ ? request.params.options
217
+ : undefined;
218
+ const tracksMutation = request.method === 'document.invoke' && documentOperation.mutates === true && requestOptions?.dryRun !== true;
219
+ if (tracksMutation)
220
+ this.beginDocumentRpcMutation(sessionId);
221
+ let result;
222
+ try {
223
+ const response = await this.sendJsonRpcRequest(request.method, request.params, this.resolveWatchdogTimeout(options.timeoutMs), { requestTimeoutMs });
224
+ result = mapDocumentLifecycleResult(operation.operationId, response, sessionId, operation.operationId === 'doc.save' ? request.params.path === undefined : undefined);
225
+ }
226
+ catch (error) {
227
+ if (tracksMutation) {
228
+ const indeterminate = error instanceof SuperDocCliError && ['TIMEOUT', 'HOST_TIMEOUT', 'HOST_DISCONNECTED'].includes(error.code);
229
+ this.settleDocumentRpcMutation(sessionId, indeterminate ? 'indeterminate' : 'not-applied');
230
+ }
231
+ if (operation.operationId === 'doc.close' &&
232
+ request.params.discard === true &&
233
+ error instanceof SuperDocCliError &&
234
+ error.code === 'TIMEOUT') {
235
+ this.indeterminateDiscardCloses.add(sessionId);
236
+ }
237
+ throw error;
238
+ }
239
+ if (tracksMutation) {
240
+ const failedReceipt = typeof result === 'object' &&
241
+ result !== null &&
242
+ !Array.isArray(result) &&
243
+ result.success === false;
244
+ this.settleDocumentRpcMutation(sessionId, failedReceipt ? 'not-applied' : 'applied');
245
+ }
246
+ if (operation.operationId === 'doc.close') {
175
247
  this.documentRpcSessions.delete(sessionId);
248
+ this.clearDirtyDocumentRpcSession(sessionId);
249
+ this.indeterminateDiscardCloses.delete(sessionId);
250
+ }
251
+ else if (operation.operationId === 'doc.save') {
252
+ if (request.params.mode === undefined || request.params.mode === 'review-preserving') {
253
+ this.markDocumentRpcSessionSaved(sessionId);
254
+ }
255
+ }
176
256
  return result;
177
257
  }
178
258
  if (this.processMode === 'document') {
@@ -209,6 +289,20 @@ export class HostTransport {
209
289
  const resultRecord = response;
210
290
  return mapCliInvocationResult(operation, resultRecord.data);
211
291
  }
292
+ runWhileActive(start) {
293
+ if (this.terminalDisposalError) {
294
+ return Promise.reject(this.terminalDisposalError);
295
+ }
296
+ if (this.stopping) {
297
+ return Promise.reject(new SuperDocCliError('Host is disposing.', {
298
+ code: 'HOST_DISCONNECTED',
299
+ }));
300
+ }
301
+ const activeCall = start();
302
+ this.activeCalls.add(activeCall);
303
+ void activeCall.then(() => this.activeCalls.delete(activeCall), () => this.activeCalls.delete(activeCall));
304
+ return activeCall;
305
+ }
212
306
  async ensureConnected() {
213
307
  if (this.connecting) {
214
308
  await this.connecting;
@@ -261,7 +355,7 @@ export class HostTransport {
261
355
  }));
262
356
  });
263
357
  child.on('close', (code, signal) => {
264
- if (this.stopping) {
358
+ if (this.stopping && this.dirtyDocumentRpcSessions.size === 0) {
265
359
  this.cleanupProcess(null);
266
360
  return;
267
361
  }
@@ -347,6 +441,65 @@ export class HostTransport {
347
441
  details: { feature: DOCUMENT_RPC_REQUEST_TIMEOUT_FEATURE },
348
442
  });
349
443
  }
444
+ beginDocumentRpcMutation(sessionId) {
445
+ const state = this.documentRpcMutationStates.get(sessionId) ?? {
446
+ confirmedDirty: this.dirtyDocumentRpcSessions.has(sessionId),
447
+ pendingCount: 0,
448
+ };
449
+ state.pendingCount += 1;
450
+ this.documentRpcMutationStates.set(sessionId, state);
451
+ this.dirtyDocumentRpcSessions.add(sessionId);
452
+ }
453
+ settleDocumentRpcMutation(sessionId, outcome) {
454
+ const state = this.documentRpcMutationStates.get(sessionId);
455
+ if (!state)
456
+ return;
457
+ state.pendingCount = Math.max(0, state.pendingCount - 1);
458
+ if (outcome !== 'not-applied')
459
+ state.confirmedDirty = true;
460
+ if (state.confirmedDirty || state.pendingCount > 0) {
461
+ this.dirtyDocumentRpcSessions.add(sessionId);
462
+ return;
463
+ }
464
+ this.clearDirtyDocumentRpcSession(sessionId);
465
+ }
466
+ clearDirtyDocumentRpcSession(sessionId) {
467
+ this.dirtyDocumentRpcSessions.delete(sessionId);
468
+ this.documentRpcMutationStates.delete(sessionId);
469
+ }
470
+ markDocumentRpcSessionSaved(sessionId) {
471
+ const state = this.documentRpcMutationStates.get(sessionId);
472
+ if (!state || state.pendingCount === 0) {
473
+ this.clearDirtyDocumentRpcSession(sessionId);
474
+ return;
475
+ }
476
+ state.confirmedDirty = false;
477
+ this.dirtyDocumentRpcSessions.add(sessionId);
478
+ }
479
+ async persistDirtyDocumentRpcSessions() {
480
+ if (this.dirtyDocumentRpcSessions.size === 0)
481
+ return;
482
+ if (!this.hostFeatures.has(DOCUMENT_RPC_SOURCE_SAVE_FEATURE)) {
483
+ throw new SuperDocCliError('Document host cannot persist dirty sessions during client disposal because it does not support source saves.', {
484
+ code: 'DOCUMENT_RPC_INPUT_UNSUPPORTED',
485
+ details: { feature: DOCUMENT_RPC_SOURCE_SAVE_FEATURE },
486
+ });
487
+ }
488
+ for (const sessionId of this.dirtyDocumentRpcSessions) {
489
+ const response = await this.sendJsonRpcRequest('document.save', { sessionId }, this.resolveWatchdogTimeout(undefined));
490
+ mapDocumentLifecycleResult('doc.save', response, sessionId, true);
491
+ this.markDocumentRpcSessionSaved(sessionId);
492
+ }
493
+ }
494
+ async reconcileIndeterminateDiscardCloses() {
495
+ for (const sessionId of this.indeterminateDiscardCloses) {
496
+ const response = await this.sendJsonRpcRequest('document.close', { sessionId, discard: true }, this.resolveWatchdogTimeout(undefined));
497
+ mapDocumentLifecycleResult('doc.close', response, sessionId);
498
+ this.documentRpcSessions.delete(sessionId);
499
+ this.clearDirtyDocumentRpcSession(sessionId);
500
+ this.indeterminateDiscardCloses.delete(sessionId);
501
+ }
502
+ }
350
503
  async sendJsonRpcRequest(method, params, watchdogTimeoutMs, metadata = {}) {
351
504
  const child = this.child;
352
505
  if (!child || !child.stdin.writable) {
@@ -478,8 +631,17 @@ export class HostTransport {
478
631
  this.cleanupProcess(error);
479
632
  }
480
633
  cleanupProcess(error) {
634
+ if (error && this.dirtyDocumentRpcSessions.size > 0 && !this.terminalDisposalError) {
635
+ this.terminalDisposalError = new SuperDocCliError('Document host disconnected before dirty sessions could be persisted.', {
636
+ code: 'HOST_DISCONNECTED',
637
+ details: { causeCode: error.code, causeDetails: error.details },
638
+ });
639
+ }
481
640
  this.hostFeatures.clear();
482
641
  this.documentRpcSessions.clear();
642
+ this.dirtyDocumentRpcSessions.clear();
643
+ this.documentRpcMutationStates.clear();
644
+ this.indeterminateDiscardCloses.clear();
483
645
  const child = this.child;
484
646
  if (child) {
485
647
  child.removeAllListeners();
@@ -0,0 +1,8 @@
1
+ 'use strict';
2
+
3
+ // AUTO-GENERATED by scripts/embed-version.mjs — DO NOT EDIT.
4
+ // Source of truth: package.json. Regenerated on every SDK build so the
5
+ // SDK retains its own version identity when bundled into another package.
6
+ const SDK_VERSION = '2.8.0';
7
+
8
+ exports.SDK_VERSION = SDK_VERSION;
@@ -0,0 +1 @@
1
+ export declare const SDK_VERSION = "2.8.0";
@@ -0,0 +1,4 @@
1
+ // AUTO-GENERATED by scripts/embed-version.mjs — DO NOT EDIT.
2
+ // Source of truth: package.json. Regenerated on every SDK build so the
3
+ // SDK retains its own version identity when bundled into another package.
4
+ export const SDK_VERSION = '2.8.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@superdoc/sdk",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "description": "Node SDK for SuperDoc, wrapping the SuperDoc CLI to read and edit .docx files from JavaScript and TypeScript.",
5
5
  "private": false,
6
6
  "license": "AGPL-3.0",
@@ -37,17 +37,17 @@
37
37
  "typescript": "^5.9.2"
38
38
  },
39
39
  "optionalDependencies": {
40
- "@superdoc/sdk-darwin-arm64": "2.7.0",
41
- "@superdoc/sdk-darwin-x64": "2.7.0",
42
- "@superdoc/sdk-linux-x64": "2.7.0",
43
- "@superdoc/sdk-linux-arm64": "2.7.0",
44
- "@superdoc/sdk-windows-x64": "2.7.0"
40
+ "@superdoc/sdk-darwin-arm64": "2.8.0",
41
+ "@superdoc/sdk-darwin-x64": "2.8.0",
42
+ "@superdoc/sdk-linux-x64": "2.8.0",
43
+ "@superdoc/sdk-linux-arm64": "2.8.0",
44
+ "@superdoc/sdk-windows-x64": "2.8.0"
45
45
  },
46
46
  "publishConfig": {
47
47
  "access": "public"
48
48
  },
49
49
  "scripts": {
50
- "build": "rm -rf dist && node scripts/embed-prompts.mjs && node scripts/embed-tools.mjs && tsc && rollup -c rollup.cjs.config.mjs && rm -rf dist/prompts && mkdir -p dist/prompts && cp src/prompts/*.md dist/prompts/ && pnpm run audit:publish",
50
+ "build": "rm -rf dist && node scripts/embed-version.mjs && node scripts/embed-prompts.mjs && node scripts/embed-tools.mjs && tsc && rollup -c rollup.cjs.config.mjs && rm -rf dist/prompts && mkdir -p dist/prompts && cp src/prompts/*.md dist/prompts/ && pnpm run audit:publish",
51
51
  "audit:publish": "node ../../../../scripts/audit-publish-artifact.mjs dist --label sdk-node-dist",
52
52
  "typecheck": "tsc --noEmit",
53
53
  "test:document-host": "bun test src/runtime/__tests__/host-spawn-args.test.ts src/runtime/__tests__/document-rpc.test.ts src/__tests__/structured-document-rpc.e2e.test.ts src/__tests__/request-timeout-ms.e2e.test.ts",