Skip to content

Commit 189fefc

Browse files
authored
feat(types): execInPage, onRequest & party APIs + supports() capability helper (#10933)
* feat(types): add execInPage and onRequest APIs to Presence/iFrame * docs(types): document feature-detection for execInPage/onRequest on older extensions * feat(premid): add supports() helper for version-safe capability detection * feat(lint): error on unguarded execInPage/onRequest with autofix to wrap in supports() check * feat(types): add party to PresenceData (2.14.0) * fix(types): infer execInPage arg types into the page closure Type execInPage's rest args as a tuple generic (A extends unknown[]) so the values passed are forwarded to the closure with their types instead of erasing to unknown[]. Applies to both Presence and iFrame overloads. Addresses review feedback on #10933. * docs: document execInPage, onRequest and the party object - presence-class (API + guide): detailed execInPage (function + declarative ExecInPageSpec forms) and onRequest (RequestFilter, InterceptedRequest, unsubscribe), both gated behind the supports() helper. - presence-data: party / PartyData (only honored on ActivityType.Playing). - utility-functions: supports() capability-detection helper. - iframe: execInPage reference. For #10933. * docs: note dot-path support for execInPage pick/omit pick/omit accept dot-paths in the runtime (PreMiD/Extension#780); document the nested-field capability on the type and in the API reference. * style: realign execInPage spec table
1 parent ed87a4e commit 189fefc

11 files changed

Lines changed: 678 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
!cli/bin/**/*.*js
44
!*.config.*js
55
!.github/scripts/**/*.mjs
6+
!eslint-rules/**/*.mjs
67
websites/**/dist
78
websites/**/tsconfig.json
89
node_modules

@types/premid/index.d.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,18 @@ declare global {
150150
* @since 2.8.0
151151
*/
152152
smallImageUrl?: string
153+
/**
154+
* Party info shown on Playing activities (e.g. "2 of 5").
155+
*
156+
* Only honored on `type: ActivityType.Playing`. `partyId` is optional —
157+
* when omitted a stable fallback is generated.
158+
* @since 2.14.0
159+
*/
160+
party?: {
161+
partyId?: string
162+
partySize: number
163+
maxPartySize: number
164+
}
153165
}
154166

155167
interface MediaPresenceData extends BasePresenceData {
@@ -429,6 +441,56 @@ declare global {
429441
content: T
430442
}
431443

444+
/**
445+
* Declarative, CSP-immune form of `Presence#execInPage` / `iFrame#execInPage`.
446+
* Use instead of a closure when you only need to read a value or call a page
447+
* function — it works even on pages whose CSP blocks `eval`.
448+
* @since 2.14
449+
*/
450+
interface ExecInPageSpec {
451+
/** Dot-path to a value on `window` to read, e.g. `'player.track.name'`. */
452+
get?: string
453+
/** Dot-path to a page function to invoke, e.g. `'spotifyPlayer.getState'`. */
454+
call?: string
455+
/** Arguments passed to the `call` function (must be serializable). */
456+
args?: unknown[]
457+
/** Keep only these keys of the result. Dot-paths allowed, e.g. `'track.name'`. */
458+
pick?: string[]
459+
/** Drop these keys from the result. Dot-paths allowed, e.g. `'track.art'`. */
460+
omit?: string[]
461+
}
462+
463+
/**
464+
* Filter for `Presence#onRequest`.
465+
* @since 2.14
466+
*/
467+
interface RequestFilter {
468+
/** Match the request URL — substring (string) or pattern (RegExp). */
469+
url?: string | RegExp
470+
/** Match the HTTP method (case-insensitive). One or many. */
471+
method?: string | string[]
472+
}
473+
474+
/**
475+
* Read-only snapshot of a captured request/response passed to an
476+
* `Presence#onRequest` callback.
477+
* @since 2.14
478+
*/
479+
interface InterceptedRequest {
480+
url: string
481+
method: string
482+
requestHeaders: Record<string, string>
483+
requestBody: string | null
484+
status: number
485+
statusText: string
486+
ok: boolean
487+
responseHeaders: Record<string, string>
488+
responseBody: string | null
489+
/** URL of the frame the request originated from (page or iframe). */
490+
frameUrl: string
491+
timestamp: number
492+
}
493+
432494
/**
433495
* Useful tools for developing presences
434496
* @link https://docs.premid.app/en/dev/presence/class
@@ -662,6 +724,74 @@ declare global {
662724
getPageVariable<T extends Record<string, any> = Record<string, unknown>>(
663725
...variables: string[]
664726
): Promise<T>
727+
/**
728+
* Run code in the web page's own realm and get its (serializable) return value.
729+
*
730+
* Unlike `getPageVariable`, the function executes *inside the page*, so you
731+
* can call the page's own functions and reshape the result — stripping
732+
* non-serializable parts (streams, DOM nodes, circular refs) — before it is
733+
* sent back. The return value must be JSON-serializable.
734+
*
735+
* The closure cannot reference activity-side variables; pass them as args.
736+
* @example
737+
* const track = await presence.execInPage((id) => {
738+
* const s = window.spotifyPlayer.getCurrentState()
739+
* return { id, title: s.track.name, paused: s.paused }
740+
* }, userId)
741+
* @remarks
742+
* Added in extension 2.14. Older versions lack this method, so feature-detect
743+
* with the bundled `supports` helper before calling:
744+
* ```ts
745+
* import { supports } from 'premid'
746+
* if (supports(presence, 'execInPage')) {
747+
* const data = await presence.execInPage(() => window.appState)
748+
* }
749+
* ```
750+
* @since 2.14
751+
*/
752+
execInPage<T = unknown, A extends unknown[] = unknown[]>(
753+
fn: (...args: A) => T | Promise<T>,
754+
...args: A
755+
): Promise<T>
756+
/**
757+
* Declarative, CSP-immune variant of `execInPage`. Reads a value (`get`) or
758+
* calls a page function (`call`) and optionally strips fields — works even
759+
* on pages whose CSP blocks `eval`.
760+
* @example
761+
* const paused = await presence.execInPage({ call: 'spotifyPlayer.isPaused' })
762+
* @since 2.14
763+
*/
764+
execInPage<T = unknown>(spec: ExecInPageSpec): Promise<T>
765+
/**
766+
* Read (never modify) requests the page makes via `fetch` or
767+
* `XMLHttpRequest`, including requests made inside the activity's iframes.
768+
* The callback receives a read-only snapshot of the request and its response.
769+
*
770+
* Interception runs from `document_start`; requests that complete before the
771+
* activity registers a filter are replayed with request metadata only (no
772+
* response body).
773+
* @param filter Match by URL (substring or RegExp) and/or HTTP method.
774+
* @param callback Invoked with each matching request.
775+
* @returns Unsubscribe function.
776+
* @example
777+
* presence.onRequest({ url: '/api/now-playing', method: 'GET' }, (req) => {
778+
* const data = JSON.parse(req.responseBody ?? '{}')
779+
* })
780+
* @remarks
781+
* Added in extension 2.14. Older versions lack this method, so feature-detect
782+
* with the bundled `supports` helper before calling:
783+
* ```ts
784+
* import { supports } from 'premid'
785+
* if (supports(presence, 'onRequest')) {
786+
* presence.onRequest({ url: '/api/now-playing' }, (req) => { ... })
787+
* }
788+
* ```
789+
* @since 2.14
790+
*/
791+
onRequest(
792+
filter: RequestFilter,
793+
callback: (request: InterceptedRequest) => void
794+
): () => void
665795
/**
666796
* Sends data back to application
667797
* @param event Event
@@ -789,6 +919,20 @@ declare global {
789919
* @since 2.0-BETA3
790920
*/
791921
getUrl(): Promise<string>
922+
/**
923+
* Run code in the iframe page's own realm and get its (serializable) return
924+
* value. See `Presence#execInPage` for the full contract.
925+
* @remarks
926+
* Added in extension 2.14 — feature-detect with the bundled `supports`
927+
* helper (`supports(iframe, 'execInPage')`) before calling so older
928+
* extensions don't throw.
929+
* @since 2.14
930+
*/
931+
execInPage<T = unknown, A extends unknown[] = unknown[]>(
932+
fn: (...args: A) => T | Promise<T>,
933+
...args: A
934+
): Promise<T>
935+
execInPage<T = unknown>(spec: ExecInPageSpec): Promise<T>
792936
/**
793937
* Subscribe to events emitted by the extension
794938
* @param eventName

docs/v1/api/iframe.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,31 @@ const url = await iframe.getUrl()
6262
console.log(url) // "https://example.com/embed/video"
6363
```
6464

65+
### execInPage
66+
67+
::: tip Requires extension 2.14+
68+
Added in extension **2.14**. Guard the call with the bundled [`supports`](/v1/api/utility-functions#supports) helper (`supports(iframe, 'execInPage')`) so it degrades gracefully on older extensions.
69+
:::
70+
71+
<!-- eslint-skip -->
72+
73+
```typescript
74+
execInPage<T = unknown, A extends unknown[] = unknown[]>(fn: (...args: A) => T | Promise<T>, ...args: A): Promise<T>;
75+
execInPage<T = unknown>(spec: ExecInPageSpec): Promise<T>;
76+
```
77+
78+
Runs code in the iframe page's own realm and resolves with its (serializable) return value. Behaves exactly like [`Presence#execInPage`](/v1/api/presence-class#execinpage) — see there for the full contract, the declarative `ExecInPageSpec` form, and examples.
79+
80+
#### Example
81+
82+
```typescript
83+
import { supports } from 'premid'
84+
85+
if (supports(iframe, 'execInPage')) {
86+
const duration = await iframe.execInPage(() => window.player.getDuration())
87+
}
88+
```
89+
6590
### on
6691

6792
<!-- eslint-skip -->

docs/v1/api/presence-class.md

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,153 @@ const {
124124
)
125125
```
126126

127+
### execInPage
128+
129+
::: tip Requires extension 2.14+
130+
`execInPage` was added in extension **2.14**. Activities run on every installed version, so guard the call with the bundled [`supports`](/v1/api/utility-functions#supports) helper — on older extensions the method is missing, and feature-detecting lets it degrade gracefully instead of throwing.
131+
:::
132+
133+
<!-- eslint-skip -->
134+
135+
```typescript
136+
execInPage<T = unknown, A extends unknown[] = unknown[]>(fn: (...args: A) => T | Promise<T>, ...args: A): Promise<T>;
137+
execInPage<T = unknown>(spec: ExecInPageSpec): Promise<T>;
138+
```
139+
140+
Runs code in the **web page's own realm** and resolves with its return value. Unlike [`getPageVariable`](#getpagevariable), which only reads a variable, `execInPage` runs a function _inside the page_ — so you can call the page's own functions and reshape the result before it is sent back to the activity.
141+
142+
The return value must be **JSON-serializable**. Strip non-serializable parts (DOM nodes, streams, circular references) inside the function before returning.
143+
144+
::: warning The closure is isolated
145+
The function is serialized and re-evaluated in the page, so it **cannot reference activity-side variables** (imports, closures, module scope). Pass any values it needs as trailing arguments — they are forwarded to the function with their types preserved.
146+
:::
147+
148+
#### Function form
149+
150+
##### Parameters
151+
152+
- `fn`: A function executed in the page. Receives `...args` and returns a serializable value (or a promise of one).
153+
- `args`: Serializable values forwarded to `fn` as arguments.
154+
155+
##### Example
156+
157+
```typescript
158+
import { supports } from 'premid'
159+
160+
if (supports(presence, 'execInPage')) {
161+
const track = await presence.execInPage((userId) => {
162+
const state = window.spotifyPlayer.getCurrentState()
163+
return { userId, title: state.track.name, paused: state.paused }
164+
}, currentUserId)
165+
}
166+
```
167+
168+
#### Declarative form (`ExecInPageSpec`)
169+
170+
For pages whose Content-Security-Policy blocks `eval`, pass a declarative spec instead of a closure. It reads a value or calls a page function by dot-path — no code evaluation, so it works under strict CSP.
171+
172+
| Property | Type | Description |
173+
| -------- | ----------- | ---------------------------------------------------------------------------- |
174+
| `get` | `string` | Dot-path to a value on `window` to read (e.g. `'player.track.name'`). |
175+
| `call` | `string` | Dot-path to a page function to invoke (e.g. `'spotifyPlayer.getState'`). |
176+
| `args` | `unknown[]` | Arguments passed to the `call` function (must be serializable). |
177+
| `pick` | `string[]` | Keep only these keys of the result. Dot-paths allowed (e.g. `'track.name'`). |
178+
| `omit` | `string[]` | Drop these keys from the result. Dot-paths allowed (e.g. `'track.art'`). |
179+
180+
##### Example
181+
182+
```typescript
183+
// Read a nested value
184+
const title = await presence.execInPage<string>({ get: 'player.track.name' })
185+
186+
// Call a page function and keep only some fields of the result
187+
const state = await presence.execInPage({
188+
call: 'spotifyPlayer.getState',
189+
pick: ['paused', 'position'],
190+
})
191+
192+
// Dot-paths pull nested fields out of a call result
193+
const track = await presence.execInPage({
194+
call: 'spotifyPlayer.getState',
195+
pick: ['track.name', 'track.artist'],
196+
}) // → { track: { name, artist } }
197+
```
198+
199+
### onRequest
200+
201+
::: tip Requires extension 2.14+
202+
`onRequest` was added in extension **2.14**. Guard the call with the bundled [`supports`](/v1/api/utility-functions#supports) helper so it degrades gracefully on older extensions.
203+
:::
204+
205+
<!-- eslint-skip -->
206+
207+
```typescript
208+
onRequest(filter: RequestFilter, callback: (request: InterceptedRequest) => void): () => void;
209+
```
210+
211+
**Reads** (never modifies) requests the page makes via `fetch` or `XMLHttpRequest`, including requests made inside the activity's iframes. Use it to pull data straight from a site's own API responses instead of scraping the DOM.
212+
213+
Interception runs from `document_start`. Requests that complete **before** your activity registers a filter are replayed to the callback with request metadata only — no `responseBody`.
214+
215+
#### Parameters
216+
217+
- `filter` (`RequestFilter`): Which requests to match. Omitting a field matches everything for that field.
218+
219+
| Property | Type | Description |
220+
| -------- | -------------------- | --------------------------------------------------------------- |
221+
| `url` | `string \| RegExp` | Match the request URL — substring (string) or pattern (RegExp). |
222+
| `method` | `string \| string[]` | Match the HTTP method (case-insensitive). One or many. |
223+
224+
- `callback`: Invoked with an `InterceptedRequest` for each matching request.
225+
226+
#### Returns
227+
228+
An **unsubscribe** function. Call it to stop receiving requests (e.g. when the relevant page section is gone).
229+
230+
#### InterceptedRequest
231+
232+
A read-only snapshot of the request and its response passed to the callback:
233+
234+
| Property | Type | Description |
235+
| ----------------- | ------------------------ | --------------------------------------------------------- |
236+
| `url` | `string` | Request URL |
237+
| `method` | `string` | HTTP method |
238+
| `requestHeaders` | `Record<string, string>` | Request headers |
239+
| `requestBody` | `string \| null` | Request body, if any |
240+
| `status` | `number` | Response status code |
241+
| `statusText` | `string` | Response status text |
242+
| `ok` | `boolean` | `true` for 2xx responses |
243+
| `responseHeaders` | `Record<string, string>` | Response headers |
244+
| `responseBody` | `string \| null` | Response body (`null` for requests replayed at page load) |
245+
| `frameUrl` | `string` | URL of the frame the request originated from |
246+
| `timestamp` | `number` | When the request completed (Unix ms) |
247+
248+
#### Example
249+
250+
```typescript
251+
import { supports } from 'premid'
252+
253+
if (supports(presence, 'onRequest')) {
254+
const unsubscribe = presence.onRequest(
255+
{ url: '/youtubei/v1/player', method: 'POST' },
256+
(request) => {
257+
if (!request.responseBody)
258+
return
259+
260+
const { videoDetails } = JSON.parse(request.responseBody)
261+
// videoDetails.title, videoDetails.author, videoDetails.lengthSeconds, ...
262+
},
263+
)
264+
265+
// Later, to stop listening:
266+
// unsubscribe()
267+
}
268+
```
269+
270+
::: warning Read-only
271+
`onRequest` can only observe requests — it cannot modify, block, or replay them. It is intended for reading data the page already fetches, not for making your own requests.
272+
:::
273+
127274
### getSetting
128275

129276
<!-- eslint-skip -->

0 commit comments

Comments
 (0)