wasmedge-hs-0.17.1.0: src/WasmEdge/Memory/Codec.hs
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{- |
Module : WasmEdge.Memory.Codec
Description : Typeclass for serialising Haskell values through Wasm linear memory
Copyright : (c) 2026 Tushar Adhatrao
License : MIT
Stability : experimental
WebAssembly's type system only has four numeric types and references.
Every complex value — @Text@, @ByteString@, a list, or any ADT — must be
transported through __linear memory__: the Wasm module's heap.
The canonical pattern (used by the Rust and Go SDKs) is:
1. The guest (Wasm) module exports @alloc(size: i32) -> i32@ and
@dealloc(ptr: i32, len: i32)@.
2. The host passes a @(ptr: i32, len: i32)@ pair for every buffer argument.
3. Both sides serialise complex data to\/from a flat byte representation.
'WasmMemory' standardises step 3 on the Haskell side.
== Encoding conventions
* Primitive numeric types use __little-endian__ binary encoding (matches Wasm's
native byte order).
* 'Text' is encoded as __UTF-8__ bytes.
* 'ByteString' is passed __as-is__ (zero-copy where possible).
* Complex ADTs should implement 'WasmMemory' using 'jsonWasmPoke' \/ 'jsonWasmPeek',
which serialises via @aeson@ JSON — the same convention used by @wit-bindgen@
and the WASI ecosystem.
== Typical usage
@
-- Host reads a Text value from a Wasm linear memory buffer:
txt \<- wasmPeek \@Text mem offset len
-- Host writes an ADT back into linear memory (assuming a JSON instance):
Right n \<- wasmPoke mem retOffset myRecord
@
See 'WasmEdge.Memory.Alloc' for the high-level bracket that combines
allocation, marshalling, and function invocation in one call.
-}
module WasmEdge.Memory.Codec
( -- * Core typeclass
WasmMemory (..)
-- * Fixed-size subclass
, FixedWasmMemory (..)
, fixedPoke
, fixedPeek
-- * JSON codec helpers (for custom ADTs)
, jsonWasmPoke
, jsonWasmPeek
, WasmJSON (..)
) where
import Data.Aeson (FromJSON, ToJSON)
import Data.Aeson qualified as Aeson
import Data.Bits (shiftL, shiftR, (.&.), (.|.))
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BL
import Data.Int (Int16, Int32, Int64, Int8)
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Word (Word16, Word32, Word64, Word8)
import GHC.Float (castDoubleToWord64, castFloatToWord32, castWord32ToFloat, castWord64ToDouble)
import WasmEdge.Error (ErrCategory (..), WasmEdgeError (..), WasmResult)
import WasmEdge.Memory (MemoryInstance, getMemoryData, setMemoryData)
-- ---------------------------------------------------------------------------
-- Core typeclass
-- ---------------------------------------------------------------------------
{- | A type that can be serialised to and deserialised from a contiguous slice
of WebAssembly linear memory.
Minimal complete definition: 'wasmPeek' + 'wasmPoke'.
For fixed-size types (primitives, fixed-size tuples), implement
'FixedWasmMemory' and use 'fixedPeek' \/ 'fixedPoke' as the default methods.
For JSON-serialisable ADTs use 'jsonWasmPeek' \/ 'jsonWasmPoke'.
-}
class WasmMemory a where
{- | Read a value of type @a@ from @len@ bytes of linear memory at @offset@.
Returns 'Left' on out-of-bounds access or deserialisation failure.
-}
wasmPeek ::
-- | The linear memory instance
MemoryInstance ->
-- | Byte offset in linear memory
Word32 ->
-- | Byte length of the slice
Word32 ->
IO (WasmResult a)
{- | Write a value of type @a@ into linear memory at @offset@.
Returns @Right n@ where @n@ is the number of bytes written, or 'Left' on
out-of-bounds access.
-}
wasmPoke ::
-- | The linear memory instance
MemoryInstance ->
-- | Byte offset to write at
Word32 ->
-- | The value to serialise
a ->
-- | @Right@ bytes written, or @Left@ error
IO (WasmResult Word32)
-- ---------------------------------------------------------------------------
-- Fixed-size subclass
-- ---------------------------------------------------------------------------
{- | A 'WasmMemory' type with a statically-known byte width.
Implement this for all primitive types.
-}
class WasmMemory a => FixedWasmMemory a where
-- | Byte width of the serialised form.
wasmWidth :: Proxy a -> Word32
-- | Serialise to exactly 'wasmWidth' bytes.
serialise :: a -> ByteString
-- | Deserialise from at least 'wasmWidth' bytes.
deserialise :: ByteString -> Either String a
-- | Default 'wasmPoke' for any 'FixedWasmMemory' instance.
fixedPoke :: (FixedWasmMemory a) => MemoryInstance -> Word32 -> a -> IO (WasmResult Word32)
fixedPoke mem offset val = do
let bs = serialise val
res <- setMemoryData mem offset bs
pure (fmap (const (fromIntegral (BS.length bs))) res)
-- | Default 'wasmPeek' for any 'FixedWasmMemory' instance.
fixedPeek ::
forall a.
(FixedWasmMemory a) =>
MemoryInstance ->
Word32 ->
Word32 ->
IO (WasmResult a)
fixedPeek mem offset _len = do
let w = wasmWidth (Proxy @a)
res <- getMemoryData mem offset w
pure $
res >>= \bs ->
case deserialise bs of
Left msg ->
Left
( WasmEdgeError
ErrCategory_UserDefined
10
(TE.decodeUtf8Lenient (BS.pack (map (fromIntegral . fromEnum) msg)))
)
Right v -> Right v
-- ---------------------------------------------------------------------------
-- JSON codec helpers
-- ---------------------------------------------------------------------------
{- | Write any 'ToJSON' value into linear memory as a UTF-8 JSON blob.
Returns the number of bytes written.
-}
jsonWasmPoke :: (ToJSON a) => MemoryInstance -> Word32 -> a -> IO (WasmResult Word32)
jsonWasmPoke mem offset val = do
let bs = BL.toStrict (Aeson.encode val)
res <- setMemoryData mem offset bs
pure (fmap (const (fromIntegral (BS.length bs))) res)
{- | Read a UTF-8 JSON blob from linear memory and decode it as a 'FromJSON'
value.
-}
jsonWasmPeek :: (FromJSON a) => MemoryInstance -> Word32 -> Word32 -> IO (WasmResult a)
jsonWasmPeek mem offset len = do
res <- getMemoryData mem offset len
pure $
res >>= \bs ->
case Aeson.eitherDecodeStrict bs of
Left msg ->
Left
( WasmEdgeError
ErrCategory_UserDefined
11
(TE.decodeUtf8Lenient (BS.pack (map (fromIntegral . fromEnum) msg)))
)
Right v -> Right v
{- | Newtype wrapper to derive 'WasmMemory' for any type with 'ToJSON' and 'FromJSON' via @DerivingVia@.
@
data Person = Person { name :: Text, age :: Int }
deriving stock (Generic, Show, Eq)
deriving anyclass (ToJSON, FromJSON)
deriving (WasmMemory) via (WasmJSON Person)
@
-}
newtype WasmJSON a = WasmJSON {unWasmJSON :: a}
deriving stock (Eq, Show)
deriving newtype (ToJSON, FromJSON)
instance (ToJSON a, FromJSON a) => WasmMemory (WasmJSON a) where
wasmPoke mem offset (WasmJSON val) = jsonWasmPoke mem offset val
wasmPeek mem offset len = fmap WasmJSON <$> jsonWasmPeek mem offset len
-- ---------------------------------------------------------------------------
-- Utility: little-endian encode / decode helpers
-- ---------------------------------------------------------------------------
leWord16 :: Word16 -> ByteString
leWord16 w =
BS.pack
[ fromIntegral (w .&. 0xFF)
, fromIntegral ((w `shiftR` 8) .&. 0xFF)
]
leWord32 :: Word32 -> ByteString
leWord32 w =
BS.pack
[ fromIntegral (w .&. 0xFF)
, fromIntegral ((w `shiftR` 8) .&. 0xFF)
, fromIntegral ((w `shiftR` 16) .&. 0xFF)
, fromIntegral ((w `shiftR` 24) .&. 0xFF)
]
leWord64 :: Word64 -> ByteString
leWord64 w =
BS.pack
[ fromIntegral (w .&. 0xFF)
, fromIntegral ((w `shiftR` 8) .&. 0xFF)
, fromIntegral ((w `shiftR` 16) .&. 0xFF)
, fromIntegral ((w `shiftR` 24) .&. 0xFF)
, fromIntegral ((w `shiftR` 32) .&. 0xFF)
, fromIntegral ((w `shiftR` 40) .&. 0xFF)
, fromIntegral ((w `shiftR` 48) .&. 0xFF)
, fromIntegral ((w `shiftR` 56) .&. 0xFF)
]
fromLeWord16 :: ByteString -> Word16
fromLeWord16 bs
| BS.length bs < 2 = 0
| otherwise =
fromIntegral (BS.index bs 0)
.|. (fromIntegral (BS.index bs 1) `shiftL` 8)
fromLeWord32 :: ByteString -> Word32
fromLeWord32 bs
| BS.length bs < 4 = 0
| otherwise =
fromIntegral (BS.index bs 0)
.|. (fromIntegral (BS.index bs 1) `shiftL` 8)
.|. (fromIntegral (BS.index bs 2) `shiftL` 16)
.|. (fromIntegral (BS.index bs 3) `shiftL` 24)
fromLeWord64 :: ByteString -> Word64
fromLeWord64 bs
| BS.length bs < 8 = 0
| otherwise =
fromIntegral (BS.index bs 0)
.|. (fromIntegral (BS.index bs 1) `shiftL` 8)
.|. (fromIntegral (BS.index bs 2) `shiftL` 16)
.|. (fromIntegral (BS.index bs 3) `shiftL` 24)
.|. (fromIntegral (BS.index bs 4) `shiftL` 32)
.|. (fromIntegral (BS.index bs 5) `shiftL` 40)
.|. (fromIntegral (BS.index bs 6) `shiftL` 48)
.|. (fromIntegral (BS.index bs 7) `shiftL` 56)
-- ---------------------------------------------------------------------------
-- Primitive instances
-- ---------------------------------------------------------------------------
-- | 'Word8' — 1 byte, identity encoding.
instance WasmMemory Word8 where
wasmPeek mem offset _ = fmap (fmap BS.head) (getMemoryData mem offset 1)
wasmPoke mem offset v = fmap (fmap (const 1)) (setMemoryData mem offset (BS.singleton v))
instance FixedWasmMemory Word8 where
wasmWidth _ = 1
serialise = BS.singleton
deserialise bs
| BS.null bs = Left "WasmMemory Word8: empty buffer"
| otherwise = Right (BS.head bs)
-- | 'Int8' — 1 byte, two's complement.
instance WasmMemory Int8 where
wasmPeek mem offset len = fmap (fmap (fromIntegral . BS.head)) (getMemoryData mem offset (max 1 len))
wasmPoke mem offset v = fmap (fmap (const 1)) (setMemoryData mem offset (BS.singleton (fromIntegral v)))
instance FixedWasmMemory Int8 where
wasmWidth _ = 1
serialise = BS.singleton . fromIntegral
deserialise bs
| BS.null bs = Left "WasmMemory Int8: empty buffer"
| otherwise = Right (fromIntegral (BS.head bs))
-- | 'Word16' — 2 bytes, little-endian.
instance WasmMemory Word16 where
wasmPeek mem offset _ = fmap (fmap fromLeWord16) (getMemoryData mem offset 2)
wasmPoke mem offset v = fmap (fmap (const 2)) (setMemoryData mem offset (leWord16 v))
instance FixedWasmMemory Word16 where
wasmWidth _ = 2
serialise = leWord16
deserialise bs
| BS.length bs < 2 = Left "WasmMemory Word16: buffer too short"
| otherwise = Right (fromLeWord16 bs)
-- | 'Int16' — 2 bytes, little-endian two's complement.
instance WasmMemory Int16 where
wasmPeek mem offset _ = fmap (fmap (fromIntegral . fromLeWord16)) (getMemoryData mem offset 2)
wasmPoke mem offset v = fmap (fmap (const 2)) (setMemoryData mem offset (leWord16 (fromIntegral v)))
instance FixedWasmMemory Int16 where
wasmWidth _ = 2
serialise = leWord16 . fromIntegral
deserialise bs
| BS.length bs < 2 = Left "WasmMemory Int16: buffer too short"
| otherwise = Right (fromIntegral (fromLeWord16 bs))
-- | 'Word32' — 4 bytes, little-endian.
instance WasmMemory Word32 where
wasmPeek mem offset _ = fmap (fmap fromLeWord32) (getMemoryData mem offset 4)
wasmPoke mem offset v = fmap (fmap (const 4)) (setMemoryData mem offset (leWord32 v))
instance FixedWasmMemory Word32 where
wasmWidth _ = 4
serialise = leWord32
deserialise bs
| BS.length bs < 4 = Left "WasmMemory Word32: buffer too short"
| otherwise = Right (fromLeWord32 bs)
-- | 'Int32' — 4 bytes, little-endian two's complement.
instance WasmMemory Int32 where
wasmPeek mem offset _ = fmap (fmap (fromIntegral . fromLeWord32)) (getMemoryData mem offset 4)
wasmPoke mem offset v = fmap (fmap (const 4)) (setMemoryData mem offset (leWord32 (fromIntegral v)))
instance FixedWasmMemory Int32 where
wasmWidth _ = 4
serialise = leWord32 . fromIntegral
deserialise bs
| BS.length bs < 4 = Left "WasmMemory Int32: buffer too short"
| otherwise = Right (fromIntegral (fromLeWord32 bs))
-- | 'Word64' — 8 bytes, little-endian.
instance WasmMemory Word64 where
wasmPeek mem offset _ = fmap (fmap fromLeWord64) (getMemoryData mem offset 8)
wasmPoke mem offset v = fmap (fmap (const 8)) (setMemoryData mem offset (leWord64 v))
instance FixedWasmMemory Word64 where
wasmWidth _ = 8
serialise = leWord64
deserialise bs
| BS.length bs < 8 = Left "WasmMemory Word64: buffer too short"
| otherwise = Right (fromLeWord64 bs)
-- | 'Int64' — 8 bytes, little-endian two's complement.
instance WasmMemory Int64 where
wasmPeek mem offset _ = fmap (fmap (fromIntegral . fromLeWord64)) (getMemoryData mem offset 8)
wasmPoke mem offset v = fmap (fmap (const 8)) (setMemoryData mem offset (leWord64 (fromIntegral v)))
instance FixedWasmMemory Int64 where
wasmWidth _ = 8
serialise = leWord64 . fromIntegral
deserialise bs
| BS.length bs < 8 = Left "WasmMemory Int64: buffer too short"
| otherwise = Right (fromIntegral (fromLeWord64 bs))
-- | 'Float' — 4 bytes, IEEE 754 single-precision little-endian.
instance WasmMemory Float where
wasmPeek mem offset _ =
fmap (fmap (castWord32ToFloat . fromLeWord32)) (getMemoryData mem offset 4)
wasmPoke mem offset v =
fmap (fmap (const 4)) (setMemoryData mem offset (leWord32 (castFloatToWord32 v)))
instance FixedWasmMemory Float where
wasmWidth _ = 4
serialise = leWord32 . castFloatToWord32
deserialise bs
| BS.length bs < 4 = Left "WasmMemory Float: buffer too short"
| otherwise = Right (castWord32ToFloat (fromLeWord32 bs))
-- | 'Double' — 8 bytes, IEEE 754 double-precision little-endian.
instance WasmMemory Double where
wasmPeek mem offset _ =
fmap (fmap (castWord64ToDouble . fromLeWord64)) (getMemoryData mem offset 8)
wasmPoke mem offset v =
fmap (fmap (const 8)) (setMemoryData mem offset (leWord64 (castDoubleToWord64 v)))
instance FixedWasmMemory Double where
wasmWidth _ = 8
serialise = leWord64 . castDoubleToWord64
deserialise bs
| BS.length bs < 8 = Left "WasmMemory Double: buffer too short"
| otherwise = Right (castWord64ToDouble (fromLeWord64 bs))
-- | 'Bool' — 1 byte: 0 = 'False', 1 = 'True'.
instance WasmMemory Bool where
wasmPeek mem offset len = fmap (fmap (/= 0)) (wasmPeek @Word8 mem offset len)
wasmPoke mem offset v = wasmPoke mem offset (if v then 1 :: Word8 else 0)
instance FixedWasmMemory Bool where
wasmWidth _ = 1
serialise False = BS.singleton 0
serialise True = BS.singleton 1
deserialise bs
| BS.null bs = Left "WasmMemory Bool: empty buffer"
| otherwise = Right (BS.head bs /= 0)
-- ---------------------------------------------------------------------------
-- Variable-length instances
-- ---------------------------------------------------------------------------
{- | 'ByteString' — raw bytes, zero-copy.
The entire slice @[offset .. offset+len)@ is returned\/written as-is.
Writing an empty 'ByteString' is a no-op (returns @Right 0@).
-}
instance WasmMemory ByteString where
wasmPeek = getMemoryData
wasmPoke _mem _offset bs
| BS.null bs = pure (Right 0)
wasmPoke mem offset bs = do
res <- setMemoryData mem offset bs
pure (fmap (const (fromIntegral (BS.length bs))) res)
{- | 'Text' — UTF-8 encoded bytes.
The codec is strict UTF-8: an invalid byte sequence returns @Left@ with a
'WasmEdgeError' rather than silently replacing bytes with the replacement
character. Use 'WasmMemory ByteString' if you need raw bytes.
Writing empty 'Text' is a no-op (returns @Right 0@).
-}
instance WasmMemory Text where
wasmPeek mem offset len = do
res <- getMemoryData mem offset len
pure $
res >>= \bs ->
case TE.decodeUtf8' bs of
Left unicodeEx ->
Left
( WasmEdgeError
ErrCategory_UserDefined
12
("WasmMemory Text: invalid UTF-8 in guest memory: " <> T.pack (show unicodeEx))
)
Right txt -> Right txt
wasmPoke _mem _offset t
| T.null t = pure (Right 0)
wasmPoke mem offset t = do
let bs = TE.encodeUtf8 t
res <- setMemoryData mem offset bs
pure (fmap (const (fromIntegral (BS.length bs))) res)
-- | @['Word8']@ — raw bytes (wraps via 'ByteString').
instance WasmMemory [Word8] where
wasmPeek mem offset len = fmap (fmap BS.unpack) (getMemoryData mem offset len)
wasmPoke mem offset ws = wasmPoke mem offset (BS.pack ws)
-- ---------------------------------------------------------------------------
-- Pair of fixed-size values
-- ---------------------------------------------------------------------------
{- | A pair of fixed-size values is stored sequentially: @a@ at @offset@,
@b@ at @offset + wasmWidth \@a@.
-}
instance (FixedWasmMemory a, FixedWasmMemory b) => WasmMemory (a, b) where
wasmPeek mem offset _ = do
let wa = wasmWidth (Proxy @a)
wb = wasmWidth (Proxy @b)
ra <- wasmPeek mem offset wa
rb <- wasmPeek mem (offset + wa) wb
pure $ (,) <$> ra <*> rb
wasmPoke mem offset (a, b) = do
let wa = wasmWidth (Proxy @a)
ra <- wasmPoke mem offset a
rb <- wasmPoke mem (offset + wa) b
pure $ (+) <$> ra <*> rb