Skip to content

Back state up to the account

Your app writes its state to the account once and reads it back on whichever device the person signs in on next, and a first run reads as empty rather than as a failure. This page covers the bounded wait, the read, the write and its unlock, following the account, and keeping one writer.

The account is the FKN identity a person carries between sites, and the person connects it to your site with connect(), see connecting. cloud.fs is the file system whose bytes live in the account’s storage and nowhere on this device, see three file systems. Every call on it goes through the broker, the connection your page holds into FKN, and resolves when the account has answered. The app is the media library from the other pages: the list of what the person kept is serialized and written to backup/library.bin, and the same path is read on the next device.

Three things went wrong in the integration behind this page, and each cost real time. Absence is worded two different ways, and an app that matches only one of them retries forever. A custom error code does not cross out of the broker, and available() resolves whether a connect token is held rather than reporting a capability. The steps below meet them in order.

cloud.fs and account.info() wait for the broker connection with no deadline. On a page where the broker never connects they never settle, and neither does a status line that awaits them. apiWithin from @fkn/lib/api is the bounded wait the socket calls use.

It resolves once the broker has answered and rejects with BrokerUnreachableError after 8,000 ms, then after 1,000 ms per call once any deadline in the realm was missed. A realm is one JavaScript execution context, such as this window, and the class is created in yours, so instanceof works. Run it before the first read, and resolve a fallback rather than rejecting, because a UI has to say something:

app.ts
const
const readBackup: () => Promise<Library | null>
readBackup
= async ():
interface Promise<T>

Represents the completion of an asynchronous operation

Promise
<
type Library = {
items: string[];
}
Library
| null> => {
try {
await
function apiWithin(what: string): Promise<{
cloud: {
fetch: (input: ProxyFetchInput, init: ProxyFetchInit) => Promise<Response>;
quota: () => Promise<{
overQuota: boolean;
remaining: number;
usedBytes: number;
limitBytes: number;
premium: boolean;
bytesPerSecond: number;
}>;
dns: {
lookup: (hostname: string, options?: {
all?: boolean | undefined;
family?: 0 | 4 | 6;
} | undefined) => Promise<{
address: string;
family: 0 | 4 | 6;
} | {
address: string;
family: 0 | 4 | 6;
}[] | undefined>;
};
fs: {
available: () => Promise<boolean>;
... 11 more ...;
setAdoptSource: (next: AdoptState | null, run: (() => Promise<void>) | null) => Promise<...>;
};
webvpn: {
...;
};
};
... 16 more ...;
hideInstallPrompt: () => Promise<...>;
}>
apiWithin
('the library backup') // resolves once the broker answered, so the read below cannot park
return
const decode: (data: Buffer | string) => Library
decode
(await
(alias) namespace cloud
import cloud
cloud
.
namespace cloud_d_exports.fs
export cloud_d_exports.fs
fs
.
const fs_d_exports.promises: {
readFile: (path: import("node:fs").PathLike, options?: ReadOptions) => Promise<Buffer | string>;
writeFile: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => Promise<void>;
unlink: (path: import("node:fs").PathLike) => Promise<void>;
rename: (from: import("node:fs").PathLike, to: import("node:fs").PathLike) => Promise<void>;
readdir: (path: import("node:fs").PathLike) => Promise<string[]>;
mkdir: (_path?: import("node:fs").PathLike, _options?: MakeOptions) => Promise<void>;
... 4 more ...;
access: (path: import("node:fs").PathLike) => Promise<void>;
}
export fs_d_exports.promises
promises
.
readFile: (path: import("node:fs").PathLike, options?: ReadOptions) => Promise<Buffer | string>
readFile
('backup/library.bin'))
} catch (
function (local var) error: unknown
error
) {
if (!(
function (local var) error: unknown
error
instanceof
class BrokerUnreachableError
BrokerUnreachableError
)) throw
function (local var) error: unknown
error
function (local var) error: BrokerUnreachableError
error
.
Error.message: string
message
// @fkn/lib: no broker connection within 8000ms, so the library backup could not be requested
return null // a resolved fallback, so the page can say the account was not reached
}
}
const
const library: Library | null
library
= await
const readBackup: () => Promise<Library | null>
readBackup
() // the library, or null after 8 seconds with no broker

The phrase you pass is the one the message repeats after so. One apiWithin at startup is enough, because once the broker has answered every later call finds it at once. It bounds the wait for the broker and nothing after it: the read runs against a connected broker and has no deadline of its own, and a cap on that is a Promise.race of yours, see reading the account. A timer that wins says the call was not observed, never that the backup is gone, so keep the state you had.

Which calls wait and which give up is on connection and lifecycle.

