@rentaltide/app-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +150 -0
- package/dist/app.d.ts +64 -0
- package/dist/app.js +184 -0
- package/dist/embedLocations.d.ts +21 -0
- package/dist/embedLocations.js +83 -0
- package/dist/events.d.ts +21 -0
- package/dist/events.js +22 -0
- package/dist/host.d.ts +44 -0
- package/dist/host.js +91 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +23 -0
- package/dist/protocol.d.ts +93 -0
- package/dist/protocol.js +44 -0
- package/dist/scopes.d.ts +25 -0
- package/dist/scopes.js +129 -0
- package/dist/types.d.ts +92 -0
- package/dist/types.js +9 -0
- package/package.json +50 -0
- package/src/app.ts +265 -0
- package/src/embedLocations.ts +101 -0
- package/src/events.ts +39 -0
- package/src/host.ts +138 -0
- package/src/index.ts +32 -0
- package/src/protocol.ts +104 -0
- package/src/scopes.ts +158 -0
- package/src/types.ts +115 -0
package/src/app.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-side client. Runs inside the embedded iframe and is the only thing most
|
|
3
|
+
* developers import:
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { createApp } from '@rentaltide/app-sdk';
|
|
7
|
+
* const app = await createApp();
|
|
8
|
+
* console.log(app.context.account.customerId);
|
|
9
|
+
* const res = await app.api.get('/bookings?limit=5');
|
|
10
|
+
* app.toast('Loaded!', 'success');
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { BridgeEnvelope, envelope, isBridgeEnvelope } from './protocol';
|
|
15
|
+
import type {
|
|
16
|
+
ApiMethod,
|
|
17
|
+
ApiRequest,
|
|
18
|
+
ApiResponse,
|
|
19
|
+
HostContext,
|
|
20
|
+
ThemeTokens,
|
|
21
|
+
ToastSeverity,
|
|
22
|
+
} from './types';
|
|
23
|
+
|
|
24
|
+
export const SDK_VERSION = '0.1.0';
|
|
25
|
+
|
|
26
|
+
export interface CreateAppOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Origins of the RentalTide host(s) allowed to talk to this app. Defaults to
|
|
29
|
+
* the document referrer's origin. Set this in production for defense in depth.
|
|
30
|
+
*/
|
|
31
|
+
hostOrigins?: string[];
|
|
32
|
+
/** Auto-report content height to the host so the iframe grows to fit. Default true. */
|
|
33
|
+
autoResize?: boolean;
|
|
34
|
+
/** Per-request timeout in ms. Default 15000. */
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
/** Your app's client id, sent on handshake (optional, useful in logs). */
|
|
37
|
+
appId?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ApiCallOptions {
|
|
41
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RentalTideApp {
|
|
45
|
+
/** The latest host context. Updated in place as the host pushes changes. */
|
|
46
|
+
readonly context: HostContext;
|
|
47
|
+
/** Tell the host the app has finished its initial render. */
|
|
48
|
+
ready(): void;
|
|
49
|
+
/** Subscribe to context changes (e.g. user navigates to another booking). Returns an unsubscribe fn. */
|
|
50
|
+
onContextChange(cb: (ctx: HostContext) => void): () => void;
|
|
51
|
+
/** Subscribe to host theme changes (e.g. dark-mode toggle). Returns an unsubscribe fn. */
|
|
52
|
+
onThemeChange(cb: (theme: ThemeTokens) => void): () => void;
|
|
53
|
+
/** Scoped, host-proxied RentalTide API. The app never sees raw credentials. */
|
|
54
|
+
api: {
|
|
55
|
+
request<T = unknown>(req: ApiRequest): Promise<ApiResponse<T>>;
|
|
56
|
+
get<T = unknown>(path: string, opts?: ApiCallOptions): Promise<ApiResponse<T>>;
|
|
57
|
+
post<T = unknown>(path: string, body?: unknown, opts?: ApiCallOptions): Promise<ApiResponse<T>>;
|
|
58
|
+
put<T = unknown>(path: string, body?: unknown, opts?: ApiCallOptions): Promise<ApiResponse<T>>;
|
|
59
|
+
patch<T = unknown>(
|
|
60
|
+
path: string,
|
|
61
|
+
body?: unknown,
|
|
62
|
+
opts?: ApiCallOptions,
|
|
63
|
+
): Promise<ApiResponse<T>>;
|
|
64
|
+
delete<T = unknown>(path: string, opts?: ApiCallOptions): Promise<ApiResponse<T>>;
|
|
65
|
+
};
|
|
66
|
+
/** Show a snackbar in the host UI. */
|
|
67
|
+
toast(message: string, severity?: ToastSeverity): void;
|
|
68
|
+
/** Ask the host to navigate to an in-app path. */
|
|
69
|
+
navigate(path: string): void;
|
|
70
|
+
/** Manually report height (or omit to measure the document). */
|
|
71
|
+
resize(height?: number): void;
|
|
72
|
+
/** Re-fetch the current context from the host. */
|
|
73
|
+
refreshContext(): Promise<HostContext>;
|
|
74
|
+
/** Tear down listeners and reject any in-flight requests. */
|
|
75
|
+
destroy(): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface Pending {
|
|
79
|
+
resolve: (value: unknown) => void;
|
|
80
|
+
reject: (reason: Error) => void;
|
|
81
|
+
timer: ReturnType<typeof setTimeout>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function safeOrigin(url: string): string {
|
|
85
|
+
try {
|
|
86
|
+
return url ? new URL(url).origin : '';
|
|
87
|
+
} catch {
|
|
88
|
+
return '';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Initialize the app bridge. Resolves once the host handshake completes and the
|
|
94
|
+
* initial {@link HostContext} is available.
|
|
95
|
+
*/
|
|
96
|
+
export async function createApp(options: CreateAppOptions = {}): Promise<RentalTideApp> {
|
|
97
|
+
if (typeof window === 'undefined' || window.parent === window) {
|
|
98
|
+
throw new Error('[rentaltide-app-sdk] createApp() must run inside the RentalTide host iframe.');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const timeoutMs = options.timeoutMs ?? 15000;
|
|
102
|
+
const autoResize = options.autoResize ?? true;
|
|
103
|
+
const referrerOrigin = safeOrigin(document.referrer);
|
|
104
|
+
const allowedOrigins = options.hostOrigins ?? (referrerOrigin ? [referrerOrigin] : []);
|
|
105
|
+
|
|
106
|
+
let hostOrigin: string = referrerOrigin || '*';
|
|
107
|
+
let context: HostContext | null = null;
|
|
108
|
+
const pending = new Map<string, Pending>();
|
|
109
|
+
const contextListeners = new Set<(c: HostContext) => void>();
|
|
110
|
+
const themeListeners = new Set<(t: ThemeTokens) => void>();
|
|
111
|
+
|
|
112
|
+
const originAllowed = (origin: string): boolean =>
|
|
113
|
+
allowedOrigins.length === 0 || allowedOrigins.includes(origin);
|
|
114
|
+
|
|
115
|
+
const post = (env: BridgeEnvelope): void => {
|
|
116
|
+
window.parent.postMessage(env, hostOrigin);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const sendRequest = <T>(type: string, payload?: unknown): Promise<T> => {
|
|
120
|
+
const env = envelope('request', type, { payload });
|
|
121
|
+
return new Promise<T>((resolve, reject) => {
|
|
122
|
+
const timer = setTimeout(() => {
|
|
123
|
+
pending.delete(env.id);
|
|
124
|
+
reject(new Error(`[rentaltide-app-sdk] "${type}" timed out after ${timeoutMs}ms`));
|
|
125
|
+
}, timeoutMs);
|
|
126
|
+
pending.set(env.id, { resolve: resolve as Pending['resolve'], reject, timer });
|
|
127
|
+
post(env);
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const reportHeight = (): void => {
|
|
132
|
+
const height = Math.ceil(
|
|
133
|
+
document.documentElement?.scrollHeight || document.body?.scrollHeight || 0,
|
|
134
|
+
);
|
|
135
|
+
post(envelope('request', 'ui.resize', { payload: { height } }));
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const handleMessage = (event: MessageEvent): void => {
|
|
139
|
+
if (event.source !== window.parent) return;
|
|
140
|
+
if (!isBridgeEnvelope(event.data)) return;
|
|
141
|
+
if (!originAllowed(event.origin)) return;
|
|
142
|
+
// Lock onto the first valid host origin so subsequent posts are targeted.
|
|
143
|
+
if (hostOrigin === '*') hostOrigin = event.origin;
|
|
144
|
+
|
|
145
|
+
const env = event.data as BridgeEnvelope;
|
|
146
|
+
|
|
147
|
+
if (env.kind === 'response' && env.replyTo) {
|
|
148
|
+
const p = pending.get(env.replyTo);
|
|
149
|
+
if (!p) return;
|
|
150
|
+
clearTimeout(p.timer);
|
|
151
|
+
pending.delete(env.replyTo);
|
|
152
|
+
if (env.ok === false) {
|
|
153
|
+
p.reject(new Error(env.error?.message || `Request "${env.type}" failed`));
|
|
154
|
+
} else {
|
|
155
|
+
p.resolve(env.payload);
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (env.kind === 'event') {
|
|
161
|
+
if (env.type === 'context.changed') {
|
|
162
|
+
context = env.payload as HostContext;
|
|
163
|
+
contextListeners.forEach((cb) => cb(context as HostContext));
|
|
164
|
+
} else if (env.type === 'theme.changed') {
|
|
165
|
+
const theme = env.payload as ThemeTokens;
|
|
166
|
+
if (context) context = { ...context, theme };
|
|
167
|
+
themeListeners.forEach((cb) => cb(theme));
|
|
168
|
+
} else if (env.type === 'host.closing') {
|
|
169
|
+
destroy();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
window.addEventListener('message', handleMessage);
|
|
175
|
+
|
|
176
|
+
let resizeObserver: ResizeObserver | null = null;
|
|
177
|
+
const destroy = (): void => {
|
|
178
|
+
window.removeEventListener('message', handleMessage);
|
|
179
|
+
resizeObserver?.disconnect();
|
|
180
|
+
pending.forEach((p) => {
|
|
181
|
+
clearTimeout(p.timer);
|
|
182
|
+
p.reject(new Error('[rentaltide-app-sdk] app bridge destroyed'));
|
|
183
|
+
});
|
|
184
|
+
pending.clear();
|
|
185
|
+
contextListeners.clear();
|
|
186
|
+
themeListeners.clear();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// Handshake — establishes the host origin and initial context.
|
|
190
|
+
context = await sendRequest<HostContext>('handshake', {
|
|
191
|
+
appId: options.appId,
|
|
192
|
+
sdkVersion: SDK_VERSION,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (autoResize && typeof ResizeObserver !== 'undefined') {
|
|
196
|
+
resizeObserver = new ResizeObserver(() => reportHeight());
|
|
197
|
+
resizeObserver.observe(document.documentElement);
|
|
198
|
+
reportHeight();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const buildReq = (
|
|
202
|
+
method: ApiMethod,
|
|
203
|
+
path: string,
|
|
204
|
+
body?: unknown,
|
|
205
|
+
opts?: ApiCallOptions,
|
|
206
|
+
): ApiRequest => ({ method, path, body, query: opts?.query });
|
|
207
|
+
|
|
208
|
+
const api: RentalTideApp['api'] = {
|
|
209
|
+
request<T = unknown>(req: ApiRequest) {
|
|
210
|
+
return sendRequest<ApiResponse<T>>('api.request', req);
|
|
211
|
+
},
|
|
212
|
+
get<T = unknown>(path: string, opts?: ApiCallOptions) {
|
|
213
|
+
return sendRequest<ApiResponse<T>>('api.request', buildReq('GET', path, undefined, opts));
|
|
214
|
+
},
|
|
215
|
+
post<T = unknown>(path: string, body?: unknown, opts?: ApiCallOptions) {
|
|
216
|
+
return sendRequest<ApiResponse<T>>('api.request', buildReq('POST', path, body, opts));
|
|
217
|
+
},
|
|
218
|
+
put<T = unknown>(path: string, body?: unknown, opts?: ApiCallOptions) {
|
|
219
|
+
return sendRequest<ApiResponse<T>>('api.request', buildReq('PUT', path, body, opts));
|
|
220
|
+
},
|
|
221
|
+
patch<T = unknown>(path: string, body?: unknown, opts?: ApiCallOptions) {
|
|
222
|
+
return sendRequest<ApiResponse<T>>('api.request', buildReq('PATCH', path, body, opts));
|
|
223
|
+
},
|
|
224
|
+
delete<T = unknown>(path: string, opts?: ApiCallOptions) {
|
|
225
|
+
return sendRequest<ApiResponse<T>>('api.request', buildReq('DELETE', path, undefined, opts));
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
get context() {
|
|
231
|
+
return context as HostContext;
|
|
232
|
+
},
|
|
233
|
+
ready() {
|
|
234
|
+
reportHeight();
|
|
235
|
+
},
|
|
236
|
+
onContextChange(cb) {
|
|
237
|
+
contextListeners.add(cb);
|
|
238
|
+
return () => contextListeners.delete(cb);
|
|
239
|
+
},
|
|
240
|
+
onThemeChange(cb) {
|
|
241
|
+
themeListeners.add(cb);
|
|
242
|
+
return () => themeListeners.delete(cb);
|
|
243
|
+
},
|
|
244
|
+
api,
|
|
245
|
+
toast(message, severity = 'info') {
|
|
246
|
+
sendRequest('ui.toast', { message, severity }).catch(() => undefined);
|
|
247
|
+
},
|
|
248
|
+
navigate(path) {
|
|
249
|
+
sendRequest('host.navigate', { path }).catch(() => undefined);
|
|
250
|
+
},
|
|
251
|
+
resize(height) {
|
|
252
|
+
if (typeof height === 'number') {
|
|
253
|
+
post(envelope('request', 'ui.resize', { payload: { height } }));
|
|
254
|
+
} else {
|
|
255
|
+
reportHeight();
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
async refreshContext() {
|
|
259
|
+
const next = await sendRequest<HostContext>('context.get');
|
|
260
|
+
context = next;
|
|
261
|
+
return next;
|
|
262
|
+
},
|
|
263
|
+
destroy,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry of every surface where an app can embed, with the resource the host
|
|
3
|
+
* provides there and sensible scope suggestions. Drives the app-creation form,
|
|
4
|
+
* the sandbox location picker, and the developer docs — one source of truth.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { EmbedLocation, EmbedResourceType } from './types';
|
|
8
|
+
import type { Scope } from './scopes';
|
|
9
|
+
|
|
10
|
+
export interface EmbedLocationMeta {
|
|
11
|
+
id: EmbedLocation;
|
|
12
|
+
label: string;
|
|
13
|
+
description: string;
|
|
14
|
+
/** Resource type the host provides in `context.resource` here (null if none). */
|
|
15
|
+
resourceType: EmbedResourceType | null;
|
|
16
|
+
/** Scopes an app embedding here will typically need. */
|
|
17
|
+
suggestedScopes: Scope[];
|
|
18
|
+
/** Rough shape of the embed surface, for layout hints. */
|
|
19
|
+
surface: 'sidebar' | 'panel' | 'card' | 'fullwidth';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const EMBED_LOCATIONS: Record<EmbedLocation, EmbedLocationMeta> = {
|
|
23
|
+
'dashboard-widget': {
|
|
24
|
+
id: 'dashboard-widget',
|
|
25
|
+
label: 'Dashboard widget',
|
|
26
|
+
description: 'A card on the main operator dashboard. No specific resource in context.',
|
|
27
|
+
resourceType: null,
|
|
28
|
+
suggestedScopes: ['read:analytics', 'read:bookings'],
|
|
29
|
+
surface: 'card',
|
|
30
|
+
},
|
|
31
|
+
'order-details': {
|
|
32
|
+
id: 'order-details',
|
|
33
|
+
label: 'Order details',
|
|
34
|
+
description: 'Sidebar panel on an order page. Receives the current order.',
|
|
35
|
+
resourceType: 'order',
|
|
36
|
+
suggestedScopes: ['read:bookings'],
|
|
37
|
+
surface: 'sidebar',
|
|
38
|
+
},
|
|
39
|
+
'booking-details': {
|
|
40
|
+
id: 'booking-details',
|
|
41
|
+
label: 'Booking details',
|
|
42
|
+
description: 'Panel on a single booking. Receives the current booking.',
|
|
43
|
+
resourceType: 'booking',
|
|
44
|
+
suggestedScopes: ['read:bookings'],
|
|
45
|
+
surface: 'sidebar',
|
|
46
|
+
},
|
|
47
|
+
'customer-profile': {
|
|
48
|
+
id: 'customer-profile',
|
|
49
|
+
label: 'Customer profile',
|
|
50
|
+
description: 'Panel on a customer profile. Receives the current customer.',
|
|
51
|
+
resourceType: 'customer',
|
|
52
|
+
suggestedScopes: ['read:customers'],
|
|
53
|
+
surface: 'panel',
|
|
54
|
+
},
|
|
55
|
+
'inventory-detail': {
|
|
56
|
+
id: 'inventory-detail',
|
|
57
|
+
label: 'Inventory detail',
|
|
58
|
+
description: 'Panel on an inventory item. Receives the current inventory item.',
|
|
59
|
+
resourceType: 'inventory',
|
|
60
|
+
suggestedScopes: ['read:inventory'],
|
|
61
|
+
surface: 'panel',
|
|
62
|
+
},
|
|
63
|
+
'asset-tracking': {
|
|
64
|
+
id: 'asset-tracking',
|
|
65
|
+
label: 'Asset tracking',
|
|
66
|
+
description: 'Panel on the asset/nav board. Receives the current asset.',
|
|
67
|
+
resourceType: 'asset',
|
|
68
|
+
suggestedScopes: ['read:inventory', 'read:bookings'],
|
|
69
|
+
surface: 'panel',
|
|
70
|
+
},
|
|
71
|
+
'checkout-flow': {
|
|
72
|
+
id: 'checkout-flow',
|
|
73
|
+
label: 'Checkout flow',
|
|
74
|
+
description: 'Step injected into the booking checkout. Receives the in-progress cart.',
|
|
75
|
+
resourceType: 'cart',
|
|
76
|
+
suggestedScopes: ['read:bookings', 'read:pos'],
|
|
77
|
+
surface: 'fullwidth',
|
|
78
|
+
},
|
|
79
|
+
'pos-cart': {
|
|
80
|
+
id: 'pos-cart',
|
|
81
|
+
label: 'POS cart',
|
|
82
|
+
description: 'Panel beside the point-of-sale cart. Receives the active cart.',
|
|
83
|
+
resourceType: 'cart',
|
|
84
|
+
suggestedScopes: ['read:pos', 'write:pos'],
|
|
85
|
+
surface: 'sidebar',
|
|
86
|
+
},
|
|
87
|
+
'settings-panel': {
|
|
88
|
+
id: 'settings-panel',
|
|
89
|
+
label: 'Settings panel',
|
|
90
|
+
description: 'A configuration page reachable from the app-store entry. No resource.',
|
|
91
|
+
resourceType: null,
|
|
92
|
+
suggestedScopes: [],
|
|
93
|
+
surface: 'fullwidth',
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const EMBED_LOCATION_LIST: EmbedLocationMeta[] = Object.values(EMBED_LOCATIONS);
|
|
98
|
+
|
|
99
|
+
export function getEmbedLocationMeta(id: string): EmbedLocationMeta | undefined {
|
|
100
|
+
return (EMBED_LOCATIONS as Record<string, EmbedLocationMeta>)[id];
|
|
101
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog of outbound webhook events RentalTide can send to your app.
|
|
3
|
+
*
|
|
4
|
+
* Register a webhook URL + secret and request the `webhooks:receive` scope to
|
|
5
|
+
* receive these. Each delivery is signed (see the developer docs for
|
|
6
|
+
* verification). This list mirrors the server's emitter.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface WebhookEventMeta {
|
|
10
|
+
event: string;
|
|
11
|
+
description: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const WEBHOOK_EVENT_CATALOG: WebhookEventMeta[] = [
|
|
15
|
+
{ event: 'booking.created', description: 'A new booking/order was created.' },
|
|
16
|
+
{ event: 'booking.updated', description: 'A booking changed (notes, timing, add-ons, assets).' },
|
|
17
|
+
{ event: 'booking.status_changed', description: 'A booking moved to a new status.' },
|
|
18
|
+
{ event: 'booking.checked_in', description: 'A booking was checked in.' },
|
|
19
|
+
{ event: 'booking.cancelled', description: 'A booking was cancelled.' },
|
|
20
|
+
{ event: 'booking.rescheduled', description: 'A booking was rescheduled.' },
|
|
21
|
+
{ event: 'payment.received', description: 'A payment was recorded against a booking.' },
|
|
22
|
+
{ event: 'payment.refunded', description: 'A refund was issued.' },
|
|
23
|
+
{ event: 'customer.created', description: 'A new customer was created.' },
|
|
24
|
+
{ event: 'customer.updated', description: 'A customer record was updated.' },
|
|
25
|
+
{ event: 'app.installed', description: 'Your app was installed by a merchant.' },
|
|
26
|
+
{ event: 'app.uninstalled', description: 'Your app was uninstalled (deprovision here).' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export const WEBHOOK_EVENTS = WEBHOOK_EVENT_CATALOG.map((e) => e.event);
|
|
30
|
+
|
|
31
|
+
export type WebhookEventName = (typeof WEBHOOK_EVENT_CATALOG)[number]['event'];
|
|
32
|
+
|
|
33
|
+
/** Shape of every webhook request body RentalTide POSTs to your endpoint. */
|
|
34
|
+
export interface WebhookPayload<T = Record<string, unknown>> {
|
|
35
|
+
event: string;
|
|
36
|
+
/** ISO-8601 timestamp the payload was created. */
|
|
37
|
+
timestamp: string;
|
|
38
|
+
data: T;
|
|
39
|
+
}
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side bridge. Runs in the RentalTide host (the main app and the developer
|
|
3
|
+
* sandbox) — one instance per embedded iframe. It answers the app's handshake,
|
|
4
|
+
* proxies scoped API calls, and forwards UI intents (toast/navigate/resize).
|
|
5
|
+
*
|
|
6
|
+
* Security model:
|
|
7
|
+
* - Only messages from this iframe's `contentWindow` are accepted.
|
|
8
|
+
* - Only messages from the declared `appOrigin` are accepted.
|
|
9
|
+
* - `onApiRequest` is responsible for enforcing the installation's granted
|
|
10
|
+
* scopes (see {@link requiredScope}/{@link hasScope} in `./scopes`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { BridgeEnvelope, envelope, isBridgeEnvelope } from './protocol';
|
|
14
|
+
import type { ApiRequest, ApiResponse, HostContext, ThemeTokens, ToastSeverity } from './types';
|
|
15
|
+
|
|
16
|
+
export interface CreateHostBridgeOptions {
|
|
17
|
+
/** The iframe element the app is mounted in. */
|
|
18
|
+
iframe: HTMLIFrameElement;
|
|
19
|
+
/**
|
|
20
|
+
* The exact origin the app is served from (validated on every message).
|
|
21
|
+
* Use `'*'` only for trusted local sandboxes; never in production.
|
|
22
|
+
*/
|
|
23
|
+
appOrigin: string;
|
|
24
|
+
/** Returns the current host context (called on handshake/context.get). */
|
|
25
|
+
getContext: () => HostContext;
|
|
26
|
+
/**
|
|
27
|
+
* Handle a proxied API request. MUST enforce the installation's granted
|
|
28
|
+
* scopes before performing the call.
|
|
29
|
+
*/
|
|
30
|
+
onApiRequest: (req: ApiRequest, ctx: HostContext) => Promise<ApiResponse>;
|
|
31
|
+
onResize?: (height: number) => void;
|
|
32
|
+
onToast?: (message: string, severity: ToastSeverity) => void;
|
|
33
|
+
onNavigate?: (path: string) => void;
|
|
34
|
+
onClose?: () => void;
|
|
35
|
+
/** Optional observer of all traffic — used by the sandbox message log. */
|
|
36
|
+
onMessage?: (direction: 'in' | 'out', env: BridgeEnvelope) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface HostBridge {
|
|
40
|
+
/** Push a fresh context to the app (defaults to `getContext()`). */
|
|
41
|
+
pushContext(ctx?: HostContext): void;
|
|
42
|
+
/** Notify the app that the host theme changed. */
|
|
43
|
+
pushTheme(theme: ThemeTokens): void;
|
|
44
|
+
/** Tear down the listener and tell the app it's closing. */
|
|
45
|
+
destroy(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createHostBridge(options: CreateHostBridgeOptions): HostBridge {
|
|
49
|
+
const {
|
|
50
|
+
iframe,
|
|
51
|
+
appOrigin,
|
|
52
|
+
getContext,
|
|
53
|
+
onApiRequest,
|
|
54
|
+
onResize,
|
|
55
|
+
onToast,
|
|
56
|
+
onNavigate,
|
|
57
|
+
onClose,
|
|
58
|
+
onMessage,
|
|
59
|
+
} = options;
|
|
60
|
+
|
|
61
|
+
// `srcdoc`/sandboxed iframes report a `null` origin; postMessage must use '*'.
|
|
62
|
+
const postTarget = appOrigin === 'null' ? '*' : appOrigin;
|
|
63
|
+
|
|
64
|
+
const send = (env: BridgeEnvelope): void => {
|
|
65
|
+
onMessage?.('out', env);
|
|
66
|
+
iframe.contentWindow?.postMessage(env, postTarget);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const respond = (replyTo: string, type: string, payload?: unknown): void => {
|
|
70
|
+
send(envelope('response', type, { replyTo, ok: true, payload }));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const respondError = (replyTo: string, type: string, code: string, message: string): void => {
|
|
74
|
+
send(envelope('response', type, { replyTo, ok: false, error: { code, message } }));
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const handleMessage = async (event: MessageEvent): Promise<void> => {
|
|
78
|
+
if (event.source !== iframe.contentWindow) return;
|
|
79
|
+
if (appOrigin !== '*' && appOrigin !== 'null' && event.origin !== appOrigin) return;
|
|
80
|
+
if (!isBridgeEnvelope(event.data)) return;
|
|
81
|
+
|
|
82
|
+
const env = event.data as BridgeEnvelope;
|
|
83
|
+
if (env.kind !== 'request') return;
|
|
84
|
+
onMessage?.('in', env);
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
switch (env.type) {
|
|
88
|
+
case 'handshake':
|
|
89
|
+
case 'context.get':
|
|
90
|
+
respond(env.id, env.type, getContext());
|
|
91
|
+
break;
|
|
92
|
+
case 'api.request': {
|
|
93
|
+
const res = await onApiRequest(env.payload as ApiRequest, getContext());
|
|
94
|
+
respond(env.id, env.type, res);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case 'ui.resize':
|
|
98
|
+
// Fire-and-forget; the app does not await a response.
|
|
99
|
+
onResize?.((env.payload as { height: number }).height);
|
|
100
|
+
break;
|
|
101
|
+
case 'ui.toast': {
|
|
102
|
+
const p = env.payload as { message: string; severity?: ToastSeverity };
|
|
103
|
+
onToast?.(p.message, p.severity ?? 'info');
|
|
104
|
+
respond(env.id, env.type);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case 'host.navigate':
|
|
108
|
+
onNavigate?.((env.payload as { path: string }).path);
|
|
109
|
+
respond(env.id, env.type);
|
|
110
|
+
break;
|
|
111
|
+
case 'host.close':
|
|
112
|
+
onClose?.();
|
|
113
|
+
respond(env.id, env.type);
|
|
114
|
+
break;
|
|
115
|
+
default:
|
|
116
|
+
respondError(env.id, env.type, 'unknown_method', `Unknown method: ${env.type}`);
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
const message = err instanceof Error ? err.message : 'Host error';
|
|
120
|
+
respondError(env.id, env.type, 'host_error', message);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
window.addEventListener('message', handleMessage);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
pushContext(ctx) {
|
|
128
|
+
send(envelope('event', 'context.changed', { payload: ctx ?? getContext() }));
|
|
129
|
+
},
|
|
130
|
+
pushTheme(theme) {
|
|
131
|
+
send(envelope('event', 'theme.changed', { payload: theme }));
|
|
132
|
+
},
|
|
133
|
+
destroy() {
|
|
134
|
+
send(envelope('event', 'host.closing'));
|
|
135
|
+
window.removeEventListener('message', handleMessage);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @rentaltide/app-sdk — Official SDK for building embedded apps on RentalTide.
|
|
3
|
+
*
|
|
4
|
+
* App developers import the app-side client:
|
|
5
|
+
* import { createApp } from '@rentaltide/app-sdk';
|
|
6
|
+
*
|
|
7
|
+
* The RentalTide host (and the developer sandbox) import the host bridge:
|
|
8
|
+
* import { createHostBridge } from '@rentaltide/app-sdk';
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Data shapes
|
|
12
|
+
export * from './types';
|
|
13
|
+
|
|
14
|
+
// Wire protocol (envelopes, request/event maps, helpers)
|
|
15
|
+
export * from './protocol';
|
|
16
|
+
|
|
17
|
+
// OAuth scopes + the API-proxy scope policy
|
|
18
|
+
export * from './scopes';
|
|
19
|
+
|
|
20
|
+
// Embed-location registry
|
|
21
|
+
export * from './embedLocations';
|
|
22
|
+
|
|
23
|
+
// Webhook event catalog
|
|
24
|
+
export * from './events';
|
|
25
|
+
|
|
26
|
+
// App-side client (runs in the iframe)
|
|
27
|
+
export { createApp, SDK_VERSION } from './app';
|
|
28
|
+
export type { CreateAppOptions, ApiCallOptions, RentalTideApp } from './app';
|
|
29
|
+
|
|
30
|
+
// Host-side bridge (runs in RentalTide / the sandbox)
|
|
31
|
+
export { createHostBridge } from './host';
|
|
32
|
+
export type { CreateHostBridgeOptions, HostBridge } from './host';
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire protocol for the RentalTide App Bridge.
|
|
3
|
+
*
|
|
4
|
+
* Host and embedded app communicate exclusively over `window.postMessage`.
|
|
5
|
+
* Every message is a {@link BridgeEnvelope} stamped with a namespace marker and
|
|
6
|
+
* protocol version so unrelated `message` events (and other postMessage users on
|
|
7
|
+
* the page) are ignored.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ApiRequest, ApiResponse, HostContext, ThemeTokens, ToastSeverity } from './types';
|
|
11
|
+
|
|
12
|
+
export const PROTOCOL_VERSION = '1';
|
|
13
|
+
|
|
14
|
+
/** Marker present on every bridge message; used to filter foreign messages. */
|
|
15
|
+
export const BRIDGE_NAMESPACE = 'rentaltide-app-bridge';
|
|
16
|
+
|
|
17
|
+
export type MessageKind = 'request' | 'response' | 'event';
|
|
18
|
+
|
|
19
|
+
export interface BridgeError {
|
|
20
|
+
code: string;
|
|
21
|
+
message: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface BridgeEnvelope<P = unknown> {
|
|
25
|
+
/** Always {@link BRIDGE_NAMESPACE}. */
|
|
26
|
+
__rt: typeof BRIDGE_NAMESPACE;
|
|
27
|
+
/** Protocol version (see {@link PROTOCOL_VERSION}). */
|
|
28
|
+
v: string;
|
|
29
|
+
/** Unique message id used to correlate responses to requests. */
|
|
30
|
+
id: string;
|
|
31
|
+
kind: MessageKind;
|
|
32
|
+
/** Method name (requests/responses) or event name (events). */
|
|
33
|
+
type: string;
|
|
34
|
+
payload?: P;
|
|
35
|
+
/** Present on responses. */
|
|
36
|
+
ok?: boolean;
|
|
37
|
+
/** Present on failed responses. */
|
|
38
|
+
error?: BridgeError;
|
|
39
|
+
/** Id of the request a response is answering. */
|
|
40
|
+
replyTo?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* App → Host request methods, with their request and response payload types.
|
|
45
|
+
* This is the canonical reference for what an app may ask the host to do.
|
|
46
|
+
*/
|
|
47
|
+
export interface RequestMap {
|
|
48
|
+
handshake: { req: { appId?: string; sdkVersion: string }; res: HostContext };
|
|
49
|
+
'context.get': { req: void; res: HostContext };
|
|
50
|
+
'api.request': { req: ApiRequest; res: ApiResponse };
|
|
51
|
+
'ui.toast': { req: { message: string; severity?: ToastSeverity }; res: void };
|
|
52
|
+
'ui.resize': { req: { height: number }; res: void };
|
|
53
|
+
'host.navigate': { req: { path: string }; res: void };
|
|
54
|
+
'host.close': { req: void; res: void };
|
|
55
|
+
}
|
|
56
|
+
export type RequestType = keyof RequestMap;
|
|
57
|
+
|
|
58
|
+
/** Host → App events, with their payload types. */
|
|
59
|
+
export interface EventMap {
|
|
60
|
+
'context.changed': HostContext;
|
|
61
|
+
'theme.changed': ThemeTokens;
|
|
62
|
+
'host.closing': void;
|
|
63
|
+
}
|
|
64
|
+
export type EventType = keyof EventMap;
|
|
65
|
+
|
|
66
|
+
/** Narrowing type guard for incoming `message` events. */
|
|
67
|
+
export function isBridgeEnvelope(data: unknown): data is BridgeEnvelope {
|
|
68
|
+
if (typeof data !== 'object' || data === null) return false;
|
|
69
|
+
const env = data as Record<string, unknown>;
|
|
70
|
+
return (
|
|
71
|
+
env.__rt === BRIDGE_NAMESPACE &&
|
|
72
|
+
typeof env.id === 'string' &&
|
|
73
|
+
typeof env.kind === 'string' &&
|
|
74
|
+
typeof env.type === 'string'
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Generates a correlation id (RFC4122 v4 when crypto is available). */
|
|
79
|
+
export function newId(): string {
|
|
80
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
81
|
+
return crypto.randomUUID();
|
|
82
|
+
}
|
|
83
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
84
|
+
const r = (Math.random() * 16) | 0;
|
|
85
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
86
|
+
return v.toString(16);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Builds a well-formed envelope. */
|
|
91
|
+
export function envelope<P>(
|
|
92
|
+
kind: MessageKind,
|
|
93
|
+
type: string,
|
|
94
|
+
parts: Partial<BridgeEnvelope<P>> = {},
|
|
95
|
+
): BridgeEnvelope<P> {
|
|
96
|
+
return {
|
|
97
|
+
__rt: BRIDGE_NAMESPACE,
|
|
98
|
+
v: PROTOCOL_VERSION,
|
|
99
|
+
id: parts.id ?? newId(),
|
|
100
|
+
kind,
|
|
101
|
+
type,
|
|
102
|
+
...parts,
|
|
103
|
+
};
|
|
104
|
+
}
|