feat(plugins): add session storage api for plugin bridge

This commit is contained in:
geracobo
2026-07-16 18:58:38 -06:00
committed by Maurice
parent 0fc04909a8
commit 9d8d0b1653
7 changed files with 214 additions and 0 deletions
@@ -25,6 +25,7 @@ function fromFrame(frame: HTMLIFrameElement, data: unknown) {
afterEach(() => {
cleanup();
sessionStorage.clear();
navigate.mockClear();
Object.values(toast).forEach((f) => f.mockClear());
invoke.mockClear();
@@ -317,4 +318,44 @@ describe('PluginFrame', () => {
expect(geo.clearWatch).toHaveBeenCalledWith(7);
});
});
it('FE-PLUGINS-FRAME-015: brokered plugin session state round-trips through host sessionStorage', () => {
const { container } = render(<PluginFrame pluginId="demo" />);
const iframe = container.querySelector('iframe')!;
const posted: Array<Record<string, unknown>> = [];
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
fromFrame(iframe, { type: 'trek:session:set', requestId: 's1', key: 'dismissed', value: { version: 1 } });
fromFrame(iframe, { type: 'trek:session:get', requestId: 's2', key: 'dismissed' });
expect(posted.find((m) => m.requestId === 's1')).toMatchObject({ type: 'trek:response' });
expect(posted.find((m) => m.requestId === 's2')).toMatchObject({ type: 'trek:response', data: { version: 1 } });
// The raw key is host-owned: it includes the authenticated user + plugin id,
// not just the value name supplied by the untrusted frame.
expect(sessionStorage.key(0)).toContain('trek:plugin-session:7:demo:plugin:');
expect(sessionStorage.key(0)).not.toBe('dismissed');
});
it('FE-PLUGINS-FRAME-016: trip session state requires a trip and is partitioned from plugin scope', () => {
const noTrip = render(<PluginFrame pluginId="demo" />);
const noTripFrame = noTrip.container.querySelector('iframe')!;
const noTripPosted: Array<Record<string, unknown>> = [];
(noTripFrame.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => noTripPosted.push(m as Record<string, unknown>);
fromFrame(noTripFrame, { type: 'trek:session:get', requestId: 's1', key: 'filters', scope: 'trip' });
expect(noTripPosted.find((m) => m.requestId === 's1')).toMatchObject({ type: 'trek:error', code: 'NO_TRIP_CONTEXT' });
noTrip.unmount();
const { container } = render(<PluginFrame pluginId="demo" tripId="42" />);
const iframe = container.querySelector('iframe')!;
const posted: Array<Record<string, unknown>> = [];
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
fromFrame(iframe, { type: 'trek:session:set', requestId: 's2', key: 'filters', value: ['flight'], scope: 'trip' });
fromFrame(iframe, { type: 'trek:session:get', requestId: 's3', key: 'filters' });
fromFrame(iframe, { type: 'trek:session:get', requestId: 's4', key: 'filters', scope: 'trip' });
expect(posted.find((m) => m.requestId === 's3')).toMatchObject({ type: 'trek:response', data: undefined });
expect(posted.find((m) => m.requestId === 's4')).toMatchObject({ type: 'trek:response', data: ['flight'] });
});
});
@@ -106,6 +106,8 @@ interface PluginFrameProps {
path?: string
}
type PluginSessionStorageScope = 'plugin' | 'trip'
type Inbound =
| { type: 'trek:ready' }
| { type: 'trek:context:request' }
@@ -113,6 +115,10 @@ type Inbound =
| { type: 'trek:notify'; level?: 'info' | 'success' | 'warning' | 'error'; message?: string; duration?: number }
| { type: 'trek:resize'; height?: number }
| { type: 'trek:invoke'; requestId: string; sub: string; method?: string; body?: unknown }
| { type: 'trek:session:get'; requestId: string; key: string; scope?: PluginSessionStorageScope }
| { type: 'trek:session:set'; requestId: string; key: string; value: unknown; scope?: PluginSessionStorageScope }
| { type: 'trek:session:remove'; requestId: string; key: string; scope?: PluginSessionStorageScope }
| { type: 'trek:session:clear'; requestId: string; scope?: PluginSessionStorageScope }
| { type: 'trek:confirm'; requestId: string; title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }
| { type: 'trek:openExternal'; url?: string }
| { type: 'trek:geolocation'; requestId: string; action?: 'get' | 'watch' | 'clear' }
@@ -142,6 +148,38 @@ interface ConfirmRequest {
danger: boolean
}
/**
* Creates the prefix and full key for host-owned plugin session storage.
*
* trek:plugin-session:{userId}:{pluginId}:plugin:{key}
* trek:plugin-session:{userId}:{pluginId}:trip:{tripId}:{key}
*
* Each dynamic segment is URI-encoded, so plugin-controlled values cannot alter
* the key format. For clear, omit logicalKey; the returned full key is then
* the same as the scoped prefix.
*/
function createPluginSessionStorageKeys(
userId: string | number | null,
pluginId: string,
scope: PluginSessionStorageScope,
tripId: string | null,
logicalKey?: string,
) {
const namespace = 'trek:plugin-session:'
const encodedUserId = encodeURIComponent(String(userId))
const encodedPluginId = encodeURIComponent(pluginId)
const encodedTripId = scope === 'trip' ? encodeURIComponent(tripId!) : null
const encodedLogicalKey = logicalKey === undefined ? undefined : encodeURIComponent(logicalKey)
const storageScopeSegment = encodedTripId === null ? scope : `${scope}:${encodedTripId}`
const storageKeyPrefix = `${namespace}${encodedUserId}:${encodedPluginId}:${storageScopeSegment}:`
const storageKey = encodedLogicalKey === undefined ? storageKeyPrefix : `${storageKeyPrefix}${encodedLogicalKey}`
return {
storageKeyPrefix,
storageKey,
}
}
export default function PluginFrame({ pluginId, tripId = null, placeId = null, dayId = null, reservationId = null, fill = false, className, title, path = 'index.html' }: PluginFrameProps) {
const frameRef = useRef<HTMLIFrameElement | null>(null)
// A sandboxed frame may navigate ITSELF (connect-src can't stop that), and its
@@ -350,6 +388,65 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
)
break
}
case 'trek:session:clear': {
const scope = msg.scope === 'trip' ? 'trip' : 'plugin'
// tripId required if we are on a trip-scoped key.
if (scope === 'trip' && tripId == null) {
post({ type: 'trek:error', requestId: msg.requestId, code: 'NO_TRIP_CONTEXT', message: 'trip session storage requires a trip context' })
break
}
const { storageKeyPrefix } = createPluginSessionStorageKeys(userId, pluginId, scope, tripId)
try {
const keysToRemove: string[] = []
for (let i = 0; i < sessionStorage.length; i += 1) {
const storageKey = sessionStorage.key(i)
if (storageKey?.startsWith(storageKeyPrefix)) keysToRemove.push(storageKey)
}
keysToRemove.forEach((storageKey) => sessionStorage.removeItem(storageKey))
post({ type: 'trek:response', requestId: msg.requestId, data: undefined })
} catch (error) {
const message = error instanceof Error ? error.message : 'session storage failed'
post({ type: 'trek:error', requestId: msg.requestId, code: 'SESSION_STORAGE_ERROR', message })
}
break
}
case 'trek:session:get':
case 'trek:session:set':
case 'trek:session:remove': {
const scope = msg.scope === 'trip' ? 'trip' : 'plugin'
if (scope === 'trip' && tripId == null) {
post({ type: 'trek:error', requestId: msg.requestId, code: 'NO_TRIP_CONTEXT', message: 'trip session storage requires a trip context' })
break
}
const { storageKey } = createPluginSessionStorageKeys(userId, pluginId, scope, tripId, msg.key)
try {
if (msg.type === 'trek:session:get') {
const storedValue = sessionStorage.getItem(storageKey)
post({ type: 'trek:response', requestId: msg.requestId, data: storedValue === null ? undefined : JSON.parse(storedValue) })
break
}
if (msg.type === 'trek:session:set') {
const serializedValue = JSON.stringify(msg.value)
if (serializedValue === undefined) {
post({ type: 'trek:error', requestId: msg.requestId, code: 'SESSION_INVALID_VALUE', message: 'session value must be JSON-serialisable' })
break
}
sessionStorage.setItem(storageKey, serializedValue)
post({ type: 'trek:response', requestId: msg.requestId, data: undefined })
break
}
sessionStorage.removeItem(storageKey)
post({ type: 'trek:response', requestId: msg.requestId, data: undefined })
} catch (error) {
const message = error instanceof Error ? error.message : 'session storage failed'
post({ type: 'trek:error', requestId: msg.requestId, code: 'SESSION_STORAGE_ERROR', message })
}
break
}
}
}
+21
View File
@@ -76,6 +76,27 @@ const data = await trek.invoke('/status') // calls your own route, host-proxie
trek.notify('success', 'Saved')
```
### Keep UI state for this browser tab
The opaque frame cannot use browser storage directly. The bridge provides
host-managed, JSON-only session state instead. It survives an iframe remount and
page reload in the same browser tab, but is not durable data. Do not use it to
store secrets.
```js
// Default: this user + this plugin + this browser tab.
await trek.session.set('dismissed-onboarding', true)
const dismissed = await trek.session.get('dismissed-onboarding')
// Explicitly partition state by the trip in view (fails without a trip context).
await trek.session.set('filters', ['flight', 'hotel'], { scope: 'trip' })
```
`get()` returns `undefined` for a missing key. `set`, `get`, `remove`, and
`clear` accept `{ scope: 'plugin' | 'trip' }`; `plugin` is the default. Use your
plugin's own logical key (for example `reservation:88:expanded`) for any finer
partitioning.
The kit applies the theme, mirrors the appearance flags (reduced-motion,
no-transparency) and auto-reports your height. It also upgrades any native
`<select>` into a host-styled, keyboard-accessible dropdown that matches TREK —
+19
View File
@@ -664,6 +664,22 @@ function ctx(){
}
function postCtx(){ if(f.contentWindow) f.contentWindow.postMessage(ctx(),"*"); }
var tt; function toast(msg){var el=document.getElementById("toast");el.textContent=msg;el.classList.add("on");clearTimeout(tt);tt=setTimeout(function(){el.classList.remove("on");},2200);}
var SESSION_PREFIX="trek:plugin-session:1:"+encodeURIComponent(${JSON.stringify(id)})+":";
function sessionMessage(m){
var trip=ctx().tripId, scope=m.scope==="trip"?"trip":"plugin";
if(scope==="trip"&&!trip){f.contentWindow.postMessage({type:"trek:error",requestId:m.requestId,code:"NO_TRIP_CONTEXT",message:"trip session storage requires a trip context"},"*");return;}
var prefix=SESSION_PREFIX+scope+(scope==="trip"?":"+encodeURIComponent(String(trip)):"")+":";
var data;
if(m.type==="trek:session:clear"){
var doomed=[];for(var i=0;i<sessionStorage.length;i++){var stored=sessionStorage.key(i);if(stored&&stored.indexOf(prefix)===0)doomed.push(stored);}doomed.forEach(function(stored){sessionStorage.removeItem(stored);});
} else {
var key=prefix+encodeURIComponent(m.key);
if(m.type==="trek:session:get"){var raw=sessionStorage.getItem(key);data=raw===null?undefined:JSON.parse(raw);}
else if(m.type==="trek:session:remove"){sessionStorage.removeItem(key);}
else {sessionStorage.setItem(key,JSON.stringify(m.value));}
}
f.contentWindow.postMessage({type:"trek:response",requestId:m.requestId,data:data},"*");
}
window.addEventListener("message", function(ev){
if(ev.source!==f.contentWindow) return;
var m=ev.data; if(!m||typeof m!=="object") return;
@@ -697,6 +713,9 @@ window.addEventListener("message", function(ev){
}
else { f.contentWindow.postMessage({type:"trek:geolocation:result",requestId:m.requestId,position:pos},"*"); }
}
else if(m.type==="trek:session:get"||m.type==="trek:session:set"||m.type==="trek:session:remove"||m.type==="trek:session:clear"){
sessionMessage(m);
}
});
var geoTick=null;
["theme","accent","rm","nt","blur","trip"].forEach(function(id){ val(id).addEventListener("change",postCtx); });
+32
View File
@@ -457,9 +457,41 @@ export const TREK_THEME_JS = `(function () {
mount: function (node, target) { (target || document.body).appendChild(node); return node; }
};
var session = {
get: function (key, opts) {
var id = 's' + (++seq);
return new Promise(function (resolve, reject) {
pending[id] = { resolve: resolve, reject: reject };
send({ type: 'trek:session:get', requestId: id, key: key, scope: opts && opts.scope });
});
},
set: function (key, value, opts) {
var id = 's' + (++seq);
return new Promise(function (resolve, reject) {
pending[id] = { resolve: resolve, reject: reject };
send({ type: 'trek:session:set', requestId: id, key: key, value: value, scope: opts && opts.scope });
});
},
remove: function (key, opts) {
var id = 's' + (++seq);
return new Promise(function (resolve, reject) {
pending[id] = { resolve: resolve, reject: reject };
send({ type: 'trek:session:remove', requestId: id, key: key, scope: opts && opts.scope });
});
},
clear: function (opts) {
var id = 's' + (++seq);
return new Promise(function (resolve, reject) {
pending[id] = { resolve: resolve, reject: reject };
send({ type: 'trek:session:clear', requestId: id, scope: opts && opts.scope });
});
}
};
var api = {
context: null,
ui: ui,
session: session,
ready: function () { send({ type: 'trek:ready' }); },
requestContext: function () { send({ type: 'trek:context:request' }); },
onContext: function (cb) {
+3
View File
@@ -27,6 +27,9 @@ describe('design kit', () => {
expect(TREK_THEME_JS).toContain('trek:resize');
// Trusts only the real parent window (opaque frame has a 'null' origin).
expect(TREK_THEME_JS).toContain('ev.source !== window.parent');
// Host-managed tab state uses the same request/response channel.
expect(TREK_THEME_JS).toContain("trek:session:get");
expect(TREK_THEME_JS).toContain('session: session');
});
it('never contains a closing tag that would break <style>/<script> inlining', () => {
+1
View File
@@ -456,6 +456,7 @@ works. Add `data-trek-native` to a field to keep the browser default; `multiple`
| `trek.onContext(cb)` | run `cb(context)` now (if already received) and on every update; returns an unsubscribe fn |
| `trek.context` | the last context (or `null`) |
| `trek.invoke(sub, { method, body })` | call your own route; returns a `Promise` (rejects with an `Error`, `.code` = HTTP status) |
| `trek.session.get(key, options?)`, `.set(key, value, options?)`, `.remove(key, options?)`, `.clear(options?)` | Host-managed JSON session state for this browser tab. `scope: 'plugin'` is the default; `scope: 'trip'` additionally partitions it by the trip in view and rejects with `NO_TRIP_CONTEXT` outside a trip. Missing `get()` values are `undefined`. Do not use it to store secrets. |
| `trek.notify(level, message, duration?)` | toast (`info`/`success`/`warning`/`error`); optional duration in ms (clamped 1.5–15s) |
| `trek.confirm({ title?, message, confirmLabel?, cancelLabel?, danger? })` | host-rendered native confirm dialog; resolves `true`/`false` (one at a time — a second concurrent request resolves `false`) |
| `trek.navigate(to)` | in-app navigation (relative paths only) |