@fkn/lib gives your app a path-based subset of Node’s fs, in three file systems that differ in where the bytes live. This page covers the three file systems, the in-memory layer behind the synchronous calls, the three call forms, scope and paths, and the error codes.
You pick a file system by its import path. The default is fs. One mount() call is all it needs before Node-style code runs against it:
Converts a JavaScript value to a JavaScript Object Notation (JSON) string.
@param ― value A JavaScript value, usually an object or array, to be converted.
@param ― replacer A function that transforms the results.
@param ― space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.
@throws ― {TypeError} If a circular reference or a BigInt value is found.
stringify({
items: never[]
items: [] }))
const
constcatalog:any
catalog=
varJSON:JSON
An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.
JSON.
JSON.parse(text: string, reviver?: (this:any, key:string, value:any) => any): any
Converts a JavaScript Object Notation (JSON) string into an object.
@param ― text A valid JSON string.
@param ― reviver A function that transforms the results. This function is called for each member of the object.
If a member contains nested objects, the nested objects are transformed before the parent object is.
@throws ― {SyntaxError} If text is not valid JSON.
parse(
var String:StringConstructor
(value?:any) => string
Allows manipulation and formatting of text strings and determination and location of substrings within strings.
Every synchronous call after mount() reads and writes memory, so it returns at once. The write reaches the backing store a moment later, on its own. The in-memory layer below explains how that layer is filled. The read is wrapped in String(...) because the return type is Buffer | string whatever encoding you pass, see sync, callback and promise forms.
Each file system is a subpath import and a namespace on the root export: fs, opfs and cloud.fs, from import { fs, opfs, cloud } from '@fkn/lib'. Each also has a /promises subpath that carries only the promise members:
Import
Where the bytes live
Account
Forms
@fkn/lib/fs
this device’s OPFS, with a copy in the account when one is connected
optional
sync, callback, promise
@fkn/lib/opfs
this device’s OPFS only
none
sync, callback, promise
@fkn/lib/cloud/fs
the account’s storage
required
callback, promise
OPFS is the browser’s origin private file system, a store each origin owns and can use without asking the user. The account is the FKN identity a person carries between sites.
fs and opfs each keep an in-memory layer of the same design, one instance each. fs adds the account behind its own. A write through opfs therefore reaches fs.readFileSync only once opfs has written it to OPFS in the background and fs has run a fresh hydrate with remount().
Importing @fkn/lib/fs or @fkn/lib/cloud/fs in a window mounts the broker frame at import time. The broker frame is the hidden fkn.app iframe the library mounts. The broker is the connection that frame gives your app into FKN, and both file systems reach the account through it, see how it works.
@fkn/lib/opfs and @fkn/lib/opfs/promises are the storage entries that never contact the broker, which makes them the right pick for a device-only cache, see entry points.
A window realm, one JavaScript execution context such as a tab’s main thread, has no synchronous file access. So fs and opfs keep a per-realm copy of every file in memory and answer the synchronous calls from it.
mount() fills that copy from the backing store, a step called a hydrate: it lists the store and reads every listed file. For fs a hydrate reads every file in this origin’s OPFS and, when an account is connected, every object of this app that this device does not already hold, caching each one into OPFS as it downloads. The whole working set lives in RAM for the life of the realm.
When no broker answers, the fs hydrate’s listing waits 8 seconds for the account probe, and every later probe waits 1 second until one answers.
The synchronous forms never mount on their own. Before the first mount() resolves they see only what this realm has written:
existsSync('library/catalog.json') // true when a copy exists on this device or in the account
A synchronous write before that point is fine. The path is marked dirty, meaning its bytes have not reached the backing store yet. The later hydrate leaves it alone. The callback and promise forms await mount() themselves, so code that uses only those never has to call it.
mount() hands back the promise of the hydrate it already started, so a second call while the first is still running waits on the same one. remount() forces a fresh one. A hydrate in which a listed path could not be read, a locked file for one, is recorded as incomplete. The next mount() more than 5 seconds after that hydrate finished then runs a fresh one instead, see error codes.
A hydrate only ever adds and refreshes paths. A file that vanished from the backing store stays in memory until the page reloads.
A fresh hydrate reads through the backing store. For fs that store reads OPFS first and asks the account only when OPFS has nothing at the path, so remount() alone re-reads the copy this device holds. Another device’s write needs two calls: pull(path) writes the account’s bytes into OPFS, then remount() refreshes the in-memory layer, see the read rule.
The callback forms take Node’s (error, result) callback. exists keeps Node’s legacy single-argument one. The /promises subpath carries the promise members alone:
appendFile('library/log.txt', 'opened\n') // creates the file when there is none
writeFile hands its callback an error or null. readFile hands it the error and then the data. exists answers a bare boolean. The data is typed optional, so the read callback checks for undefined before touching it.
writeFile and appendFile take a string, a Uint8Array (a Buffer included), an ArrayBuffer or a DataView. They copy the bytes, so a buffer you reuse afterwards cannot change a stored file. They refuse a Blob with TypeError('The "data" argument must be of type string, Buffer, TypedArray, or DataView'). A string is encoded with the encoding in the options, utf8 by default.
The encoding does not narrow the return type. readFileSync(path, 'utf8') and promises.readFile(path, 'utf8') are typed Buffer | string on every file system, because the declarations carry no overloads. The value is a string whenever an encoding was given, so coerce with String(...) before JSON.parse. The option types are not exported by name either, so write Parameters<typeof fs.promises.writeFile>[2], see TypeScript.
A synchronous write goes to memory, marks the path dirty, and schedules a flush 250 ms later. That delayed background write is the write-behind. A flush is also started on pagehide and when the document becomes hidden, so the last write usually reaches the backing store before a tab closes.
Converts a JavaScript value to a JavaScript Object Notation (JSON) string.
@param ― value A JavaScript value, usually an object or array, to be converted.
@param ― replacer A function that transforms the results.
@param ― space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.
@throws ― {TypeError} If a circular reference or a BigInt value is found.
stringify({
items: number[]
items: [1, 2, 3] }))
(alias) namespaceshell
import shell
shell.
shell_d_exports.busyReasons(): string[]
export shell_d_exports.busyReasons
What a shell reload would sever right now, as a list of human-readable reasons, empty when
nothing is bound to the broker connection.
This is the evidence for deciding whether to call applyUpdate yet: open sockets and listening
servers, a streaming proxy response, a mounted package, a live frame attachment, unflushed
write-behind. An empty list is not a promise that a reload is free, only that this realm holds
nothing the lib knows about, so an app with state of its own should weigh that too.
It is per REALM, and that distinction has already caused a wrong reading once. A worker that
imports @fkn/lib/net keeps its own tally, which this function cannot see from the window. An
app whose transfers live in a worker should ask the worker, not the page.
busyReasons() // includes 'unsaved files' until the write-behind has flushed
flush() // the OPFS write is attempted now rather than on the timer, and the account copy can still land later
Directories exist in memory only. A written file creates its parents implicitly. OPFS creates those parents around the file as it writes it, and in the account the path is the whole key, so the parents reappear in a listing. An empty directory made with mkdirSync is gone after a reload.
cloud.fs has no in-memory layer. Every read, write, delete and listing goes through the broker frame to the account’s storage and resolves when the account has answered. Every member, available() included, waits for the broker connection without a deadline, so in a realm with no broker it waits forever rather than rejecting, see connecting.
A call to the account cannot be synchronous, so cloud.fs has no *Sync member. Asking for one is a compile error rather than a runtime surprise:
Error ts(2551) ― Property 'readFileSync' does not exist on type 'typeof fs_d_exports'. Did you mean 'readFile'?
The error points at readFile, the callback form. The promise members are the ones to use, since a writeFile that resolved has been committed in the account:
stat('library/catalog.json') // 12, and the account's updatedAt as a Date
}
available() answers whether the broker holds a connect token, that is, whether an account is connected to this site. It does not probe reachability or the locked state. A call after it answered true can still reject with storage: not connected when the token was refused in the meantime, or with StorageLockedError, see locked.
Without an account every call that reaches it rejects with that first message: a read, a write, a delete, a rename, and the listing-backed readdir, stat and access, plus quota() and readFileSealed(). Four members resolve instead: available() and unlock() answer false, encryption() answers { unlocked: false, enrolled: false, keyEpoch: null }, and mkdir() never reaches the account.
A few members behave differently from their memory-backed twins, because the account holds objects rather than a tree:
rename reads the object, writes it to the new path and deletes the old one, three round trips that are not atomic, for one object rather than a subtree
readdir and stat list the whole app scope on every call, so readdir of a prefix with no objects resolves [] where fs and opfs throw ENOENT, while stat still throws it
mkdir does nothing and resolves, since directories are implicit in the key space
rmdir is rm, so a non-recursive rmdir('library') asks for an object named library and leaves everything under library/ in place
unlink and a non-recursive rm of a path that holds no object resolve, since the service reports a delete of nothing as done, so rm takes recursive but no force
Node’s fs has no notion of a MIME type. The account does: every account object carries a content type. Without one, the library infers it from the file extension for 18 extensions, json, txt, html, htm, css, js, mjs, xml, csv, md, svg, png, jpg, jpeg, gif, webp, pdf and wasm. You can pass contentType in the write options instead:
new (elements:Iterable<number>) =>Uint8Array<ArrayBuffer> (+6 overloads)
Uint8Array([137, 80, 78, 71]), {
contentType?: string |undefined
contentType: 'image/png' }) // image/png, from the option, since the path has no extension to infer from
An unknown extension with no option sends no type. The broker then commits the account object as application/octet-stream. On fs and opfs the option is remembered on the file’s in-memory entry and sent again at every flush. A rewrite without the option keeps the previous one.
The account object is the only place the type is stored durably. The library accepts mode and flag for Node compatibility and ignores them.
OPFS belongs to the browser origin of the realm that calls it, in that browser profile. It is not account scoped: signing out or switching accounts leaves it untouched. Every library that uses OPFS on the origin shares the same root, so fs and opfs list files other code put there too.
Account objects are keyed on the account, the app’s origin and the path. The service enforces the key, so two apps writing library/catalog.json receive two objects. An app never sees another app’s files through readdir or stat. The quota, 1,000,000,000 bytes on a free account by default, is the account’s total across every app.
The broker refuses .fkn and anything under .fkn/ with storage: reserved path. Those rows belong to the platform.
fs and opfs normalise every path the way Node resolves a relative one: segments are split on /, empty and . segments are dropped, .. pops the previous segment, and the result has no leading slash. The path can also be a URL, whose decoded pathname is what gets normalised:
URL('https://example.org/cache/poster.png')) // true, the decoded pathname is used
Every spelling above names the same file. The root is '', also reached by '/', so writing to it answers EISDIR.
cloud.fs hands readFile, writeFile, unlink and a non-recursive rm the path exactly as you wrote it, and normalises only for rename, readdir, stat and a recursive rm. The service refuses a path that is empty, longer than 1,024 characters or deeper than 64 segments, or that holds a control character or any empty, . or .. segment, with Invalid path. A leading slash produces an empty first segment, so cloud.fs.promises.writeFile('/library/catalog.json', ...) is refused where fs would quietly write library/catalog.json.
a missing path is read, stated, listed, unlinked, rmdired, renamed or accessed, or mkdir without recursive finds no parent
EEXIST
mkdir on an existing path without recursive
ENOTDIR
readdir or rmdir on a file, a file in the middle of a path, renaming a directory over a file
EISDIR
reading, writing or unlinking a directory, writing to the root, renaming a file over a directory
ENOTEMPTY
removing a non-empty directory without recursive
ERR_FS_EISDIR
rm on a directory without recursive
EBUSY
renaming the root
EINVAL
renaming a path into itself or its own subtree
rm with force and a recursive rmdir swallow the missing-path case. EBUSY and EINVAL repeat the code in place of the text.
One code sits outside that shape. A path the account lists but this device cannot read yet, a locked file, is present but unhydrated: existsSync is true and readdirSync lists it. readFileSync, statSync (and lstatSync, the same function), writeFileSync, appendFileSync and renameSync throw storage: <path> exists but could not be read, retry once its scope is available instead, an Error with code set to FKN_E2E_LOCKED and neither path nor syscall on it. What to do with one is on encryption.
The usual pattern reads a file with a fallback and lets everything else through:
readOr('library/settings.json', '{}') // '{}' until the app writes one
Only ENOENT means there is nothing there, so every other code is rethrown. The same helper gains a locked branch on encryption.
On cloud.fs, stat and access answer ENOENT the same way. A readFile of a missing object rejects with StorageNotFoundError instead, whose code is FKN_STORAGE_NOT_FOUND and which isNotFound(error) recognises. The locked and unreachable cases, and the messages that come with a key that changed, are on encryption. The order to test them in is on handling errors.
stat, lstat, statSync and lstatSync answer a Stats object. The table is the whole object:
Field
What it holds
size
the byte length, 0 for a directory
mtimeMs, atimeMs, ctimeMs
one timestamp, the same on all three
mtime
mtimeMs as a Date
mode
0o644 for a file, 0o755 for a directory
isFile(), isDirectory()
which kind of node this is
isSymbolicLink()
always false, lstat is stat
Nothing else is on it. Under the Node fs polyfill alias Node’s own types declare more fields, and those read undefined, see TypeScript.
On fs and opfs a file hydrated from the backing store carries the store’s updatedAt as its mtimeMs. A file written in this realm carries the time of the write. A directory carries the time it appeared, 0 for the root. On cloud.fs a file’s mtimeMs is the object’s updatedAt, 0 when it cannot be parsed, and a directory’s is always 0:
The hybrid fs lists the path but cannot open it yet, code FKN_E2E_LOCKED. Call cloud.fs.unlock(), then remount(), and the fresh hydrate reads the path.
StorageNotFoundError: nothing is stored at the path, so a first write is safe. The message it carries is often Not found or storage: read failed (404) instead, so test isNotFound(error).