mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user