diff --git a/.gitignore b/.gitignore index 67b9111..634e8d8 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,8 @@ old-ocaml/ .claude/ probe probe.c + +# raylib built for the browser: a 1.6MB archive and the pinned checkout it +# came from. vendor/raylib/build-web.sh makes both, and the path is named to a +# build through FLAN_RAYLIB_WEB, not committed. +vendor/raylib/web/ diff --git a/BUILT.md b/BUILT.md index 710fe8c..cce067d 100644 --- a/BUILT.md +++ b/BUILT.md @@ -242,7 +242,9 @@ Two claims that got run together in an earlier note, for the record: - *raylib does not work on wasm* — false. It works through emscripten. What is true is that it does not work on the **wasi** path, which is what the headless table targets, and which has no GL and no browser. - *a game loop cannot be expressed on wasm* — false. The browser cannot be blocked, so a web build drives the loop with -`emscripten_set_main_loop` instead of a `while`. That is a different `main`, not a different program. +`emscripten_set_main_loop` instead of a `while`. ~~That is a different `main`, not a different program.~~ **The premise +held and the conclusion did not.** It is the same `main` and the same program: `-sASYNCIFY` answers the same browser +fact without cutting anything in half. See "The browser is the third target" below. **Three edits were made to sand.flan's own text** when it was ported, and they are language decisions rather than fixes: @@ -1272,5 +1274,70 @@ an `ExperimentalWarning` to stderr on every run and the harness compares combine one (same reason), and `flan run --target=` (a `.wasm` is not something this host execs — build it and point a runtime at it). -Still open: raylib on wasm, which plan.org wants through emscripten and its own sysroot. wasi-sdk is right for the -headless table; it is not necessarily right for the eventual game build. +~~Still open: raylib on wasm, which plan.org wants through emscripten and its own sysroot.~~ **Done — and it is a third +target, not a mode of this one.** wasi-sdk is right for the headless table and was never going to be right for the game +build. See the next section. + +## The browser is the third target + +`flan build --target=web` produces a page, its JS and a `.wasm`, and a raylib example opens in a browser from source +that was not touched. The two wasm targets share the word and almost nothing else, so `is_wasi` and `is_web` are +separate predicates and `is_wasm` is their union — the union is exactly the set of facts about the *machine* (32-bit +pointers, no `dlopen`), which is what the refusals are about, and nothing else is shared. + +**The compiler is `emcc`, not `clang`, and that is the whole of the sysroot story.** Everything the wasi target has to +find by hand — a sysroot, a builtins archive, a shadow resource directory, the `__main_argc_argv` shim — is what emcc +*is*. `target_flags` for `web` is the empty list; the only thing checked is that emcc exists, refused by name where the +reason can say so. The one fact that had to be true for any of this: **emcc takes a `.ll` on its command line**, which +it does, so `Emit`'s output needs no change and the IR stays target-independent. The object cache keys on the compiler +binary's path, size and mtime as it always did — now of *whichever* compiler the target uses, so an emcc `flan_rt.o` +and a clang one cannot collide. + +**The main loop: `-sASYNCIFY`, not `emscripten_set_main_loop`.** The older note above had the browser fact right — +it cannot be blocked — and drew the wrong conclusion from it. `emscripten_set_main_loop` wants the loop body as a +callback, so every one of the eleven examples that writes + +```lisp +(until (rl/window-should-close?) ...) +``` + +would have to be split by hand into an init and a tick, and the web program would stop being the native program. +Asyncify rewrites the module so a call can suspend across a return to the event loop, and raylib's web platform is +built for precisely that: `WindowShouldClose()` on `PLATFORM_WEB` is an `emscripten_sleep(16)` that then returns false +(raylib 5.5, `platforms/rcore_web.c`, read rather than assumed). So the loop yields once a frame at a call it already +makes, and **no example changed a character**. The price is real and is paid by every web build: asyncify instruments +the whole module, roughly doubling code size. It is not applied per-program because "does this program block" is not a +question `Build` can answer, and a flag set that varies per program is a cache key that varies per program. + +**`link` lines can be addressed to a target.** `vendor/raylib/link` named `libraylib.so.550`, which exists on the host +and nowhere else. A line may now carry `@native`, `@wasi` or `@web`, an untagged line applies everywhere — which is +what every existing `link` file already is — and `${NAME}` expands from the environment. The selection happens in +`Build` and not in `Load`, which is where the file is read, because **`Load` resolves imports before a target is +chosen**: the same program is built for both, and a package's linker arguments arrive here as a flat list of strings. +`Load`'s part in this is to pass the lines through untouched, which it already did. + +**raylib for the browser is built, not installed.** No emscripten port provides it (`emcc --show-ports`: there is +`contrib.glfw3` and no raylib), so `vendor/raylib/build-web.sh` clones raylib at the **5.5** tag — the one whose +`.so.550` the host links, because `raylib.flan` carries raylib's struct layouts and enum values and two targets built +from different raylibs would disagree about them in silence — and compiles the seven modules with +`-DPLATFORM_WEB -DGRAPHICS_API_OPENGL_ES2` into one archive under `vendor/raylib/web/` (gitignored). `rglfw.c` is not +among them: the web platform uses emscripten's own GLFW port, which is why `link` carries `@web -sUSE_GLFW=3`. No +headers are installed, for the reason the host build needs none — the generated shim declares its own prototypes. + +**The HTML shell is a string in `Build`, not a file in the tree**, for the same reason `Runtime_src` is: it has to be +wherever the compiler is, and a build that cannot find its own shell fails for a reason nobody spelled. It is a canvas +and a `Module.print` that puts stdout on the page; `FLAN_WEB_SHELL` replaces it. `--shell-file` is passed only when the +output is a `.html`, because emcc accepts and ignores it otherwise. + +**Refused by name, inherited whole:** `--dev`, `--debug`, `Build.shared` and `flan run --target=` are refused for +`web` exactly as for `wasm32`, each naming `web` rather than `wasm32` in the message. `--sanitize` is refused too, but +the web half of that refusal is weaker than the wasi half and says so: emscripten *does* ship an ASan, and nothing here +has ever run it. A sanitizer that has never been run is one whose silence means nothing. + +**What the test can honestly check.** `test/test_web.ml` is headless and permanently so. It probes — emscripten may not +be installed, and the raylib archive is not in the tree — and skips with the reason rather than going red. What it +asserts: the three files exist, the module starts with `\0asm`, the page references its own JS and carries the canvas, +and node runs the emitted JS and gets `ok`. For raylib it builds `core-basic-window.flan` unchanged and then reads the +module for the two things that would be false if the mechanism were wrong: an `asyncify_start_unwind` export, and a +`glViewport` import that can only have come from raylib's web platform. Import and export names are plain strings in +the binary, so this needs no wasm reader. diff --git a/NEXT.md b/NEXT.md index f235e75..5678ac5 100644 --- a/NEXT.md +++ b/NEXT.md @@ -295,6 +295,44 @@ crosses as a parameter — a C function that returns one returns something Flan the shape the language already has. Three constructs unexercised anywhere else in the repo worked first try: a fixed array with a struct element, a 2-D struct array, and `[N string]` as both `defconst` and mutable `defvar`. +### The web target: what it does not reach yet + +`flan build --target=web` works, a raylib example builds unchanged and `test/test_web.ml` is green — see BUILT.md, +"The browser is the third target", for the mechanism and why asyncify rather than `emscripten_set_main_loop`. Four +things it does not cover. + +**1. `sand.flan` has no web build, and the cause is one missing `#include`.** `vendor/agent/flan_agent.c` does not +compile under emcc: *variable has incomplete type 'struct timeval'* at line 426, because emscripten's headers do not +pull `` in transitively the way glibc's do. `sand.flan`'s `main` calls `(agent/start ...)` +unconditionally, so `Reach` cannot prune the package, so the flagship program stops at that error — even without +`--dev`. Beneath the include is a structural fact worth deciding rather than patching around: **the agent is a socket +server and the browser has no sockets**, which is the same family as the `--dev` refusal. So the two fixes are not +equivalent — add the include and the agent compiles into a web build that can never accept a connection, or refuse +`vendor:agent` by name on a web target the way `--dev` is refused. The second is the honest one. Neither was taken +here: `vendor/agent/` belonged to another lane this session. + +**2. Assets are two questions and only one of them is about emscripten.** `sand.flan` does +`(rl/load-texture "brush.png")` against a bare relative path. + +- The easy half: a bare relative path has no meaning on a target with no filesystem. emscripten's answer is + `--embed-file` or `--preload-file` into MEMFS, and both are *linker arguments*, so they are already expressible as an + `@web` line in a package's `link` file. No new mechanism is needed for a package. +- The hard half, and the actual design question: **the file that needs the asset is structurally the one file that + cannot declare it.** `Load` hands out `lflags` only for a directory package (`one_file` → `[]`), and `main` is not + exported, so a program can never be a package. The program doing the `load-texture` therefore has no link channel at + all. Answering this means either giving a single-file program a way to carry build arguments, or making assets their + own declaration rather than a linker flag. No flag was invented for it here. + +**3. Nothing has been opened in a browser.** The test is headless and permanently so: it asserts the artifact's shape, +the `asyncify_start_unwind` export and the `glViewport` import, and that node runs the emitted JS. Whether the canvas +actually paints is unverified by anything in CI, and a human should look once. + +**4. Unmeasured and untested.** Asyncify's cost is quoted from emscripten's documentation (roughly a doubling of code +size) and not measured here, and no frame time on web has been taken at all. raylib's audio and any use of threads on +the web target are untried. And a **wasi** build that reaches raylib now fails on undefined symbols rather than on a +missing `-l:libraylib.so.550`, because that line is tagged `@native` — the same error one step later, and a worse +message. + ### `break`, and why it was not built Settled, so the next attempt is cheap rather than a rediscovery: diff --git a/bin/main.ml b/bin/main.ml index 8cd61ec..35d178f 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -81,7 +81,8 @@ let sanitize_flag = "--sanitize" let flags = [ no_checks_flag; dev_flag; debug_flag; sanitize_flag ] -(* [--target=wasm32-wasi], the one cross target. Unlike the flags above it +(* [--target=wasm32-wasi] and [--target=web], the two cross targets. Unlike + the flags above, a target carries a value, so it is matched by prefix and stripped from the residual arguments by the same test — otherwise [-o out --target=X] falls into the usage error. *) @@ -187,13 +188,17 @@ let () = let base = Filename.remove_extension (Filename.basename path) in (* A wasm module is not an executable and must not be named like one: the extension is what tells a runtime, and a reader, what it is. *) + (* A web build is three files — the page, its JS and the module — and + the page is the one named here: emcc derives the other two from it, + and it is the one a browser opens. *) (match target with + | Some t when Flan.Build.is_web t -> base ^ ".html" | Some t when String.starts_with ~prefix:"wasm32" t -> base ^ ".wasm" | _ -> base) | _ -> prerr_endline "usage: flan build [-o out] [--no-bounds-checks] \ - [--dev] [--debug] [--sanitize] [--target=wasm32-wasi]"; + [--dev] [--debug] [--sanitize] [--target=wasm32-wasi|web]"; exit 2 in with_errors path (fun () -> @@ -285,7 +290,7 @@ let () = prerr_endline "usage: flan (read|parse|check|emit|shim) ...\n\ \ flan build [-o out] [--no-bounds-checks] [--dev] \ - [--debug] [--sanitize] [--target=wasm32-wasi]\n\ + [--debug] [--sanitize] [--target=wasm32-wasi|web]\n\ \ flan run [args...]\n\ \ flan reload [-o out.so]\n\ \ flan dev [-s socket]"; diff --git a/lib/build.ml b/lib/build.ml index 4886c31..af575e3 100644 --- a/lib/build.ml +++ b/lib/build.ml @@ -4,12 +4,24 @@ {v flan → typed IR → .ll → clang --target={native,wasm32} v} + Three targets now, and the third takes a different driver: the browser is + built by emcc, which accepts the same .ll. See [is_web]. + Not the dev path — that one never invokes the clang driver, because the driver *is* the cost, and goes llc + ld -shared + dlopen instead. That is [shared], at the bottom of this file, measured at ~19ms. *) let clang = try Sys.getenv "FLAN_CLANG" with Not_found -> "clang" +(* The browser target's compiler. emcc is a clang driver with a sysroot, a + builtins archive, a JS runtime and an HTML shell already attached, so the + whole of [wasm_sysroot], [wasm_builtins] and [wasm_resource_dir] below — + everything the wasi target has to find by hand — is what emcc *is*. It also + accepts a .ll on its command line, which is the one thing that had to be + true for this target to exist at all: [Emit] writes IR text and nothing + else. *) +let emcc = try Sys.getenv "FLAN_EMCC" with Not_found -> "emcc" + let write path contents = let ch = open_out path in output_string ch contents; @@ -50,7 +62,10 @@ let cachedir () = d type opts = { - target : string option; (* None is the host; "wasm32-wasi" is the other *) + (* None is the host. "wasm32-wasi" is the headless one, run by a WASI + runtime; "web" is the browser one, built by emscripten. They share the + word wasm and almost nothing else — see [is_wasi] and [is_web]. *) + target : string option; opt : string; keep : bool; (* leave the .ll behind *) checks : bool; (* bounds-check [at] and [slice] *) @@ -134,11 +149,38 @@ let cflags opts = let getenv name = try Some (Sys.getenv name) with Not_found -> None -let is_wasm t = String.starts_with ~prefix:"wasm32" t +(* ── Two wasm targets, spelled apart ───────────────────────────────── + [web] is emscripten's: a browser, a GL context, a JS runtime, and a clang + whose sysroot and builtins come with it. [wasm32-wasi] is the headless one: + a WASI runtime, no GL, no browser, and a sysroot this file has to find. + "wasm32-unknown-emscripten" is accepted as a synonym for [web] because that + is the triple, and someone will write it. + + What the two share is the machine — 32-bit pointers, no dlopen — which is + exactly the set of things [is_wasm] guards: the three refusals below are + about the machine and so they apply to both. *) +let is_web t = + t = "web" || t = "emscripten" + || String.starts_with ~prefix:"wasm32-unknown-emscripten" t + || String.starts_with ~prefix:"wasm32-emscripten" t + +let is_wasi t = String.starts_with ~prefix:"wasm32" t && not (is_web t) +let is_wasm t = is_web t || is_wasi t let wasm_target opts = match opts.target with Some t when is_wasm t -> true | _ -> false +let web_target opts = + match opts.target with Some t when is_web t -> true | _ -> false + +let wasi_target opts = + match opts.target with Some t when is_wasi t -> true | _ -> false + +(* Which compiler a target is built by. This is not a flag difference: emcc is + a different program with a different driver, and the object cache key below + carries it for the same reason it carries clang's mtime. *) +let compiler opts = if web_target opts then emcc else clang + (* Where on PATH a program is, or None. *) let on_path prog = let dirs = String.split_on_char ':' (try Sys.getenv "PATH" with Not_found -> "") in @@ -256,6 +298,19 @@ let wasm_resource_dir () = let target_flags opts = match opts.target with | None -> [] + (* The browser target adds no flags to a compile at all: emcc already is the + triple, the sysroot and the builtins. What it does need is to exist, and + an emcc that is not there must be refused here — where the reason can name + it — and not at the first "command not found" from a subshell. *) + | Some t when is_web t -> + if on_path emcc = None && not (Sys.file_exists emcc) then + failwith + (Printf.sprintf + "web: no %s on PATH. The browser target is built by emscripten: \ + install an emsdk and source its emsdk_env.sh, or set FLAN_EMCC to \ + the emcc to use." + emcc); + [] | Some t when not (is_wasm t) -> [ "--target=" ^ t ] | Some t -> let sysroot = wasm_sysroot () in @@ -285,23 +340,202 @@ let wasm_main_source = "int flan_entry(int argc, char **argv) __asm__(\"main\");\n\ int __main_argc_argv(int argc, char **argv) { return flan_entry(argc, argv); }\n" +(* ── The browser: the main loop, the shell, and the link ───────────── + The mechanism, and why it is not the one the old note predicted. + + BUILT.md says a web build "drives the loop with [emscripten_set_main_loop] + instead of a [while]. That is a different [main], not a different program." + The first half is right about the browser and wrong about what it costs + here: [emscripten_set_main_loop] wants the loop body as a callback, so every + example that spells + + (until (rl/window-should-close?) ...) + + would have to be cut in half — an init and a tick — by hand, in every file, + and the resulting program would no longer be the one that runs natively. + + [-sASYNCIFY] is the other half of the same browser fact and costs none of + that. It rewrites the module so a call can suspend and resume across a + return to the event loop, and raylib's web platform is built for it: + [WindowShouldClose] on PLATFORM_WEB is an [emscripten_sleep(16)] that then + returns false (raylib 5.5, platforms/rcore_web.c). So the Flan [until] loop + yields to the browser once a frame, at the call it already makes, and not + one example changes. Same program, same [main], same source on both targets. + + What it costs is real and worth writing down: asyncify instruments the whole + module, which is roughly a doubling of code size and a measurable slowdown + on the instrumented paths. It is applied to every web build rather than to + the ones that block, because "does this program block" is not a question + this file can answer and a flag set that varies per program is a cache key + that varies per program. *) + +let web_shell_default = + {html| + + + + + flan + + + + +

