fix(feishu): stabilize post-merge card workflows

This commit is contained in:
xmanrui 2026-08-24 17:56:06 +08:00
parent 9d1dc71794
commit bd9c58cf9e
19 changed files with 3372 additions and 456 deletions

View file

@ -4,6 +4,8 @@ import { fileURLToPath } from 'node:url';
import { build } from 'esbuild';
import { larkSdkHandshakePatch } from './lark-sdk-handshake-patch.mjs';
const sourceDirectory = dirname(fileURLToPath(import.meta.url));
const packageRoot = resolve(sourceDirectory, '../..');
const outputPath = resolve(packageRoot, 'lib/index.js');
@ -25,6 +27,7 @@ await build({
target: ['node22'],
mainFields: ['module', 'main'],
external,
plugins: [larkSdkHandshakePatch],
outfile: outputPath,
banner: {
js: [

View file

@ -0,0 +1,181 @@
import { readFile } from 'node:fs/promises';
const PATCHES = [
{
label: 'WSClient pending-socket state',
before: ` this.wsConfig = new WSConfig();
this.reconnectGeneration = 0;
this.isConnecting = false;`,
after: ` this.wsConfig = new WSConfig();
this.reconnectGeneration = 0;
this.pendingWsInstance = null;
this.isConnecting = false;`,
},
{
label: 'WSClient pending-socket registration',
before: ` if (!wsInstance) {
return Promise.resolve(false);
}
return new Promise((resolve) => {`,
after: ` if (!wsInstance) {
return Promise.resolve(false);
}
this.pendingWsInstance = wsInstance;
return new Promise((resolve) => {`,
},
{
label: 'WSClient pending-socket settlement',
before: ` if (timer)
clearTimeout(timer);
resolve(ok);`,
after: ` if (timer)
clearTimeout(timer);
if (this.pendingWsInstance === wsInstance)
this.pendingWsInstance = null;
resolve(ok);`,
},
{
label: 'WSClient handshake-timeout listener cleanup',
before: ` this.logger.error('[ws]', \`handshake timeout after \${this.handshakeTimeoutMs}ms\`);
wsInstance.removeAllListeners();`,
after: ` this.logger.error('[ws]', \`handshake timeout after \${this.handshakeTimeoutMs}ms\`);
wsInstance.removeAllListeners('open');`,
},
{
label: 'WSClient reconnect generation fences',
before: ` const tryConnect = () => __awaiter(this, void 0, void 0, function* () {
this.reconnectInfo.lastConnectTime = Date.now();
const pullResult = yield this.pullConnectConfig();
if (!pullResult.ok)
return pullResult;
const connected = yield this.connect();
if (!connected)
return { ok: false, retryable: true };
this.communicate();
return { ok: true };
});`,
after: ` const tryConnect = () => __awaiter(this, void 0, void 0, function* () {
if (currentGeneration !== this.reconnectGeneration)
return { ok: false, retryable: false, cancelled: true };
this.reconnectInfo.lastConnectTime = Date.now();
const pullResult = yield this.pullConnectConfig();
if (currentGeneration !== this.reconnectGeneration)
return { ok: false, retryable: false, cancelled: true };
if (!pullResult.ok)
return pullResult;
const connected = yield this.connect();
if (currentGeneration !== this.reconnectGeneration)
return { ok: false, retryable: false, cancelled: true };
if (!connected)
return { ok: false, retryable: true };
this.communicate();
return { ok: true };
});`,
},
{
label: 'WSClient initial-connect cancellation fence',
before: ` try {
result = yield tryConnect();
}
finally {
this.isConnecting = false;
}
if (result.ok) {`,
after: ` try {
result = yield tryConnect();
}
finally {
if (currentGeneration === this.reconnectGeneration) {
this.isConnecting = false;
}
}
if (currentGeneration !== this.reconnectGeneration || result.cancelled) {
return;
}
if (result.ok) {`,
},
{
label: 'WSClient pending-socket close',
before: ` const wsInstance = this.wsConfig.getWSInstance();
if (wsInstance) {`,
after: ` const pendingWsInstance = this.pendingWsInstance;
if (pendingWsInstance) {
this.pendingWsInstance = null;
pendingWsInstance.removeAllListeners('open');
try {
if (force) {
pendingWsInstance.terminate();
}
else {
pendingWsInstance.close();
}
}
catch ( /* best effort */_a) { /* best effort */ }
}
const wsInstance = this.wsConfig.getWSInstance();
if (wsInstance) {`,
},
{
label: 'WSClient idempotent start guard',
before: ` const { eventDispatcher } = params;
if (!eventDispatcher) {
this.logger.warn('[ws]', 'client need to start with a eventDispatcher');
return;
}
// Clear any terminal-error state left over from a previous session so`,
after: ` const { eventDispatcher } = params;
if (!eventDispatcher) {
this.logger.warn('[ws]', 'client need to start with a eventDispatcher');
return;
}
const liveWsInstance = this.wsConfig.getWSInstance();
if (this.terminalError) {
this.isConnecting = false;
}
if (this.isConnecting ||
(liveWsInstance && liveWsInstance.readyState !== WebSocket.CLOSED)) {
this.logger.debug('[ws]', 'start ignored because client is already connecting or connected');
return;
}
// Clear any terminal-error state left over from a previous session so`,
},
];
function replaceExactlyOnce(source, patch, sourcePath) {
const matches = source.split(patch.before).length - 1;
if (matches !== 1) {
throw new Error(
`${sourcePath}: expected exactly one reviewed ${patch.label} marker, found ${matches}`,
);
}
return source.replace(patch.before, patch.after);
}
/**
* Patch @larksuiteoapi/node-sdk 1.73.0's WSClient lifecycle.
*
* The vendor client cannot normally close a socket until its WebSocket
* handshake has opened. Its timeout also removes the socket's `error`
* listener before terminating it, and the initial connection path can resume
* after close() and start a zombie reconnect loop. Track and safely terminate
* the pending socket, retain its error listener, and fence every async initial
* connection stage with the reconnect generation already maintained by the
* SDK. Every reviewed source fragment must match exactly once so an SDK source
* change fails the build instead of silently losing the compatibility fix.
*/
export function patchLarkSdkHandshakeSource(source, sourcePath = 'Lark SDK') {
return PATCHES.reduce(
(patched, patch) => replaceExactlyOnce(patched, patch, sourcePath),
source,
);
}
export const larkSdkHandshakePatch = {
name: 'dsh-lark-sdk-websocket-lifecycle-fix',
setup(build) {
build.onLoad({ filter: /@larksuiteoapi[\\/]node-sdk[\\/](es|lib)[\\/]index\.js$/ }, async ({ path }) => ({
contents: patchLarkSdkHandshakeSource(await readFile(path, 'utf8'), path),
loader: 'js',
}));
},
};