Skip to content

Demo web without PyScript and JS + improve and fix jsffi#484

Open
paugier wants to merge 47 commits into
spylang:mainfrom
paugier:web-without-pyscript-js
Open

Demo web without PyScript and JS + improve and fix jsffi#484
paugier wants to merge 47 commits into
spylang:mainfrom
paugier:web-without-pyscript-js

Conversation

@paugier
Copy link
Copy Markdown
Contributor

@paugier paugier commented Apr 24, 2026

Fixes #483 and co-authored with Claude.

I didn't clean the history (commits). Please tell me @antocuni if you think it is useful.

Description produced with Claude:

This PR (fixes #483) adds the primitives needed to drive 60fps canvas animations from SPy/WebAssembly without relying on PyScript, Pyodide, or any hand-written JavaScript. The changes span the C/WASM runtime (libspy), the SPy Python-level module (jsffi.py), new tests, and two canvas demo applications.

1. New JsVal tagged-union type (C side)

The old design passed JavaScript values around purely as JsRef (an opaque integer handle into a JS object table). The new design introduces a JsVal struct — a tagged union encoding a value along with its type tag (JSREF, F64, I32, STR, BOOL, FUNCPTR). This avoids unnecessary JS object allocations for primitive arguments. Each JsVal is split into (int32_t tag, double payload) when crossing the WASM→JS boundary via EM_JS.

2. jsffi_call_method.h/.c — variadic method calls (0–6 args)

Previously only call_method_1 existed. A new header/source pair generates jsffi_call_method_0 through jsffi_call_method_6, all taking JsVal-based arguments. The C file is #include-d directly into jsffi.c so all EM_JS functions share the same translation unit and can access the jsffi global object set up by jsffi_init().

3. jsffi.c — extended JS runtime object table and new primitives

  • jsffi_init() now pre-populates well-known singleton values: document (id 2), undefined (3), null (4), true (5), false (6).
  • jsffi.from_jsval(tag, val) is added to decode JsVal pairs on the JS side.
  • New WASM exports: jsffi_f64, jsffi_drop_ref, jsffi_setattr (now takes a JsVal pair instead of a JsRef), jsffi_u8array_from_ptr, jsffi_new_ImageData, jsffi_to_i32, jsffi_to_f64, jsffi_request_animation_frame, jsffi_debug_n_jsrefs.
  • jsffi_wrap_func is removed and replaced by JsVal-based function pointer passing.
  • $wasmMemory is added to EM_JS_DEPS (needed for Uint8ClampedArray from a raw WASM pointer).

Note that jsffi_u8array_from_ptr and jsffi_new_ImageData are needed because there is no equivalent for the JS new keyword. jsffi_request_animation_frame avoids the creation of few JsRefs.

4. spy/vm/modules/jsffi.py — SPy-side module

  • New W_JsVal type with __convert_from__ that auto-converts f64, i32, str, JsRef, or a function pointer into a JsVal.
  • W_JsRef.__call_method__ now supports 0–6 arguments via a generated dispatch (w_js_call_method_0w_js_call_method_6), created dynamically using inspect.Signature.
  • W_JsRef.__convert_from__ now also handles f64 → JsRef.
  • W_JsRef.__convert_to__ added for converting a JsRef to i32, f64, or JsVal.
  • New module-level functions: w_get_Document, w_js_f64, w_js_to_i32, w_js_to_f64, w_drop_ref, w_js_u8array_from_ptr, w_js_new_ImageData, w_request_animation_frame, w__debug_n_jsrefs.

5. unsafe/misc.py

A small fix: sizeof() now imports W_JsRef so it can compute its size correctly (needed for global pointer variables of JsRef type).

6. Tests (tests/compiler/test_jsffi.py)

New tests for all the above: call_method_{0..6}, to_i32, to_f64, u8array_from_ptr, get_Document, and request_animation_frame.

7. New examples (examples/jsffi/canvas/)

Two complete browser demos, both written entirely in SPy:

  • demo_image_data.spy — a colour animation driven by RGB sliders, rendered via putImageData into a canvas pixel buffer.
  • demo_particles.spy — a 60fps bouncing-particle simulation using the Canvas 2D API (arc, fillRect, createRadialGradient), with controls for particle count, speed, and radius.

A create_html.py script generates HTML pages using FastHTML + Tailwind/DaisyUI. In release mode, the total payload (.wasm + Emscripten glue .mjs) is ~91 KB — vs. ~10 MB for a PyScript/Pyodide deployment.

The existing examples/jsffi/ files were reorganised into a minimal/ subdirectory to keep things clean.

@paugier paugier marked this pull request as draft April 24, 2026 15:38
@paugier
Copy link
Copy Markdown
Contributor Author

paugier commented Apr 24, 2026

from jsffi import init as js_init, get_GlobalThis, js_i32, get_Console
from jsffi import js_u8array_from_ptr, js_new_ImageData, js_request_animation_frame, js_to_f64

from unsafe import gc_alloc, gc_ptr

WIDTH: i32 = 800
HEIGHT: i32 = 600
SIZE: i32 = WIDTH * HEIGHT * 4


def simulate_step(buf: gc_ptr[u8], speed: f64) -> None:
    for idx in range(SIZE):
        buf[idx] = 100


def frame(timestamp: f64, buf: gc_ptr[u8]) -> None:

    console = get_Console()
    console.log("hello")

    window = get_GlobalThis()
    speed = js_to_f64(window.document.getElementById("speed").value)
    simulate_step(buf, speed)
    u8arr = js_u8array_from_ptr(buf, SIZE)
    image_data = js_new_ImageData(u8arr, WIDTH, HEIGHT)

    canvas = window.document.getElementById("canvas")
    ctx = canvas.getContext("2d")
    ctx.putImageData(image_data, js_i32(0), js_i32(0))

    def _frame(_timestamp: f64) -> None:
        frame(_timestamp, buf)

    js_request_animation_frame(_frame)

def main() -> None:
    js_init()
    buf = gc_alloc[u8](SIZE)

    frame(1.25, buf)

    def _frame(timestamp: f64) -> None:
        frame(timestamp, buf)

    js_request_animation_frame(_frame)

gives

  File "/home/pierre/dev/spy/spy/doppler.py", line 148, in shift_stmt
    return magic_dispatch(self, "shift_stmt", stmt)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/pierre/dev/spy/spy/util.py", line 75, in magic_dispatch
    raise NotImplementedError(f"{clsname}.{methname}")
NotImplementedError: DopplerFrame.shift_stmt_FuncDef

@paugier
Copy link
Copy Markdown
Contributor Author

paugier commented Apr 24, 2026

Inner functions are not fully supported (they are supported only inside @blue functions)

You might be able to circument the problem by making _frame module-level and pass buf via a global variable (ugly but could work)

I tried things like that:

from jsffi import init as js_init, get_GlobalThis, js_i32, get_Console
from jsffi import js_u8array_from_ptr, js_new_ImageData, js_request_animation_frame, js_to_f64

from unsafe import gc_alloc, gc_ptr
from _list import list

WIDTH: i32 = 800
HEIGHT: i32 = 600
SIZE: i32 = WIDTH * HEIGHT * 4


def simulate_step(buf: gc_ptr[u8], speed: f64) -> None:
    for idx in range(SIZE):
        buf[idx] = 100


# var glob_list: list[gc_ptr[u8]] = list[gc_ptr[u8]]()
# var glob_var: gc_ptr[u8] = gc_alloc[u8](SIZE)
var buf: gc_ptr[u8] = gc_alloc[u8](SIZE)


def frame(timestamp: f64) -> None:

    # buf: gc_ptr[u8] = glob_list[0]
    # buf: gc_ptr[u8] = glob_var

    console = get_Console()
    console.log("hello")

    window = get_GlobalThis()
    speed = js_to_f64(window.document.getElementById("speed").value)
    simulate_step(buf, speed)
    u8arr = js_u8array_from_ptr(buf, SIZE)
    image_data = js_new_ImageData(u8arr, WIDTH, HEIGHT)

    canvas = window.document.getElementById("canvas")
    ctx = canvas.getContext("2d")
    ctx.putImageData(image_data, js_i32(0), js_i32(0))

    js_request_animation_frame(frame)


def main() -> None:
    js_init()
    # buf = gc_alloc[u8](SIZE)
    # glob_var = buf
    # glob_list.append(buf)

    js_request_animation_frame(frame)

This fails with

  File "/home/pierre/dev/spy/spy/backend/c/cmodwriter.py", line 232, in emit_content
    self.emit_obj(fqn, w_obj)
  File "/home/pierre/dev/spy/spy/backend/c/cmodwriter.py", line 255, in emit_obj
    assert isinstance(w_content, W_I32), "WIP: var type not supported"

I'm not too far from something really cool but it seems SPy is not yet ready for that.

@antocuni
Copy link
Copy Markdown
Member

@paugier I managed to get this working with the following diff.

It's still a hack because it uses a global variable, but it probably lets you to proceed.
Note that support for global NULL ptr variables already existed (I introduced it long time ago to support this demo), but for some reason it got broken.
It clearly misses a test, so if you want feel free to commit this change but add a proper test to unsafe/test_ptr.py to ensure it doesn't break again in the future :)

diff --git a/examples/jsffi-canvas/demo.spy b/examples/jsffi-canvas/demo.spy
index 042ec744..aadad061 100644
--- a/examples/jsffi-canvas/demo.spy
+++ b/examples/jsffi-canvas/demo.spy
@@ -8,6 +8,8 @@ WIDTH: i32 = 800
 HEIGHT: i32 = 600
 SIZE: i32 = WIDTH * HEIGHT * 4
 
+var BUF: gc_ptr[u8] = gc_ptr[u8].NULL
+
 
 def simulate_step(buf: gc_ptr[u8], speed: f64) -> None:
     for idx in range(SIZE):
@@ -36,9 +38,9 @@ def frame(timestamp: f64, buf: gc_ptr[u8]) -> None:
 
 def main() -> None:
     js_init()
-    buf = gc_alloc[u8](SIZE)
+    BUF = gc_alloc[u8](SIZE)
 
-    frame(1.25, buf)
+    frame(1.25, BUF)
 
     # def _frame(timestamp: f64) -> None:
     #     frame(timestamp, buf)
diff --git a/spy/backend/c/cmodwriter.py b/spy/backend/c/cmodwriter.py
index 98ec8f88..6fe8346b 100644
--- a/spy/backend/c/cmodwriter.py
+++ b/spy/backend/c/cmodwriter.py
@@ -252,22 +252,24 @@ class CModuleWriter:
             w_content = w_obj.get()
             w_T = self.ctx.vm.dynamic_type(w_content)
             # we support only int global variables for now
-            assert isinstance(w_content, W_I32), "WIP: var type not supported"
-            intval = self.ctx.vm.unwrap(w_content)
-            c_type = self.ctx.w2c(w_T)
-            self.tbh_globals.wl(f"extern {c_type} {fqn.c_name};")
-            self.tbc_globals.wl(f"{c_type} {fqn.c_name} = {intval};")
-
-        # ==== misc consts ====
-        elif isinstance(w_T, W_PtrType):
-            # for now, we only support NULL constnts
-            assert isinstance(w_obj, W_Ptr)
-            assert w_obj.addr == 0, (
-                "only NULL pointers can be stored in constants for now"
-            )
-            c_type = self.ctx.w2c(w_T)
-            self.tbh_globals.wl(f"extern {c_type} {fqn.c_name};")
-            self.tbc_globals.wl(f"{c_type} {fqn.c_name} = {{0}};")
+            if isinstance(w_content, W_I32):
+                intval = self.ctx.vm.unwrap(w_content)
+                c_type = self.ctx.w2c(w_T)
+                self.tbh_globals.wl(f"extern {c_type} {fqn.c_name};")
+                self.tbc_globals.wl(f"{c_type} {fqn.c_name} = {intval};")
+
+            elif isinstance(w_T, W_PtrType):
+                # for now, we only support NULL constnts
+                assert isinstance(w_content, W_Ptr)
+                assert w_content.addr == 0, (
+                    "only NULL pointers can be stored in constants for now"
+                )
+                c_type = self.ctx.w2c(w_T)
+                self.tbh_globals.wl(f"extern {c_type} {fqn.c_name};")
+                self.tbc_globals.wl(f"{c_type} {fqn.c_name} = {{0}};")
+
+            else:
+                raise WIP("var type `{w_T}` not supported")
 
         else:
             raise NotImplementedError("WIP")

@paugier paugier force-pushed the web-without-pyscript-js branch 2 times, most recently from f894b8f to b1971a1 Compare April 29, 2026 05:19
@paugier paugier force-pushed the web-without-pyscript-js branch from 2bea565 to e161a50 Compare April 30, 2026 16:46
@paugier paugier force-pushed the web-without-pyscript-js branch from 3f5513a to ba106d1 Compare April 30, 2026 22:48
@paugier paugier force-pushed the web-without-pyscript-js branch from fe8f639 to 8c4618b Compare May 1, 2026 05:55
@paugier
Copy link
Copy Markdown
Contributor Author

paugier commented May 1, 2026

My plans on this work:

  • finalize (code reordering, few comments to explain why things are like that, split the 2 demos in 2 directories so that things are simpler for readers)
  • wait for Fix var NULL ptr global and the tests #488
  • Fix the history with few clear commits
  • PR review and merge
  • Improve the "2d context" demo so that it
    • does something funny, that people want to share (I'm thinking about a geometrical disguised face
    • uses a separated disguised face SPy lib (a local disguised_face_lib.spy) based on ndarray and that can be developed without jsffi
    • build these 2 demos in the CI?

Independently, the particle demo should also be improved with using random (#490)...

Finally, communicate about these SPy demos.

@paugier paugier force-pushed the web-without-pyscript-js branch from c74b8c3 to e872a90 Compare May 6, 2026 14:16
@paugier paugier force-pushed the web-without-pyscript-js branch from c8e4094 to a0eb879 Compare May 13, 2026 14:30
@paugier paugier marked this pull request as ready for review May 13, 2026 14:51
@paugier paugier changed the title draft: Web without PyScript and JS Demo web without PyScript and JS + improve and fix jsffi May 13, 2026
@paugier paugier force-pushed the web-without-pyscript-js branch 4 times, most recently from f3bba2c to ece079f Compare May 13, 2026 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

jsffi: add primitives needed for 60 fps canvas demos driven purely from SPy

2 participants