mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(plugins): add session storage api for plugin bridge
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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); });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user