feat(napplet): structured-clone transport + relay.subscribe push channel

Stock napplets (built on @napplet/core) post structured-clone OBJECTS,
not JSON strings, and expect object replies — our shell only forwarded
strings, so their messages were dropped. Bridge the transport so real
napplets interop:

- shell.html bridges object<->string both directions: applet->native
  serializes object envelopes; native->applet parses and posts a
  structured-clone object (what the SDK reads via e.data.type).
- resource.bytes returns a real Blob: the shell rebuilds it from the
  host's base64 bytes+mime before delivering.
- relay.subscribe is push-based: relay.event (per match) then relay.eose,
  keyed by subId, no .result — matching @napplet/shim. New MSG_PUSH IPC
  frame carries unsolicited envelopes the host forwards verbatim;
  relay.close is a fire-and-forget no-op. Delivers the initial snapshot
  then EOSE (a live tail is a follow-up).

Injected shim updated to accept object messages, dispatch subscription
pushes by subId, and use the push-based subscribe. Codec gains
encodeRelayEvent/encodeRelayEose/readSubId with unit tests.

The shell/shim are JS and not covered by the JVM tests — this needs
on-device verification with a playground napplet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-21 15:34:36 +00:00
parent 17b3252e76
commit 56d836724f
7 changed files with 171 additions and 37 deletions
@@ -151,14 +151,26 @@ than dumping raw content. Corrected in code: identity reads now emit method-spec
`storage.keys` returns `keys`; `relay.publish`/`publishEncrypted` read the template from `event`;
`getProfile` builds a `ProfileData` object. Locked by `NappletProtocolJsonTest`.
**Transport gap still open (the real blocker for stock napplets).** `@napplet/core` posts
**Transport: structured-clone objects + subscription push — landed.** `@napplet/core` posts
**structured-clone objects** (not JSON strings) via `target.postMessage(obj)` and validates
cloneability — that is how `resource.bytes` returns a real `Blob`. Our shell relay
(`shell.html`) only forwards `typeof e.data === 'string'` and posts replies back as strings, so a
stock napplet's object messages are dropped today. Making the shell bridge object↔string (and
converting the `resource.bytes` base64 reply into a real `Blob`), plus the `relay.subscribe`
push channel (`relay.event`/`relay.eose`, no `.result`) and multi-`filters` queries, is the next
focused pass — and it needs on-device verification.
cloneability — that is how `resource.bytes` returns a real `Blob`, and why a stock napplet's
object messages were dropped before (our shell only forwarded strings). Fixed:
- **`shell.html` bridges object↔string both ways.** applet→native serializes object envelopes to
the string the native bridge carries (requests carry no Blobs); native→applet parses the reply
to an object and posts a **structured-clone object** (what the SDK reads via `e.data.type`), not
a string. The injected shim accepts either form.
- **`resource.bytes` Blob.** The shell rebuilds a real `Blob` from the host's base64 `bytes`+`mime`
before delivering, so both the SDK and our shim resolve to a `Blob`.
- **`relay.subscribe` push channel.** Subscriptions are answered with `relay.event` (one per match)
then `relay.eose`, keyed by `subId` — no `.result`, matching the SDK. A new `MSG_PUSH` IPC frame
lets the broker push unsolicited envelopes the host forwards verbatim; `relay.close` is a
fire-and-forget no-op. Today this delivers the **initial snapshot then EOSE**.
Still open: a **live subscription tail** (push as events arrive, not just the snapshot) plus
`identity.onChanged`/`inc.on`; multi-`filters` queries (we use the first filter); the Blossom
`upload` gateway; and **on-device verification** — the shell/shim changes are JS and not exercised
by the JVM unit tests.
## Update (2026-06-20, later): verified against `@napplet/shim@0.16.0` and corrected
+21 -4
View File
@@ -22,16 +22,33 @@
var bridge = window.__nappletBridge;
// applet -> native: only forward messages that came from our applet iframe.
// The @napplet SDK posts structured-clone objects; our injected shim posts strings. The
// native bridge carries only strings, so serialize objects here. (Requests carry no Blobs.)
window.addEventListener('message', function (e) {
if (e.source !== iframe.contentWindow) return;
if (typeof e.data !== 'string') return;
if (bridge) bridge.postMessage(e.data);
var data = e.data;
if (typeof data !== 'string') {
try { data = JSON.stringify(data); } catch (_) { return; }
}
if (bridge) bridge.postMessage(data);
});
// native -> applet: hand the broker's reply down into the iframe.
// native -> applet: hand the broker's reply (or a subscription push) down into the iframe as
// a structured-clone object, which is what the SDK expects (it reads e.data.type, not a string).
if (bridge) {
bridge.onmessage = function (e) {
iframe.contentWindow.postMessage(e.data, '*');
var msg;
try { msg = JSON.parse(e.data); } catch (_) { return; }
// resource.bytes resolves to a real Blob in the SDK; rebuild it from the base64 the host sent.
if (msg && msg.type === 'resource.bytes.result' && typeof msg.bytes === 'string') {
try {
var bin = atob(msg.bytes), u = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
msg.blob = new Blob([u], { type: msg.mime || '' });
delete msg.bytes;
} catch (_) {}
}
iframe.contentWindow.postMessage(msg, '*');
};
}
@@ -127,8 +127,26 @@ class NappletBrokerService : Service() {
val requestType = runCatching { NappletProtocolJson.readType(payload) }.getOrNull() ?: "napplet"
scope.launch {
// Unsubscribe is fire-and-forget: snapshot subscriptions have no live tail to cancel.
if (requestType == "relay.close") {
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Done))
return@launch
}
val response = process(identity, declared, payload)
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, response))
// A subscription is answered with relay.event/relay.eose pushes (keyed by subId), not a
// .result — matching @napplet/shim. Today it delivers the initial matches then EOSE; a
// live tail is a follow-up.
val subId = if (requestType == "relay.subscribe") runCatching { NappletProtocolJson.readSubId(payload) }.getOrNull() else null
if (subId != null) {
if (response is NappletResponse.Events) {
response.events.forEach { push(replyTo, NappletProtocolJson.encodeRelayEvent(subId, it)) }
}
push(replyTo, NappletProtocolJson.encodeRelayEose(subId))
} else {
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, response))
}
}
return true
}
@@ -416,6 +434,22 @@ class NappletBrokerService : Service() {
}
}
/** Sends an unsolicited push (a `relay.event`/`relay.eose` envelope) for the host to forward verbatim. */
private fun push(
replyTo: Messenger,
payload: String,
) {
val message =
Message.obtain(null, NappletIpc.MSG_PUSH).apply {
data = Bundle().apply { putString(NappletIpc.KEY_PAYLOAD, payload) }
}
try {
replyTo.send(message)
} catch (e: RemoteException) {
Log.w("NappletBrokerService", "Applet host went away before push could be delivered", e)
}
}
companion object {
private const val QUERY_TIMEOUT_MS = 8_000L
private const val WALLET_TIMEOUT_MS = 60_000L
@@ -357,15 +357,24 @@ class NappletHostActivity : ComponentActivity() {
}
private fun onBrokerReply(msg: Message): Boolean {
if (msg.what != NappletIpc.MSG_RESPONSE) return false
val data = msg.data ?: return true
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
when (msg.what) {
NappletIpc.MSG_RESPONSE -> {
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
// payload is the broker's {type:"...result", ok, ...}; inject the correlation id for the shim.
val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject()
result.put("id", id)
bridgeReplyProxy?.postMessage(result.toString())
// payload is the broker's {type:"...result", ok, ...}; inject the correlation id for the shim.
val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject()
result.put("id", id)
bridgeReplyProxy?.postMessage(result.toString())
}
// A subscription push (relay.event/relay.eose) is keyed by subId, not a request id; forward verbatim.
NappletIpc.MSG_PUSH -> {
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
bridgeReplyProxy?.postMessage(payload)
}
else -> return false
}
return true
}
@@ -419,28 +428,35 @@ class NappletHostActivity : ComponentActivity() {
private const val SHIM_JS = """
(function(){
if (window.__nappletShimInstalled) return; window.__nappletShimInstalled = true;
var seq = 0, pending = {};
var seq = 0, pending = {}, subs = {};
function send(env){ env.id = env.id || ('r' + (seq++)); parent.postMessage(JSON.stringify(env), '*'); return env.id; }
function call(type, fields){
return new Promise(function(resolve, reject){
var id = 'r' + (seq++);
var env = { type: type }; if (fields) for (var k in fields) env[k] = fields[k];
var id = send(env);
pending[id] = { resolve: resolve, reject: reject };
var env = { type: type, id: id };
if (fields) for (var k in fields) env[k] = fields[k];
parent.postMessage(JSON.stringify(env), '*');
});
}
// Fire-and-forget (no .result awaited), used for subscribe/unsubscribe.
function post(type, fields){ var env = { type: type }; if (fields) for (var k in fields) env[k] = fields[k]; send(env); }
window.addEventListener('message', function(e){
if (e.source !== parent) return;
if (typeof e.data !== 'string') return;
var msg; try { msg = JSON.parse(e.data); } catch (_) { return; }
if (!msg || !msg.id) return;
var msg; if (typeof e.data === 'string') { try { msg = JSON.parse(e.data); } catch (_) { return; } } else { msg = e.data; }
if (!msg) return;
// Subscription pushes are keyed by subId, not a request id.
if (msg.type === 'relay.event' || msg.type === 'relay.eose') {
var sub = subs[msg.subId]; if (!sub) return;
if (msg.type === 'relay.event') { if (sub.onEvent) sub.onEvent(msg.event); }
else { if (sub.onEose) sub.onEose(); }
return;
}
if (!msg.id) return;
var p = pending[msg.id]; if (!p) return; delete pending[msg.id];
if (msg.ok) p.resolve(msg);
else { var err = new Error(msg.reason || msg.operation || msg.error || 'napplet error'); err.napplet = msg; p.reject(err); }
});
function field(promise, name){ return promise.then(function(m){ return m[name]; }); }
function normFilters(filters){ return Array.isArray(filters) ? { filters: filters } : { filter: filters || {} }; }
function b64ToBytes(b64){ var bin = atob(b64); var u = new Uint8Array(bin.length); for (var i=0;i<bin.length;i++) u[i]=bin.charCodeAt(i); return u; }
function bytesToB64(bytes){ var u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); var s=''; for (var i=0;i<u.length;i++) s+=String.fromCharCode(u[i]); return btoa(s); }
var actionSeq = 0;
var napplet = {
@@ -477,14 +493,14 @@ class NappletHostActivity : ComponentActivity() {
publish: function(template, options){ return field(call('relay.publish', { event: template, options: options }), 'event'); },
publishEncrypted: function(template, recipient, encryption){ return field(call('relay.publishEncrypted', { event: template, recipient: recipient, encryption: encryption || 'nip44' }), 'event'); },
query: function(filters){ return field(call('relay.query', normFilters(filters)), 'events'); },
// subscribe currently delivers the initial matches; a live tail is a follow-up.
// subscribe is push-based: the shell sends relay.event/relay.eose keyed by subId. Today it
// delivers the initial matches then EOSE; a live tail is a follow-up.
subscribe: function(filters, onEvent, onEose, options){
call('relay.subscribe', normFilters(filters)).then(function(m){
var events = m.events || [];
if (typeof onEvent === 'function') events.forEach(function(ev){ onEvent(ev); });
if (typeof onEose === 'function') onEose();
});
return { close: function(){} };
var subId = 's' + (seq++);
subs[subId] = { onEvent: onEvent, onEose: onEose };
var env = normFilters(filters); env.subId = subId;
post('relay.subscribe', env);
return { close: function(){ delete subs[subId]; post('relay.close', { subId: subId }); } };
}
},
storage: {
@@ -499,8 +515,9 @@ class NappletHostActivity : ComponentActivity() {
payInvoice: function(invoice){ return field(call('value.payInvoice', { invoice: invoice }), 'preimage'); }
},
resource: {
bytes: function(url){ return call('resource.bytes', { url: url }).then(function(m){ return new Blob([b64ToBytes(m.bytes)], { type: m.mime || '' }); }); },
bytesAsObjectURL: function(url){ return call('resource.bytes', { url: url }).then(function(m){ return URL.createObjectURL(new Blob([b64ToBytes(m.bytes)], { type: m.mime || '' })); }); }
// The shell rebuilds the Blob from the host's base64 before this resolves.
bytes: function(url){ return field(call('resource.bytes', { url: url }), 'blob'); },
bytesAsObjectURL: function(url){ return field(call('resource.bytes', { url: url }), 'blob').then(function(blob){ return URL.createObjectURL(blob); }); }
},
// upload.blob is an Amethyst-specific extension (not part of @napplet/shim).
upload: {
@@ -33,6 +33,13 @@ object NappletIpc {
/** Broker → host: the matching reply. Carries [KEY_REQUEST_ID] and [KEY_PAYLOAD]. */
const val MSG_RESPONSE = 2
/**
* Broker → host: an **unsolicited push** (a live `relay.event`/`relay.eose` for a subscription).
* Carries only [KEY_PAYLOAD] — a full envelope keyed by the applet's `subId`, not a request id —
* which the host forwards to the applet verbatim.
*/
const val MSG_PUSH = 3
const val KEY_REQUEST_ID = "requestId"
const val KEY_PAYLOAD = "payload"
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.napplet
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
@@ -54,6 +55,27 @@ object NappletProtocolJson {
/** The `type` discriminant of a request envelope, used to build the matching `.result` type. */
fun readType(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("type")
/** The `subId` of a subscription request, used to key the `relay.event`/`relay.eose` pushes back to it. */
fun readSubId(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("subId")
/** A `relay.event` push: delivers one matching [event] to the subscription [subId] (no request id). */
fun encodeRelayEvent(
subId: String,
event: Event,
): String =
buildJsonObject {
put("type", "relay.event")
put("subId", subId)
put("event", json.parseToJsonElement(event.toJson()))
}.toString()
/** A `relay.eose` push: signals end-of-stored-events for the subscription [subId]. */
fun encodeRelayEose(subId: String): String =
buildJsonObject {
put("type", "relay.eose")
put("subId", subId)
}.toString()
/** Parses a request envelope. Returns `null` for an unrecognized `type` so the broker can deny it. */
fun decodeRequest(envelopeJson: String): NappletRequest? {
val o = json.parseToJsonElement(envelopeJson).jsonObject
@@ -188,6 +188,31 @@ class NappletProtocolJsonTest {
assertEquals("relay.publish", NappletProtocolJson.readType("""{"type":"relay.publish","id":"9"}"""))
}
@Test
fun readsSubIdFromASubscription() {
assertEquals("s7", NappletProtocolJson.readSubId("""{"type":"relay.subscribe","id":"1","subId":"s7","filters":[{}]}"""))
assertNull(NappletProtocolJson.readSubId("""{"type":"relay.query"}"""))
}
@Test
fun encodesSubscriptionPushesKeyedBySubId() {
val ev = json.parseToJsonElement(NappletProtocolJson.encodeRelayEvent("s1", sampleEvent())).jsonObject
assertEquals("relay.event", ev["type"]?.jsonPrimitive?.content)
assertEquals("s1", ev["subId"]?.jsonPrimitive?.content)
assertEquals(
"a".repeat(64),
ev["event"]
?.jsonObject
?.get("id")
?.jsonPrimitive
?.content,
)
val eose = json.parseToJsonElement(NappletProtocolJson.encodeRelayEose("s1")).jsonObject
assertEquals("relay.eose", eose["type"]?.jsonPrimitive?.content)
assertEquals("s1", eose["subId"]?.jsonPrimitive?.content)
}
// ---- encode responses (".result" envelope) ----
@Test