Guides
The web
The same app in a browser, as WebAssembly and the page's DOM; asuka dev --platform web, a static site to deploy, embedding, and what it doesn't do yet.
The same app runs in a browser: its Go code compiled to WebAssembly, its
views the page's DOM. The web is one of Asuka's platforms beside iOS,
Android, the Mac, Windows and Linux, from the same Go code. Each view the core makes is an element placed at
the frame the core laid out, as each native host places its widgets, so
the layout is Asuka's own (flexbox and grid, in Go), the same on every
platform; leaves are measured by the real elements, as the native hosts
measure theirs. Controls are the browser's own (<button>, <input>,
<select>, <progress>, <dialog>), so keyboard handling, focus,
autofill, spelling and screen readers come with them.
asuka dev --platform web examples/counter # serves it and opens it in your browser; reloads as you save
asuka build --platform web examples/counter # a static site, in examples/counter/dist/webWhat follows says what the web does, and, where a browser can't do what a phone does, what happens instead.
asuka dev --platform web
asuka dev --platform web builds the app for the browser (GOOS=js GOARCH=wasm),
serves it at http://localhost:8790/ (or a free port) and opens it. Each
save rebuilds it, in well under a second for the examples, and the page
loads the new build with the app's signals' values kept, as the other
platforms' reloads keep them. The app's output (fmt.Println, log, a
panic) and the page's errors print in the terminal; a build that fails
shows its errors over the page until the next one builds.
The flags, beside asuka dev's own (--lang, --dir, --no-vet, -v):
--port: the server's port.--lan: serve to your network too, and print its addresses: open the app in a phone's browser.--no-open: don't open the browser.--once: build and serve, without watching.
asuka build --platform web
asuka build --platform web makes a static site in dist/web:
index.htmlandmanifest.webmanifest(a web app manifest, so a browser offers to install the app; its icons fromasuka.json'sicon, with a favicon and iOS's home screen icon);- the app,
app.<digest>.wasm, built stripped and with-trimpath; - the web host's files in
asuka/<digest>/(with SQLite's WebAssembly build, about 0.9 MB, 0.4 MB gzipped, for an app that importsasuka/sqlite), and the app's own scripts (web/*.js) inweb/<digest>/; - the app's service worker,
sw.<digest>.js, besideindex.html(its scope is the site's folder): it shows the app's notifications and pushes while no page of it is open (Notifications and Web Push); - the app's
assets/; - a gzipped copy of each script and of the wasm (
.gz), for servers that serve precompressed files; _headers, which Cloudflare and Netlify read:index.htmland the manifestno-cache, the files named by their digests cached for good.
Serve the folder from any web server or CDN: the files named by their
content's digest change name when they change, so they can be cached for
good, and only index.html must be fetched again. Serve .wasm as
application/wasm. A counter is about 6.5 MB of WebAssembly, 1.7 MB
gzipped (1.3 MB with brotli, which your CDN can do); the app's own code
and the packages it imports add to that.
What the page reports
The environment a web app sees (ui.UseEnvironment) is the browser's:
Platformisui.PlatformWeb.- The size is the page's (or the element's the app is in), and changes as the window does; the size classes are Material's, from the width.
- The safe area is the page's (
env(safe-area-inset-*): a phone's notch and home indicator, with the page'sviewport-fit=cover), and the keyboard is how much of the page a phone's keyboard covers. - Dark mode is
prefers-color-scheme(or the app'sui.SetColorScheme), Reduce Motionprefers-reduced-motion, the text size the browser's default font size (FontScale, which text follows), the scale the display's pixel ratio. - The locale, languages, time zone, first weekday and hour cycle are the
browser's (
navigator.languages,Intl).
How views map
- Text is the browser's text in the system's font (
system-ui: San Francisco on Apple's systems, Segoe UI on Windows, Roboto on Android), on a type ramp of the web's (body 16). The app's fonts inassets/fontsload as the app names them (Font.Family). - A
Stackshows its top screen under a header bar of its own: a back button, the screen's title, its bar buttons, its search field. Pushes and pops slide. Going back is the browser's too: its back button (and a phone's back gesture) goes back in the app while there's somewhere to go back to, as do Escape, Alt+Left (⌘[ on a Mac), and a swipe from the leading edge on a touch screen.ui.OnBacktakes them as on the other platforms. Tabsare a tab bar at the bottom; aSplitViewa sidebar beside its detail, or a drawer the detail's header opens on a narrow window.- A
Sheetis a modal<dialog>: a bottom sheet on a narrow window (medium is half the height; the grabber drags it up or away), a centered card on a wide one. Escape, a click outside or a swipe down dismisses it. AnAlertis a modal<dialog>, an action sheet's buttons a list. APopoverand a menu are popovers next to what they point at. Aui.Windowis a panel in the page the user can move and resize. - A
Listis virtualized by the host, as on the Mac, Windows and Linux: only the rows in view (and half a screen around them) are bound; sections' headers stick; a chat's list stays at its end. Its rows' swipe actions are their context menu (a right-click, or a long press). Imageis an<img>(decoded off the main thread, cached by the browser),Videoa<video>,WebViewan<iframe>(see Web views),Canvasa 2D canvas,Shapean SVG path,ShaderWebGL 2 (the Kage program translated to GLSL ES 3.00),Symbolthe Material Symbol the Android host draws for the SF Symbol name.ui.Animateanimates frames and visual props with the Web Animations API (springs as CSSlinear()curves); enter and exit transitions and matched views (Style.Match) work as on iOS.- Presses, long presses (and right-clicks), hover, pressed states, pans, swipes, pinches (two fingers, or a trackpad's pinch) and rotations work with a mouse, a pen or touch; drag and drop is the browser's, files from other apps included.
MenuBar's items' shortcuts work in the page (⌘ on a Mac, Ctrl elsewhere); the page shows no menu bar of its own, where the desktop apps show the app's menus: the Mac's menu bar, the window's menu bar on Windows, the header bar's main menu on Linux.
The app's files
asuka/store, crash records and the files your app writes in store.Dir
are kept: the page gives Go a file system in memory, the data directory's
files loaded from the browser's storage (IndexedDB) before the app starts
and written back a moment after they change (a large file, such as a
database, in chunks of 64 KiB: only the chunks that changed), and as the
page is hidden or closed. What the user drops, pastes or picks is copied
into the app's caches (/tmp), as on iOS and Android, and comes to the
app as a path.
The browser keeps an origin's storage until the user clears it, or, when the device runs out of space, until it evicts the origin's (Safari also does after seven days without a visit, for a site that isn't installed to the home screen).
SQLite
asuka/sqlite works as on the other platforms, live queries included.
Its SQLite is SQLite's own WebAssembly build (from sqlite.org, which
asuka build --platform web and asuka dev --platform web ship with the
apps that import asuka/sqlite, and load before the app starts), driven
from Go synchronously. A database is a file in the app's data directory,
like any other, so Go's os sees it (a backup is os.ReadFile), and it
is kept with the app's other files: each call SQLite answers runs to its
end before the page does anything else, so what's kept is always a
state between two calls, which SQLite's rollback journal recovers from,
as after a crash.
What differs:
- The journal is a rollback journal, not write-ahead logging (which
needs memory shared between connections), and a database has one
connection, so reads wait for a write in progress rather than running
beside it. The page has one thread: a write blocks it while it runs,
as on any platform's main thread;
ExecAsync,TxAsyncandWatchrun between the page's events. - Two
sqlite.Opens of one file lock each other out, as two processes would; a statement that finds the file locked is run again once the other has had its turn, for the busy timeout's 5 seconds. - A transaction committed in the last moment before the tab closes may not be kept (the browser may close it before IndexedDB has written it).
A page of your own that embeds an app (below) passes sqlite, the URL
of the directory with sqlite3.mjs and sqlite3.wasm, to start.
Native modules
Asuka's packages call the host's native modules (ui.Call). In a browser:
asuka/secure: the values are encrypted (AES-GCM) with a key the browser keeps and won't let out (a non-extractable WebCrypto key), in IndexedDB. It isn't a keychain: script running in the app's origin can't read the key, but it can ask it to decrypt. It needshttps(orlocalhost).device:GetInfofrom the user agent (Chromium's client hints give the system's version and an Android phone's model);Clipboard, its images (PNG, saved to the caches) andOnClipboardChange(the app's own copies, and other apps' where the user lets the page read the clipboard);KeepAwake(the Screen Wake Lock, taken again when the page shows);Browse(a new tab);PickPhotosandPickFiles(the browser's file picker; the files copied to the caches).ShareFiles: the share sheet with the files (navigator.share), else each file downloaded.asuka/preview: the file opened in a new tab (images, video, audio, text, PDF, HTML); another type isErrNoViewer.asuka/print: HTML, files (PDFs in the browser's viewer) and the app's own web views, with the browser's print dialog; the result is alwaysUnknown(a browser doesn't say).PDFisn't on the web: a browser makes PDFs only through its print dialog (Save as PDF).asuka/compose:mailto:andsms:links; with attachments, the share sheet.asuka/notifyandasuka/push: the Notification API and Web Push, through the app's service worker: see Notifications and Web Push.asuka/network: online fromnavigator.onLine; Wi-Fi, cellular or Ethernet where the browser says (Chrome on Android), metered for cellular, constrained for Data Saver.asuka/location: the Geolocation API;PlaceNameisn't on the web (a browser has no geocoder).asuka/sensors: the Generic Sensor API (Chrome) or device motion and orientation events (Safari asks on a tap), in Android's axes and units; no barometer or pedometer.asuka/speech:speechSynthesis, the browser's voices.asuka/audio: an audio element per player (the browser's autoplay rules apply: play from a tap).asuka/audio/record:MediaRecorder, AAC in MPEG-4 where the browser records it, else Opus in WebM.asuka/camera:Takeopens a phone's camera, or a dialog with the live camera and a shutter;camera.Viewis the camera in a<video>(Capture,record, and scanning codes where the browser hasBarcodeDetector).asuka/scanandasuka/vision's codes useBarcodeDetector, faces and text only where the browser has them.asuka/imageedit: a canvas (JPEG or PNG; a remote image needs CORS).asuka/maps: a map the host draws of OpenStreetMap's tiles, as on Windows: pins, their callouts, a drag, the wheel, a pinch, a double click and the keyboard to move it, and the attribution OpenStreetMap asks for. Satellite and Hybrid show the streets (no free imagery);ShowsUsershows the user's location once the page has the location permission (asuka/locationasks; the map never does). The tiles come fromtile.openstreetmap.orgas its usage policy asks of a web page (only what's on screen, the browser's cache, its Referer); for heavy use, passmapTiles(another server's URL, with{z},{x}and{y}) tostart(Embedding).asuka/crash: Go's runtime can't write its crash output to a file in a browser, so the host keeps what it prints and, as the program ends with a panic or a fatal error, writes it to the run's record in the app's files: the next launch reports it. An uncaught JavaScript error (the host's, or yourweb/*.js) is anExceptionreport at once, not fatal (the page goes on). A page marks its run alive inlocalStorage, so other open pages of the app leave it alone.asuka/biometric: the platform's authenticator through WebAuthn (Touch ID, Face ID, Windows Hello, Android's screen lock), user verification required: the firstAuthenticatemakes a passkey for the site, named after the app. The prompt is the browser's, so the reason and cancel texts don't show.asuka/webauth: a popup, done when it comes back to a page of the app's own origin (a custom scheme can't open a page).asuka/screen:Lock(only full screen, or an installed app); hiding the bars is full screen.asuka/library:Savedownloads the file.asuka/contacts:Pickwhere the browser has the Contact Picker (Chrome on Android);Adddownloads a vCard, andasuka/calendar'sAddan.icsfile.asuka/trackinganswers Denied when the browser sends Global Privacy Control or Do Not Track, else Allowed, without a prompt.- Not on the web, with an error that says so (or the package's own
"not available"):
asuka/review,asuka/integrity,asuka/agerange,asuka/appleauth,asuka/periodic,asuka/widget,asuka/photos(Denied:device.PickPhotospicks photos), reading contacts and calendars,asuka/transcribe's files,asuka/screen'sPreventCaptureandOnScreenshot.
Notifications and Web Push
Every web app has a service worker, which the CLI writes (sw.js under
asuka dev, sw.<digest>.js in a build) and the page registers as it
starts, scoped to the app's folder. It has no fetch handler, so the page
loads as it would without it; it shows the app's notifications and
pushes, and answers their clicks, whether or not a page of the app is
open. A browser runs service workers only for pages served over HTTPS,
or from localhost (asuka dev's, but not --lan's addresses).
asuka/notify
Requestasks with the browser's prompt; browsers ask only from a tap or a click, so call it from one.Showshows it through the worker, so it keeps its buttons and its click works after the page closes. (A page without the worker, an embedding page that doesn't register it, shows the page's own notifications, without buttons; their clicks reach only that page.)- A notification's ID is its tag: one with the same ID replaces it, and
alerts again unless
Silent.Subtitleis the body's first line,Imagethe picture (anhttpsURL or an asset), a message's sender's picture (From) the icon.BadgeandSetBadgeset the installed app's badge (navigator.setAppBadge). - A category's actions (
SetCategories) are the notification's buttons (Chrome and Edge show two; Firefox and Safari none), aReplyaction a text field where the browser has inline replies (Chrome on Android and desktop:Response.Replyhas the text; elsewhere it's a button). The categories are kept for the worker, so a push that names one has them too. - A tap focuses the app's page, goes to its
URL(ui.OnOpenURL) and reachesOnAction(Action ""); a button reachesOnActionwith its ID, in the page in front, else any page of the app. With no page open, the worker opens one (a browser runs the app's code only in a page), and the app gets the response as it starts, as an app launched by a tap does: setOnActionin the root component. An action withoutOpendoesn't bring a page that's open to the front, but with none open it opens one, in front.Response.Deferhas nothing to hold: the page runs on. Presentasks the app how to show one that comes while the page is in front, a push's too:Banner,Quiet(no sound),Hide.Showing,Remove,RemoveThread,RemoveAllandCancelreach the worker's notifications, the ones from earlier pages and pushes too.- Android's channels don't exist here: accepted and ignored, as on iOS.
Scheduled notifications
A browser can't show a notification at a time while no page of the app
is open: there's no API for it (Chrome's Notification Triggers, which
did, were abandoned in 2021), and a service worker runs only when an
event wakes it, not on a timer. So Schedule does what a browser can:
- What's scheduled is kept in the app's store (Cache Storage, which the app's pages and its worker share), not in the page.
- While any page of the app is open, it shows at its time: each page keeps a timer for the next one (a page that schedules or cancels one tells the others), and the first to find it due shows it, once (a Web Lock). A page in the background may be late: browsers slow hidden pages' timers to about once a minute; it shows when the page comes back in front at the latest.
- One that came due while no page was open shows when the next one opens, at once, with its due time passed.
- Before that, as a best effort, the worker shows the due ones whenever something wakes it with no page open: a push, a click, and Chrome's periodic background sync, which the page asks for while anything is scheduled. Chrome grants that only to an installed app (added to the home screen or installed from the address bar), runs it at most about every 12 hours, as the browser sees fit (how much the user engages with the app, the battery, the network), and other browsers don't have it: never count on it.
For a notification that must show at its time with the app closed, send a push from your server at that time.
asuka/push: Web Push
Web Push needs a VAPID key pair: the app's server signs its pushes with
the private key, and the browser subscribes with the public one, which
goes in asuka.json:
{ "web": { "vapidPublicKey": "BKpYsAT0ADj2pHQQ..." } }Make the pair once, and keep the private key on the server; a new pair
needs new subscriptions. webpush.GenerateKeys (below) makes one, as
does npx web-push generate-vapid-keys.
push.Register() asks to allow notifications (from a tap: browsers ask
only then), subscribes the app's worker with the key (a subscription
made with another key is replaced) and answers with:
Token: the subscription's JSON, asPushSubscription.toJSON()has it:{"endpoint": "https://...", "expirationTime": null, "keys": {"p256dh": "...", "auth": "..."}}. Send it to your server as it is: Web Push libraries take it as it is. The browser may change it (pushsubscriptionchange): register at each start, as on the other platforms, and send it when it's not the one the server has.Service:"webpush".Allowed: true. Without permission,Registerfails: a browser subscribes only a site that shows its pushes (userVisibleOnly).
It fails, saying why, without web.vapidPublicKey, without the worker
(an insecure page), and where the browser has no Web Push: Safari on iOS
and iPadOS has it only for an app added to the Home Screen (16.4 and
later).
A push shows as a notification, whether or not a page is open. A page
of the app that is open gets it first (push.OnReceive; every open
page), may rewrite it (push.Rewrite, which runs in the page in front,
within about ten seconds; with no page open, a push shows as it came)
and says how it shows (notify.Present). Its tap reaches
push.OnOpen, notify.OnAction (Remote set) and, with a "url" in
its data, ui.OnOpenURL, as a local notification's does.
What the server sends
The payload is JSON, encrypted for the subscription (RFC 8291's
aes128gcm) and sent with a VAPID token (RFC 8292), as every Web Push
library does. The worker reads these keys, the same a push.Message
has on the other platforms:
| Key | |
|---|---|
title, body, subtitle |
what shows (the subtitle is the body's first line) |
data |
the app's own keys, push.Message's Data (a value that isn't a string comes as its JSON); "url" is what a tap opens the app with (ui.OnOpenURL) |
id |
the notification's tag: a push with the same id replaces it |
category |
one of notify.SetCategories' categories: its actions are the notification's buttons |
thread, summary |
push.Message's, for notify.RemoveThread and the app |
image |
a picture: an https URL, or one of the app's assets |
badge |
the installed app's badge |
silent |
shown without a sound |
{
"id": "chat-7",
"title": "Ada",
"body": "Lunch?",
"category": "message",
"thread": "chat-7",
"data": { "url": "myapp://chat/7", "chat": "7" }
}A push without a title or body is a data message: the open pages get
it (OnReceive), nothing shows. Send those only while the app is open:
a browser expects each push to show a notification, and when none
shows with no page in front, Chrome shows one of its own ("This site has
been updated in the background") and Safari ends the subscription after
a few. A payload that isn't JSON shows as the body. A payload is at most
3993 bytes.
From Go, github.com/egoist/asuka/push/webpush encrypts, signs and sends,
with the standard library only:
import "github.com/egoist/asuka/push/webpush"
var sender = &webpush.Sender{
Keys: webpush.Keys{Public: os.Getenv("VAPID_PUBLIC_KEY"), Private: os.Getenv("VAPID_PRIVATE_KEY")},
Subject: "mailto:ops@example.com", // where the push service can reach you
}
func notifyChat(ctx context.Context, token string) error {
sub, err := webpush.ParseSubscription(token) // the app's push.Register Token
if err != nil {
return err
}
err = sender.Send(ctx, sub, webpush.Notification{
ID: "chat-7", Title: "Ada", Body: "Lunch?", Category: "message",
Data: map[string]string{"url": "myapp://chat/7"},
}, &webpush.Options{Urgency: "high"})
if errors.Is(err, webpush.ErrGone) {
return forgetSubscription(token) // it ended: the user, or the browser
}
return err
}Options set the push's TTL (how long the push service keeps it for
a browser that's offline; four weeks by default), Urgency and Topic
(a later push with the same topic replaces one still waiting).
webpush.Keys.Authorization and webpush.Encrypt are the two halves,
for a server that sends its own requests.
From Node, with the web-push
library:
import webpush from "web-push";
webpush.setVapidDetails("mailto:ops@example.com", process.env.VAPID_PUBLIC_KEY, process.env.VAPID_PRIVATE_KEY);
// token: the app's push.Register Token
await webpush.sendNotification(JSON.parse(token), JSON.stringify({
title: "Ada", body: "Lunch?", category: "message", data: { url: "myapp://chat/7" },
}), { urgency: "high" });
// A 404 or 410 (err.statusCode): the subscription ended; forget it.Web views
A WebView is an <iframe>, made anew for each page it loads, so it
keeps its own history and adds nothing to the tab's.
A page of the app's own site (its assets, HTML, a file it wrote, any URL
of the page's origin) gets everything: the host loads it into the frame
with its real URL, window.asuka.postMessage and Preload there before
the page's scripts; EvalJS and EvalJSResult (a promise awaited); its
load, failure and state events (URL, title, pushState, progress, back
and forward); back, forward, reload and stop; its links, forms and
location changes load through the view; Block (its markup, fetch,
XHR, sendBeacon, WebSocket, EventSource, elements' src and href;
a request already under way when it's caught may still go out);
OnRequest (Resource Timing); OnNewWindow (window.open,
target=_blank); OnDownload (an attachment, a type a frame can't
show, <a download>); WebCookies (document.cookie: not HttpOnly
ones). HTML's relative links resolve against the app's assets.
Another site's page only loads, because browsers keep sites apart:
Navigated once it has loaded, Failed when the site can't be reached.
The view can't tell when a site refuses to be framed
(X-Frame-Options, frame-ancestors): the browser shows its own error
page. It can't follow where the page's links lead or read its title, and
can't run script there, Block or Preload it, report its requests,
read its cookies or set the user agent (script and cookies answer with an
error, the others warn once). The frame is sandboxed without top-level
navigation, so the page can't take the app away.
A page of another site that cooperates talks with the view through
postMessage (the view accepts messages only from its own frame, and
sends script only to the origin that said it's ready):
// In the page: messages to the app (OnMessage), its URL and title, and EvalJS.
const app = (m) => parent.postMessage({ asuka: m.asuka ?? true, ...m }, "*");
addEventListener("message", async (e) => {
if (e.source !== parent || !e.data?.asuka) return;
if (e.data.asuka === "hello") app({ asuka: "ready", url: location.href, title: document.title });
if (e.data.asuka === "eval") {
try { app({ asuka: "result", id: e.data.id, value: JSON.stringify(await (0, eval)(e.data.script)) }); }
catch (err) { app({ asuka: "result", id: e.data.id, error: String(err) }); }
}
});
app({ asuka: "ready", url: location.href, title: document.title });
app({ text: "Hello from the page" }); // OnMessageasuka/js runs its script in a hidden sandboxed frame of an origin of its
own, so it can't reach the app's page or storage.
Native code
An app's own views and modules for the web are JavaScript, in web/*.js
beside its Go code, as its Swift is in ios/ and macos/, its Kotlin
in android/, and its desktop views and modules in Go are in package
native: see Native code. The page loads them before the app starts; they register with
the global asuka:
// web/natives.js
asuka.registerNative("Stepper", (ctx) => {
const el = document.createElement("input");
el.type = "number";
el.oninput = () => ctx.emit("changed", { value: Number(el.value) });
return {
el, // the view, sized to the frame the core gives it
set(name, value) { if (name === "value") el.value = value; },
measure(c) { return [120, 36]; }, // its size within c ({width, height, availW, availH}, NaN when free)
};
});
asuka.registerModule("greeter", {
// a method takes the call's bytes and returns (or resolves to) the result's
hello: (args) => new TextEncoder().encode("Hello, " + new TextDecoder().decode(args)),
});A kind no script registers shows nothing, and the page logs asuka: no native view "Stepper" is registered, as the other hosts do. asuka dev
reloads the page when a script in web/ changes.
examples/media has a native view, its JavaScript in
examples/media/web/natives.js.
Embedding
The page asuka build --platform web makes runs the app full page. To run it in an
element of a page of your own, load the host's loader and start the app
in it; it then follows the element's size:
<link rel="stylesheet" href="asuka/<digest>/style.css">
<div id="app" style="width: 390px; height: 700px"></div>
<script type="module">
import { start } from "./asuka/<digest>/asuka.js";
start({ container: document.getElementById("app"), wasm: "app.<digest>.wasm", name: "com.example.app", serviceWorker: "sw.<digest>.js" });
</script>Several apps can run on one page, each in its element. serviceWorker
registers the app's worker (the build's sw.<digest>.js, from the
folder whose pages it serves): without it, notifications are the page's
own and asuka/push can't subscribe. Pass vapidPublicKey too for
asuka/push, as index.html does, and mapTiles for asuka/maps' tiles
from a server of your own ("https://tiles.example.com/{z}/{x}/{y}.png";
OpenStreetMap's by default).
What it doesn't do yet
- A text field's accessory bar shows only where there's an on-screen keyboard (a touch screen).
- A notification scheduled with
asuka/notifycan't show at its time while no page of the app is open: browsers have no way to schedule one (above).