The Loup Web Book — contents
  1. Chapter 1: Getting Started
  2. Chapter 2: The Web Modes
  3. Chapter 3: Variables and Data Types
  4. Chapter 4: Functions
  5. Chapter 5: Control Flow
  6. Chapter 6: Collections
  7. Chapter 7: Structs
  8. Chapter 8: Enums and Pattern Matching
  9. Chapter 9: Classes and Objects
  10. Chapter 10: Interfaces
  11. Chapter 11: Generics
  12. Chapter 12: Error Handling
  13. Chapter 13: Modules and Packages
  14. Chapter 14: Template Fences — HTML and CSS
  15. Chapter 15: Async and Concurrency
  16. Chapter 16: Reactive UI
  17. Chapter 17: Web Components
  18. Chapter 18: DOM, Storage, Workers, and JavaScript Interop
  19. Chapter 19: SSR, Hydration, and Deployment
  20. Chapter 20: Memory Safety in Web Apps

Chapter 18: DOM, Storage, Workers, and JavaScript Interop

⚠ Planned — not in Loup 1.25. This chapter describes the design in the Language Reference (§2-3.1–§2-3.3 for JavaScript interop, §2-1.10 for workers, §1-16.6.5 for passing data to scripts). The toolchain does not implement it yet, so its listings are design sketches and are not verified.

Sometimes a browser app has to reach past the reactive UI of Chapter 16: call JavaScript, touch the DOM, store data, or move work off the main thread. This chapter covers what the Reference specifies for each and says where it specifies nothing. None of it runs in a browser today; the two checks the compiler already performs are shown with real output, and those listings are checked.

Calling JavaScript: extern js

An extern js declaration gives a JavaScript function or value a Loup type (§2-3.1):

Fragment — not a complete program
@mode(web.compiled)

extern js func parseInt(s: string, radix: int): int;
extern js func fetchJson(url: string): Task[string];
extern js val document: Any;

Listing 18-1: JavaScript declarations (design sketch)

The declared types are trusted, not checked, so a mismatch is a runtime FfiTypeError. A function returning a Promise is declared with a Task[T] result. A typed array backed by a SharedArrayBuffer needs @unsafe("sharedMemory: …") on the declaration. extern js needs a JavaScript host (browser, node, bun, deno), and the toolchain already rejects it in a native program:

This code does not compile
@mode(host.native)

extern js func parseInt(s: string, radix: int): int;

func main(): void {
    println("never runs");
}
main.lphc:3:1: error[E0001]: E_EXTERN_JS_NOT_NATIVE: 'extern js' is not available in 'host.native' or 'freestanding' modes; use 'web.scripted', 'web.compiled', 'host.scripted', or 'host.dual'

A native binary has no JavaScript engine, so keep bindings in a web file. There the declaration is accepted today, but calling it fails to type-check (type of 'parseInt' is not yet known), because nothing binds it.

Being called from JavaScript: @jsexport

@jsexport makes a module-level func or async func a named export of the compiled module (§2-3.2). Arguments are converted to the declared Loup types (FfiTypeError on failure), an async export returns a Promise, and the annotation is valid only for the browser and service targets. A synchronous export must not await (E_JSEXPORT_AWAIT), and the toolchain checks that already:

This code does not compile
@mode(web.compiled)

async func fetchSummary(userId: string): Task[string] {
    return "summary for " + userId;
}

@jsexport
func onMessage(userId: string): void {
    let summary = await fetchSummary(userId);
    println(summary);
}

func main(): void {
    onMessage("ann");
}
main.lpweb:7:1: error[E0001]: Synchronous @jsexport function must not contain 'await'

JavaScript calls a synchronous export synchronously, so it cannot wait. Make the export async so JavaScript receives a Promise; main becomes async too, which ruling MAIN-1 allows:

@mode(web.compiled)

async func fetchSummary(userId: string): Task[string] {
    return "summary for " + userId;
}

@jsexport
async func onMessage(userId: string): Task[void] {
    let summary = await fetchSummary(userId);
    println(summary);
}

async func main(): Task[void] {
    await onMessage("ann");
}

Listing 18-2: An asynchronous @jsexport function