On a first run there is no backup and the read rejects. Absence arrives worded two completely different ways: Not found when the service, the FKN server that keeps the account’s files, refuses to presign a path with no committed row, and storage: read failed (404) when the presign succeeded and the object itself answered 404. Both mean the same thing. An app that matched only the first read a missing backup as a transient failure and retried forever, and a library went un-backed-up for a day with no error anywhere.

isNotFound from @fkn/lib/cloud/fs covers both. The library catches every cloud.fs read and write rejection on your side and, for absence, throws a fresh StorageNotFoundError whose code is FKN_STORAGE_NOT_FOUND. That code is there because it is minted in your realm. An error thrown in the broker crosses a realm before you catch it, and only its name, message, stack and cause survive the hop, so a code set beside a message in the broker is gone by the time you see it, see what crosses a realm.

Test absence with the helper and render an empty library:

app.ts
const
const empty: Library
empty
:
type Library = {
items: string[];
}
Library
= {
items: string[]
items
: [] }
const
const restore: () => Promise<Library>
restore
= async ():
interface Promise<T>

Represents the completion of an asynchronous operation

Promise
<
type Library = {
items: string[];
}
Library
> => {
try {
return
const decode: (data: Buffer | string) => Library
decode
(await
(alias) namespace cloud
import cloud
cloud
.
namespace cloud_d_exports.fs
export cloud_d_exports.fs
fs
.
const fs_d_exports.promises: {
readFile: (path: import("node:fs").PathLike, options?: ReadOptions) => Promise<Buffer | string>;
writeFile: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => Promise<void>;
unlink: (path: import("node:fs").PathLike) => Promise<void>;
rename: (from: import("node:fs").PathLike, to: import("node:fs").PathLike) => Promise<void>;
readdir: (path: import("node:fs").PathLike) => Promise<string[]>;
mkdir: (_path?: import("node:fs").PathLike, _options?: MakeOptions) => Promise<void>;
... 4 more ...;
access: (path: import("node:fs").PathLike) => Promise<void>;
}
export fs_d_exports.promises
promises
.
readFile: (path: import("node:fs").PathLike, options?: ReadOptions) => Promise<Buffer | string>
readFile
('backup/library.bin'))
} catch (
function (local var) error: unknown
error
) {
if (
function isNotFound(error: unknown): boolean

Whether an error from this module means the path is empty, as opposed to unreadable.

isNotFound
(
function (local var) error: unknown
error
)) return
const empty: Library
empty
// true on a first run, for 'Not found' and for 'storage: read failed (404)' alike
throw
function (local var) error: unknown
error
// locked, unreachable and a changed key pass through, since none of them is empty
}
}
const
const library: Library
library
= await
const restore: () => Promise<Library>
restore
()
const render: (library: Library, note: string) => void
render
(
const library: Library
library
,
const library: Library
library
.
items: string[]
items
.
Array<string>.length: number

Gets or sets the length of the array. This is a number one higher than the highest index in the array.

length
? '' : 'nothing saved yet') // what a first run shows, and the first write is safe

isNotFound answered true and the page rendered “nothing saved yet”. Nothing else is empty. A path that could not be read is not one to write a first backup over, so the locked case, storage: api unreachable and the messages of a changed key all pass through. The order to test them in is on handling errors.

cloud.fs.promises.writeFile resolves once the object is committed in the account, so a resolved write is a durable one, see cloud.fs is async only. Every file is encrypted in the broker before it is uploaded, and your app never handles a key, see encryption.

Locked means the broker holds no usable key for this app and account. A write on a locked account does not fail at once.

It raises the broker’s unlock card, Storage locked. Click to unlock., inside the call and waits on the person, with no deadline. StorageLockedError arrives only after the card was dismissed, could not be shown, or delivered no usable key, see locked.

Ask once more through unlock(), which raises the same card and answers what it answered, and retry the write once:

app.ts
const
const save: (attempt?: number) => Promise<boolean>
save
= async (
attempt: number
attempt
= 0):
interface Promise<T>

Represents the completion of an asynchronous operation