+    
+    {{{ SCRIPT }}}
+  
+
+|html}
+
+(* The shell is a string here rather than a file in the tree for the same
+   reason [Runtime_src] is: it has to be present wherever the compiler is, and
+   a build that cannot find its own shell is a build that fails for a reason
+   nobody spelled. FLAN_WEB_SHELL replaces it. *)
+let web_shell_file () =
+  match getenv "FLAN_WEB_SHELL" with
+  | Some p when Sys.file_exists p -> p
+  | Some p ->
+    failwith (Printf.sprintf "web: FLAN_WEB_SHELL is %s, which does not exist" p)
+  | None ->
+    let p = Filename.concat (workdir ()) "flan-shell.html" in
+    write p web_shell_default;
+    p
+
+(* The flags the browser target adds at the link, and only at the link.
+
+   ALLOW_MEMORY_GROWTH because the default heap is 16MB and a texture is not
+   small. EXPORT_ES6=0 and the default MODULARIZE are left alone so that the
+   .js is a plain script both the shell and node can run — which is what makes
+   the test headless.
+
+   --shell-file only when the output is a page. Asking emcc for a .js and
+   handing it an HTML shell is accepted and ignored, which is the kind of
+   silence this file tries not to produce. *)
+let web_link_flags ~out =
+  [ "-sASYNCIFY"; "-sALLOW_MEMORY_GROWTH=1" ]
+  @ (if Filename.extension out = ".html" then
+       [ "--shell-file"; Filename.quote (web_shell_file ()) ]
+     else [])
+
+(* ── A [link] line may be addressed to one target ────────────────────
+   `vendor/raylib/link` names Fedora's libraylib.so.550, which exists on the
+   host and nowhere else; the browser wants a static archive built by
+   emscripten and three -s flags besides. So a line may carry a tag:
+
+       @native -l:libraylib.so.550
+       @web    ${FLAN_RAYLIB_WEB}
+
+   and an untagged line applies to every target, which is what every existing
+   `link` file is.
+
+   The selection happens *here* and not in [Load], which is where the file is
+   read, because [Load] resolves imports before a target is chosen — the same
+   program is built for both — and a package's linker arguments are carried to
+   this function as a flat list of strings. [Load] passing the lines through
+   untouched is the whole of its part in this.
+
+   ${NAME} expands from the environment. An unset one is refused by name: the
+   archive a web build needs is built once by vendor/raylib/build-web.sh and
+   lives at a path only that machine knows, and the alternative to naming it
+   here is a linker error about GLFW symbols. *)
+
+let link_tags = [ "native"; "wasi"; "web" ]
+
+let target_tag opts =
+  if web_target opts then "web"
+  else if wasi_target opts then "wasi"
+  else "native"
+
+(* ${NAME} → the environment's NAME. Nothing else is substituted: this is not a
+   shell, and a linker argument containing a $ that is not a ${ is left alone
+   rather than guessed at. *)
+let expand_vars ~where s =
+  let b = Buffer.create (String.length s) in
+  let n = String.length s in
+  let rec go i =
+    if i >= n then ()
+    else if i + 1 < n && s.[i] = '$' && s.[i + 1] = '{' then
+      match String.index_from_opt s (i + 2) '}' with
+      | None -> Buffer.add_char b s.[i]; go (i + 1)
+      | Some j ->
+        let name = String.sub s (i + 2) (j - i - 2) in
+        (match getenv name with
+         | Some v -> Buffer.add_string b v
+         | None ->
+           failwith
+             (Printf.sprintf
+                "%s: the linker argument %s wants %s, which is not set in the \
+                 environment"
+                where s name));
+        go (j + 1)
+    else (Buffer.add_char b s.[i]; go (i + 1))
+  in
+  go 0;
+  Buffer.contents b
+
+let select_lflags opts flags =
+  let want = target_tag opts in
+  List.filter_map
+    (fun f ->
+       if String.length f > 0 && f.[0] = '@' then begin
+         let tag, rest =
+           match String.index_opt f ' ' with
+           | Some i ->
+             (String.sub f 1 (i - 1),
+              String.trim (String.sub f (i + 1) (String.length f - i - 1)))
+           | None -> (String.sub f 1 (String.length f - 1), "")
+         in
+         if not (List.mem tag link_tags) then
+           failwith
+             (Printf.sprintf
+                "link: @%s is not a target — the tags are %s, and an untagged \
+                 line applies to all of them"
+                tag
+                (String.concat ", " (List.map (fun t -> "@" ^ t) link_tags)));
+         if tag = want && rest <> "" then
+           Some (expand_vars ~where:("link: @" ^ tag) rest)
+         else None
+       end
+       else Some (expand_vars ~where:"link" f))
+    flags
+
 (* What the compiler itself is, cheaply: its path, size and mtime. A clang
    upgrade changes one of those, so the key changes with it — without paying a
    [clang --version] subprocess on every build, which would cost most of what
-   the cache buys. *)
-let clang_stamp =
-  lazy
-    (let path =
-       if Filename.is_relative clang then
-         let dirs = String.split_on_char ':' (try Sys.getenv "PATH" with Not_found -> "") in
-         (try List.find (fun d -> Sys.file_exists (Filename.concat d clang))
-                dirs |> fun d -> Filename.concat d clang
-          with Not_found -> clang)
-       else clang
-     in
-     match Unix.stat path with
-     | st -> Printf.sprintf "%s:%d:%f" path st.Unix.st_size st.Unix.st_mtime
-     | exception Unix.Unix_error _ -> path)
+   the cache buys. Taken of whichever compiler the target uses, so an emcc
+   object and a clang object of the same source cannot collide. *)
+let stamp_of prog =
+  let path =
+    if Filename.is_relative prog then
+      let dirs = String.split_on_char ':' (try Sys.getenv "PATH" with Not_found -> "") in
+      (try List.find (fun d -> Sys.file_exists (Filename.concat d prog))
+             dirs |> fun d -> Filename.concat d prog
+       with Not_found -> prog)
+    else prog
+  in
+  match Unix.stat path with
+  | st -> Printf.sprintf "%s:%d:%f" path st.Unix.st_size st.Unix.st_mtime
+  | exception Unix.Unix_error _ -> path
+
+let clang_stamp = lazy (stamp_of clang)
 
 (* Compile one C translation unit to an object file, reusing a cached one when
    the source text, the compiler and the flags are all unchanged. The key has
@@ -312,11 +546,12 @@ let compile_c ~opts ?tflags ~src ~name () =
      resource directory decide which headers and which builtins an object was
      built against, so repointing either must not serve a stale .o. *)
   let tflags = match tflags with Some f -> f | None -> target_flags opts in
