fix(ios): ios local access limit for self-hosted workspace (#15338)

## Summary
- Route iOS nbstore worker token reads through the main-thread
MessagePort and skip Capacitor Auth on `/socket.io` polling so self-host
WebSocket/XHR sync no longer hangs on Connect timeout.
- Harden workspace `flavour:id` routing, DocSyncPeer abort/status
handling, and `resetSync` so local selfhost edits push and Mac browsers
can receive them.
- Soften root-doc readiness waits and session-exchange throttling to
keep mobile selfhost sign-in/sync stable under retries.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

* **New Features**
* Improved workspace switching across local and remote environments,
preserving workspace type and server context.
  * Added support for page navigation with query parameters.
* Added iOS local-network permission messaging for self-hosted
workspaces.
* **Bug Fixes**
* Improved document synchronization, reset handling, prioritized
document refreshes, and retry behavior.
* Prevented authentication headers and refresh attempts for socket
connection requests.
* Improved workspace reopening and routing when multiple workspace types
share an ID.
  * Fixed handling of unlimited data query limits.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
keepClamDown
2026-08-24 09:33:58 +08:00
committed by GitHub
parent b530198a3b
commit cd6593c659
15 changed files with 448 additions and 78 deletions
@@ -113,6 +113,12 @@ class TestDocStorage implements DocStorage {
}
}
class TimestampBlindDocStorage extends IndexedDBDocStorage {
override async getDocTimestamps(): Promise<DocClocks> {
return {};
}
}
class PermissionDeniedRemoteDocStorage implements DocStorage {
readonly storageType = 'doc' as const;
readonly connection = new DummyConnection();
@@ -306,7 +312,7 @@ test('doc', async () => {
type: 'workspace',
});
const peerBDoc = new IndexedDBDocStorage({
const peerBDoc = new TimestampBlindDocStorage({
id: 'ws1',
flavour: 'b',
type: 'workspace',
@@ -340,6 +346,26 @@ test('doc', async () => {
docId: 'doc1',
bin: update,
});
const prioritizedDocId = 'prioritized-doc';
const localPrioritizedDoc = new YDoc();
localPrioritizedDoc.getMap('test').set('local', true);
const localPrioritizedClock = await peerA.get('doc').pushDocUpdate({
docId: prioritizedDocId,
bin: encodeStateAsUpdate(localPrioritizedDoc),
});
await peerASync.setPeerPushedClock('b', localPrioritizedClock);
const remotePrioritizedDoc = new YDoc();
remotePrioritizedDoc.getMap('test').set('remote', true);
await peerB.get('doc').pushDocUpdate({
docId: prioritizedDocId,
bin: encodeStateAsUpdate(remotePrioritizedDoc),
});
const rootDoc = new YDoc();
rootDoc.getMap('meta').set('name', 'Self-host workspace');
await peerB.get('doc').pushDocUpdate({
docId: 'ws1',
bin: encodeStateAsUpdate(rootDoc),
});
const sync = new Sync({
local: peerA,
@@ -348,6 +374,7 @@ test('doc', async () => {
c: peerC,
},
});
const removeRootPriority = sync.doc.addPriority('ws1', 100);
sync.start();
await new Promise(resolve => setTimeout(resolve, 1000));
@@ -366,8 +393,33 @@ test('doc', async () => {
hello: 'world',
},
});
const root = await peerA.get('doc').getDoc('ws1');
expectYjsEqual(root!.bin, {
meta: {
name: 'Self-host workspace',
},
});
const prioritized = await peerA.get('doc').getDoc(prioritizedDocId);
expectYjsEqual(prioritized!.bin, {
test: {
local: true,
},
});
}
const removeDocPriority = sync.doc.addPriority(prioritizedDocId, 100);
await vi.waitFor(async () => {
const prioritized = await peerA.get('doc').getDoc(prioritizedDocId);
expectYjsEqual(prioritized!.bin, {
test: {
local: true,
remote: true,
},
});
});
doc.getMap('test').set('foo', 'bar');
const update2 = encodeStateAsUpdate(doc);
await peerC.get('doc').pushDocUpdate({
@@ -394,6 +446,13 @@ test('doc', async () => {
},
});
}
removeDocPriority();
removeRootPriority();
sync.stop();
peerA.disconnect();
peerB.disconnect();
peerC.disconnect();
});
test('blob', async () => {
@@ -24,6 +24,7 @@ import {
import { createNode } from './node-builder';
const SQLITE_INDEXER_VERSION_OFFSET = 1;
const NATIVE_INDEXER_MAX_LIMIT = 0xffffffff;
export class SqliteIndexerStorage extends IndexerStorageBase {
static readonly identifier = 'SqliteIndexerStorage';
@@ -83,7 +84,7 @@ export class SqliteIndexerStorage extends IndexerStorageBase {
String(table),
toNativeQuery(query),
String(field),
limit,
toNativeLimit(limit),
skip,
options?.hits
? toNativeOptions(options.hits, hitLimit, hitSkip)
@@ -240,7 +241,7 @@ function toNativeOptions(
): NativeIndexSearchOptions {
const highlights = options?.highlights?.map(item => String(item.field)) ?? [];
return {
limit,
limit: toNativeLimit(limit),
offset,
fields: [
...new Set([...(options?.fields?.map(String) ?? []), ...highlights]),
@@ -248,3 +249,7 @@ function toNativeOptions(
highlights,
};
}
function toNativeLimit(limit: number) {
return limit === Infinity ? NATIVE_INDEXER_MAX_LIMIT : limit;
}
+54 -8
View File
@@ -34,6 +34,8 @@ export interface DocSyncDocState {
errorMessage: string | null;
}
const RESET_SYNC_CONNECT_TIMEOUT_MS = 30_000;
export interface DocSync {
readonly state$: Observable<DocSyncState>;
docState$(docId: string): Observable<DocSyncDocState>;
@@ -50,6 +52,8 @@ export class DocSyncImpl implements DocSync {
new DocSyncPeer(peerId, this.storages.local, this.sync, remote)
);
private abort: AbortController | null = null;
private running: Promise<void> = Promise.resolve();
private resetting: Promise<void> | null = null;
private readonly _state$ = combineLatest(
this.peers.map(peer => peer.peerState$)
@@ -155,12 +159,16 @@ export class DocSyncImpl implements DocSync {
if (this.abort) {
this.abort.abort(MANUALLY_STOP);
}
const previous = this.running;
const abort = new AbortController();
this.abort = abort;
Promise.allSettled(
this.peers.map(peer => peer.mainLoop(abort.signal))
).catch(error => {
console.error(error);
this.running = previous.then(async () => {
if (abort.signal.aborted) {
return;
}
await Promise.allSettled(
this.peers.map(peer => peer.mainLoop(abort.signal))
);
});
}
@@ -174,12 +182,50 @@ export class DocSyncImpl implements DocSync {
return () => undo.forEach(fn => fn());
}
async resetSync() {
resetSync() {
if (this.resetting) {
return this.resetting;
}
const resetting = this.performReset().finally(() => {
if (this.resetting === resetting) {
this.resetting = null;
}
});
this.resetting = resetting;
return resetting;
}
private async performReset() {
const running = this.abort !== null;
const activeRun = this.running;
const shouldConnectSyncStorage =
this.sync.connection.status === 'idle' ||
this.sync.connection.status === 'closed';
this.stop();
await this.sync.clearClocks();
if (running) {
this.start();
await activeRun;
if (shouldConnectSyncStorage) {
this.sync.connection.connect();
}
const abort = new AbortController();
const timeoutId = setTimeout(() => {
abort.abort(new Error('Connect to remote timeout'));
}, RESET_SYNC_CONNECT_TIMEOUT_MS) as ReturnType<typeof setTimeout> & {
unref?: () => void;
};
timeoutId.unref?.();
try {
await this.sync.connection.waitForConnected(abort.signal);
await this.sync.clearClocks();
} catch (error) {
console.error('Failed to reset sync', error);
throw error;
} finally {
clearTimeout(timeoutId);
if (running) {
this.start();
} else if (shouldConnectSyncStorage) {
this.sync.connection.disconnect();
}
}
}
}
+26 -10
View File
@@ -280,9 +280,14 @@ export class DocSyncPeer {
(await this.syncMetadata.getPeerPulledRemoteClock(this.peerId, docId))
?.timestamp ?? null;
const remoteClock = this.status.remoteClocks.get(docId);
const hasRemoteClock = remoteClock.getTime() > 0;
const hasPulled = pulled !== null && pulled.getTime() > 0;
if (
remoteClock &&
(pulled === null || pulled.getTime() < remoteClock.getTime())
hasRemoteClock
? !hasPulled ||
(pulled !== null && pulled.getTime() < remoteClock.getTime())
: (this.prioritySettings.get(docId) ?? 0) > 0 &&
(!clock || !hasPulled)
) {
await this.jobs.pull(docId, signal);
}
@@ -503,10 +508,7 @@ export class DocSyncPeer {
if (!this.status.docs.has(docId)) {
this.status.docs.add(docId);
this.statusUpdatedSubject$.next(docId);
this.schedule({
type: 'connect',
docId,
});
this.schedule({ type: 'connect', docId });
}
},
};
@@ -756,6 +758,11 @@ export class DocSyncPeer {
for (const docId of this.status.remoteClocks.keys()) {
this.actions.addDoc(docId);
}
for (const [docId, priority] of this.prioritySettings) {
if (priority > 0) {
this.actions.addDoc(docId);
}
}
// begin to process jobs
@@ -890,13 +897,22 @@ export class DocSyncPeer {
addPriority(id: string, priority: number) {
const oldPriority = this.prioritySettings.get(id) ?? 0;
this.prioritySettings.set(id, priority);
this.status.jobDocQueue.setPriority(id, oldPriority + priority);
const newPriority = oldPriority + priority;
this.prioritySettings.set(id, newPriority);
this.status.jobDocQueue.setPriority(id, newPriority);
if (oldPriority <= 0 && newPriority > 0 && this.status.syncing) {
if (!this.status.docs.has(id)) {
this.actions.addDoc(id);
} else {
this.schedule({ type: 'connect', docId: id });
}
}
return () => {
const currentPriority = this.prioritySettings.get(id) ?? 0;
this.prioritySettings.set(id, currentPriority - priority);
this.status.jobDocQueue.setPriority(id, currentPriority - priority);
const restoredPriority = currentPriority - priority;
this.prioritySettings.set(id, restoredPriority);
this.status.jobDocQueue.setPriority(id, restoredPriority);
};
}