Promise
<boolean> => {
try {
await
(alias) namespace cloud
import cloud
cloud
.
namespace cloud_d_exports.fs
export cloud_d_exports.fs
fs
.
const fs_d_exports.promises: {
readFile: (path: import("node:fs").PathLike, options?: ReadOptions) => Promise<Buffer | string>;
writeFile: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => Promise<void>;
unlink: (path: import("node:fs").PathLike) => Promise<void>;
rename: (from: import("node:fs").PathLike, to: import("node:fs").PathLike) => Promise<void>;
readdir: (path: import("node:fs").PathLike) => Promise<string[]>;
mkdir: (_path?: import("node:fs").PathLike, _options?: MakeOptions) => Promise<void>;
... 4 more ...;
access: (path: import("node:fs").PathLike) => Promise<void>;
}
export fs_d_exports.promises
promises
.
writeFile: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => Promise<void>
writeFile
('backup/library.bin',
const encode: (library: Library) => Uint8Array
encode
(
const library: Library
library
)) // committed in the account once this resolves
return true
} catch (
function (local var) error: unknown
error
) {
if (!(
function (local var) error: unknown
error
instanceof
class StorageLockedError
StorageLockedError
)) throw
function (local var) error: unknown
error
if (
attempt: number
attempt
> 0 || !(await
(alias) namespace cloud
import cloud
cloud
.
namespace cloud_d_exports.fs
export cloud_d_exports.fs
fs
.
fs_d_exports.unlock(): Promise<boolean>
export fs_d_exports.unlock
unlock
())) return false // a second dismissal is an answer, so the card is not raised again
return
const save: (attempt?: number) => Promise<boolean>
save
(
attempt: number
attempt
+ 1) // the write again, now that the broker holds a key
}
}
await
const save: (attempt?: number) => Promise<boolean>
save
() // true, after one unlock card when the account was locked

The card appeared once, inside the write, and the write completed when the person unlocked. unlock() runs only after a dismissal, and the retry only once, so a lock the person keeps declining cannot loop the card in front of them. Every other refusal passes through: a write with no account connected rejects with storage: not connected, a full account with Storage quota exceeded, and a key that changed with the messages on encryption.

.bin is not an extension the library knows, so the broker stores the object as application/octet-stream, and a contentType option chooses otherwise, see content types.

account.onChange(callback) calls you whenever the connection changes: an account connected, its token refreshed or refused, or a disconnect. The callback receives no argument, so call account.info() for the new state, which the broker caches for 5 seconds, see following changes.

cloud.fs.available() answers whether the broker holds a connect token for this site, and nothing about reachability or the locked state, see cloud.fs is async only. It is not a capability check, and it answers false both when no token is held and when the call itself failed, since the library turns a rejection into false. A person who is signed in can therefore read false, and onChange stays silent then because the account did not change.

So read info() beside it. A name next to false is a token the broker does not hold yet, and asking again is the only event coming. null next to false is a signed-out person, for whom onChange is the event to wait for:

app.ts
const
const sync: () => Promise<void>
sync
= async () => {
const
const info: account.AccountInfo | null
info
= await
(alias) namespace account
import account
account
.
account_d_exports.info(): Promise<account.AccountInfo | null>
export account_d_exports.info
info
() // null while nobody is connected, the username otherwise
if (
const info: account.AccountInfo | null
info
=== null) return
const stopWriting: () => void
stopWriting
() // nowhere to back up to, and nothing is wrong
if (await
(alias) namespace cloud
import cloud
cloud
.
namespace cloud_d_exports.fs
export cloud_d_exports.fs
fs
.
fs_d_exports.available(): Promise<boolean>
export fs_d_exports.available
available
()) return
const restore: () => Promise<void>
restore
() // a connect token is held, so the read runs
function setTimeout<[]>(callback: () => void, delay?: number): NodeJS.Timeout (+1 overload)
setTimeout
(
const sync: () => Promise<void>
sync
, 5_000) // signed in with no token yet, and no event follows a late grant, so ask again on an interval of your own
}
const
const unsubscribe: () => void
unsubscribe
= await
(alias) namespace account
import account
account
.
account_d_exports.onChange(callback: () => void): Promise<() => void>
export account_d_exports.onChange
onChange
(
const sync: () => Promise<void>
sync
) // onChange resolves to its own unsubscribe
await
const sync: () => Promise<void>
sync
()
// later, on teardown: unsubscribe() removes this callback only, and the broker keeps its registration

onChange resolved to its unsubscribe, and the first sync ran before any change. Keep the username beside the state you restored. When info() answers a different name, the state on this device belongs to the previous account, so read the new account’s backup before writing anything, or one person’s library lands in another’s account. Signing out clears the token and the keys the broker remembered for this app, so the next write rejects with storage: not connected until the person connects again, see disconnecting.

Two tabs of the same app both restore, both follow the account, and both write backup/library.bin. The service refuses a write that overlaps another’s presign and commit with Concurrent update, retry, and a write that starts after the other’s commit replaces the object. So the tab holding the older library overwrites the one holding the newer, and a debounce on each only decides which loses.

Keep one writer per browser. The Web Locks API is the election: the first tab to request the lock holds it, every other tab waits at the same call, and the browser releases the lock when the holder closes, so the next tab becomes the writer without a message between them:

