Use release.wasm without the SDK
Download your project's compiled bundle from the panel, ship it inside your app and call the functions with a drop-in loader file — no Hikotest SDK, no network access, no tokens.
Who this is for
Every deploy from the panel produces two plain files: release.wasm (your compiled business logic) and manifest.json(the list of functions and their typed signatures). You don't have to use the Hikotest SDK or over-the-air updates to run them — the files are yours, and they work fully offline.
| No SDK dependency | A single copy-paste loader file per platform — the code is yours to read and audit |
| No network, no tokens | The bundle ships inside your app binary; nothing is fetched at runtime |
| Store-review friendly | Behavior only changes when you ship a new file through your normal release process |
| Reversible | The same release.wasm works with the SDK — switch to OTA updates later without changing your logic |
1Download the release files
In the panel, open your project's Releases page and download both assets of the version you want to ship: release.wasm and manifest.json. Releases are immutable and semver-tagged (e.g. v1.0.12) — pin the tag you shipped so you always know exactly what runs in production.
2Add the files to your app
| Android | Put both files in src/main/assets/ (create the folder if it does not exist) |
| iOS | Add both files to your target as bundle resources (Copy Bundle Resources) |
| Web / Node | Serve them as static files (e.g. public/) or read them from disk on the server |
3Drop in the loader
The loader is a single file that reads the manifest, instantiates the module and hides the WASM calling details (numbers pass directly; strings travel through linear memory). Copy the one for your platform into your project.
Web — zero dependencies
Browsers and Node.js 18+ run WebAssembly natively, so this is plain TypeScript with no packages to install.
// hiko-wasm.ts — Hikotest standalone loader (no SDK, no network).
// Ship release.wasm + manifest.json inside your app and call functions locally.
// Zero dependencies — modern browsers and Node.js 18+ have WebAssembly built in.
//
// Contract: Hikotest WASM ABI v1 (see the standalone guide for details).
// Note: hiko_alloc never frees; in long-lived processes with heavy string
// traffic, re-instantiate the module periodically if memory growth matters.
export type HikoType = 'int' | 'float' | 'string' | 'boolean';
export type HikoValue = string | number | boolean;
export interface HikoFunction {
name: string;
description?: string | null;
signature: { params: { name: string; type: HikoType }[]; returns: HikoType };
}
export interface HikoManifest {
abi: number;
bundleVersion?: string | null;
config?: Record<string, HikoValue>;
functions: HikoFunction[];
}
// ─── Integrity verification (docs/WASM_INTEGRITY.md §6) ──────────────────────
// Optional, opt-in. The panel publishes a detached `integrity.json` asset next to
// release.wasm + manifest.json (SHA-256 of each + an Ed25519 signature over both).
// This loader can verify those bytes before instantiating. Default is `off` — the
// loader byte-behaves exactly as before unless you pass a mode + the integrity data.
//
// Verify needs the RAW manifest.json bytes (not the parsed object): the signature
// binds the exact asset bytes, so we must not re-serialize (canonicalization-free).
export type VerifyMode = 'off' | 'warn' | 'enforce';
export interface HikoIntegrity {
integrity: number;
bundleVersion?: string;
wasm: { sha256: string };
manifest: { sha256: string };
alg?: string;
keyId?: string | null;
signature?: string | null;
}
/** Public key map — keyId → SPKI PEM. Mirror of src/lib/wasm/signing-keys.mjs.
* PUBLIC keys only; safe to ship inside client apps. Rotation: add the new keyId
* here (keep the old one during the grace period). See docs/WASM_INTEGRITY.md §5. */
const PUBLIC_SIGNING_KEYS: Record<string, string> = {
'hk-2026-07': `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAogmXuGBXvXMzYXnvBnBYgE8vpjJNTXe0Fcz6RDxoCD4=
-----END PUBLIC KEY-----
`,
};
/** Signing message — fixed-format UTF-8 text (no JSON canonicalization), identical
* across all 3 SDKs + loaders. Binds wasm + manifest hash AND keyId. */
const SIGN_MESSAGE_PREFIX = 'hikotest.integrity.v1';
function buildSigningMessage(i: HikoIntegrity): string {
return [
SIGN_MESSAGE_PREFIX,
`bundleVersion=${i.bundleVersion}`,
`wasm.sha256=${i.wasm.sha256}`,
`manifest.sha256=${i.manifest.sha256}`,
`keyId=${i.keyId}`,
].join('\n');
}
export interface VerifyResult { ok: boolean; verified: boolean; reason?: string }
// Return WITHOUT an explicit type annotation so TS infers Uint8Array<ArrayBuffer>
// (a fresh ArrayBuffer-backed view) — WebCrypto's BufferSource params reject the
// generic Uint8Array<ArrayBufferLike> that Uint8Array.from() would produce.
function base64ToBytes(b64: string) {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function pemToDer(pem: string) {
return base64ToBytes(pem.replace(/-----[A-Z ]+-----/g, '').replace(/\s+/g, ''));
}
async function sha256Hex(bytes: BufferSource): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes);
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
async function verifyEd25519(message: string, signatureB64: string, spkiPem: string): Promise<boolean> {
try {
const key = await crypto.subtle.importKey('spki', pemToDer(spkiPem), { name: 'Ed25519' }, false, ['verify']);
return await crypto.subtle.verify(
{ name: 'Ed25519' }, key, base64ToBytes(signatureB64), new TextEncoder().encode(message),
);
} catch {
return false;
}
}
/** Mirror of integrity-core.mjs verifyIntegrity (client side, WebCrypto).
* `ok`: bytes are consistent with the integrity statement. `verified`: an Ed25519
* signature was actually checked (Layer 2). */
async function verifyIntegrity(
integrity: HikoIntegrity | undefined,
wasmBytes: BufferSource,
manifestBytes: BufferSource | null,
): Promise<VerifyResult> {
if (!integrity || integrity.integrity !== 1) {
return { ok: false, verified: false, reason: 'integrity.json missing or unsupported version' };
}
if (!manifestBytes) {
return { ok: false, verified: false, reason: 'raw manifest.json bytes not provided (opts.manifestBytes)' };
}
if (await sha256Hex(wasmBytes) !== integrity.wasm?.sha256) {
return { ok: false, verified: false, reason: 'wasm.sha256 mismatch (transit corruption / tamper)' };
}
if (await sha256Hex(manifestBytes) !== integrity.manifest?.sha256) {
return { ok: false, verified: false, reason: 'manifest.sha256 mismatch (tamper)' };
}
if (!integrity.signature) {
return { ok: true, verified: false, reason: 'unsigned (Layer 1 — hash only)' };
}
const pem = integrity.keyId ? PUBLIC_SIGNING_KEYS[integrity.keyId] : undefined;
if (!pem) {
return { ok: false, verified: false, reason: `unknown/invalid keyId: ${integrity.keyId}` };
}
if (!(await verifyEd25519(buildSigningMessage(integrity), integrity.signature, pem))) {
return { ok: false, verified: false, reason: 'Ed25519 signature failed (forged source?)' };
}
return { ok: true, verified: true };
}
export interface HikoLoadOptions {
/** `off` (default) = no verification, today's behavior. `warn` = verify if
* integrity is present, log on mismatch but still run; skip silently if absent.
* `enforce` = reject if integrity is absent OR verification fails. */
verify?: VerifyMode;
/** Parsed integrity.json (the detached asset shipped next to release.wasm). */
integrity?: HikoIntegrity;
/** RAW bytes of manifest.json — required to verify the manifest hash. */
manifestBytes?: BufferSource;
}
export class HikoWasm {
private constructor(
private readonly exports: Record<string, unknown>,
private readonly signatures: Map<string, HikoFunction>,
/** Remote config values embedded in the manifest (empty object if none). */
readonly config: Record<string, HikoValue>,
) {}
/** Instantiate from the raw bytes of release.wasm and the parsed manifest.json.
* Pass `opts.verify` ('warn' | 'enforce') + `opts.integrity` + `opts.manifestBytes`
* to check the detached integrity.json before instantiating (default: no check). */
static async load(
wasmBytes: BufferSource,
manifest: HikoManifest,
opts: HikoLoadOptions = {},
): Promise<HikoWasm> {
const mode = opts.verify ?? 'off';
if (mode !== 'off') {
if (!opts.integrity) {
// §6: "yoksa geç" — warn skips a missing integrity.json silently; enforce rejects.
if (mode === 'enforce') throw new Error('Hikotest: integrity.json required in enforce mode but not provided');
} else {
const result = await verifyIntegrity(opts.integrity, wasmBytes, opts.manifestBytes ?? null);
if (!result.ok) {
const msg = `Hikotest integrity check failed: ${result.reason}`;
if (mode === 'enforce') throw new Error(msg);
console.warn(`[hiko] ${msg} (verify=warn → running anyway)`);
}
}
}
if (manifest.abi !== 1) throw new Error(`Unsupported Hikotest ABI: ${manifest.abi}`);
// AssemblyScript modules import env.abort — instantiation fails without it.
const { instance } = await WebAssembly.instantiate(wasmBytes, {
env: { abort: () => { throw new Error('wasm abort'); } },
});
return new HikoWasm(
instance.exports as Record<string, unknown>,
new Map(manifest.functions.map((f) => [f.name, f])),
manifest.config ?? {},
);
}
/** The functions available in this bundle. */
functions(): HikoFunction[] {
return [...this.signatures.values()];
}
/** Call a function by name; arguments follow the manifest signature order. */
call(name: string, ...args: HikoValue[]): HikoValue {
const fn = this.signatures.get(name);
if (!fn) throw new Error(`Function not in manifest: ${name}`);
const { params, returns } = fn.signature;
if (args.length !== params.length) {
throw new Error(`${name} expects ${params.length} argument(s), got ${args.length}`);
}
// Numbers-only signatures use the plain named export.
if (!params.some((p) => p.type === 'string') && returns !== 'string') {
const raw = (this.exports[name] as (...a: number[]) => number)(
...params.map((p, i) => toNumber(p.type, args[i])),
);
return returns === 'boolean' ? raw !== 0 : raw;
}
// Signatures with strings go through the hiko_run_<name> wrapper export:
// a string parameter becomes a UTF-8 (ptr, len) pair via hiko_alloc; a string
// return is a pointer to [u32 little-endian length][UTF-8 bytes].
const memory = this.exports.memory as WebAssembly.Memory;
const alloc = this.exports.hiko_alloc as (size: number) => number;
const wasmArgs: number[] = [];
params.forEach((p, i) => {
if (p.type === 'string') {
const bytes = new TextEncoder().encode(String(args[i]));
const ptr = alloc(bytes.length);
new Uint8Array(memory.buffer).set(bytes, ptr);
wasmArgs.push(ptr, bytes.length);
} else {
wasmArgs.push(toNumber(p.type, args[i]));
}
});
const raw = (this.exports[`hiko_run_${name}`] as (...a: number[]) => number)(...wasmArgs);
if (returns === 'string') {
const len = new DataView(memory.buffer).getUint32(raw, true);
return new TextDecoder().decode(new Uint8Array(memory.buffer, raw + 4, len));
}
return returns === 'boolean' ? raw !== 0 : raw;
}
}
function toNumber(type: HikoType, v: HikoValue): number {
return type === 'boolean' ? (v ? 1 : 0) : Number(v);
}
Download hiko-wasm.ts — or copy the code block above into your project as-is.
Android — Chicory runtime
Android has no built-in WASM engine; add the pure-JVM Chicory runtime (no NDK, no JNI, no native libraries):
implementation("com.dylibso.chicory:runtime:1.5.3")// HikoWasm.kt — Hikotest standalone loader (no SDK, no network).
// Ship release.wasm + manifest.json in your app's assets/ and call functions locally.
//
// Dependency (pure JVM, no NDK/JNI):
// implementation("com.dylibso.chicory:runtime:1.5.3")
//
// Contract: Hikotest WASM ABI v1 (see the standalone guide for details).
// Note: hiko_alloc never frees; in long-lived processes with heavy string
// traffic, re-create the HikoWasm instance periodically if memory growth matters.
package com.example.hiko // change to your package
import android.content.Context
import com.dylibso.chicory.runtime.HostFunction
import com.dylibso.chicory.runtime.ImportValues
import com.dylibso.chicory.runtime.Instance
import com.dylibso.chicory.wasm.Parser
import com.dylibso.chicory.wasm.types.FunctionType
import com.dylibso.chicory.wasm.types.ValType
import org.json.JSONObject
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.KeyFactory
import java.security.MessageDigest
import java.security.Signature
import java.security.spec.X509EncodedKeySpec
import java.util.Base64
class HikoWasm private constructor(
private val instance: Instance,
private val signatures: Map<String, Signature>,
/** Remote config values embedded in the manifest (empty if none). */
val config: Map<String, Any>,
) {
data class Param(val name: String, val type: String)
data class Signature(val params: List<Param>, val returns: String)
// ─── Integrity verification (docs/WASM_INTEGRITY.md §6) ──────────────────
// Optional, opt-in. Verify the detached integrity.json (SHA-256 of release.wasm
// + manifest.json, plus an Ed25519 signature over both) before instantiating.
// Default OFF → byte-behaves as before. WARN = verify if present, log + run on
// mismatch, skip silently if absent. ENFORCE = reject if absent or verify fails.
// NOTE: Ed25519 via java.security requires Android API 33+ (or a Conscrypt/
// BouncyCastle provider on older devices); on plain JVM it needs Java 15+.
enum class VerifyMode { OFF, WARN, ENFORCE }
data class VerifyResult(val ok: Boolean, val verified: Boolean, val reason: String?)
companion object {
/** Public key map — keyId → SPKI PEM. Mirror of src/lib/wasm/signing-keys.mjs.
* PUBLIC keys only; safe to ship in the app. Rotation: add the new keyId here. */
private val PUBLIC_SIGNING_KEYS = mapOf(
"hk-2026-07" to """
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAogmXuGBXvXMzYXnvBnBYgE8vpjJNTXe0Fcz6RDxoCD4=
-----END PUBLIC KEY-----
""".trimIndent(),
)
/** Fixed-format signing message — identical across all 3 SDKs + loaders. */
private const val SIGN_MESSAGE_PREFIX = "hikotest.integrity.v1"
/** Load release.wasm + manifest.json from the app's assets/ directory.
* Pass `verify` (WARN/ENFORCE) to check integrity.json before instantiating. */
fun fromAssets(
context: Context,
wasmAsset: String = "release.wasm",
manifestAsset: String = "manifest.json",
verify: VerifyMode = VerifyMode.OFF,
integrityAsset: String = "integrity.json",
): HikoWasm {
val integrityJson = if (verify != VerifyMode.OFF) {
runCatching { context.assets.open(integrityAsset).bufferedReader().readText() }.getOrNull()
} else null
return load(
context.assets.open(wasmAsset).readBytes(),
context.assets.open(manifestAsset).bufferedReader().readText(),
verify,
integrityJson,
)
}
/** Instantiate from the raw bytes of release.wasm and the manifest.json text.
* `integrityJson` = raw text of the detached integrity.json asset (verify only). */
fun load(
wasmBytes: ByteArray,
manifestJson: String,
verify: VerifyMode = VerifyMode.OFF,
integrityJson: String? = null,
): HikoWasm {
if (verify != VerifyMode.OFF) {
if (integrityJson == null) {
// §6: WARN skips a missing integrity.json silently; ENFORCE rejects.
require(verify != VerifyMode.ENFORCE) {
"Hikotest: integrity.json required in enforce mode but not provided"
}
} else {
val result = verifyIntegrity(
JSONObject(integrityJson), wasmBytes, manifestJson.toByteArray(Charsets.UTF_8),
)
if (!result.ok) {
val msg = "Hikotest integrity check failed: ${result.reason}"
if (verify == VerifyMode.ENFORCE) error(msg)
System.err.println("[hiko] $msg (verify=warn → running anyway)")
}
}
}
val manifest = JSONObject(manifestJson)
require(manifest.optInt("abi") == 1) { "Unsupported Hikotest ABI: ${manifest.optInt("abi")}" }
val signatures = buildMap {
val fns = manifest.getJSONArray("functions")
for (i in 0 until fns.length()) {
val f = fns.getJSONObject(i)
val sig = f.getJSONObject("signature")
val params = sig.getJSONArray("params")
put(
f.getString("name"),
Signature(
params = (0 until params.length()).map {
val p = params.getJSONObject(it)
Param(p.getString("name"), p.getString("type"))
},
returns = sig.getString("returns"),
),
)
}
}
val config = buildMap {
val cfg = manifest.optJSONObject("config") ?: return@buildMap
for (key in cfg.keys()) put(key, cfg.get(key))
}
// AssemblyScript modules import env.abort — instantiation fails without it.
val abort = HostFunction(
"env",
"abort",
FunctionType.of(listOf(ValType.I32, ValType.I32, ValType.I32, ValType.I32), listOf()),
) { _, _ -> throw IllegalStateException("wasm abort") }
val instance = Instance.builder(Parser.parse(wasmBytes))
.withImportValues(ImportValues.builder().addFunction(abort).build())
.build()
return HikoWasm(instance, signatures, config)
}
/** Mirror of integrity-core.mjs verifyIntegrity (client side, JCA). */
fun verifyIntegrity(integrity: JSONObject, wasmBytes: ByteArray, manifestBytes: ByteArray): VerifyResult {
if (integrity.optInt("integrity") != 1) {
return VerifyResult(false, false, "integrity.json missing or unsupported version")
}
if (sha256Hex(wasmBytes) != integrity.getJSONObject("wasm").getString("sha256")) {
return VerifyResult(false, false, "wasm.sha256 mismatch (transit corruption / tamper)")
}
if (sha256Hex(manifestBytes) != integrity.getJSONObject("manifest").getString("sha256")) {
return VerifyResult(false, false, "manifest.sha256 mismatch (tamper)")
}
if (integrity.isNull("signature")) {
return VerifyResult(true, false, "unsigned (Layer 1 — hash only)")
}
val keyId = integrity.optString("keyId")
val pem = PUBLIC_SIGNING_KEYS[keyId]
?: return VerifyResult(false, false, "unknown/invalid keyId: $keyId")
if (!verifyEd25519(buildSigningMessage(integrity), integrity.getString("signature"), pem)) {
return VerifyResult(false, false, "Ed25519 signature failed (forged source?)")
}
return VerifyResult(true, true, null)
}
private fun sha256Hex(bytes: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
private fun buildSigningMessage(i: JSONObject): String = listOf(
SIGN_MESSAGE_PREFIX,
"bundleVersion=${i.optString("bundleVersion")}",
"wasm.sha256=${i.getJSONObject("wasm").getString("sha256")}",
"manifest.sha256=${i.getJSONObject("manifest").getString("sha256")}",
"keyId=${i.optString("keyId")}",
).joinToString("\n")
private fun verifyEd25519(message: String, signatureB64: String, spkiPem: String): Boolean = try {
val der = Base64.getDecoder().decode(
spkiPem.replace(Regex("-----[A-Z ]+-----"), "").replace(Regex("\\s"), ""),
)
val pub = KeyFactory.getInstance("Ed25519").generatePublic(X509EncodedKeySpec(der))
Signature.getInstance("Ed25519").run {
initVerify(pub)
update(message.toByteArray(Charsets.UTF_8))
verify(Base64.getDecoder().decode(signatureB64))
}
} catch (e: Exception) {
false
}
}
/** The function names available in this bundle. */
fun functions(): Set<String> = signatures.keys
/** Call a function by name; arguments follow the manifest signature order. */
fun call(name: String, vararg args: Any): Any {
val sig = signatures[name] ?: error("Function not in manifest: $name")
require(args.size == sig.params.size) {
"$name expects ${sig.params.size} argument(s), got ${args.size}"
}
val hasString = sig.returns == "string" || sig.params.any { it.type == "string" }
// Chicory's calling convention is long[]: floats travel as raw f64 bits.
// A string parameter becomes a UTF-8 (ptr, len) pair via hiko_alloc.
val wasmArgs = ArrayList<Long>(sig.params.size + 1)
sig.params.forEachIndexed { i, p ->
when (p.type) {
"string" -> {
val bytes = args[i].toString().toByteArray(Charsets.UTF_8)
val ptr = instance.export("hiko_alloc").apply(bytes.size.toLong())[0]
instance.memory().write(ptr.toInt(), bytes)
wasmArgs.add(ptr)
wasmArgs.add(bytes.size.toLong())
}
"float" -> wasmArgs.add((args[i] as Number).toDouble().toRawBits())
"boolean" -> wasmArgs.add(if (args[i] == true) 1L else 0L)
else -> wasmArgs.add((args[i] as Number).toLong()) // int
}
}
// Signatures with strings are called through the hiko_run_<name> wrapper export.
val export = instance.export(if (hasString) "hiko_run_$name" else name)
val raw = export.apply(*wasmArgs.toLongArray())[0]
return when (sig.returns) {
"boolean" -> raw != 0L
"float" -> Double.fromBits(raw)
"string" -> {
// String return = pointer to [u32 little-endian length][UTF-8 bytes].
val len = ByteBuffer.wrap(instance.memory().readBytes(raw.toInt(), 4))
.order(ByteOrder.LITTLE_ENDIAN).int
String(instance.memory().readBytes(raw.toInt() + 4, len), Charsets.UTF_8)
}
else -> raw.toInt() // int
}
}
}
Download HikoWasm.kt — or copy the code block above into your project as-is.
iOS — WasmKit runtime
iOS likewise needs a runtime; add the pure-Swift WasmKit package (nothing to notarize, no binary frameworks):
.package(url: "https://github.com/swiftwasm/WasmKit.git", from: "0.1.5")
// HikoWasm.swift — Hikotest standalone loader (no SDK, no network).
// Ship release.wasm + manifest.json as bundle resources and call functions locally.
//
// Dependency (Swift Package Manager — pure Swift, no binary frameworks):
// .package(url: "https://github.com/swiftwasm/WasmKit.git", from: "0.1.5")
// product: "WasmKit"
//
// Contract: Hikotest WASM ABI v1 (see the standalone guide for details).
// Note: hiko_alloc never frees; in long-lived processes with heavy string
// traffic, re-create the HikoWasm instance periodically if memory growth matters.
import Foundation
import CryptoKit
import WasmKit
final class HikoWasm {
enum HikoError: Error {
case badManifest
case unsupportedABI(Int)
case unknownFunction(String)
case badArguments(String)
case missingExport(String)
case aborted
case integrityFailed(String)
}
struct Param { let name: String; let type: String }
struct Signature { let params: [Param]; let returns: String }
// ─── Integrity verification (docs/WASM_INTEGRITY.md §6) ──────────────────
// Optional, opt-in. Verify the detached integrity.json (SHA-256 of release.wasm
// + manifest.json, plus an Ed25519 signature over both) before instantiating.
// Default .off → byte-behaves as before. .warn = verify if present, log + run on
// mismatch, skip silently if absent. .enforce = reject if absent or verify fails.
enum VerifyMode { case off, warn, enforce }
struct VerifyResult { let ok: Bool; let verified: Bool; let reason: String? }
/// Public key map — keyId → SPKI PEM. Mirror of src/lib/wasm/signing-keys.mjs.
/// PUBLIC keys only; safe to ship in the app. Rotation: add the new keyId here.
private static let publicSigningKeys: [String: String] = [
"hk-2026-07": """
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAogmXuGBXvXMzYXnvBnBYgE8vpjJNTXe0Fcz6RDxoCD4=
-----END PUBLIC KEY-----
""",
]
/// Fixed-format signing message — identical across all 3 SDKs + loaders.
private static let signMessagePrefix = "hikotest.integrity.v1"
private let instance: Instance
private let memory: Memory
private let signatures: [String: Signature]
/// Remote config values embedded in the manifest (empty if none).
let config: [String: Any]
/// Load release.wasm + manifest.json from the app bundle.
/// Pass `verify` (.warn/.enforce) to check integrity.json before instantiating.
convenience init(
bundle: Bundle = .main,
wasmResource: String = "release",
manifestResource: String = "manifest",
verify: VerifyMode = .off,
integrityResource: String = "integrity"
) throws {
guard let wasmURL = bundle.url(forResource: wasmResource, withExtension: "wasm"),
let manifestURL = bundle.url(forResource: manifestResource, withExtension: "json")
else { throw HikoError.missingExport("release.wasm / manifest.json not found in bundle") }
let integrityJSON: Data? = verify != .off
? bundle.url(forResource: integrityResource, withExtension: "json").flatMap { try? Data(contentsOf: $0) }
: nil
try self.init(
wasmBytes: Data(contentsOf: wasmURL),
manifestJSON: Data(contentsOf: manifestURL),
verify: verify,
integrityJSON: integrityJSON
)
}
/// Instantiate from the raw bytes of release.wasm and manifest.json.
/// `integrityJSON` = raw bytes of the detached integrity.json asset (verify only).
init(wasmBytes: Data, manifestJSON: Data, verify: VerifyMode = .off, integrityJSON: Data? = nil) throws {
if verify != .off {
if let integrityJSON = integrityJSON {
let result = HikoWasm.verifyIntegrity(integrityJSON, wasmBytes: wasmBytes, manifestBytes: manifestJSON)
if !result.ok {
let msg = "Hikotest integrity check failed: \(result.reason ?? "")"
if verify == .enforce { throw HikoError.integrityFailed(msg) }
FileHandle.standardError.write(Data("[hiko] \(msg) (verify=warn → running anyway)\n".utf8))
}
} else if verify == .enforce {
throw HikoError.integrityFailed("integrity.json required in enforce mode but not provided")
}
}
guard let manifest = try JSONSerialization.jsonObject(with: manifestJSON) as? [String: Any],
let functions = manifest["functions"] as? [[String: Any]]
else { throw HikoError.badManifest }
let abi = manifest["abi"] as? Int ?? -1
guard abi == 1 else { throw HikoError.unsupportedABI(abi) }
var signatures: [String: Signature] = [:]
for f in functions {
guard let name = f["name"] as? String,
let sig = f["signature"] as? [String: Any],
let params = sig["params"] as? [[String: Any]],
let returns = sig["returns"] as? String else { continue }
signatures[name] = Signature(
params: params.compactMap { p in
guard let n = p["name"] as? String, let t = p["type"] as? String else { return nil }
return Param(name: n, type: t)
},
returns: returns
)
}
self.signatures = signatures
self.config = (manifest["config"] as? [String: Any]) ?? [:]
let store = Store(engine: Engine())
var imports = Imports()
// AssemblyScript modules import env.abort — instantiation fails without it.
imports.define(
module: "env", name: "abort",
Function(store: store, parameters: [.i32, .i32, .i32, .i32], results: []) { _, _ in
throw HikoError.aborted
}
)
let module = try parseWasm(bytes: [UInt8](wasmBytes))
instance = try module.instantiate(store: store, imports: imports)
guard let memory = instance.exports[memory: "memory"] else {
throw HikoError.missingExport("memory")
}
self.memory = memory
}
/// The function names available in this bundle.
var functions: [String] { Array(signatures.keys) }
/// Call a function by name; arguments follow the manifest signature order.
/// Pass Int / Double / Bool / String values; returns one of the same.
@discardableResult
func call(_ name: String, _ args: Any...) throws -> Any {
guard let sig = signatures[name] else { throw HikoError.unknownFunction(name) }
guard args.count == sig.params.count else {
throw HikoError.badArguments("\(name) expects \(sig.params.count) argument(s), got \(args.count)")
}
let hasString = sig.returns == "string" || sig.params.contains { $0.type == "string" }
var wasmArgs: [Value] = []
for (i, p) in sig.params.enumerated() {
switch p.type {
case "string":
// A string parameter becomes a UTF-8 (ptr, len) pair via hiko_alloc.
let bytes = [UInt8](stringValue(args[i]).utf8)
guard let alloc = instance.exports[function: "hiko_alloc"] else {
throw HikoError.missingExport("hiko_alloc")
}
let ptr = try alloc([.i32(UInt32(bytes.count))])[0].i32
memory.withUnsafeMutableBufferPointer(offset: UInt(ptr), count: bytes.count) {
$0.copyBytes(from: bytes)
}
wasmArgs.append(.i32(ptr))
wasmArgs.append(.i32(UInt32(bytes.count)))
case "float":
wasmArgs.append(.f64(try doubleValue(args[i], for: name).bitPattern))
case "boolean":
wasmArgs.append(.i32((args[i] as? Bool == true) ? 1 : 0))
default: // int
wasmArgs.append(.i32(UInt32(bitPattern: Int32(truncatingIfNeeded: try intValue(args[i], for: name)))))
}
}
// Signatures with strings are called through the hiko_run_<name> wrapper export.
let exportName = hasString ? "hiko_run_\(name)" : name
guard let fn = instance.exports[function: exportName] else {
throw HikoError.missingExport(exportName)
}
let raw = try fn(wasmArgs)[0]
switch sig.returns {
case "boolean":
return raw.i32 != 0
case "float":
return Double(bitPattern: raw.f64)
case "string":
// String return = pointer to [u32 little-endian length][UTF-8 bytes].
let data = memory.data
let ptr = Int(raw.i32)
let len = Int(data[ptr]) | Int(data[ptr + 1]) << 8 | Int(data[ptr + 2]) << 16 | Int(data[ptr + 3]) << 24
return String(decoding: data[(ptr + 4)..<(ptr + 4 + len)], as: UTF8.self)
default: // int
return Int(Int32(bitPattern: raw.i32))
}
}
private func stringValue(_ v: Any) -> String {
v as? String ?? String(describing: v)
}
private func doubleValue(_ v: Any, for name: String) throws -> Double {
if let d = v as? Double { return d }
if let i = v as? Int { return Double(i) }
throw HikoError.badArguments("\(name): expected a numeric argument, got \(v)")
}
private func intValue(_ v: Any, for name: String) throws -> Int {
if let i = v as? Int { return i }
throw HikoError.badArguments("\(name): expected an Int argument, got \(v)")
}
// ─── Integrity helpers (mirror of integrity-core.mjs, client side, CryptoKit) ──
/// `ok`: bytes are consistent with the integrity statement. `verified`: an Ed25519
/// signature was actually checked (Layer 2).
static func verifyIntegrity(_ integrityJSON: Data, wasmBytes: Data, manifestBytes: Data) -> VerifyResult {
guard let i = try? JSONSerialization.jsonObject(with: integrityJSON) as? [String: Any],
(i["integrity"] as? Int) == 1 else {
return VerifyResult(ok: false, verified: false, reason: "integrity.json missing or unsupported version")
}
guard let wasmSha = (i["wasm"] as? [String: Any])?["sha256"] as? String,
let manifestSha = (i["manifest"] as? [String: Any])?["sha256"] as? String else {
return VerifyResult(ok: false, verified: false, reason: "integrity.json malformed")
}
if sha256Hex(wasmBytes) != wasmSha {
return VerifyResult(ok: false, verified: false, reason: "wasm.sha256 mismatch (transit corruption / tamper)")
}
if sha256Hex(manifestBytes) != manifestSha {
return VerifyResult(ok: false, verified: false, reason: "manifest.sha256 mismatch (tamper)")
}
guard let signature = i["signature"] as? String, !signature.isEmpty else {
return VerifyResult(ok: true, verified: false, reason: "unsigned (Layer 1 — hash only)")
}
let keyId = i["keyId"] as? String ?? ""
guard let pem = publicSigningKeys[keyId] else {
return VerifyResult(ok: false, verified: false, reason: "unknown/invalid keyId: \(keyId)")
}
let message = buildSigningMessage(
bundleVersion: i["bundleVersion"] as? String ?? "", wasmSha: wasmSha, manifestSha: manifestSha, keyId: keyId,
)
if !verifyEd25519(message: message, signatureB64: signature, spkiPEM: pem) {
return VerifyResult(ok: false, verified: false, reason: "Ed25519 signature failed (forged source?)")
}
return VerifyResult(ok: true, verified: true, reason: nil)
}
private static func sha256Hex(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
private static func buildSigningMessage(bundleVersion: String, wasmSha: String, manifestSha: String, keyId: String) -> String {
[
signMessagePrefix,
"bundleVersion=\(bundleVersion)",
"wasm.sha256=\(wasmSha)",
"manifest.sha256=\(manifestSha)",
"keyId=\(keyId)",
].joined(separator: "\n")
}
private static func verifyEd25519(message: String, signatureB64: String, spkiPEM: String) -> Bool {
let b64 = spkiPEM
.replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "")
.replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "")
.components(separatedBy: .whitespacesAndNewlines).joined()
// Ed25519 SPKI DER = 12-byte header + 32-byte raw key. CryptoKit wants the raw key.
guard let der = Data(base64Encoded: b64), der.count == 44,
let sig = Data(base64Encoded: signatureB64),
let key = try? Curve25519.Signing.PublicKey(rawRepresentation: der.suffix(32)) else {
return false
}
return key.isValidSignature(sig, for: Data(message.utf8))
}
}
Download HikoWasm.swift — or copy the code block above into your project as-is.
4Call your functions
Arguments follow the parameter order shown in the manifest (and in the panel). The loader resolves types from the manifest, so calls stay this simple:
import { HikoWasm } from './hiko-wasm';
const [wasmBytes, manifest] = await Promise.all([
fetch('/release.wasm').then((r) => r.arrayBuffer()),
fetch('/manifest.json').then((r) => r.json()),
]);
const hiko = await HikoWasm.load(wasmBytes, manifest);
hiko.call('calculateTax', 100, 18); // 118
hiko.call('checkCoupon', 'HIKO20', 250); // trueval hiko = HikoWasm.fromAssets(context)
hiko.call("calculateTax", 100, 18) // 118
hiko.call("checkCoupon", "HIKO20", 250) // truelet hiko = try HikoWasm()
try hiko.call("calculateTax", 100, 18) // 118
try hiko.call("checkCoupon", "HIKO20", 250) // trueHow updates work in standalone mode
- There are no over-the-air updates: your app runs exactly the files you shipped, until you ship new ones.
- To update, download the assets of a newer release tag from the panel and replace the two files in your next app release — the loader itself never needs to change.
- If you later want updates without store review, the same releases work with the Hikotest SDK — your panel workflow and functions stay identical.
Under the hood (optional reading)
The loaders implement the Hikotest WASM ABI v1. You never need these details for normal use, but for auditing:
| int / boolean | i32 (boolean: 0 = false, 1 = true) |
| float | f64 |
| string | UTF-8 through linear memory — parameters as (ptr, len) pairs via hiko_alloc; returns as a pointer to [u32 LE length][bytes] |
| Entry points | Number-only signatures: the export named after the function. Signatures with strings: the hiko_run_<name> wrapper export |
| Required import | env.abort — the loaders provide it for you |
The full contract lives in the panel repository as docs/WASM_ABI.md if you are building your own host in another language.