@browsonic/astro
v1.3.3
Published
Astro adapter for @browsonic/sdk — View Transitions navigation breadcrumbs, client-side capture helpers. Apache-2.0.
Downloads
1,378
Maintainers
Readme
@browsonic/astro
Astro adapter for @browsonic/sdk — auto-injecting Astro Integration, View Transitions navigation breadcrumbs (with optional intent phase), Astro Actions error wrapper, partial-hydration island awareness, and ergonomic capture wrappers.
Version: 1.2.12 (
package.json). The0.1/0.2/0.3labels in the source docstrings are the feature waves this surface was built in — they are not package versions.Pure-TypeScript helpers. No
.astrocomponents shipped. Astro is multi-framework on the client; per-framework boundaries belong in the framework's own adapter (@browsonic/react,@browsonic/vue,@browsonic/svelte). Load-bearing surfaces: thebrowsonicIntegrationfactory (injects config + SDK loader + navigation hookup), View Transitions instrumentation with intent phase, Astro Actions wrapper (withBrowsonicAstroAction), andtagAsAstroIsland(name)for cross-framework island context.
Why this adapter
Astro projects use multiple component frameworks side-by-side (React + Vue + Svelte islands in the same app). Each island brings its own boundary primitive — we don't try to unify them. What this package adds is the shared client-side instrumentation that doesn't live in any single framework adapter:
- View Transitions navigation breadcrumbs. Astro's client router emits
astro:after-swapondocumentwhen it swaps in a new page; we listen for that event and emit a breadcrumb. - Standalone capture wrappers. Drop into a
<script>block in any layout without picking a framework.
Install
npm install @browsonic/sdk @browsonic/astroBoth are peer dependencies, declared in package.json as @browsonic/sdk >=3.12.0 and astro >=4.0.0. The package itself has no runtime dependencies (no dependencies block at all).
Quickstart — Astro Integration (recommended)
The browsonicIntegration factory auto-wires everything on every page via astro:config:setup → injectScript('page', …). Pass apiEndpoint, appKey and apiKey (plus optional environment) and the integration injects three tiny page scripts: window.Browsonic.config = { ... }, an @browsonic/sdk loader that boots the SDK from that config, and the View Transitions navigation hookup. No manual <script>import '@browsonic/sdk'</script> in a layout.
Two import shapes work, and both are covered by the packaging tests: the named import from the package root (below), and the default import from the @browsonic/astro/integration subpath, which mirrors @browsonic/nextjs/instrumentation. There is no package-level export default, so import browsonic from '@browsonic/astro' is not one of them — use the subpath if you want the default-import shape.
// astro.config.mjs
import { defineConfig } from 'astro/config';
import { browsonicIntegration } from '@browsonic/astro';
export default defineConfig({
integrations: [
browsonicIntegration({
apiEndpoint: 'https://your-ingest-endpoint.test/v1/events',
appKey: 'your-app-key', // required — the SDK sends it as X-APP-KEY
apiKey: 'your-publishable-ingest-key', // sent as X-API-KEY; POST /v1/events requires an ingest-role key
environment: 'production',
includeIntent: true, // emit `phase: 'intent'` breadcrumb on `astro:before-preparation`
}),
],
});That's the entire wire-up — with apiEndpoint set, the integration loads the SDK, which initialises itself from the injected window.Browsonic.config.
Two behaviours worth knowing before you ship this:
apiEndpointandappKeyare both required by the SDK, not just recommended.validateConfigrejects a config missing either one, soinit()logs[Browsonic] Invalid config: …and returnsfalse— the SDK loads but never starts and no events flow. The integration itself does not validate; it only writes whichever keys you passed.- Auto-init logs a warning on every page load. When the SDK boots from a
window.Browsonic.configthat was injected before it loaded — exactly what this integration does — it emits aconsole.warnnaming the endpoint. That is deliberate (an attacker-injected config should be auditable in the console), not a misconfiguration.
Loading the SDK yourself. The integration pulls in @browsonic/sdk (the full entry, with the default plugin set) for you whenever apiEndpoint is supplied. Pass loadSdk: false to opt out — e.g. when you import the minimal @browsonic/sdk/core entry yourself, so you don't end up with two SDK singletons. Conversely, pass loadSdk: true with no apiEndpoint when a layout sets window.Browsonic.config inline and you just want the integration to load the SDK.
Quickstart — Navigation breadcrumbs (manual)
If you'd rather not use the integration, drop the listener in a root layout:
---
// src/layouts/Base.astro
---
<html>
<head>...</head>
<body>
<slot />
<script>
import { registerNavigationBreadcrumbs } from '@browsonic/astro';
registerNavigationBreadcrumbs({ includeIntent: true });
</script>
</body>
</html>With includeIntent: true as above, each View Transitions navigation emits two breadcrumbs:
// on astro:before-preparation
{
category: 'navigation',
message: '/from-path → /to-path (intent)',
data: { from: '/from-path', to: '/to-path', source: 'astro:view-transitions', phase: 'intent' }
}
// on astro:after-swap
{
category: 'navigation',
message: '/from-path → /to-path',
data: { from: '/from-path', to: '/to-path', source: 'astro:view-transitions', phase: 'completed' }
}Leave includeIntent off (the default) and only the astro:after-swap breadcrumb fires, with phase omitted entirely. data.contentCollection is added when the page carries the meta tag described under Content Collections. The listener is nothing more than a document event listener: pages that never fire astro:after-swap — e.g. a layout without Astro's client router, so navigation is a full page load — produce no breadcrumbs.
Quickstart — Standalone capture
<script>
import { captureError, addBreadcrumb } from '@browsonic/astro';
async function loadProduct(id) {
try {
const res = await fetch(`/api/product/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
addBreadcrumb({ category: 'http', message: `GET /api/product/${id}` });
return await res.json();
} catch (err) {
captureError(err);
throw err;
}
}
</script>API
registerNavigationBreadcrumbs(options?)
Wires a document listener that emits a navigation breadcrumb on every astro:after-swap event. Returns the unsubscribe handle so callers can detach when needed (it removes both listeners).
| Option | Type | Default |
| ----------------- | ----------- | --------------------------------- |
| sdk | Browsonic | window.Browsonic.getBrowsonic() |
| eventName | string | 'astro:after-swap' |
| includeIntent | boolean | false |
| intentEventName | string | 'astro:before-preparation' |
includeIntent: true adds the second listener described above; intentEventName is only consulted when it is on.
Browser-only — short-circuits to a no-op (returning a no-op unsubscribe) when typeof document === 'undefined', so importing it from server / build-time code doesn't crash.
captureError / captureMessage / addBreadcrumb
Standalone wrappers around the global SDK singleton. Resolve the SDK from window.Browsonic.getBrowsonic() at call time. All three are no-ops when the SDK is unreachable. captureMessage(message, level?) takes 'info' | 'warn' | 'error' | 'fatal', defaulting to 'info'.
Astro Actions
withBrowsonicAstroAction(handler, options?) wraps a server-side action handler so unhandled throws are reported (with astro.action.name + astro.runtime: 'action' tags, mirrored into the astro context bucket) and then re-thrown so Astro returns the failure unchanged. options.tagNamespace renames the astro.action prefix; options.actionName is optional and the name tag is skipped when it is absent.
Read this before you rely on it. Astro Actions execute on the server, and the SDK is a browser library — resolveSdk() returns null in any Node / edge / worker runtime. In a deployed app this wrapper therefore sends no report; it re-throws and nothing else. The capture path only fires where window.Browsonic is genuinely reachable (a test harness, or dev-server HMR). Use it if you want the hook in place for the day server-side capture exists; do not count it as server error coverage today. The same note lives in src/resolve-sdk.ts.
// src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';
import { withBrowsonicAstroAction } from '@browsonic/astro';
export const server = {
signup: defineAction({
accept: 'form',
input: z.object({ email: z.string().email() }),
handler: withBrowsonicAstroAction(
async ({ email }) => {
// ... business logic that may throw
},
{ actionName: 'signup' },
),
}),
};Re-throw order matters — consuming the error here would mask every reported failure as a successful return value. Mirrors withBrowsonicRouteHandler from @browsonic/nextjs.
Content Collections breadcrumbs
If your pages render from an Astro Content Collection
(src/content/<collection>/<entry>.md), the navigation breadcrumb
can carry the collection identity by adding a build-time meta tag.
The runtime listener already reads it on every after-swap.
renderContentCollectionMeta injects nothing itself — it returns the
<meta> HTML string for the page to insert (optional metaName
overrides the default browsonic:content-collection).
---
// src/pages/blog/[slug].astro
import { renderContentCollectionMeta } from '@browsonic/astro';
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({ params: { slug: post.slug }, props: { post } }));
}
const { post } = Astro.props;
const meta = renderContentCollectionMeta({ collection: 'blog', entry: post.slug });
---
<html>
<head>
<Fragment set:html={meta} />
<title>{post.data.title}</title>
</head>
<body><slot /></body>
</html>Result: the after-swap breadcrumb for any navigation that lands on
this page carries data.contentCollection: 'blog/<slug>' alongside
the URL — the listener reads the meta tag out of the document after
the swap, so the value identifies the destination page, not the one
navigated away from. The intent-phase breadcrumb does not carry it
(it fires before the new document exists). Pages that don't call
renderContentCollectionMeta simply omit the field — no error.
readContentCollectionFromDocument(metaName?) — the reader the
listener uses internally — is exported too, for consumers wiring
their own navigation telemetry.
tagAsAstroIsland(name, options?)
Stamp astro.island = <name> on the SDK so subsequent captured events (including those from a per-framework boundary inside the island) carry the island name. setTag is an alias for addMetadata, so the value lands in the SDK singleton's metadata bucket and ships with every later event as a metadata entry — the events read API filters on metadataKey / metadataValue. It is sticky until another island overwrites it, so no cross-adapter coordination is needed. The helper also calls setContext('astro', { island: name }); setContext replaces the named bucket rather than merging into it. options.tagKey overrides the astro.island key.
Returns true when the tag was set, false when the call was a no-op (SSR, no SDK reachable, or the SDK's own setTag threw).
// src/components/ContactForm.tsx — a React island
import { useEffect } from 'react';
import { tagAsAstroIsland } from '@browsonic/astro';
export function ContactForm() {
useEffect(() => {
tagAsAstroIsland('ContactForm');
}, []);
// ... island content
}Browser-only short-circuit on SSR. Defensive try/catch keeps a thrown setTag from unmounting the island.
resolveSdk(explicit?)
Lower-level lookup helper for when you need explicit SDK access. Returns the explicit instance if given, otherwise window.Browsonic.getBrowsonic(), otherwise null — including in every server / build-time context, where there is no window to read.
Defensive contract
- The host app must never crash because reporting failed.
- SDK calls are wrapped in
try { ... } catch {}, with one exception: thesdk.withScope(…)call insrc/actions.tsis unguarded (its body is guarded). It relies on the>=3.12.0peer floor, wherewithScopeexists — against an older SDK it would throw in place of the action's own error. - The View Transitions listener short-circuits in non-browser contexts.
What this package does NOT do
- Component-framework error boundaries. Use the framework-specific adapter (
@browsonic/react,@browsonic/vue,@browsonic/svelte) inside the corresponding island. Pair it withtagAsAstroIsland(name)to attribute captured errors to the island they came from. - Server-side rendering capture. Astro's SSR runs in Node; the SDK is browser-only.
withBrowsonicAstroActionruns on the server, and in a deployed app that means it re-throws with no report sent — the capture path only reaches an SDK wherewindow.Browsonicexists (test harness, dev HMR). - Auto-injecting per-collection metadata. The Content Collections breadcrumb (above) requires consumers to call
renderContentCollectionMetafrom their[slug].astropage or layout — we don't auto-detect collection-rendered pages from the Astro Integration. A build-time auto-injector would need a transform over everyastro:content-using page, i.e. an Astro compiler step inside a package that deliberately ships none. Decided out of scope 2026-07-27; reopens if the per-page opt-in turns out to be a real coverage gap on a site with many collection pages.
License
Apache-2.0. See the repo root LICENSE and the package NOTICE.
