Browser boundaries#
A browser boundary is the line between your logic and the outside world: localStorage, navigator, location, the network. Craft keeps direct access out of your services while still making each of those dependencies explicit in the graph — so a test can replace exactly them, and nothing else.
Use them when a service touches the platform. Then test with boundaryOnly: the whole application graph stays real and only the boundaries are mocked, which is what makes a passing test mean something.
Browser boundaries keep direct browser access out of your craftService implementations while still making those dependencies explicit in the service graph.
Every boundary on this page is backed by a global crafted service marked with browserBoundary: true.
WARNING
Some APIs are not documented here yet.
Import#
The main DSL exports are:
import {
BrowserCrypto,
BrowserDocument,
BrowserHistory,
BrowserLocation,
BrowserNavigator,
BrowserPerformance,
BrowserWindow,
Console,
Cookies,
LocalStorage,
SessionStorage,
} from '@craft-ts/core';
When you need to derive methods for later reuse, each boundary also exposes the usual generated helpers:
import { ConsoleService, CONSOLE_SERVICE_META_DATA } from '@craft-ts/core';
The same pattern exists for the other boundaries:
LocalStorageServiceSessionStorageServiceCookiesServiceBrowserLocationServiceBrowserHistoryServiceBrowserNavigatorServiceBrowserPerformanceServiceBrowserCryptoServiceBrowserDocumentServiceBrowserWindowService
Motivation#
Direct browser access inside a service hides dependencies inside business logic and makes tracking harder.
Browser boundaries solve that in two complementary ways:
use
yield* X.method(...)when the browser interaction should happen directly inside the generatoruse
XService(...)when you want to derive bound browser helpers and reuse them inside returned callbacks
Mental Model#
There are two valid ways to use a browser boundary.
Direct DSL#
Use the DSL when the browser interaction belongs to the generator itself.
import { Console, craftService, craftExpose } from '@craft-ts/core';
const { BootLogger } = craftService(
{ name: 'BootLogger', providedIn: 'global' },
function* () {
yield* Console.log('boot');
yield* Console.info('config loaded');
yield* craftExpose('ready', true);
},
);
Derived Service Helper#
Use XService(...) when the browser method needs to stay callable later from a returned method.
import { ConsoleService, craftService, craftExpose } from '@craft-ts/core';
const { AuditTrail } = craftService(
{ name: 'AuditTrail', providedIn: 'global' },
function* () {
const consoleService = yield* ConsoleService(
undefined,
({ log, error }) => ({
log,
error,
}),
);
yield* craftExpose('trackUserAction', (action: string) =>
consoleService.log('user action', action));
yield* craftExpose('trackFailure', (error: unknown) =>
consoleService.error('unexpected failure', error));
},
);
That second form is what preserves derivability while still tracking the browser dependency explicitly.
Core Examples#
Console#
yield * Console.log('my service run');
yield * Console.error('unexpected failure', error);
Local Storage#
yield * LocalStorage.setItem('token', token);
const persistedToken = yield * LocalStorage.getItem('token');
const entryCount = yield * LocalStorage.length();
Session Storage#
yield * SessionStorage.setItem('active-tab', 'settings');
const tab = yield * SessionStorage.getItem('active-tab');
Cookies#
yield *
Cookies.set('session', sessionId, {
path: '/',
sameSite: 'strict',
});
const session = yield * Cookies.get('session');
const hasSession = yield * Cookies.has('session');
Location#
const href = yield * BrowserLocation.href();
const pathname = yield * BrowserLocation.pathname();
yield * BrowserLocation.reload();
History#
yield * BrowserHistory.replaceState({ step: 2 }, '', '/checkout?step=2');
const state = yield * BrowserHistory.state();
Document#
yield * BrowserDocument.setTitle('Checkout');
const title = yield * BrowserDocument.title();
Window#
const width = yield * BrowserWindow.innerWidth();
yield * BrowserWindow.scrollTo(0, 0);
yield * BrowserWindow.alert('Cache cleared! The page will reload.');
const confirmed =
yield * BrowserWindow.confirm('Cache cleared! The page will reload.');
if (confirmed) {
yield * BrowserLocation.reload();
}
Performance And Crypto#
const now = yield * BrowserPerformance.now();
const uuid = yield * BrowserCrypto.randomUUID();
API Reference#
Every service below is:
providedIn: 'global'browserBoundary: trueexposed both as a DSL object and as generated service helpers
Console and ConsoleService#
Methods:
debuginfologwarnerrortracegroupgroupCollapsedgroupEndtimetimeEnd
Generated helpers:
ConsoleServiceConsoleServiceCONSOLE_SERVICE_META_DATA
LocalStorage and LocalStorageService#
Methods:
getItemsetItemremoveItemclearkeylength
SessionStorage and SessionStorageService#
Methods:
getItemsetItemremoveItemclearkeylength
Cookies and CookiesService#
Methods:
getgetAllsetremovehas
BrowserLocation and BrowserLocationService#
Methods:
hreforiginprotocolhosthostnameportpathnamesearchhashassignreplacereload
BrowserHistory and BrowserHistoryService#
Methods:
lengthstatebackforwardgopushStatereplaceState
BrowserNavigator and BrowserNavigatorService#
Methods:
userAgentlanguagelanguagesonLinecookieEnabledsendBeacon
BrowserPerformance and BrowserPerformanceService#
Methods:
nowmarkmeasureclearMarksclearMeasures
BrowserCrypto and BrowserCryptoService#
Methods:
randomUUIDgetRandomValuesdigest
BrowserDocument and BrowserDocumentService#
Methods:
titlesetTitlelangsetLangdirsetDirvisibilityStatehasFocus
BrowserWindow and BrowserWindowService#
Methods:
innerWidthinnerHeightscrollXscrollYscrollToalertconfirm
Related Adapter: CraftHttpClient#
CraftHttpClient is implemented, but it is not a browser boundary.
Unlike Console, LocalStorage, or BrowserLocation, CraftHttpClient is a typed service boundary. It belongs in the dependency graph rather than being treated as a browser-global.
Its contract is intentionally different:
it is not treated as
browserBoundary: trueit requires
success: response<T>()inside a declarative builderit can declare ordered
exceptions: [function* (...) { ... }]rulesit returns a promise of
Success | craftException({ _tag: 'HttpError' })
Usage looks like this:
const getUsers =
yield *
CraftHttpClient.get(({ response }) => ({
url: '/api/users',
params: { page: 1 },
success: response<User[]>(),
}));
const createUser =
yield *
CraftHttpClient.post(({ response }) => ({
url: '/api/users',
payload,
success: response<User>(),
}));
const login =
yield *
CraftHttpClient.post(({ response }) => ({
url: '/api/login',
payload,
success: response<{ token: string }>(),
exceptions: [
function* ({ status, code, content }) {
if (!(yield* status(400))) return;
if (!(yield* code('PASSWORD_REQUIRED'))) return;
if (!(yield* content('Password is required'))) return;
return craftException({ _tag: 'PASSWORD_REQUIRED' });
},
],
}));
const users = await getUsers();
const createdUser = await createUser();
const loginResult = await login();
Design Constraints#
The browser boundaries stay intentionally narrow.
Reads are exposed as methods so the public API stays uniform with
yield*.Raw
window,document, and DOM nodes are not exposed as public outputs.BrowserDocumentandBrowserWindowremain minimal rather than becoming generic escape hatches.
This keeps the API focused on explicit browser interactions instead of reintroducing broad direct access to host globals.
Relationship With craftService#
Browser boundaries participate in the same dependency tracking model as any other crafted service.
craftServiceis what you use to consume them and compose higher-level services.Use a small
craftServiceadapter for host dependencies that are not part of this built-in browser boundary set.
See Also#
Architecture rules — assert HTTP only crosses a boundary