app.ts
let
let writer: boolean
writer
= false
const
const schedule: () => void
schedule
= () => { if (
let writer: boolean
writer
)
const sync: () => Promise<void>
sync
() } // a second tab reaches this line and starts nothing
const onLibraryChange: (callback: () => void) => void
onLibraryChange
(
const schedule: () => void
schedule
)
await
(alias) namespace account
import account
account
.
account_d_exports.onChange(callback: () => void): Promise<() => void>
export account_d_exports.onChange
onChange
(
const schedule: () => void
schedule
)
var navigator: Navigator

The Window.navigator read-only property returns a reference to the Navigator object, which has methods and properties about the application running the script.

MDN Reference

navigator
.
NavigatorLocks.locks: LockManager
locks
.
LockManager.request<Promise<void>>(name: string, callback: LockGrantedCallback<Promise<void>>): Promise<Promise<void>> (+1 overload)

The request() method of the LockManager interface requests a Lock object with parameters specifying its name and characteristics.

MDN Reference

request
('library-backup', async () => {
let writer: boolean
writer
= true // this tab is the writer, and every other tab is parked on this request
await
const sync: () => Promise<void>
sync
()
await new
var Promise: PromiseConstructor
new <never>(executor: (resolve: (value: PromiseLike<never>) => void, reject: (reason?: any) => void) => void) => Promise<never>

Creates a new Promise.

@paramexecutor A callback used to initialize the promise. This callback is passed two arguments: a resolve callback used to resolve the promise with a value or the result of another promise, and a reject callback used to reject the promise with a provided reason or error.

Promise
<never>(() => {}) // held for the life of the tab, and released by the browser when it closes
})

Every tab follows the account and the library, and only the holder acts on either. A second tab runs schedule and starts nothing, until the first tab closes and the lock passes to it, at which point its callback restores and takes over. Coalesce the writer’s writes with a debounce of your own, since the library changes more often than it is worth committing.

Sign in on a second device and open the app there. The library the first device wrote renders, and a device that has never saved renders “nothing saved yet” with no error and no retry. When that is not what you see:

  1. The first device reports a failure and keeps retrying, and the second shows nothing. The read matches one wording of absence. Not found and storage: read failed (404) both mean nothing is stored, and isNotFound answers true for both.
  2. A write never settles and no error arrives. On a locked account the write raised the unlock card inside the call and is waiting on the person, with no deadline. StorageLockedError arrives only after the card is dismissed.
  3. Neither the read nor account.info() settles, and no card is showing. The page has no broker connection, and apiWithin is what turns that into @fkn/lib: no broker connection within 8000ms, so the library backup could not be requested after 8,000 ms.
  4. available() answers false while info() answers a name. The token was not held when it was asked, and no event follows, so ask again.
  5. The write rejects with Not signed in. The token this site holds no longer names a live session, so connect again, see connecting.
  6. The write rejects with Storage quota exceeded. The account is full across every app it connects, and cloud.fs.quota() reports the headroom, as the next section shows.

Two quotas apply, and neither is the other. cloud.fs.quota() is the account’s storage across every app it connects, 1,000,000,000 bytes and 10,000 objects on a free account by default, and a backup write counts against it. cloud.quota() is today’s metered volume through the relay, which holds the real socket for net and dgram, and the proxy, which cloud.fetch sends a request through, and its fields are on account and quota:

app.ts
const
const storage: cloud.fs.StorageQuota
storage
= await
(alias) namespace cloud
import cloud
cloud
.
namespace cloud_d_exports.fs
export cloud_d_exports.fs
fs
.
fs_d_exports.quota(): Promise<cloud.fs.StorageQuota>
export fs_d_exports.quota
quota
() // the account's storage, across every app it connects
const storage: cloud.fs.StorageQuota
storage
.
remaining: number
remaining
// bytes a write may still add, 0 once the account is full
const storage: cloud.fs.StorageQuota
storage
.
objects: number
objects
<
const storage: cloud.fs.StorageQuota
storage
.
maxObjects: number
maxObjects
// true while a new path can still be created
const
const volume: cloud.QuotaStatus
volume
= await
(alias) namespace cloud
import cloud
cloud
.
cloud_d_exports.quota(): Promise<cloud.QuotaStatus>
export cloud_d_exports.quota
quota
() // today's volume through the relay and the proxy, where a cloud.fs write is not counted
const volume: cloud.QuotaStatus
volume
.
throttled: boolean

transfers are actually being rate-limited right now (overQuota and not premium)

throttled
// false, or true once a free account spent the day's volume

Both calls wait for the broker like the read does, so they run after the apiWithin of the first step. cloud.quota() rejects when the broker is replaced under it, see a replaced broker.

opfs never contacts the broker, so a copy of the last good backup written there renders while the account is unreachable, and the read on encryption falls back to it on storage: api unreachable.

cloud.fs.readFileSealed() reads the backup and the time it was sealed, so a device can refuse to apply a copy older than the one it already holds, see reading with the seal time.