Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/demo-capacity-sync-halt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@xnetjs/runtime': minor
---

`NodeStoreSyncProvider` now handles hub capacity rejections gracefully: on the first `QUOTA_EXCEEDED` (over the hub's per-user cap) or `STORAGE_FULL` (hub disk full) rejection it pauses outbound sync instead of re-flooding the hub, keeps local data intact, and resumes on the next reconnect. Subscribe to the new `onSyncBlocked(listener)` API (with `SyncBlockedReason`/`SyncBlockedListener` types) to surface a "storage full" notice in your app.
21 changes: 21 additions & 0 deletions apps/web/src/lib/share-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ describe('claimShareLink', () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('nope', { status: 502 })))
await expect(claimShareLink(input, 'token')).rejects.toMatchObject({ code: 'HTTP_502' })
})

it('maps a network-layer failure to HUB_UNREACHABLE naming the hub (0290)', async () => {
// fetch() rejects with a bare TypeError for hub-down / CORS-less edge
// errors — the user should see an outage, not "Failed to fetch".
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
await expect(claimShareLink(input, 'token')).rejects.toMatchObject({
name: 'ShareClaimError',
code: 'HUB_UNREACHABLE',
message: expect.stringContaining('https://hub.example.com')
})
})
})

describe('claim error text', () => {
Expand Down Expand Up @@ -136,6 +147,9 @@ describe('docRouteFor', () => {
params: { dashboardId: 'd' }
})
expect(docRouteFor('view', 'e')).toEqual({ to: '/view/$viewId', params: { viewId: 'e' } })
expect(docRouteFor('space', 'f')).toEqual({ to: '/space/$spaceId', params: { spaceId: 'f' } })
// Workspaces have no viewer route — a claimed bench lands home (0280/0290).
expect(docRouteFor('workspace', 'g')).toEqual({ to: '/', params: {} })
})
})

Expand Down Expand Up @@ -241,6 +255,13 @@ describe('hubApiFetch', () => {
'Hub request failed (500)'
)
})

it('names the hub on network-layer failures instead of "Failed to fetch" (0290)', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
await expect(hubApiFetch('https://hub.x', 'tok', '/shares/links')).rejects.toThrow(
"Your hub (https://hub.x) isn't responding"
)
})
})

describe('isPrivateHubHost and URL normalization', () => {
Expand Down
65 changes: 46 additions & 19 deletions apps/web/src/lib/share-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type ShareLinkInput = {

export type ShareClaimResult = {
resource: string
docType: 'page' | 'database' | 'canvas' | 'dashboard' | 'view' | 'space'
docType: 'page' | 'database' | 'canvas' | 'dashboard' | 'view' | 'space' | 'workspace'
role: 'read' | 'comment' | 'write'
endpoint: string
}
Expand Down Expand Up @@ -88,15 +88,25 @@ export async function claimShareLink(
authToken: string
): Promise<ShareClaimResult> {
const hub = normalizeHubHttpUrl(input.hub)
const response = await fetch(`${hub}/shares/links/${encodeURIComponent(input.linkId)}/claim`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`
},
body: JSON.stringify({ secret: input.secret }),
cache: 'no-store'
})
let response: Response
try {
response = await fetch(`${hub}/shares/links/${encodeURIComponent(input.linkId)}/claim`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`
},
body: JSON.stringify({ secret: input.secret }),
cache: 'no-store'
})
} catch {
// Network-layer failure (hub down / edge error without CORS headers) —
// surface the hub, not a bare "Failed to fetch" (exploration 0290).
throw new ShareClaimError(
'HUB_UNREACHABLE',
`The hub issuing this link (${hub}) isn't responding — it may be down or restarting. Try again shortly.`
)
}

const data = (await response.json().catch(() => null)) as
| (ShareClaimResult & { code?: string; error?: string })
Expand Down Expand Up @@ -129,6 +139,8 @@ export function shareClaimErrorMessage(code: string): string {
return 'This share link is missing or has a corrupted secret. Copy the full link and try again.'
case 'RATE_LIMITED':
return 'Too many attempts. Wait a minute and try again.'
case 'HUB_UNREACHABLE':
return "The hub issuing this link isn't responding — it may be down or restarting. Try again shortly."
default:
return 'The share link could not be claimed.'
}
Expand Down Expand Up @@ -218,15 +230,26 @@ export async function hubApiFetch(
path: string,
init: { method?: string; body?: unknown } = {}
): Promise<unknown> {
const response = await fetch(`${hubHttpUrl}${path}`, {
method: init.method ?? 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`
},
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
cache: 'no-store'
})
let response: Response
try {
response = await fetch(`${hubHttpUrl}${path}`, {
method: init.method ?? 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`
},
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
cache: 'no-store'
})
} catch {
// fetch() rejects with a bare TypeError ("Failed to fetch") for every
// network-layer failure — including an edge 502 served without CORS
// headers while the hub is down (exploration 0290). Name the hub so the
// user sees an outage, not a mystery.
throw new Error(
`Your hub (${hubHttpUrl}) isn't responding — it may be down or restarting. Try again shortly.`
)
}
const data = (await response.json().catch(() => null)) as { error?: string } | null
if (!response.ok) {
throw new Error(data?.error ?? `Hub request failed (${response.status})`)
Expand All @@ -250,6 +273,10 @@ export function docRouteFor(
return { to: '/view/$viewId', params: { viewId: resource } }
case 'space':
return { to: '/space/$spaceId', params: { spaceId: resource } }
case 'workspace':
// Workspaces have no viewer route; land home — the granted node syncs
// and appears in the receiver's workspace switcher (0280).
return { to: '/', params: {} }
default:
return { to: '/doc/$docId', params: { docId: resource } }
}
Expand Down
Loading
Loading