+  let cc = compiler opts in
   let key =
     Digest.to_hex
       (Digest.string
          (String.concat "\000"
-            [ name; src; Lazy.force clang_stamp; opts.opt;
+            [ name; src; stamp_of cc; opts.opt;
               String.concat " " (cflags opts);
               String.concat " " tflags ]))
   in
@@ -330,14 +565,14 @@ let compile_c ~opts ?tflags ~src ~name () =
     let tmp = Printf.sprintf "%s.%d.tmp" obj (Unix.getpid ()) in
     let cmd =
       String.concat " "
-        ([ Filename.quote clang; opts.opt ]
+        ([ Filename.quote cc; opts.opt ]
          @ cflags opts
          @ [ "-c" ] @ tflags
          @ [ Filename.quote c; "-o"; Filename.quote tmp ])
     in
     let code = Sys.command cmd in
     if code <> 0 then
-      failwith (Printf.sprintf "%s failed (exit %d) on %s" clang code name);
+      failwith (Printf.sprintf "%s failed (exit %d) on %s" cc code name);
     (try Unix.rename tmp obj with Unix.Unix_error _ -> ());
     (try Sys.remove c with Sys_error _ -> ())
   end;
@@ -351,10 +586,15 @@ let executable ?(opts = default) ?(csrcs = []) ?(lflags = []) ?(pnames = [])
   (* A dev build is the REPL's, and the REPL reaches a running process through
      [-rdynamic] and [dlopen]. Neither exists on wasm32, so the combination is
      refused rather than quietly producing a module nothing can attach to. *)
+  (* Named for the target that was asked for, since two of them answer to
+     [wasm_target] now and "wasm32:" on a --target=web build reads as a
+     compiler that did not hear the question. *)
+  let tname = if web_target opts then "web" else "wasm32" in
   if wasm_target opts && opts.dev then
     failwith
-      "wasm32: --dev is native only — the reload path is dlopen, which wasm32 \
-       has no equivalent of";
+      (tname
+       ^ ": --dev is native only — the reload path is dlopen, which wasm has \
+          no equivalent of");
   (* Refused rather than emitted-and-hoped-for. The member offsets in the DWARF
      are computed for the host's layout — [ptr] 8 bytes — and wasm32's pointer
      is 4, so a slice's [len] is at byte 8 there and at byte 16 here. Emitting
@@ -363,16 +603,26 @@ let executable ?(opts = default) ?(csrcs = []) ?(lflags = []) ?(pnames = [])
      keeps meeting at the FFI boundary. *)
   if wasm_target opts && opts.debug then
     failwith
-      "wasm32: --debug is native only — the DWARF member offsets are computed \
-       for the host's layout, and wasm32's 32-bit pointer moves every one of \
-       them";
+      (tname
+       ^ ": --debug is native only — the DWARF member offsets are computed \
+          for the host's layout, and wasm32's 32-bit pointer moves every one \
+          of them");
   (* There is no wasm32 sanitizer runtime to link against: clang accepts
      -fsanitize=address for the triple and the link fails on
      __asan_report_load4. Refused by name rather than met at the linker. *)
+  (* emscripten does ship an ASan, so the web half of this refusal is weaker
+     than the wasi half: it is untested here rather than known to be
+     impossible. Refused all the same, because a sanitizer that has never been
+     run is a sanitizer whose silence means nothing, and the sweep this project
+     runs (@sanitize) is native. *)
   if wasm_target opts && opts.sanitize then
     failwith
-      "wasm32: --sanitize is native only — there is no libclang_rt.asan for \
-       wasm32-wasi to link against";
+      (if web_target opts then
+         "web: --sanitize is native only — emscripten has its own ASan, and \
+          nothing here has ever run it; the sanitizer sweep is the native one"
+       else
+         "wasm32: --sanitize is native only — there is no libclang_rt.asan for \
+          wasm32-wasi to link against");
   (* -O0 is not a choice a debug build offers: [llvm.dbg.declare] describes an
      alloca, and at -O2 mem2reg deletes the alloca. [sanitize] deliberately
      does not do this: see [opts]. *)
@@ -399,8 +649,11 @@ let executable ?(opts = default) ?(csrcs = []) ?(lflags = []) ?(pnames = [])
   let objs =
     cc Runtime_src.source "flan_rt.c"
     :: [ cc Runtime_src.dev_source "flan_dev.c" ]
-    (* wasi-libc's entry point, which is not [main]. See [wasm_main_source]. *)
-    @ (if wasm_target opts then [ cc wasm_main_source "flan_wasm_main.c" ] else [])
+    (* wasi-libc's entry point, which is not [main]. See [wasm_main_source].
+       Not the browser's: emscripten's start code calls [main] under that name,
+       so the .ll's @main is already the entry point and the shim would be a
+       second definition of it. *)
+    @ (if wasi_target opts then [ cc wasm_main_source "flan_wasm_main.c" ] else [])
     (* The generated half of the FFI: one translation unit holding a typedef
        per struct that crosses and a wrapper per (declare-c ...), compiled
        exactly like a package's hand-written .c. It rides on the program
@@ -414,7 +667,7 @@ let executable ?(opts = default) ?(csrcs = []) ?(lflags = []) ?(pnames = [])
   in
   let cmd =
     String.concat " "
-      ([ Filename.quote clang; opts.opt; "-Wno-override-module" ]
+      ([ Filename.quote (compiler opts); opts.opt; "-Wno-override-module" ]
        (* -g at the link so clang does not strip, and keeps the object files'
           debug sections; the .ll carries its own. The sanitizer flags have to
           be here too — they are what pulls in libclang_rt.asan and the UBSan
@@ -424,9 +677,12 @@ let executable ?(opts = default) ?(csrcs = []) ?(lflags = []) ?(pnames = [])
        @ cflags opts
        @ (if opts.dev then [ "-rdynamic" ] else [])
        @ tflags
+       @ (if web_target opts then web_link_flags ~out else [])
        @ [ Filename.quote ll ]
        @ List.map Filename.quote objs
-       @ lflags
+       (* A package's linker arguments, with the lines addressed to another
+          target dropped and ${VAR} expanded. See [select_lflags]. *)
+       @ select_lflags opts lflags
        (* The prelude declares sqrtf, so every link needs libm. It goes here
           and not in the leading flags: the default --as-needed drops a
           library named before the object that wants it, so at -O2 this would
@@ -480,8 +736,9 @@ let shared ?(opts = default) ~ir ~out () : timing =
   let opts = if opts.debug then { opts with opt = "-O0" } else opts in
   if wasm_target opts then
     failwith
-      "wasm32: the reload path is native only — it is llc + ld -shared + \
-       dlopen, and wasm32 has no dlopen";
+      ((if web_target opts then "web" else "wasm32")
+       ^ ": the reload path is native only — it is llc + ld -shared + dlopen, \
+          and wasm has no dlopen");
   let dir = workdir () in
   let base = Filename.remove_extension (Filename.basename out) in
   let ll = Filename.concat dir (base ^ ".ll") in
diff --git a/test/dune b/test/dune
index 4efb846..8fa2e3b 100644
--- a/test/dune
+++ b/test/dune
@@ -33,6 +33,23 @@
   ; wasmer is installed.
   (file wasm-run.mjs)))
 
+; The web target, in its own stanza rather than in the table above because it
+; is the one case whose toolchain is a separate install: emscripten, and a
+; raylib archive built by vendor/raylib/build-web.sh. It probes for both and
+; skips with the reason, so it is green on a machine that has neither.
+(test
+ (name test_web)
+ (modules test_web)
+ (libraries flan unix)
+ (deps
+  (glob_files programs/*.flan)
+  ; The raylib bindings and the ported example the raylib case builds. The
+  ; example imports examples/digits.flan, so the directory comes whole.
+  (glob_files %{workspace_root}/vendor/raylib/*)
+  (glob_files %{workspace_root}/examples/*)
+  ; flan run --target=web is refused by the CLI, so the CLI has to be here.
+  (file %{workspace_root}/bin/main.exe)))
+
 ; The corpus a second time under ASan and UBSan. Its own alias and not part of
 ; `dune test`: a sanitized build is a statically linked 1.8MB binary that takes
 ; tens of seconds to produce, so the sweep is minutes against the existing
diff --git a/test/test_web.ml b/test/test_web.ml
new file mode 100644
index 0000000..c1dea12
--- /dev/null
+++ b/test/test_web.ml
@@ -0,0 +1,229 @@
+(* The web target: a Flan program built for the browser by emscripten.
+
+   Headless, on purpose and permanently. Nothing here drives a browser; what a
+   test can honestly say about a page it never opens is that the artifact is
+   the shape a browser needs — three files, a wasm that is a wasm, a page that
+   loads the JS beside it — and that the module has the two things the target
+   is *for*: asyncify, so a Flan `until` loop can yield to the event loop, and
+   raylib's GL imports, so the loop has something to draw with.
+
+   Everything is probed rather than assumed, the same way the wasm32 case in
+   test_acceptance is: emscripten may not be installed, and the raylib archive
+   is built once by vendor/raylib/build-web.sh and is not in the tree. A missing
+   piece is a skip with the reason, never a red test. *)
+
+open Flan
+
+let failures = ref 0
+
+let scratch = Filename.get_temp_dir_name ()
+
+let fail fmt = Printf.ksprintf (fun s -> incr failures; print_endline ("FAIL  " ^ s)) fmt
+
+let read path =
+  let ch = open_in_bin path in
+  let n = in_channel_length ch in
+  let s = really_input_string ch n in
+  close_in ch; s
+
+let contains hay needle =
+  let n = String.length needle and h = String.length hay in
+  let rec go i = i + n <= h && (String.sub hay i n = needle || go (i + 1)) in
+  go 0
+
+let have prog = Sys.command (Printf.sprintf "command -v %s > /dev/null 2>&1" prog) = 0
+
+(* One web build, through [Load] and [Reach] exactly as [flan build] does it,
+   so a package's per-target link lines are selected here too. *)
+let web_build ?(opt = "-O2") path out =
+  let l = Load.program ~file:path (Parse.program (Reader.read_file path)) in
+  let p = Check.program l.Load.decls in
+  let p, csrcs, lflags = Reach.link l p in
+  ignore
+    (Build.executable ~opts:{ Build.default with opt; target = Some "web" }
+       ~csrcs ~lflags p ~out)
+
+(* emcc derives the JS and the module from the page's name. *)
+let parts html =
+  let base = Filename.remove_extension html in
+  (html, base ^ ".js", base ^ ".wasm")
+
+let cleanup html =
+  let a, b, c = parts html in
+  List.iter (fun f -> try Sys.remove f with Sys_error _ -> ()) [ a; b; c ]
+
+(* The archive vendor/raylib/build-web.sh makes. FLAN_RAYLIB_WEB is how a
+   `link` line names it; the two relative paths are where the script puts it by
+   default, seen from a test run out of _build. Found here and put back into
+   the environment, so that a developer who has built it once does not also
+   have to remember to export it before running the suite. That putenv outlives
+   this call, so the raylib case being the last web build in the process is
+   load-bearing: anything built after it would pick the archive up silently. *)
+let raylib_web () =
+  let cands =
+    (match Sys.getenv_opt "FLAN_RAYLIB_WEB" with Some s -> [ s ] | None -> [])
+    @ [ "../../../vendor/raylib/web/libraylib-5.5.a";
+        "../vendor/raylib/web/libraylib-5.5.a" ]
+  in
+  match List.find_opt Sys.file_exists cands with
+  | Some p ->
+    let p = try Unix.realpath p with Unix.Unix_error _ -> p in
+    Unix.putenv "FLAN_RAYLIB_WEB" p;
+    Some p
+  | None -> None
+
+let () =
+  (* ── The probe ─────────────────────────────────────────────────────
+     The smallest program there is, built for the browser. If emscripten is
+     absent this is where it says so, and nothing below runs. *)
+  let probe = Filename.concat scratch "flan-web-probe.html" in
+  let outcome =
+    match web_build "programs/unit-main.flan" probe with
+    | () -> Ok ()
+    | exception Failure m -> Error m
+  in
+  (match outcome with
+   | Error why -> Printf.printf "web: skipping the web target (%s)\n" why
+   | Ok () ->
+     let html, js, wasm = parts probe in
+
+     (* Three files, because that is what "openable" means: a page, the JS that
+        instantiates the module, and the module. *)
+     List.iter
+       (fun (what, f) ->
+          if not (Sys.file_exists f) then fail "web build made no %s (%s)" what f)
+       [ ("page", html); ("JS", js); ("module", wasm) ];
+
+     (* A wasm is a wasm. The four bytes are the whole format's claim about
+        itself, and an emcc that produced JS and a stub would pass every other
+        check here. *)
+     let bytes = read wasm in
+     if String.length bytes < 8 || String.sub bytes 0 4 <> "\000asm" then
+       fail "the module does not start with the wasm magic";
+
+     (* The page has to load the module's JS, or it is a page about nothing.
+        emcc minifies the shell, so this asks for the reference and not for any
+        particular spelling of the tag. *)
+     let page = read html in
+     let base = Filename.basename (Filename.remove_extension probe) in
+     if not (contains page (base ^ ".js")) then
+       fail "the page does not reference %s.js" base;
+     (* And it has to be *our* shell: the canvas raylib draws into, and the
+        Module.print that puts stdout on the page. *)
+     if not (contains page "canvas") then fail "the page has no canvas";
+
+     (* The one execution this test does. node runs the emitted JS without a
+        DOM, which is enough for a program that only prints — and it is the
+        same "does it actually run" the wasm32 case insists on. *)
+     if not (have "node") then
+       print_endline "web: skipping the run (no node)"
+     else begin
+       let out = Filename.concat scratch "flan-web-probe.out" in
+       let code =
+         Sys.command
+           (Printf.sprintf "node %s > %s 2>&1" (Filename.quote js)
+              (Filename.quote out))
+       in
+       let text = In_channel.with_open_bin out In_channel.input_all in
+       (try Sys.remove out with Sys_error _ -> ());
+       if code <> 0 || text <> "ok\n" then
+         fail "node could not run the web build: %S (exit %d)" text code
+     end;
+     cleanup probe;
+
+     (* ── raylib in the browser ────────────────────────────────────────
+        The claim BUILT.md left open. core-basic-window.flan is built for the
+        web unchanged — no edit to its `until` loop, which is the whole point
+        of choosing asyncify over emscripten_set_main_loop — and the module is
+        then read for the two things that prove the claim rather than assert
+        it: the asyncify export, and a GL import that can only have come from
+        raylib's web platform.
+
+        Import and export names are plain strings in a wasm's name sections,
+        which is why this needs no wasm reader. *)
+     (match raylib_web () with
+      | None ->
+        print_endline
+          "web: skipping the raylib case (no libraylib-5.5.a for the browser; \
+           run sh vendor/raylib/build-web.sh)"
+      | Some _ ->
+        let out = Filename.concat scratch "flan-web-window.html" in
+        (match web_build "../examples/core-basic-window.flan" out with
+         | exception Failure m -> fail "raylib for the browser: %s" m
+         | () ->
+           let _, _, wasm = parts out in
+           let bytes = read wasm in
+           if not (contains bytes "asyncify_start_unwind") then
+             fail
+               "no asyncify in the module — the `until` loop would block the \
+                browser";
+           if not (contains bytes "glViewport") then
+             fail "no GL imports in the module — raylib did not link";
+           cleanup out)));
+
+  (* ── Refused by name ───────────────────────────────────────────────
+     The web target inherits every refusal wasm32 has, and for the same
+     reasons: the reload path is dlopen, the DWARF offsets are the host's, and
+     the sanitizer sweep is native. These are asserted because "it falls out of
+     the existing predicate" is exactly the kind of thing that stops being true
+     silently. No emscripten is needed — each is refused before any compiler
+     runs.
+
+     Each case names the phrase it expects and not merely the word "web". On a
+     machine with no emscripten the *first* thing every one of these paths
+     meets is "web: no emcc on PATH", which contains the word and is a
+     different failure entirely — so a looser check would report all four as
+     refused on exactly the machine where none of them ran. *)
+  let refused what why f =
+    match f () with
+    | () -> fail "%s was accepted" what
+    | exception Failure m ->
+      if not (contains m "web") then
+        fail "%s was refused, but the reason does not name the target: %S" what m
+      else if not (contains m why) then
+        fail "%s was refused for the wrong reason: wanted %S, said %S" what why m
+  in
+  let unit_main () =
+    let path = "programs/unit-main.flan" in
+    let l = Load.program ~file:path (Parse.program (Reader.read_file path)) in
+    Check.program l.Load.decls
+  in
+  refused "--dev --target=web" "--dev is native only" (fun () ->
+    ignore
+      (Build.executable ~opts:{ Build.default with target = Some "web"; dev = true }
+         (unit_main ()) ~out:(Filename.concat scratch "flan-web-dev.html")));
+  refused "--debug --target=web" "--debug is native only" (fun () ->
+    ignore
+      (Build.executable
+         ~opts:{ Build.default with target = Some "web"; debug = true }
+         (unit_main ()) ~out:(Filename.concat scratch "flan-web-dbg.html")));
+  refused "--sanitize --target=web" "--sanitize is native only" (fun () ->
+    ignore
+      (Build.executable
+         ~opts:{ Build.default with target = Some "web"; sanitize = true }
+         (unit_main ()) ~out:(Filename.concat scratch "flan-web-san.html")));
+  refused "Build.shared --target=web" "reload path is native only" (fun () ->
+    ignore
+      (Build.shared ~opts:{ Build.default with target = Some "web" } ~ir:""
+         ~out:(Filename.concat scratch "flan-web.so") ()));
+
+  (* [flan run --target=web] is refused by the CLI rather than by [Build]: a
+     page is not something this host execs, and picking a browser for it is not
+     a decision that command has any business making. *)
+  let out = Filename.concat scratch "flan-web-run.out" in
+  let code =
+    Sys.command
+      (Printf.sprintf
+         "../bin/main.exe run programs/unit-main.flan --target=web > %s 2>&1"
+         (Filename.quote out))
+  in
+  let text = In_channel.with_open_bin out In_channel.input_all in
+  (try Sys.remove out with Sys_error _ -> ());
+  if code = 0 || not (contains text "--target is refused") then
+    fail "flan run --target=web was not refused: %S (exit %d)" text code;
+
+  if !failures = 0 then print_endline "web: ok"
+  else begin
+    Printf.printf "web: %d failure(s)\n" !failures;
+    exit 1
+  end
diff --git a/vendor/raylib/build-web.sh b/vendor/raylib/build-web.sh
new file mode 100644
index 0000000..1cc7388
--- /dev/null
+++ b/vendor/raylib/build-web.sh
@@ -0,0 +1,76 @@
+#!/bin/sh
+# raylib, built for the browser.
+#
+# The host half of this package needs no build: Fedora ships libraylib.so.550
+# and `link` names it. The browser has no such thing, so the archive has to be
+# made here, once, out of raylib's own sources with emscripten's clang.
+#
+# Pinned to the tag whose shared library the host links — 5.5 against
+# libraylib.so.550 — because `raylib.flan` carries struct layouts and enum
+# values that are raylib's, not ours, and a build where the two targets are
+# different raylibs would disagree about them silently.
+#
+# The output is a plain static archive plus nothing else: no headers are
+# installed, because the generated FFI shim declares the prototypes it uses
+# (see BUILT.md, "No raylib headers are needed").
+#
+#   sh vendor/raylib/build-web.sh
+#
+# It prints the line to export. `vendor/raylib/link` points at the archive
+# through ${FLAN_RAYLIB_WEB}, and a web build that names raylib with the
+# variable unset is refused by name rather than met at the linker.
+#
+# Environment:
+#   FLAN_RAYLIB_TAG      the raylib tag to build (default 5.5)
+#   FLAN_RAYLIB_WEB_DIR  where to put it (default vendor/raylib/web)
+#   FLAN_RAYLIB_SRC      an existing raylib checkout to build instead of cloning
+set -eu
+
+tag=${FLAN_RAYLIB_TAG:-5.5}
+here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+out=${FLAN_RAYLIB_WEB_DIR:-$here/web}
+archive=$out/libraylib-$tag.a
+
+command -v emcc > /dev/null 2>&1 || {
+  echo "build-web.sh: no emcc on PATH. Source an emsdk's emsdk_env.sh." >&2
+  exit 1
+}
+
+if [ -f "$archive" ]; then
+  echo "already built: $archive" >&2
+else
+  mkdir -p "$out"
+  if [ -n "${FLAN_RAYLIB_SRC:-}" ]; then
+    src=$FLAN_RAYLIB_SRC
+  else
+    src=$out/raylib-$tag
+    [ -d "$src" ] || git clone --depth 1 --branch "$tag" \
+      https://github.com/raysan5/raylib "$src"
+  fi
+
+  objs=$out/obj-$tag
+  rm -rf "$objs"
+  mkdir -p "$objs"
+
+  # GRAPHICS_API_OPENGL_ES2 is what WebGL is. PLATFORM_WEB makes rcore.c
+  # include platforms/rcore_web.c, whose WindowShouldClose() is an
+  # emscripten_sleep that returns false — see BUILT.md on why that is the whole
+  # reason a Flan `until` loop needs no rewriting for the browser.
+  #
+  # rglfw.c is not in the list: the web platform uses emscripten's own GLFW
+  # (-sUSE_GLFW=3, in `link`), not a compiled-in one.
+  for m in rcore rshapes rtextures rtext rmodels raudio utils; do
+    [ -f "$src/src/$m.c" ] || continue
+    echo "  emcc $m.c" >&2
+    (cd "$src/src" && emcc -c -O2 -std=gnu99 \
+       -DPLATFORM_WEB -DGRAPHICS_API_OPENGL_ES2 \
+       -I. -Iexternal/glfw/include \
+       "$m.c" -o "$objs/$m.o")
+  done
+
+  emar rcs "$archive" "$objs"/*.o
+  rm -rf "$objs"
+fi
+
+echo
+echo "export FLAN_RAYLIB_WEB=$archive"
diff --git a/vendor/raylib/link b/vendor/raylib/link
index 21fde4d..b8317b5 100644
--- a/vendor/raylib/link
+++ b/vendor/raylib/link
@@ -1,2 +1,31 @@
--l:libraylib.so.550
--lm
+# Extra linker arguments for this package, one per line. A line may be
+# addressed to one target — @native, @wasi, @web — and an untagged line
+# applies to all of them. ${NAME} expands from the environment. The selection
+# and the expansion happen in Build, which is the only place that knows which
+# target is being built; Load reads these lines and passes them through.
+
+# The host. Fedora's package installs the versioned soname and no unversioned
+# symlink, so -lraylib finds nothing and the file has to be named.
+@native -l:libraylib.so.550
+@native -lm
+
+# The browser. No shared library exists for wasm, so this is a static archive
+# built out of raylib's own sources by vendor/raylib/build-web.sh, pinned to
+# the 5.5 tag that matches the host's .so.550 — raylib.flan carries raylib's
+# struct layouts and enum values, and two targets built from different raylibs
+# would disagree about them without saying so.
+#
+# FLAN_RAYLIB_WEB is where that archive is. Unset, a web build that reaches
+# raylib is refused by name here rather than met as a page of undefined GLFW
+# symbols; build-web.sh prints the line to export.
+@web ${FLAN_RAYLIB_WEB}
+
+# raylib's web platform is GLFW on emscripten's own port, not the rglfw.c it
+# compiles in natively, and WebGL is GLES2. GL_ENABLE_GET_PROC_ADDRESS because
+# rlgl asks for extension pointers by name.
+@web -sUSE_GLFW=3
+@web -sGL_ENABLE_GET_PROC_ADDRESS
+
+# -sASYNCIFY is not here: Build adds it to every web link, because the reason
+# for it is the browser's event loop and not raylib. See Build's comment on the
+# main loop.