summary for ann

On the interpreter onMessage is an ordinary function; nothing is exported yet.

Typed facades: lang.interop.js

For common browser APIs the Reference specifies typed wrappers in lang.interop.js and recommends them over raw extern js (§2-3.3): fetch returning Task[Response]; setTimeout, setInterval and their clear functions; and, on the browser target, querySelector(selector): Option[Element], querySelectorAll, and Element and Event classes.

Fragment — not a complete program
@mode(web.compiled)

from lang.interop.js import (querySelector, Event);

func wireDismissButton(): void {
    match querySelector("#dismiss") {
        Some(button) => {
            button.addEventListener("click", func(e: Event): void {
                e.preventDefault();
                button.remove();
            });
        },
        None => printErr("no #dismiss button on this page"),
    }
}

Listing 18-3: DOM access through the typed facade (design sketch)

A missing element is a None you must handle, not a null that fails later.

web.dom, web.storage and workers

web.dom and web.storage are reserved names that the Reference mentions (ShadowRoot lives in web.dom) but does not specify. Only the stdlib requirement documents sketch them, as method lists without signatures (Element, getElementById, pushState; a Storage class with get, set, remove, clear). No cookie API is specified. This book shows no code for them.

For parallel work the Reference specifies ScriptWorker[M] in concurrent: an isolated Wasm instance with its own heap that exchanges Sendable messages with the page (§2-1.10.2). The worker module exports a workerMain function:

Fragment — not a complete program
@mode(web.compiled)

from concurrent import (WorkerChannel);

pub async func workerMain(channel: WorkerChannel[bytes]): Task[void] {
    while (true) {
        let msg = await channel.receive();
        if (msg.is_none()) {
            break;
        }
        channel.reply(checksum(msg.unwrap()));
    }
}

Listing 18-4: A worker module, src/hasher.lpweb (design sketch; checksum is assumed)

The page starts it with await ScriptWorker[bytes].spawn("./hasher.lpweb"), then uses send, await receive() and terminate(). Messages are copied, never shared, and a worker has no ui, web.dom, web.storage or extern js (§2-1.10.5). The web.workers API of earlier drafts (postMessage, onMessage) is not in the Reference. Today there is no concurrent module; the prelude's spawnWorker(path) runs workerMain to completion at the first receive() or join() and starts no browser worker (Chapter 15).

Passing data to JavaScript

Never interpolate into an inline <script> or <style> block: the Reference makes any {$ … $} there E_SCRIPT_STYLE_INTERPOLATION (§1-16.6.5), because JavaScript and CSS cannot be escaped with a substitution table. Use a data- attribute, or the SSR state block (Chapter 19):

@mode(web.compiled)

func appRoot(userName: string): string {
    let root = ```(html):(none)
    <div id="app" data-user="{$ userName | escape $}"></div>
    ```;
    return root.render({});
}

func main(): void {
    println(appRoot("ann"));
    println(appRoot("\"><img src=x onerror=alert(1)>"));
}

Listing 18-5: Handing a value to the page through a data- attribute

<div id="app" data-user="ann"></div>
<div id="app" data-user="&quot;&gt;&lt;img src=x onerror=alert(1)&gt;"></div>

This listing runs today because it only renders a fence to a string. The <script> check is not implemented yet, and html fences do not escape attribute values yet (Chapter 14), so the listing escapes the value itself with the escape filter; the second call shows a hostile value staying inside the attribute.

Summary

  • JavaScript interop, DOM facades, storage and workers are planned (Language Reference §2-3, §2-1.10); Loup 1.25 has no browser runtime.
  • extern js declares typed JavaScript bindings; the toolchain already reports E_EXTERN_JS_NOT_NATIVE in native code.
  • @jsexport exports Loup functions; the toolchain already rejects await in a synchronous export.
  • lang.interop.js is the specified facade for fetch, timers and the DOM; web.dom and web.storage are reserved but unspecified.
  • Workers are ScriptWorker[M] with a workerMain(channel) entry; today only a batch-mode spawnWorker exists.
  • Pass data to scripts through data- attributes or the SSR state block, never by interpolating into <script>.