Component explorer / Application controls
Mail: recover an expired session
Read messages, retain drafts and resume waiting data operations after a simulated sign-in.
This is a local simulation: no real mail, credentials, WebAuthn or identity-provider requests. Open tabs and drafts last for this visit. Keyed sessions are available; identity-provider redirect/reload continuation remains application work.
Code for this example
These excerpts come from the code running above, formatted for reading. They focus on the component behaviour; imports, fixtures and page setup are available in Full application source below.
Provide the sign-in interaction
This complete application handler offers simulated passkey, email and SSO flows. It settles or cancels the shared recovery and releases its dialog listeners when aborted.
From scripts/site/playground/mail.js
function recover({ signal }) {
loginCount++;
return new Promise((resolve, reject) => {
let settled = false;
const finish = error => {
if (settled)
return;
settled = true;
signal.removeEventListener("abort", abort);
loginDialog.removeEventListener("close", cancel);
loginDialog.close();
if (error)
reject(error);
else {
expired = false;
resolve();
}
};
const cancel = () => finish(new AuthenticationError("cancelled"));
const abort = () => finish(signal.reason);
const signedIn = () => finish();
const show = method => {
loginDialog.content = html `<div class="mail-auth"><aside class="mail-auth-brand"><img src="/docs-assets/aster-logo.svg" alt="Aster" width="112" height="40"><strong>Return to<br>your mail.</strong><p>Your open messages and unsent drafts stay where you left them.</p></aside>
<section class="mail-auth-form"><p class="mail-eyebrow">YOUR ASTER ACCOUNT</p><h2 id="login-title">${method ? `Continue with ${method}` : "Sign in to continue"}</h2>
<p>Your session expired. Continue as <strong>alex@example.test</strong> to finish the waiting operation.</p>
${method ? html `<p class="mail-auth-preview">Demo only. ${method === "email" ? "No email has been sent." : method === "a passkey" ? "No browser passkey prompt has been opened." : "No identity provider has been contacted."} Use the button below to simulate a successful sign-in.</p>
<button class="aster-button" data-complete-login @click=${signedIn}>Simulate successful sign-in</button><div class="mail-auth-links"><button class="aster-button" @click=${() => show()}>Other sign-in options</button></div>`
: html `<div class="mail-auth-options"><button class="aster-button" @click=${() => show("a passkey")}>${icon("M14 9a5 5 0 1 0-4 5l8 8 3-3-2-2 2-2-3-3")}<span>Sign in with a passkey</span></button>
<button class="aster-button" @click=${() => show("email")}>${icon("M3 5h18v14H3zM3 6l9 7 9-7")}<span>Continue with email</span></button>
<button class="aster-button" @click=${() => show("your organisation")}>${icon("M5 21V3h14v18M9 7h2m2 0h2M9 11h2m2 0h2M10 21v-6h4v6")}<span>Sign in with SSO</span></button></div><p class="mail-auth-preview">Local simulation. These choices demonstrate where your own login flow connects.</p>`}
<div class="mail-auth-links"><button class="aster-button" @click=${cancel}>Cancel sign-in</button><button class="aster-button" @click=${() => finish(new AuthenticationError("failed"))}>Simulate failure</button></div></section></div>`;
if (loginDialog.open)
queueMicrotask(() => loginDialog.querySelector("[data-complete-login],.mail-auth-options button")?.focus());
};
loginDialog.labelledBy = "login-title";
show();
loginDialog.addEventListener("close", cancel);
signal.addEventListener("abort", abort, {
once: true
});
queueMicrotask(() => { if (signal.aborted)
abort();
else if (!settled)
loginDialog.show(); });
});
}
Coordinate challenges and replay in the data layer
This is the framework implementation executed by the demo. It shows request parking, shared recovery, fresh credentials, generation checks, cancellation, deadlines and identity checks.
From packages/data/src/authentication.ts
export class Authentication {
readonly #options: AuthenticationOptions;
readonly #timeout: number;
readonly #lifetime = new AbortController();
readonly #recovering = signal(false);
readonly recovering: ReadonlySignal<boolean> = this.#recovering;
#recovery: Recovery | undefined;
#generation = 0;
constructor(options: AuthenticationOptions) {
const timeout = options.timeoutMs ?? 300000;
if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 2147483647)
throw new RangeError("Authentication timeoutMs must be a positive timer integer");
this.#options = Object.freeze({
...options
});
this.#timeout = timeout;
}
/** Wrap only transports that belong to this identity/credential context. */
wrap(transport: Transport): Transport {
return async (original) => {
const signal = original.signal ? AbortSignal.any([original.signal, this.#lifetime.signal]) : this.#lifetime.signal;
const request = Object.freeze({
...original, ...(original.headers ? {
headers: Object.freeze({
...original.headers
})
} : {}), signal
});
const identity = this.#options.identity();
let retried = false;
for (;;) {
signal.throwIfAborted();
if (this.#recovery)
await this.#join(this.#recovery, signal);
this.#checkIdentity(identity);
const generation = this.#generation;
const credentials = this.#options.credentials ? await interrupted(Promise.resolve().then(() => { signal.throwIfAborted(); return this.#options.credentials!(); }), signal) : {};
signal.throwIfAborted();
this.#checkIdentity(identity);
// Credential lookup can yield while another request starts recovery.
if (this.#recovery || generation !== this.#generation)
continue;
const headers = new Headers(request.headers);
for (const [key, value] of Object.entries(credentials))
headers.set(key, value);
const response = await interrupted(Promise.resolve().then(() => { signal.throwIfAborted(); return transport({
...request, headers: Object.fromEntries(headers)
}); }), signal);
const challenge = {
request, response
};
if (!(this.#options.challenge?.(challenge) ?? response.status === 401))
return response;
this.#checkIdentity(identity);
if (retried)
throw new AuthenticationError("retry-limit");
if (!(this.#options.replay?.(challenge) ?? request.method === "GET"))
throw new AuthenticationError("replay-blocked");
retried = true;
// A late response using old credentials shares the already completed recovery.
if (generation === this.#generation)
await this.#join(this.#recovery ?? this.#start(challenge, identity), signal);
}
};
}
#checkIdentity(expected: string | undefined): void {
if (expected !== undefined && this.#options.identity() !== expected)
throw new AuthenticationError("identity-changed");
}
#start(challenge: AuthenticationChallenge, identity: string | undefined): Recovery {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new AuthenticationError("timeout")), this.#timeout);
const recovery: Recovery = {
controller, waiters: 0,
promise: Promise.resolve().then(async () => {
controller.signal.throwIfAborted();
await interrupted(Promise.resolve().then(() => this.#options.recover({
...challenge, signal: controller.signal, identity
})), controller.signal);
this.#checkIdentity(identity);
this.#generation++;
}).catch(error => {
throw error instanceof AuthenticationError ? error : new AuthenticationError("failed", {
cause: error
});
}).finally(() => {
clearTimeout(timer);
if (this.#recovery === recovery) {
this.#recovery = undefined;
this.#recovering.value = false;
}
}),
};
this.#recovery = recovery;
this.#recovering.value = true;
return recovery;
}
async #join(recovery: Recovery, signal: AbortSignal): Promise<void> {
recovery.waiters++;
try {
await interrupted(recovery.promise, signal);
}
finally {
if (--recovery.waiters === 0 && this.#recovery === recovery) {
this.#recovery = undefined;
recovery.controller.abort(new AuthenticationError("cancelled"));
this.#recovering.value = false;
}
}
}
/** Cancels waiting and in-flight wrapped requests; late recovery cannot resume them. */
dispose(): void {
this.#lifetime.abort(new AuthenticationError("disposed"));
this.#recovery?.controller.abort(new AuthenticationError("disposed"));
}
}
function interrupted<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
return new Promise((resolve, reject) => {
const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); };
promise.then(value => { signal.removeEventListener("abort", abort); resolve(value); }, error => { signal.removeEventListener("abort", abort); reject(error); });
if (signal.aborted)
abort();
else
signal.addEventListener("abort", abort, {
once: true
});
});
}
Retain a draft until sending succeeds
The model keeps the same draft while authentication is pending or fails. Only a successful send removes it; the mock rejects expired requests before applying writes.
From scripts/site/playground/mail-model.js
send: own({
label: "Send message", enabled: () => {
const draft = currentDraft.value;
return !!draft && !draft.busy.value && validRecipient(draft.to.value) && !!draft.subject.value.trim() && !!draft.body.value.trim();
}, reason: () => "Enter a valid recipient email address, subject and message before sending.", run: async (signal) => {
const draft = currentDraft.peek();
if (!draft || !validRecipient(draft.to.peek()))
throw new Error("Enter a valid recipient email address.");
draft.busy.value = true;
draft.error.value = "";
try {
await ctx.create("Messages", {
id: nextMessage++, sender: "Alex Morgan", senderAddress: "alex@example.test", recipient: draft.to.peek(), subject: draft.subject.peek(), body: draft.body.peek(), folder: "Sent", unread: false, date: new Date().toISOString()
});
signal.throwIfAborted();
draft.dirty.value = false;
panels.remove(draft.id);
if (active.peek() === draft.id)
active.value = "welcome";
notice.value = "Message sent to the mock Sent folder.";
await refreshCounts();
}
catch (error) {
draft.error.value = friendlyError(error);
throw error;
}
finally {
draft.busy.value = false;
}
}
}),
;
;
Full application source Application code & shared presentation
import { Authentication, AuthenticationError, DataContext, Composite, asterComponents, compositeTypes as t, defineCompositeRegistry, defineCompositeRenderer, defineElement, elementProperty, effect, html, signal, sidebarItems } from "../lib/aster-ui.js";
import { createMockService } from "../lib/aster-ui-mock.js";
import { mailWorkspaceType, friendlyError, validRecipient } from "./mail-model.js";
const $ = selector => document.querySelector(selector);
function receivedText(value) {
if (value === null || value === undefined) return "";
const date = new Date(value);
const now = new Date();
const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1);
if (date.toDateString() === now.toDateString()) return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(date);
if (date.toDateString() === yesterday.toDateString()) return "Yesterday";
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
}
const icon = path => html`<svg viewBox="0 0 24 24" aria-hidden="true"><path d=${path}/></svg>`;
const loginDialog = $("#login");
let expired = false, loginCount = 0;
const feedback = signal();
// The application owns its login interaction; Authentication owns the waiting requests.
function recover({ signal }) {
loginCount++;
return new Promise((resolve, reject) => {
let settled = false;
const finish = error => {
if (settled) return;
settled = true; signal.removeEventListener("abort", abort); loginDialog.removeEventListener("close", cancel);
loginDialog.close();
if (error) reject(error);
else { expired = false; resolve(); }
};
const cancel = () => finish(new AuthenticationError("cancelled"));
const abort = () => finish(signal.reason);
const signedIn = () => finish();
const show = method => {
loginDialog.content = html`<div class="mail-auth"><aside class="mail-auth-brand"><img src="../docs-assets/aster-logo.svg" alt="Aster" width="112" height="40"><strong>Return to<br>your mail.</strong><p>Your open messages and unsent drafts stay where you left them.</p></aside>
<section class="mail-auth-form"><p class="mail-eyebrow">YOUR ASTER ACCOUNT</p><h2 id="login-title">${method ? `Continue with ${method}` : "Sign in to continue"}</h2>
<p>Your session expired. Continue as <strong>alex@example.test</strong> to finish the waiting operation.</p>
${method ? html`<p class="mail-auth-preview">Demo only. ${method === "email" ? "No email has been sent." : method === "a passkey" ? "No browser passkey prompt has been opened." : "No identity provider has been contacted."} Use the button below to simulate a successful sign-in.</p>
<button class="aster-button" data-complete-login @click=${signedIn}>Simulate successful sign-in</button><div class="mail-auth-links"><button class="aster-button" @click=${() => show()}>Other sign-in options</button></div>`
: html`<div class="mail-auth-options"><button class="aster-button" @click=${() => show("a passkey")}>${icon("M14 9a5 5 0 1 0-4 5l8 8 3-3-2-2 2-2-3-3")}<span>Sign in with a passkey</span></button>
<button class="aster-button" @click=${() => show("email")}>${icon("M3 5h18v14H3zM3 6l9 7 9-7")}<span>Continue with email</span></button>
<button class="aster-button" @click=${() => show("your organisation")}>${icon("M5 21V3h14v18M9 7h2m2 0h2M9 11h2m2 0h2M10 21v-6h4v6")}<span>Sign in with SSO</span></button></div><p class="mail-auth-preview">Local simulation. These choices demonstrate where your own login flow connects.</p>`}
<div class="mail-auth-links"><button class="aster-button" @click=${cancel}>Cancel sign-in</button><button class="aster-button" @click=${() => finish(new AuthenticationError("failed"))}>Simulate failure</button></div></section></div>`;
if (loginDialog.open) queueMicrotask(() => loginDialog.querySelector("[data-complete-login],.mail-auth-options button")?.focus());
};
loginDialog.labelledBy = "login-title"; show();
loginDialog.addEventListener("close", cancel);
signal.addEventListener("abort", abort, { once: true });
queueMicrotask(() => { if (signal.aborted) abort(); else if (!settled) loginDialog.show(); });
});
}
const auth = new Authentication({
identity: () => "example-tenant/alex@example.test", recover,
// This mock rejects every expired-session request before applying any operation.
replay: ({ response }) => response.status === 401,
});
const metadata = { $Version: "4.01", $EntityContainer: "Mail.Container", $Reference: { "https://oasis-tcs.github.io/odata-vocabularies/vocabularies/Org.OData.Core.V1.json": { $Include: [{ $Namespace: "Org.OData.Core.V1", $Alias: "Core" }] } }, Mail: {
Message: { $Kind: "EntityType", $Key: ["id"], id: { $Type: "Edm.Int32" }, senderAddress: {}, sender: { "@Org.OData.Core.V1.Description": "From" }, recipient: { "@Org.OData.Core.V1.Description": "To" }, subject: { "@Org.OData.Core.V1.Description": "Subject" }, body: { "@Org.OData.Core.V1.Description": "Message" }, folder: { "@Org.OData.Core.V1.Description": "Folder" }, unread: { $Type: "Edm.Boolean", "@Org.OData.Core.V1.Description": "Unread" }, date: { $Type: "Edm.DateTimeOffset", "@Org.OData.Core.V1.Description": "Received" } },
Container: { $Kind: "EntityContainer", Messages: { $Collection: true, $Type: "Mail.Message" } },
} };
const subjects = ["Friday's launch checklist", "Design review — reading pane", "A few notes from our customer call", "Team lunch next week", "Updated project timeline", "Welcome to the workspace"];
const senders = ["Priya Shah", "James Chen", "Maya Wilson", "Oliver Brown", "Sofia Patel", "Aster team"];
const data = { Messages: Array.from({ length: 64 }, (_, i) => ({ id: i + 1, sender: senders[i % 6], senderAddress: `${senders[i % 6].toLowerCase().replaceAll(" ", ".")}@example.test`, recipient: "alex@example.test", subject: subjects[i % 6] + (i > 5 ? ` · ${Math.floor(i / 6) + 1}` : ""),
body: `Hi Alex,\n\n${["The checklist is ready for your review. Could you take a look at the final items before Friday?", "I've attached my thoughts on the new reading pane. Keeping drafts open while switching folders makes this much easier to use.", "We had a useful conversation about how the team works. I'll share a summary at our next meeting."][i % 3]}\n\nThanks,\n${senders[i % 6]}`,
folder: i < 48 ? "Inbox" : i < 56 ? "Sent" : "Archive", unread: i % 3 === 0 && i < 48, date: new Date(Date.now() - (63 - i) * 2 * 60 * 60 * 1000).toISOString() })) };
let instance;
const MailPanel = defineElement({ tag: "aster-mail-panel", properties: { session: elementProperty(t.custom("mail session", value => !!value?.id), () => ({ id: "welcome" })) },
render: ({ session }) => {
switch (session.kind) {
case "preview":
case "message": {
const record = session.kind === "preview" ? session.record.value : session.record;
if (!record) return html`<section class="mail-welcome"><p class="mail-eyebrow">YOUR MAIL</p><h2>Select a message to read.</h2><p>Double-click a message or press Enter to open it in its own tab. Your drafts stay open while you browse.</p></section>`;
return html`<article class="mail-message"><header><div class="mail-avatar" aria-hidden="true">${record.sender.split(" ").map(part => part[0]).slice(0, 2).join("").toUpperCase()}</div><div><h2>${record.subject}</h2><p class="mail-meta"><strong>${record.sender}</strong><br>To: ${record.recipient} · ${new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(record.date))}</p></div></header><p class="mail-body">${record.body}</p><button class="aster-button" @click=${() => instance.model.reply(record)}>Reply</button></article>`;
}
case "draft": {
const update = (field, value) => { session[field].value = value; session.dirty.value = true; };
return html`<section class="mail-draft" aria-label="Compose message" aria-busy=${String(session.busy.value)}><h2>New message</h2>
${session.error.value ? html`<section class="mail-draft-error"><div role="status" aria-atomic="true"><h3 tabindex="-1">Message wasn’t sent</h3><p>${session.error.value}</p></div><div class="mail-feedback-actions"><button class="aster-button" ?disabled=${!instance.commands.send.enabled.value} @click=${() => { void instance.commands.send.execute().catch(() => {}); }}>Try sending again</button><button class="aster-button" @click=${event => { const panel = event.currentTarget.closest(".mail-draft"); session.error.value = ""; queueMicrotask(() => panel.querySelector("textarea")?.focus()); }}>Dismiss</button></div></section>` : ""}
<label>To<input type="email" autocomplete="email" .value=${session.to.value} aria-invalid=${String(!!session.to.value && !validRecipient(session.to.value))} aria-describedby=${`${session.id}-recipient-help`} ?disabled=${session.busy.value} @input=${e => update("to", e.target.value)}></label><small id=${`${session.id}-recipient-help`} class="mail-validation" ?hidden=${!session.to.value || validRecipient(session.to.value)}>Enter an email address such as name@example.com.</small>
<label>Subject<input .value=${session.subject.value} ?disabled=${session.busy.value} @input=${e => update("subject", e.target.value)}></label>
<label>Message<textarea .value=${session.body.value} ?disabled=${session.busy.value} @input=${e => update("body", e.target.value)}></textarea></label>
<aster-progress-status .busy=${session.busy.value} .error=${undefined} .pendingText=${"Sending message…"} .completeText=${""}></aster-progress-status>
<p class="mail-meta">${session.dirty.value ? "Unsaved draft · retained while you switch tabs" : "Enter a recipient, subject and message, then use Send message above."}</p></section>`;
}
default: return html`<section class="mail-welcome"><h2>Select a message to read.</h2><p>Open a message or start a new draft.</p></section>`;
}
},
});
void MailPanel;
try {
const mock = await createMockService({ metadata, data, wasm: new URL("../lib/odata_mock.wasm", import.meta.url) });
const calls = [];
const transport = auth.wrap(async request => {
calls.push({ method: request.method, url: request.url, expired });
if (expired) return { status: 401, headers: { "www-authenticate": 'Bearer realm="mail", error="invalid_token"' }, body: JSON.stringify({ error: { code: "SessionExpired", message: "Sign in to continue." } }) };
return mock.transport(request);
});
const ctx = await DataContext.connect(transport);
const folders = signal(["Inbox", "Sent", "Archive"].map(id => ({ id, label: id, icon: id === "Inbox" ? "↓" : id === "Sent" ? "↗" : "▤" })));
async function refreshCounts() {
const rows = await ctx.source.aggregate("Messages", { filter: { op: "eq", field: "unread", value: true }, groupBy: ["folder"] });
folders.value = folders.peek().map(item => ({ ...item, badge: String(rows.find(row => row.folder === item.id)?.count ?? 0) }));
}
async function confirmDiscard(signal) {
const dialog = $("#discard");
if (dialog.open || signal.aborted) return false;
return new Promise(resolve => {
let discard = false;
dialog.labelledBy = "discard-title";
dialog.content = html`<h2 id="discard-title">Discard this draft?</h2><p>Your unsent message will be removed.</p><div class="mail-confirm-actions"><button class="aster-button" @click=${() => dialog.close()}>Keep editing</button><button class="aster-button" @click=${() => { discard = true; dialog.close(); }}>Discard draft</button></div>`;
const finish = () => {
signal.removeEventListener("abort", abort); dialog.removeEventListener("close", finish);
resolve(!signal.aborted && discard);
};
const abort = () => { dialog.close(); finish(); };
dialog.addEventListener("close", finish, { once: true });
signal.addEventListener("abort", abort, { once: true });
queueMicrotask(() => { if (!signal.aborted) dialog.show(); else finish(); });
});
}
const authoring = defineCompositeRegistry({ inputs: { ctx: t.instance(DataContext), folders: t.signal(sidebarItems), refreshCounts: t.custom("refresh function", value => typeof value === "function"), sessionRegistry: t.custom("session component registry", value => typeof value?.component === "function") },
models: { "mail-workspace": mailWorkspaceType }, components: { Sidebar: asterComponents.Sidebar, Grid: asterComponents.Grid, Tabs: asterComponents.Tabs, CommandBar: asterComponents.CommandBar,
MailPanel: { props: { session: t.custom("mail session", value => !!value?.id) },
create: ({ props }) => {
const element = document.createElement("aster-mail-panel"); element.session = props.session;
return { element, canClose: async signal => {
const session = props.session;
if (session.busy?.peek()) return false;
const accepted = !session.dirty.peek() || await confirmDiscard(signal);
return accepted && !signal.aborted && !session.busy?.peek();
} };
} },
},
renderers: {
"mail-date": defineCompositeRenderer(["formatters"], receivedText),
"mail-sender": defineCompositeRenderer(["renderers"], record => html`<span class=${record.unread ? "mail-sender mail-unread" : "mail-sender"}>${record.unread ? html`<span class="mail-unread-dot" aria-hidden="true"></span><span class="aster-sr-only">Unread </span>` : ""}${record.sender}</span>`),
"mail-subject": defineCompositeRenderer(["renderers"], record => html`<div class="mail-subject"><span class=${record.unread ? "mail-unread" : ""}>${record.subject}</span><small>${record.body.split("\n").filter(line => line.trim())[1] ?? ""}</small></div>`),
"mail-panel": defineCompositeRenderer(["renderPanel"], item => item.value === "welcome"
? html`<aster-mail-panel .session=${{ id: "welcome", kind: "preview", record: instance.model.selection.selected }}></aster-mail-panel>`
: html`${instance.model.panels.get(item.value)?.value.value?.element ?? ""}`) } });
const response = await fetch("./mail.ui.json");
if (!response.ok) throw new Error("Mail workspace definition unavailable.");
const { definition: raw, sources } = await response.json();
const definition = authoring.validate(raw, sources);
const placement = authoring.placement(definition, place => html`<div class="mail-layout"><aside class="mail-folders"><h2>Favourites</h2>${place("folders")}</aside><div class="mail-actions">${place("actions")}</div><section class="mail-list" aria-label="Message list">${place("messages")}</section><section class="mail-reader" aria-label="Reading pane">${place("reading")}</section></div>`);
// The Tabs renderer runs during mounting; resolve the owner after model creation.
const host = $("#mail");
instance = new Composite(definition, authoring.registry, { ctx, inputs: { ctx, folders, refreshCounts, sessionRegistry: authoring.registry }, sources, placement });
instance.mount(host);
await Promise.all(Object.values(instance.children).map(child => child.ensure()));
const model = instance.model;
const stop = effect(() => { $("#notice").textContent = model.notice.value; $("#auth-progress").busy = auth.recovering.value; });
$("#auth-progress").pendingText = "Waiting for sign-in…"; $("#auth-progress").completeText = "";
host.querySelector("aster-sidebar").addEventListener("navigate", event => { event.preventDefault(); model.selection.clear(); model.folder.value = event.detail; });
const grid = host.querySelector("aster-grid");
grid.addEventListener("row-open", event => {
event.preventDefault();
const record = ctx.store.get("Messages", event.detail.key)?.entity;
model.open(record);
queueMicrotask(() => host.querySelector('[role="tab"][aria-selected="true"]')?.focus());
});
grid.addEventListener("click", event => {
if (event.target.closest('[role="row"][data-key]') && !event.target.closest('button, input, select, textarea, a[href]')) model.active.value = "welcome";
});
const tabs = host.querySelector("aster-tabs");
tabs.addEventListener("change", event => { if (event.target === tabs) model.active.value = event.detail; });
tabs.addEventListener("close", event => { if (event.target === tabs) void model.close(event.detail); });
$("#search").addEventListener("input", event => { model.search.value = event.target.value; });
function report(error, title, retry) {
if (error instanceof AuthenticationError) $("#background").checked = false;
model.notice.value = "";
feedback.value = { title, detail: friendlyError(error), retry };
}
const run = async (action, title = "Mail couldn’t update") => {
feedback.value = undefined;
try { await action(); } catch (error) { report(error, title, () => run(action, title)); }
};
const commandStops = Object.entries(instance.commands).map(([name, cmd]) => effect(() => {
const error = cmd.error.value;
if (name === "send") {
if (error) { model.notice.value = ""; requestAnimationFrame(() => { const heading = host.querySelector('[role="tabpanel"]:not([hidden]) .mail-draft-error h3'); heading?.scrollIntoView({ block: "nearest", behavior: "instant" }); heading?.focus({ preventScroll: true }); }); }
return;
}
if (cmd.busy.value) feedback.value = undefined;
if (error) report(error, ({ refresh: "Mail couldn’t refresh", read: "Messages weren’t marked as read", open: "Message couldn’t open" })[name] ?? "Mail couldn’t update", () => run(() => cmd.execute(), ({ refresh: "Mail couldn’t refresh", read: "Messages weren’t marked as read", open: "Message couldn’t open" })[name] ?? "Mail couldn’t update"));
}));
const stopFeedback = effect(() => {
const item = feedback.value;
$("#feedback").hidden = !item;
$("#feedback-title").textContent = item?.title ?? "";
$("#feedback-detail").textContent = item?.detail ?? "";
});
$("#dismiss").addEventListener("click", () => { feedback.value = undefined; host.querySelector("aster-grid")?.focus(); });
$("#retry").addEventListener("click", () => { void feedback.peek()?.retry(); });
$("#expire").addEventListener("click", event => { event.currentTarget.focus(); expired = true; void run(() => instance.commands.refresh.execute(), "Mail couldn’t refresh"); });
$("#expire-only").addEventListener("click", () => { expired = true; model.notice.value = "Session expired. Your next data operation will ask you to sign in."; });
let arrival = 1;
$("#arrival").addEventListener("click", () => void run(async () => { await ctx.create("Messages", { id: 2000 + arrival, sender: "Priya Shah", senderAddress: "priya.shah@example.test", recipient: "alex@example.test", subject: `New arrival ${arrival++}`, body: "A new message arrived while you were working. Your drafts and open messages stay mounted.", folder: "Inbox", unread: true, date: new Date().toISOString() }); await refreshCounts(); model.notice.value = "A sample message arrived in Inbox."; }));
const background = instance.activity.run(({ signal, refresh, markFresh }) => {
if (!$("#background").checked) return;
const update = async () => {
if (signal.aborted || !$("#background").checked || auth.recovering.peek()) return;
await run(async () => { await instance.commands.refresh.execute(); markFresh(); }, "Mail couldn’t refresh");
};
if (refresh) void update();
const interval = setInterval(() => void update(), 30_000);
return () => clearInterval(interval);
});
const visibility = () => instance.activity.setActive(!document.hidden);
document.addEventListener("visibilitychange", visibility);
instance.lifetime.defer(() => document.removeEventListener("visibilitychange", visibility));
$("#background").addEventListener("change", () => background.invalidate());
visibility();
await refreshCounts();
globalThis.mailDemo = { auth, ctx, mock, instance, calls, background, get loginCount() { return loginCount; } };
addEventListener("pagehide", event => { if (!event.persisted) { stop(); stopFeedback(); commandStops.forEach(dispose => dispose()); auth.dispose(); instance.dispose(); } });
} catch (error) { $("#mail").setAttribute("role", "alert"); $("#mail").textContent = error.message; auth.dispose(); }
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Mail workspace — Aster UI</title>
<link id="identity" rel="stylesheet" href="../lib/themes/lamplight.css"><link rel="stylesheet" href="../lib/aster-ui.css"><link rel="stylesheet" href="./showcase.css"><link rel="stylesheet" href="./mail.css">
</head><body><main>
<header class="mail-intro"><div><p class="mail-eyebrow">APPLICATION EXAMPLE</p><h1>Aster Mail</h1><p>Read, compose and keep your drafts when your session expires.</p></div><div class="mail-appearance"><div class="showcase-appearance"><span>Theme</span><aster-choice id="theme"></aster-choice></div><div class="showcase-appearance"><span>Scheme</span><aster-choice id="scheme"></aster-choice></div></div></header>
<details class="mail-lab"><summary>Demo controls</summary><div class="mail-demo-controls"><span>Local simulation · No real mail or sign-in requests</span><button class="aster-button" id="expire">Expire session & refresh</button><button class="aster-button" id="expire-only">Expire session</button><button class="aster-button" id="arrival">Receive sample mail</button><label><input type="checkbox" id="background"> Refresh every 30 seconds</label></div></details>
<div class="mail-top"><strong><img src="../docs-assets/aster-logo.svg" alt="" width="28" height="28"> Mail</strong><label class="mail-search"><span>Search subjects</span><input id="search" type="search" placeholder="Search this folder…"></label><span>Alex Morgan <small>alex@example.test</small></span></div>
<section id="feedback" class="mail-feedback" aria-label="Mail notification" hidden><div role="status" aria-atomic="true"><h2 id="feedback-title"></h2><p id="feedback-detail"></p></div><div class="mail-feedback-actions"><button class="aster-button" id="retry">Try again</button><button class="aster-button" id="dismiss">Dismiss</button></div></section>
<section id="mail" aria-label="Mail workspace"></section>
<p id="notice" role="status"></p>
<aster-progress-status id="auth-progress"></aster-progress-status>
<p><a href="../guide/keyed-sessions.html">Panel lifetimes & background work →</a> · <a href="../guide/authentication.html">How authentication recovery works →</a> · <a href="./mail.ui.yaml">Workspace YAML</a> · <a href="./mail.js">Application source</a> · <a href="./mail-model.js">Model source</a></p>
<aster-dialog id="login"></aster-dialog><aster-dialog id="discard"></aster-dialog>
</main><script type="module" src="./showcase-nav.js"></script><script type="module" src="./mail.js"></script></body></html>
import { Command, Collection, CompositeSessions, DataContext, Selection, command, computed, effect, compositeTypes as t, defineCompositeModel, signal, selectionPublication, keyText } from "../lib/aster-ui.js";
export const validRecipient = value => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
export function createMailWorkspace({ ctx, refreshCounts, sessionRegistry }, lifetime, activity) {
const folder = signal("Inbox"), search = signal("");
const notice = signal("Mock mail. Messages and drafts stay in this visit.");
const collection = ctx.collection(computed(() => ({ set: "Messages", orderBy: [{ field: "id", descending: true }],
filter: { op: "and", args: [{ op: "eq", field: "folder", value: folder.value }, ...(search.value.trim() ? [{ op: "contains", field: "subject", value: search.value.trim() }] : [])] },
})), { pageSize: 20, keepPages: 3 });
lifetime.defer(() => ctx.release(collection));
const selection = new Selection(ctx.store, collection, { multiple: true, fallback: false });
const active = signal("welcome");
const panels = lifetime.own(new CompositeSessions(sessionRegistry, { activity }));
const sessions = computed(() => panels.sessions.value.map(handle => handle.descriptor.inputs.session));
lifetime.defer(effect(() => panels.activate(active.value === "welcome" ? undefined : active.value)));
const add = session => panels.reconcile([...panels.sessions.peek().map(handle => handle.descriptor),
{ key: session.id, type: "MailPanel", inputs: { session }, inactive: "pause" }]);
let nextDraft = 1;
let nextMessage = 1000;
const currentDraft = computed(() => sessions.value.find(session => session.id === active.value && session.kind === "draft"));
const items = computed(() => [{ value: "welcome", label: "Reading pane" }, ...sessions.value.map(session => ({ value: session.id,
label: (session.subject.value || "New message") + (session.dirty.value ? " •" : ""), closable: true }))]);
const own = options => lifetime.own(command(options));
function open(record) {
if (!record) return;
const id = `message-${record.id}`;
if (!sessions.peek().some(session => session.id === id)) add({ id, kind: "message", record,
subject: signal(record.subject), dirty: signal(false) });
active.value = id;
}
async function close(id) {
if (await panels.requestClose(id) && active.peek() === id) active.value = "welcome";
}
function newDraft(values = {}) {
const draft = { id: `draft-${nextDraft++}`, kind: "draft", to: signal(values.to ?? ""), subject: signal(values.subject ?? ""), body: signal(values.body ?? ""), dirty: signal(!!values.subject), busy: signal(false), error: signal("") };
add(draft); active.value = draft.id;
}
function reply(record) { newDraft({ to: record.senderAddress, subject: /^Re:/i.test(record.subject) ? record.subject : `Re: ${record.subject}`, body: `\n\nOn ${record.date}, ${record.sender} wrote:\n${record.body}` }); }
const commands = {
open: own({ label: "Open selected", enabled: () => selection.any.value, run: () => { for (const record of selection.records.peek()) open(record); } }),
compose: own({ label: "New message", run: () => newDraft() }),
refresh: own({ label: "Refresh mail", run: async () => {
const results = await Promise.allSettled([collection.refresh(), refreshCounts()]);
const failed = results.find(result => result.status === "rejected");
if (failed) throw failed.reason;
notice.value = "Mail refreshed. Open messages and drafts are retained.";
} }),
read: own({ label: "Mark read", enabled: () => selection.any.value, run: async () => {
await Promise.all(selection.records.peek().map(record => ctx.update("Messages", keyText([record.id]), { unread: false })));
await refreshCounts(); notice.value = "Selected messages marked as read.";
} }),
send: own({ label: "Send message", enabled: () => {
const draft = currentDraft.value;
return !!draft && !draft.busy.value && validRecipient(draft.to.value) && !!draft.subject.value.trim() && !!draft.body.value.trim();
}, reason: () => "Enter a valid recipient email address, subject and message before sending.", run: async signal => {
const draft = currentDraft.peek();
if (!draft || !validRecipient(draft.to.peek())) throw new Error("Enter a valid recipient email address.");
draft.busy.value = true; draft.error.value = "";
try {
await ctx.create("Messages", { id: nextMessage++, sender: "Alex Morgan", senderAddress: "alex@example.test", recipient: draft.to.peek(), subject: draft.subject.peek(), body: draft.body.peek(), folder: "Sent", unread: false, date: new Date().toISOString() });
signal.throwIfAborted(); draft.dirty.value = false;
panels.remove(draft.id);
if (active.peek() === draft.id) active.value = "welcome";
notice.value = "Message sent to the mock Sent folder."; await refreshCounts();
} catch (error) { draft.error.value = friendlyError(error); throw error; }
finally { draft.busy.value = false; }
} }),
};
return { values: { collection, selection, folder, search, notice, active, sessions, panels, items, currentDraft, open, close, reply }, commands, publications: { selection: selectionPublication(selection) } };
}
export function friendlyError(error) {
switch (error?.reason) {
case "cancelled": return "Sign-in cancelled. Your unsent messages are still here. Try again when you’re ready.";
case "identity-changed": return "The account changed. Reload before continuing; this operation was not resumed.";
case "failed": return "Sign-in could not be completed. Your unsent messages are still here. Try again.";
case "timeout": return "Sign-in timed out. Your draft is retained.";
case "retry-limit": return "The service still requires sign-in. Try again later.";
default: return "Please try again. Your open messages and drafts have been kept.";
}
}
export const mailWorkspaceType = defineCompositeModel({
inputs: { ctx: t.instance(DataContext), refreshCounts: t.custom("refresh counts function", value => typeof value === "function"), sessionRegistry: t.custom("session component registry", value => typeof value?.component === "function") },
exports: { collection: t.instance(Collection), selection: t.instance(Selection), folder: t.signal(t.string), search: t.signal(t.string), notice: t.signal(t.string), active: t.signal(t.string),
panels: t.instance(CompositeSessions), sessions: t.signal(t.array(t.custom("mail session", value => !!value?.id))), items: t.signal(t.array(t.object({ value: t.string, label: t.string, closable: t.optional(t.boolean), disabled: t.optional(t.boolean) }))),
currentDraft: t.signal(t.optional(t.custom("mail draft", value => value?.kind === "draft"))), open: t.custom("open message function", value => typeof value === "function"), close: t.custom("close tab function", value => typeof value === "function"), reply: t.custom("reply function", value => typeof value === "function") },
commands: Object.fromEntries(["open", "compose", "refresh", "read", "send"].map(name => [name, t.instance(Command)])),
publications: { selection: t.publication({ any: t.boolean, many: t.boolean, count: t.number }) }, create: createMailWorkspace,
});
version: 1
name: MailWorkspace
model:
type: mail-workspace
inputs:
ctx: { ref: inputs.ctx }
refreshCounts: { ref: inputs.refreshCounts }
sessionRegistry: { ref: inputs.sessionRegistry }
publish:
messages: { ref: model.publications.selection }
commands:
compose: { use: model.commands.compose }
open:
use: model.commands.open
enabledWhen: messages.any
reason: Select messages to open.
read: { use: model.commands.read }
refresh: { use: model.commands.refresh }
send: { use: model.commands.send }
children:
folders:
type: Sidebar
props:
label: Mail folders
items: { bind: inputs.folders }
value: { bind: model.folder }
actions:
type: CommandBar
props:
label: Mail actions
showErrors: false
commands:
- { ref: commands.compose }
- { ref: commands.open }
- { ref: commands.read }
- { ref: commands.refresh }
- { ref: commands.send }
messages:
type: Grid
props:
label: Messages in the selected folder
ctx: { ref: inputs.ctx }
collection: { ref: model.collection }
selection: { ref: model.selection }
editable: false
formatters:
date: { renderer: mail-date }
renderers:
sender: { renderer: mail-sender }
subject: { renderer: mail-subject }
view:
columns:
- { field: sender, width: 150 }
- { field: subject }
- { field: date, width: 95 }
sort: []
filters: {}
frozen: 0
reading:
type: Tabs
props:
label: Open messages and drafts
items: { bind: model.items }
value: { bind: model.active }
renderPanel: { renderer: mail-panel }
layout:
type: stack
items:
- { child: folders }
- { child: actions }
- { child: messages }
- { child: reading }
html { scrollbar-gutter:stable; }
body { height:auto; min-height:100dvh; padding:24px; background:var(--aster-sunk); }
main { max-width:1600px; margin:auto; min-width:0; }
.mail-intro { display:flex; align-items:center; justify-content:space-between; gap:20px; margin-block:20px; }
.mail-intro h1 { margin:0; font-size:2rem; letter-spacing:-.04em; }.mail-intro p { margin-block:8px; color:var(--aster-text-2); }
.mail-eyebrow { font-size:.72rem; letter-spacing:.12em; }.mail-appearance,.mail-demo-controls { display:flex; flex-wrap:wrap; align-items:center; gap:12px; }
.mail-demo-controls { margin-block:20px; font-size:.84rem; }.mail-demo-controls > span { margin-inline-end:auto; color:var(--aster-text-2); }
.mail-top { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:18px; padding:18px 22px; background:var(--aster-accent); color:var(--aster-on-accent,white); border-radius:var(--aster-r-card) var(--aster-r-card) 0 0; }
.mail-top strong { display:flex; align-items:center; gap:10px; font-size:1.2rem; }.mail-top small { display:block; opacity:.9; }
.mail-search { display:grid; gap:5px; min-width:min(320px,100%); font-size:.8rem; }.mail-search input { width:100%; padding:8px 12px; border:1px solid var(--aster-rule); border-radius:var(--aster-r-ctl); background:var(--aster-surface); color:var(--aster-text); }
.mail-layout { display:grid; grid-template-columns:175px minmax(280px,1fr) minmax(300px,1fr); grid-template-rows:auto minmax(440px,58dvh); min-width:0; border:1px solid var(--aster-rule); background:var(--aster-surface); border-radius:0 0 var(--aster-r-card) var(--aster-r-card); overflow:clip; }
.mail-folders { grid-row:1 / 3; padding:16px 8px; background:var(--aster-sunk); border-inline-end:1px solid var(--aster-rule); }.mail-folders h2 { font-size:.8rem; padding-inline:12px; color:var(--aster-text-2); }
.mail-actions { grid-column:2 / 4; padding:10px; border-block-end:1px solid var(--aster-rule); }.mail-actions .aster-command-bar { flex-wrap:wrap; }
.mail-list,.mail-reader { min-width:0; min-height:0; overflow:hidden; }.mail-list { border-inline-end:1px solid var(--aster-rule); display:flex; flex-direction:column; }.mail-list > .aster-composite-child { display:flex; flex:1; min-height:0; }
.mail-list h2 { font-size:1rem; margin:16px; }.mail-list aster-grid { flex:1; min-height:0; }
.mail-reader { padding:12px 18px; overflow:auto; }.mail-reader aster-tabs { display:block; min-width:0; }.mail-reader [role="tabpanel"] { padding:20px 0; }
.mail-welcome { padding:35px 12px; }.mail-welcome h2 { font-size:1.6rem; letter-spacing:-.03em; }.mail-welcome p,.mail-meta { color:var(--aster-text-2); line-height:1.7; }
.mail-message h2 { line-height:1.3; font-size:1.35rem; }.mail-body { white-space:pre-wrap; line-height:1.8; overflow-wrap:anywhere; }
.mail-draft { display:grid; gap:16px; }.mail-draft label { display:grid; gap:6px; }.mail-draft input,.mail-draft textarea { width:100%; min-width:0; padding:10px 12px; border:1px solid var(--aster-rule); border-radius:var(--aster-r-ctl); background:var(--aster-sunk); color:var(--aster-text); font:inherit; }.mail-draft textarea { min-height:220px; resize:vertical; }
#notice { min-height:1.5em; color:var(--aster-text-2); }.mail-error { color:var(--aster-negative); }
#login .aster-dialog-panel { padding:0; max-width:740px; width:min(740px,calc(100vw - 36px)); } .mail-auth { display:grid; grid-template-columns:230px minmax(0,1fr); }
.mail-auth-brand { padding:32px 24px; background:#143e2c; color:#f8f5ee; border-radius:var(--aster-r-card) 0 0 var(--aster-r-card); display:flex; flex-direction:column; gap:28px; }.mail-auth-brand strong { font-size:2rem; line-height:1.15; font-weight:500; }.mail-auth-brand p { line-height:1.7; font-size:.9rem; opacity:.85; }
.mail-auth-form { padding:32px; min-width:0; }.mail-auth-form h2 { margin-block:0 12px; font-size:1.7rem; letter-spacing:-.03em; }.mail-auth-form > p { color:var(--aster-text-2); line-height:1.6; }.mail-auth-options { display:grid; gap:10px; margin-block:24px 16px; }.mail-auth-options button { display:flex; align-items:center; gap:12px; text-align:start; padding:12px 14px; }.mail-auth-options svg { width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:1.8; flex:none; }.mail-auth-preview { padding:12px; background:var(--aster-sunk); border-radius:var(--aster-r-ctl); font-size:.85rem; }.mail-auth-links { display:flex; flex-wrap:wrap; gap:10px; margin-block-start:16px; }.mail-auth-links button { font-size:.8rem; }
@media(max-width:1050px) { .mail-layout { grid-template-columns:150px minmax(0,1fr); grid-template-rows:auto 350px minmax(330px,auto); }.mail-actions { grid-column:2; }.mail-folders { grid-row:1 / 4; }.mail-reader { grid-column:2; border-block-start:1px solid var(--aster-rule); }.mail-list { border:0; } }
@media(max-width:600px) { body { padding:12px; }.mail-intro { display:block; }.mail-appearance { margin-block-start:18px; }.mail-layout { display:flex; flex-direction:column; }.mail-folders { border:0; padding:6px; }.mail-folders h2 { display:none; }.mail-folders .aster-sidebar ul { display:flex; flex-wrap:wrap; }.mail-folders .aster-sidebar-label { display:inline; }.mail-list { height:340px; }.mail-reader { min-height:350px; }.mail-top { padding:14px; }.mail-top > span { font-size:.8rem; }.mail-search { order:3; width:100%; }.mail-auth { grid-template-columns:minmax(0,1fr); }.mail-auth-brand { display:none; }.mail-auth-form { padding:24px; } }
.mail-lab { margin-block:16px; border:1px solid var(--aster-rule); border-radius:var(--aster-r-ctl); background:var(--aster-surface); }
.mail-lab > summary { cursor:pointer; padding:12px 16px; font-weight:600; color:var(--aster-text-2); }.mail-lab .mail-demo-controls { margin:0; padding:4px 16px 16px; }
.mail-feedback,.mail-draft-error { border:1px solid var(--aster-rule); border-inline-start:3px solid var(--aster-negative); background:var(--aster-surface); padding:16px 20px; display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:16px; }
.mail-feedback[hidden] { display:none; }.mail-feedback h2,.mail-draft-error h3 { font-size:1rem; margin:0 0 6px; }.mail-feedback p,.mail-draft-error p { margin:0; color:var(--aster-text-2); line-height:1.6; }.mail-feedback-actions { display:flex; flex-wrap:wrap; gap:8px; }
.mail-draft-error { border-radius:var(--aster-r-ctl); background:var(--aster-sunk); }.mail-message > header { display:flex; gap:14px; align-items:flex-start; padding-block:12px 20px; border-block-end:1px solid var(--aster-rule); }.mail-message h2 { margin-block:0 12px; }.mail-message .mail-meta { margin:0; }
.mail-avatar { display:grid; place-items:center; flex:none; width:42px; height:42px; border-radius:50%; background:var(--aster-sunk); color:var(--aster-accent); border:1px solid var(--aster-rule); font-weight:600; }.mail-body { padding-block:12px; }
.mail-unread { font-weight:650; }.mail-unread-dot { display:inline-block; width:6px; height:6px; border-radius:50%; background:var(--aster-accent); margin-inline-end:7px; }.mail-subject { display:grid; gap:3px; min-width:0; }.mail-subject > span,.mail-subject small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }.mail-subject small { color:var(--aster-text-2); font-size:.8rem; font-weight:400; }
.mail-sender { display:block; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }.mail-validation { color:var(--aster-negative); }
#discard .aster-dialog-panel { width:min(420px,calc(100vw - 32px)); padding:24px; box-sizing:border-box; }
#discard h2 { margin:0; font-size:1.3rem; line-height:1.35; letter-spacing:-.02em; }
#discard p { margin:12px 0 24px; color:var(--aster-text-2); line-height:1.6; }
.mail-confirm-actions { display:flex; justify-content:flex-end; flex-wrap:wrap; gap:10px; }
import { signal, type ReadonlySignal } from "@preact/signals-core";
import type { ODataRequest, ODataResponse, Transport } from "./transport.ts";
export type AuthenticationFailure = "cancelled" | "timeout" | "failed" | "identity-changed" | "retry-limit" | "replay-blocked" | "disposed";
/** Recovery failed without settling the original operation successfully. */
export class AuthenticationError extends Error {
readonly reason: AuthenticationFailure;
constructor(reason: AuthenticationFailure, options?: ErrorOptions) {
super(`Authentication recovery: ${reason}`, options);
this.name = "AuthenticationError";
this.reason = reason;
}
}
export interface AuthenticationChallenge {
readonly request: ODataRequest;
readonly response: ODataResponse;
}
export interface AuthenticationRecovery extends AuthenticationChallenge {
/** Abort UI/SDK work when recovery times out, loses all waiters or is disposed. */
readonly signal: AbortSignal;
/** Stable account and tenant identity before the challenged operation. */
readonly identity: string | undefined;
}
export interface AuthenticationOptions {
/** Stable account/tenant key; undefined is allowed before initial login. */
readonly identity: () => string | undefined;
/** Resolves only after credentials are ready. Throw on cancellation or failure. */
readonly recover: (context: AuthenticationRecovery) => Promise<void>;
/** Defaults to HTTP 401. Restrict custom detection to trusted API responses. */
readonly challenge?: (context: AuthenticationChallenge) => boolean;
/** GET only by default. Opt in writes only with a server non-application guarantee or idempotency. */
readonly replay?: (context: AuthenticationChallenge) => boolean;
/** Rebuild credential headers on every send, including retries. */
readonly credentials?: () => Readonly<Record<string, string>> | Promise<Readonly<Record<string, string>>>;
/** Total recovery deadline, including interaction. Defaults to five minutes. */
readonly timeoutMs?: number;
}
interface Recovery {
readonly controller: AbortController;
readonly promise: Promise<void>;
waiters: number;
}
/** One authentication context shared by explicitly wrapped service transports. */
export class Authentication {
readonly #options: AuthenticationOptions;
readonly #timeout: number;
readonly #lifetime = new AbortController();
readonly #recovering = signal(false);
readonly recovering: ReadonlySignal<boolean> = this.#recovering;
#recovery: Recovery | undefined;
#generation = 0;
constructor(options: AuthenticationOptions) {
const timeout = options.timeoutMs ?? 300_000;
if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 2_147_483_647) throw new RangeError("Authentication timeoutMs must be a positive timer integer");
this.#options = Object.freeze({ ...options });
this.#timeout = timeout;
}
/** Wrap only transports that belong to this identity/credential context. */
wrap(transport: Transport): Transport {
return async original => {
const signal = original.signal ? AbortSignal.any([original.signal, this.#lifetime.signal]) : this.#lifetime.signal;
const request = Object.freeze({ ...original, ...(original.headers ? { headers: Object.freeze({ ...original.headers }) } : {}), signal });
const identity = this.#options.identity();
let retried = false;
for (;;) {
signal.throwIfAborted();
if (this.#recovery) await this.#join(this.#recovery, signal);
this.#checkIdentity(identity);
const generation = this.#generation;
const credentials = this.#options.credentials ? await interrupted(Promise.resolve().then(() => { signal.throwIfAborted(); return this.#options.credentials!(); }), signal) : {};
signal.throwIfAborted();
this.#checkIdentity(identity);
// Credential lookup can yield while another request starts recovery.
if (this.#recovery || generation !== this.#generation) continue;
const headers = new Headers(request.headers);
for (const [key, value] of Object.entries(credentials)) headers.set(key, value);
const response = await interrupted(Promise.resolve().then(() => { signal.throwIfAborted(); return transport({ ...request, headers: Object.fromEntries(headers) }); }), signal);
const challenge = { request, response };
if (!(this.#options.challenge?.(challenge) ?? response.status === 401)) return response;
this.#checkIdentity(identity);
if (retried) throw new AuthenticationError("retry-limit");
if (!(this.#options.replay?.(challenge) ?? request.method === "GET")) throw new AuthenticationError("replay-blocked");
retried = true;
// A late response using old credentials shares the already completed recovery.
if (generation === this.#generation) await this.#join(this.#recovery ?? this.#start(challenge, identity), signal);
}
};
}
#checkIdentity(expected: string | undefined): void {
if (expected !== undefined && this.#options.identity() !== expected) throw new AuthenticationError("identity-changed");
}
#start(challenge: AuthenticationChallenge, identity: string | undefined): Recovery {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new AuthenticationError("timeout")), this.#timeout);
const recovery: Recovery = {
controller, waiters: 0,
promise: Promise.resolve().then(async () => {
controller.signal.throwIfAborted();
await interrupted(Promise.resolve().then(() => this.#options.recover({ ...challenge, signal: controller.signal, identity })), controller.signal);
this.#checkIdentity(identity);
this.#generation++;
}).catch(error => {
throw error instanceof AuthenticationError ? error : new AuthenticationError("failed", { cause: error });
}).finally(() => {
clearTimeout(timer);
if (this.#recovery === recovery) { this.#recovery = undefined; this.#recovering.value = false; }
}),
};
this.#recovery = recovery;
this.#recovering.value = true;
return recovery;
}
async #join(recovery: Recovery, signal: AbortSignal): Promise<void> {
recovery.waiters++;
try { await interrupted(recovery.promise, signal); }
finally {
if (--recovery.waiters === 0 && this.#recovery === recovery) {
this.#recovery = undefined;
recovery.controller.abort(new AuthenticationError("cancelled"));
this.#recovering.value = false;
}
}
}
/** Cancels waiting and in-flight wrapped requests; late recovery cannot resume them. */
dispose(): void {
this.#lifetime.abort(new AuthenticationError("disposed"));
this.#recovery?.controller.abort(new AuthenticationError("disposed"));
}
}
function interrupted<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
return new Promise((resolve, reject) => {
const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); };
promise.then(value => { signal.removeEventListener("abort", abort); resolve(value); }, error => { signal.removeEventListener("abort", abort); reject(error); });
if (signal.aborted) abort();
else signal.addEventListener("abort", abort, { once: true });
});
}
// Shared navigation for the independent examples; no vendor code is loaded here.
import "../lib/aster-ui.js";
for (const [id, label, values] of [["theme", "Theme", ["lamplight", "harbour"]], ["scheme", "Scheme", ["light", "dark"]]]) {
const choice = document.querySelector(`#${id}`);
choice.label = label;
choice.options = values.map(value => ({ value, label: value[0].toUpperCase() + value.slice(1) }));
choice.value = values[0];
}
const params = new URLSearchParams(location.search);
const themes = ["lamplight", "harbour"];
const schemes = ["light", "dark"];
const theme = params.get("theme");
const scheme = params.get("scheme");
if (themes.includes(theme)) {
document.querySelector("#theme").value = theme;
document.querySelector("#identity").href = `../lib/themes/${theme}.css`;
document.documentElement.dataset.identity = theme;
}
if (schemes.includes(scheme)) {
document.querySelector("#scheme").value = scheme;
document.documentElement.dataset.scheme = scheme;
document.documentElement.style.colorScheme = scheme;
}
const nav = document.createElement("nav");
nav.className = "showcase-nav";
nav.setAttribute("aria-label", "Component examples");
const examples = [["foundations.html", "Fields & layouts"], ["mail.html", "Mail workspace"], ["composition.html", "Composition"], ["tabs.html", "Tabs"], ["tiles.html", "Tile grids"], ["dataview.html", "DataView"], ["filtering.html", "Filtering"], ["reporting.html", "Reporting"], ["components.html", "Charts"], ["windows.html", "Windows"], ["dashboard.html", "Dashboard"]];
const back = document.createElement("a");
back.textContent = "← Component showcase";
const defaultApp = new URL(document.querySelector('meta[name="aster-site-layout"]') ? "../showcase/" : "../", document.baseURI);
let requestedApp = defaultApp;
try { requestedApp = new URL(params.get("app") ?? defaultApp.href, location.href); } catch { /* Malformed return URLs use the showcase root. */ }
const app = requestedApp.origin === location.origin ? requestedApp : defaultApp;
app.hash = "showcase";
back.href = app.href;
nav.append(back);
for (const [file, label] of examples) {
const link = document.createElement("a");
link.textContent = label;
link.href = file;
if (location.pathname.endsWith(`/${file}`)) link.setAttribute("aria-current", "page");
nav.append(link);
}
function updateLinks() {
for (const link of nav.querySelectorAll("a")) {
const url = new URL(link.href);
if (link !== back) {
url.searchParams.set("theme", document.querySelector("#theme").value);
url.searchParams.set("scheme", document.querySelector("#scheme").value);
url.searchParams.set("showcase", "1");
url.searchParams.set("app", app.pathname);
} else {
url.searchParams.set("identity", document.querySelector("#theme").value);
url.searchParams.set("scheme", document.querySelector("#scheme").value);
}
link.href = url.href;
}
}
function applyAppearance() {
const theme = document.querySelector("#theme").value;
const scheme = document.querySelector("#scheme").value;
const stylesheet = document.querySelector("#identity");
const href = new URL(`../lib/themes/${theme}.css`, document.baseURI).href;
if (stylesheet.href !== href) stylesheet.href = href;
document.documentElement.dataset.identity = theme;
document.documentElement.dataset.scheme = scheme;
document.documentElement.style.colorScheme = scheme;
updateLinks();
}
for (const id of ["theme", "scheme"]) document.querySelector(`#${id}`).addEventListener("change", applyAppearance);
applyAppearance();
const embedded = params.get("embedded") === "1" && parent !== window;
if (!embedded) {
document.querySelector("main").prepend(nav);
const demo = location.pathname.split("/").pop().replace(".html", "");
for (const link of document.querySelectorAll("main a[href]")) {
if (link.hasAttribute("download")) continue;
const target = new URL(link.href);
if (target.origin !== location.origin) continue;
if (["composition", "mail"].includes(demo) && /\.(?:js|yaml|css)$/.test(target.pathname)) {
const viewer = new URL(`../examples/${demo}.html`, location.href);
viewer.searchParams.set("source", target.pathname.split("/").pop());
viewer.searchParams.set("theme", document.querySelector("#theme").value);
viewer.searchParams.set("scheme", document.querySelector("#scheme").value);
viewer.hash = "application-source";
link.href = viewer.href;
} else if (!/\/(?:guide|reference)\//.test(target.pathname)) continue;
link.target = "_blank";
link.rel = "noopener";
link.setAttribute("aria-label", `${link.textContent.trim()} (opens in a new tab)`);
}
}
else {
const demo = location.pathname.split("/").pop().replace(".html", "");
document.documentElement.dataset.embedded = "";
document.documentElement.dataset.demo = demo;
document.querySelector("main").setAttribute("aria-label", document.title);
const state = () => parent.postMessage({ type: "aster-demo-state", state: { theme: document.querySelector("#theme").value, scheme: document.querySelector("#scheme").value } }, location.origin);
const height = () => parent.postMessage({ type: "aster-demo-height", height: Math.ceil(document.body.getBoundingClientRect().height) }, location.origin);
new ResizeObserver(height).observe(document.body);
for (const id of ["theme", "scheme"]) document.querySelector(`#${id}`).addEventListener("change", state);
addEventListener("message", event => {
if (event.origin !== location.origin || event.source !== parent) return;
if (event.data?.type === "aster-example-appearance" && schemes.includes(event.data.scheme)) {
document.querySelector("#scheme").value = event.data.scheme;
if (themes.includes(event.data.theme)) document.querySelector("#theme").value = event.data.theme;
applyAppearance(); state();
}
if (event.data?.type === "aster-demo-request") { state(); height(); }
});
state(); height();
}
.showcase-nav { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding-bottom: 12px; margin-bottom: 20px; border-bottom: 1px solid var(--aster-rule); }
.showcase-nav a { display: inline-flex; align-items: center; min-height: 44px; color: var(--aster-accent); text-underline-offset: 4px; }
.showcase-nav > a:first-child { margin-right: auto; }
.showcase-nav [aria-current] { font-weight: var(--aster-weight-strong); text-decoration-thickness: 2px; }
.showcase-appearance { display: inline-flex; align-items: center; gap: 8px; }
.showcase-appearance > span { color: var(--aster-text-2); font-weight: var(--aster-weight-label); }
.showcase-appearance aster-choice { width: 9rem; }
.showcase-appearance #scheme { width: 7rem; }
.showcase-grouping aster-choice { width: min(17rem, 65vw); }
@media (pointer: coarse) {
.showcase-appearance .aster-field, .showcase-appearance .aster-option { min-height: 44px; }
}
@media (max-width: 600px) { .showcase-nav > a:first-child { flex-basis: 100%; } }
html[data-embedded] body { height:auto; min-height:0!important; padding:20px; }
html[data-embedded] .showcase-appearance:has(#scheme) { display:none; }
html[data-embedded] main > p:last-child:has(a[href="./"]) { display:none; }
html[data-embedded][data-demo="windows"] body { min-height:820px!important; }
@media(max-width:600px) { html[data-embedded] body { padding:12px; } }
Try the workflow
- Start a message and enter a recipient, subject and body.
- Expire the session, then send: the draft stays open while you sign in.
- Cancel sign-in and retry, or choose a simulated passkey, email or SSO flow.
- Open messages, switch folders and tabs, then try closing an unsaved draft.
What the example owns
Authentication coordinates waiting requests for one identity context. The application supplies the login interaction and an explicit mock-server write replay guarantee. The YAML composition connects Sidebar, Grid, CommandBar and Tabs; its model owns keyed messages and drafts.