Skip to content

JSI Modules

s edited this page Aug 14, 2026 · 16 revisions

Native Environment: C and C++

Use the native environment when the module needs an existing C or C++ library, low-level file or memory work, or performance-sensitive code that belongs naturally in C++.

This environment can provide the module's complete implementation. It does not need Kotlin or Java.

You are also not creating a special “JSI module.” V2 always exposes JavaScript through generated JSI. C/C++ is the implementation environment you write; JSI is the connection the generator builds around it.

Add a C/C++ starter

supernote-module doctor
supernote-module add local-math --starter cpp --yes

The starter creates a C++ example under:

local_modules/local-math/android/src/main/cpp/

You can add .c, .cc, .cpp, .cxx, .h, and .hpp files below that native root. Update preserves the complete directory.

The generated CMake build uses C23 for C files and C++23 for C++ files.

What the generator handles

For marked C++ declarations, the generator creates:

  • JSI functions and native-object wrappers;
  • JavaScript argument checking and value conversion;
  • TypeScript declarations;
  • Promise and worker plumbing for explicit async calls;
  • JNI routes when native code calls marked Kotlin or Java declarations;
  • native build and React Native registration; and
  • safe runtime, feature, and object lifetime handling.

You write ordinary C and C++. Your functions do not take a jsi::Runtime, and your classes do not inherit from a generated base class.

Export a C++ function

Place the exact marker comment on its own line immediately before a top-level C++ function:

#include <cstdint>

// @SupernotePluginExport
std::int32_t add(std::int32_t left, std::int32_t right) {
  return left + right;
}

After generation, import the package and call it from JavaScript or TypeScript:

import localMath from 'local-math';

function onCalculate() {
  const total: number = localMath.add(20, 22);
  // Use total here.
}

The declaration name is the JavaScript name. There is no rename argument, so name the C++ function the way you want it to appear in index.d.ts.

Call a C library

C23 is fully supported as ordinary implementation code. A C library can expose its normal C ABI to a small marked C++ wrapper:

// checksum.c
#include <stdint.h>

int32_t checksum_adjust(int32_t value) {
  return value + 7;
}
// feature.cpp
#include <cstdint>

extern "C" std::int32_t checksum_adjust(std::int32_t value);

// @SupernotePluginExport
std::int32_t adjustedChecksum(std::int32_t value) {
  return checksum_adjust(value);
}

The extern "C" declaration is ordinary C/C++ interop. It is not a Supernote marker. The marked JavaScript boundary currently belongs in C++23; putting a Supernote marker directly on a .c declaration is rejected.

Leave ordinary helpers unmarked

Most implementation code should not have a Supernote marker:

double clampRatio(double value) {
  return std::max(0.0, std::min(value, 1.0));
}

Public C++ visibility does not expose anything to JavaScript. Call ordinary helpers through normal C++ just as you would in any other project.

Use SupernotePluginInternal only when the generator needs to create a typed, feature-aware route—for example, when generated C++ needs to call a marked Kotlin function. It is not a replacement for normal C++ function calls.

// @SupernotePluginInternal
std::int32_t rebuildIndex(std::int32_t page) {
  return page + 1;
}

An internal declaration is deliberately absent from JavaScript and index.d.ts.

Synchronous calls run on the JavaScript thread

A normal export returns before JavaScript continues:

JavaScript
    -> argument conversion
    -> your C++ function
    -> result conversion
    -> JavaScript continues

This is useful for short calculations and quick native-state queries.

It is also allowed to read a file, wait, or perform a large computation synchronously. The important consequence is that JavaScript cannot do anything else until the function returns. If the call takes two seconds, the plugin can appear frozen for two seconds.

Choose based on the behavior you want, and measure it on the target device.

Make blocking C++ work asynchronous

Add explicit async intent when JavaScript should receive a Promise and the implementation should run away from the JS thread:

#include <cstddef>
#include <cstdint>
#include <vector>

// @SupernotePluginExport
// @SupernotePluginAsync
std::vector<std::byte> loadPage(std::int32_t page) {
  return readPageBlocking(page);
}

JavaScript uses:

const bytes: Uint8Array = await localMath.loadPage(3);

For an ordinary blocking C++ function, V2 copies the arguments, registers the Promise operation, and runs the function on the plugin's shared bounded worker executor. Completion returns to the JS thread safely.

SupernotePluginAsync describes the API. The generator does not inspect an arbitrary std::future or callback type and invent an async adapter for it.

Queue capacity is deliberately bounded. If the plugin accepts work faster than the executor can hold it, the extra Promise rejects with RESOURCE_EXHAUSTED instead of allowing an unbounded memory queue.

Supported C++ values

Initial marked C++ declarations support exactly:

Meaning C++ spelling TypeScript
No value void void
Boolean bool boolean
32-bit integer std::int32_t or int32_t number
64-bit integer std::int64_t or int64_t bigint
32-bit float float number
64-bit float double number
String std::string string
Bytes std::vector<std::byte> Uint8Array

Use these canonical spellings at the marked boundary. Similar-looking aliases, pointers, references, optional values, arbitrary containers, enums, and structs are not accepted automatically.

You can use any types you want inside ordinary implementation code. Convert them at the small marked boundary.

Strings

Strings use UTF-8 while crossing native and JNI boundaries. JavaScript itself does not have a special UTF-8 string type.

Bytes

Bytes are copied values:

JavaScript Uint8Array
    -> copy the visible slice
    -> std::vector<std::byte>

Only the view's byteOffset and byteLength are passed. Empty arrays work. null, ArrayBuffer, and other typed arrays are not silently accepted.

Async calls own their copied bytes. Your worker never receives a pointer into JavaScript-owned memory.

Do not pack one number or one point into bytes just to avoid a normal JSI call. Small scalar calls are appropriate for normal UI events. A packed Uint8Array is useful when the caller already has a real batch of values and one copy can replace many separate calls.

For large values, copying can cost much more than the fixed JSI call. Keep the bytes on the side that owns them when possible, avoid echoing a large input back to JavaScript, and return only the result the caller needs.

64-bit integers

int64 maps to JavaScript bigint, never number, so IDs, offsets, file sizes, and timestamps do not silently lose precision.

Export a persistent C++ object

Use a marked class when JavaScript should keep one native instance and call it more than once.

Put the complete class in a header under the native source root:

// Document.hpp
#pragma once

#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>

// @SupernotePluginExport
class Document {
 public:
  explicit Document(std::string path);

  // @SupernotePluginExport
  std::int32_t pageCount() const;

  // @SupernotePluginExport
  // @SupernotePluginAsync
  std::vector<std::byte> loadPage(std::int32_t page);

  void rebuildTemporaryCache();  // ordinary C++, not exposed

 private:
  std::string path_;
};

Keep the implementation in an ordinary .cpp file.

With one eligible public constructor, V2 generates the normal factory automatically:

const document = localMath.Document.create('/path/to/file.note');
const count = document.pageCount();
const bytes = await document.loadPage(0);

Marking the class exposes the object type. It does not expose every public method. Each generated method still needs its own export or internal marker.

More than one constructor

If a class has multiple eligible public constructors, the generator refuses to guess. Mark the one JavaScript should use:

// @SupernotePluginExport
class Document {
 public:
  // @SupernoteConstructor
  explicit Document(std::string path);

  explicit Document(std::int64_t existingHandle);
};

Constructors are synchronous; there is no async constructor. If creation needs slow work, keep construction cheap and expose a separate explicitly async method or factory-shaped operation once that design fits your module.

Object parameters and results, returned-only objects, properties, inheritance, and custom factories are not part of the initial object model.

Object lifetime and thread safety

Generated HostObjects use shared ownership. If JavaScript starts an async method and then drops its object reference, the accepted operation keeps the C++ receiver alive until the implementation can no longer access it.

Garbage collection is not cancellation.

Lifetime safety does not make your class thread-safe. These calls may overlap:

document.loadPage(1);
document.loadPage(2);
document.pageCount();

The generator does not add a hidden mutex or serial queue around your object. Use a mutex, immutable state, your own serial executor, or another design when the implementation can be accessed concurrently.

Final generated C++ receiver destruction runs later on a managed non-JS context. Do not depend on a particular cleanup thread or exact timing. If a resource must be destroyed on a specific thread, manage that resource inside your implementation.

Errors

A synchronous C++ failure is thrown to JavaScript. A failure after an async call has been accepted rejects its Promise.

Unexpected implementation exceptions are exposed as SupernoteError with code IMPLEMENTATION_ERROR. A broken generated/runtime invariant uses INTERNAL. The C++ exception class is diagnostic information, not a stable JavaScript API.

Wrong JavaScript argument types throw TypeError, and invalid integer/range values throw RangeError, before an async operation is created.

See Error Handling for complete synchronous and async try/catch examples, stable-code handling, and generated internal C++ errors.

Call Kotlin or Java when you need it

A module using the native environment can later add the JVM environment without conversion. This is useful when native processing also needs Context, Android storage APIs, or a JVM library.

Read Using Both Environments for independent exports and the generated native-to-JVM internal caller pattern.

Regenerate and validate

After changing marked declarations:

supernote-module update local-math --yes
supernote-module validate local-math --build --verbose

Then inspect:

local_modules/local-math/index.d.ts

A local build is not the final JSI test. First load, BigInt, Promise completion, same-process reload, and teardown behavior must be exercised in the intended Supernote and PluginHost environment.

Clone this wiki locally