@waler/sdk 0.1.1

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.
@@ -0,0 +1,94 @@
1
+ import { captureCallSite, sdkError, withCallSite } from "../errors.js";
2
+ import { newId } from "../protocol/ids.js";
3
+ import { timeoutFor as defaultTimeoutFor } from "./timeouts.js";
4
+ /**
5
+ * One map of in-flight requests, keyed by envelope id, above every transport —
6
+ * which is why the timeout and the call-site stack live here and not in one.
7
+ *
8
+ * Nothing here knows what a window or an origin is: a message that reaches this
9
+ * point already cleared the transport's gates.
10
+ */
11
+ export function createRpc(transport, config = {}) {
12
+ const timeoutFor = config.timeoutFor ?? defaultTimeoutFor;
13
+ const pending = new Map();
14
+ const eventHandlers = new Set();
15
+ const settle = (envelope) => {
16
+ const entry = pending.get(envelope.id);
17
+ // An answer that arrived after its own timeout, or one for a request never
18
+ // made. Both are dropped.
19
+ if (entry === undefined)
20
+ return;
21
+ clearTimeout(entry.timer);
22
+ pending.delete(envelope.id);
23
+ if (envelope.kind === 'response') {
24
+ entry.resolve(envelope.payload);
25
+ return;
26
+ }
27
+ const body = envelope.error;
28
+ entry.reject(withCallSite(sdkError(body?.code ?? 'INTERNAL', body?.message ?? 'The shell answered with an error.', {
29
+ method: entry.method,
30
+ ...(body?.details === undefined ? {} : { details: body.details }),
31
+ }), entry.callSite));
32
+ };
33
+ transport.onMessage((envelope) => {
34
+ switch (envelope.kind) {
35
+ case 'response':
36
+ case 'error':
37
+ settle(envelope);
38
+ return;
39
+ case 'event': {
40
+ const name = envelope.event;
41
+ if (name === undefined)
42
+ return;
43
+ for (const handler of eventHandlers)
44
+ handler(name, envelope.payload);
45
+ return;
46
+ }
47
+ // The shell asking the app for something is not part of this surface.
48
+ // Ignored rather than answered.
49
+ case 'request':
50
+ return;
51
+ }
52
+ });
53
+ const envelopeFor = (method, payload, id) => ({
54
+ v: 1,
55
+ id,
56
+ /**
57
+ * One per outbound request, since a request is one user action. It travels
58
+ * with the message so a single action stays identifiable across every system
59
+ * that handles it.
60
+ */
61
+ correlationId: newId(),
62
+ kind: 'request',
63
+ method,
64
+ ...(payload === undefined ? {} : { payload }),
65
+ });
66
+ return {
67
+ request(method, payload) {
68
+ // Captured before anything async, while the caller is still on the stack.
69
+ const callSite = captureCallSite();
70
+ const id = newId();
71
+ return new Promise((resolve, reject) => {
72
+ const timer = setTimeout(() => {
73
+ pending.delete(id);
74
+ reject(withCallSite(sdkError('TIMEOUT', `The shell did not answer '${method}' in time.`, { method }), callSite));
75
+ }, timeoutFor(method));
76
+ pending.set(id, {
77
+ method,
78
+ callSite,
79
+ timer,
80
+ resolve,
81
+ reject,
82
+ });
83
+ transport.send(envelopeFor(method, payload, id));
84
+ });
85
+ },
86
+ notify(method, payload) {
87
+ transport.send(envelopeFor(method, payload, newId()));
88
+ },
89
+ onEvent(handler) {
90
+ eventHandlers.add(handler);
91
+ return () => eventHandlers.delete(handler);
92
+ },
93
+ };
94
+ }
@@ -0,0 +1,7 @@
1
+ import type { RpcConfig } from './rpc.ts';
2
+ import type { BridgeTransport, WalerSDK } from '../types.ts';
3
+ export interface SdkConfig extends RpcConfig {
4
+ readonly appId: string;
5
+ readonly surfaceUrl: string;
6
+ }
7
+ export declare function createSdkOver(transport: BridgeTransport, config: SdkConfig): WalerSDK;
@@ -0,0 +1,100 @@
1
+ import { sdkError } from "../errors.js";
2
+ import { SDK_VERSION } from "../version.js";
3
+ import { createCapabilitySet } from "./capabilities.js";
4
+ import { createEmitter } from "./events.js";
5
+ import { isReadyPayload, isShellContext } from "./ready.js";
6
+ import { createRpc } from "./rpc.js";
7
+ /**
8
+ * Checked here as well as in the shell: a shell that served a method it never
9
+ * declared would let an app ship with no fallback, and that app would break
10
+ * against an older one. `getGeolocation` needs `geolocation` — the method and
11
+ * the capability are named differently, and this is the only place that knows.
12
+ */
13
+ const CAPABILITY_BY_METHOD = {
14
+ notify: 'notify',
15
+ download: 'download',
16
+ share: 'share',
17
+ scanBarcode: 'scanBarcode',
18
+ getGeolocation: 'geolocation',
19
+ };
20
+ export function createSdkOver(transport, config) {
21
+ const rpc = createRpc(transport, config);
22
+ const emitter = createEmitter();
23
+ const capabilities = createCapabilitySet();
24
+ let context = null;
25
+ const ready = (async () => {
26
+ const answer = await rpc.request('setup', {
27
+ appId: config.appId,
28
+ url: config.surfaceUrl,
29
+ sdkVersion: SDK_VERSION,
30
+ });
31
+ if (!isReadyPayload(answer)) {
32
+ throw sdkError('INTERNAL', 'The shell answered the handshake with a malformed ReadyPayload.', {
33
+ method: 'setup',
34
+ details: answer,
35
+ });
36
+ }
37
+ capabilities.replace(answer.capabilities);
38
+ context = answer.context;
39
+ return answer;
40
+ })();
41
+ // An app may never await `ready` at all. Without a handler here, the
42
+ // NOT_IN_SHELL of the standalone path would surface as an unhandled rejection.
43
+ // This swallows nothing: the app's own `await` still rejects.
44
+ void ready.catch(() => { });
45
+ rpc.onEvent((event, payload) => {
46
+ // `context` is kept in step before the app's handlers run, so a handler that
47
+ // reads `Waler.context` sees the change it was told about rather than the
48
+ // one before it.
49
+ if (event === 'contextchange' && isShellContext(payload)) {
50
+ context = payload;
51
+ }
52
+ if (event === 'audiencechange' && context !== null) {
53
+ const change = payload;
54
+ if (typeof change.activeAudience === 'string' && Array.isArray(change.audiences)) {
55
+ context = {
56
+ ...context,
57
+ activeAudience: change.activeAudience,
58
+ audiences: change.audiences,
59
+ };
60
+ }
61
+ }
62
+ emitter.emit(event, payload);
63
+ });
64
+ /**
65
+ * Every call waits for the handshake, so failures report their real cause and a
66
+ * capability check never runs against a list that is not filled in yet.
67
+ */
68
+ const call = async (method, payload) => {
69
+ await ready;
70
+ const required = CAPABILITY_BY_METHOD[method];
71
+ if (required !== undefined && !capabilities.view.has(required)) {
72
+ throw sdkError('UNSUPPORTED', `This shell does not offer '${required}'. Check capabilities.has('${required}') before calling '${method}'.`, { method });
73
+ }
74
+ return rpc.request(method, payload);
75
+ };
76
+ // These return void, so they cannot report a failed handshake. Sending them
77
+ // after `ready` keeps them ordered behind it; with no shell they are dropped.
78
+ const tell = (method, payload) => {
79
+ void ready.then(() => rpc.notify(method, payload), () => undefined);
80
+ };
81
+ return {
82
+ ready,
83
+ get context() {
84
+ return context;
85
+ },
86
+ capabilities: capabilities.view,
87
+ getToken: (request) => call('getToken', request),
88
+ openApp: (key, params) => call('openApp', { key, ...(params === undefined ? {} : { params }) }),
89
+ navigate: (path) => tell('navigate', { path }),
90
+ close: (result) => tell('close', { result }),
91
+ setHeader: (state) => tell('setHeader', state),
92
+ notify: (options) => call('notify', options),
93
+ download: (options) => call('download', options),
94
+ share: (options) => call('share', options),
95
+ scanBarcode: () => call('scanBarcode'),
96
+ getGeolocation: () => call('getGeolocation'),
97
+ on: (event, handler) => emitter.on(event, handler),
98
+ invoke: (method, payload) => call(method, payload),
99
+ };
100
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Split by who is being waited on, not by method: a request the shell answers
3
+ * itself cannot share a budget with one that waits on a person, or the timeout
4
+ * that is useful for the first cancels a legitimate action in the second.
5
+ *
6
+ * Neither bucket is unbounded — nothing may hang without eventually reporting.
7
+ */
8
+ export declare const REQUEST_TIMEOUT_MS = 10000;
9
+ /** Long enough for a slow shell to boot, short enough that a blocked frame says so. */
10
+ export declare const HANDSHAKE_TIMEOUT_MS = 15000;
11
+ export declare const USER_ACTION_TIMEOUT_MS = 300000;
12
+ export declare function timeoutFor(method: string): number;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Split by who is being waited on, not by method: a request the shell answers
3
+ * itself cannot share a budget with one that waits on a person, or the timeout
4
+ * that is useful for the first cancels a legitimate action in the second.
5
+ *
6
+ * Neither bucket is unbounded — nothing may hang without eventually reporting.
7
+ */
8
+ export const REQUEST_TIMEOUT_MS = 10_000;
9
+ /** Long enough for a slow shell to boot, short enough that a blocked frame says so. */
10
+ export const HANDSHAKE_TIMEOUT_MS = 15_000;
11
+ export const USER_ACTION_TIMEOUT_MS = 300_000;
12
+ const USER_DRIVEN = new Set(['openApp', 'scanBarcode', 'getGeolocation', 'share']);
13
+ export function timeoutFor(method) {
14
+ if (method === 'setup')
15
+ return HANDSHAKE_TIMEOUT_MS;
16
+ return USER_DRIVEN.has(method) ? USER_ACTION_TIMEOUT_MS : REQUEST_TIMEOUT_MS;
17
+ }
@@ -0,0 +1,18 @@
1
+ import type { SdkError, SdkErrorCode } from './types.ts';
2
+ interface SdkErrorInit {
3
+ readonly method?: string;
4
+ readonly details?: unknown;
5
+ }
6
+ export declare function sdkError(code: SdkErrorCode, message: string, init?: SdkErrorInit): SdkError;
7
+ /**
8
+ * An error arriving over the bridge is built inside a message listener, so its
9
+ * own stack points at the listener and is the same for every failure. The frame
10
+ * worth having is the `await` that started the call, so it is captured on the way
11
+ * out and transplanted onto the error on the way back.
12
+ *
13
+ * Above the transports on purpose: one that had to remember to do this would be
14
+ * the one nobody can debug.
15
+ */
16
+ export declare function captureCallSite(): Error;
17
+ export declare function withCallSite(error: SdkError, callSite: Error): SdkError;
18
+ export {};
package/dist/errors.js ADDED
@@ -0,0 +1,38 @@
1
+ class WalerSdkError extends Error {
2
+ code;
3
+ method;
4
+ details;
5
+ constructor(code, message, init = {}) {
6
+ super(message);
7
+ this.name = 'SdkError';
8
+ this.code = code;
9
+ // Assigned conditionally rather than unconditionally: with
10
+ // exactOptionalPropertyTypes an explicit `undefined` is not the same as an
11
+ // absent key, and `'method' in error` is a check an app can reasonably make.
12
+ if (init.method !== undefined)
13
+ this.method = init.method;
14
+ if (init.details !== undefined)
15
+ this.details = init.details;
16
+ }
17
+ }
18
+ export function sdkError(code, message, init = {}) {
19
+ return new WalerSdkError(code, message, init);
20
+ }
21
+ /**
22
+ * An error arriving over the bridge is built inside a message listener, so its
23
+ * own stack points at the listener and is the same for every failure. The frame
24
+ * worth having is the `await` that started the call, so it is captured on the way
25
+ * out and transplanted onto the error on the way back.
26
+ *
27
+ * Above the transports on purpose: one that had to remember to do this would be
28
+ * the one nobody can debug.
29
+ */
30
+ export function captureCallSite() {
31
+ return new Error('waler-sdk call site');
32
+ }
33
+ export function withCallSite(error, callSite) {
34
+ const frames = callSite.stack?.split('\n').slice(1) ?? [];
35
+ if (frames.length > 0)
36
+ error.stack = [`${error.name}: ${error.message}`, ...frames].join('\n');
37
+ return error;
38
+ }
@@ -0,0 +1,15 @@
1
+ import type { WalerSDK, WalerSdkOptions } from './types.ts';
2
+ export type { Audience, BridgeEnvelope, BridgeTransport, Capability, CapabilitySet, DownloadOptions, GeolocationResult, HeaderState, KnownCapability, KnownSdkErrorCode, NotifyOptions, Platform, PlatformEndpoints, ReadyPayload, ScanResult, SdkError, SdkErrorCode, SdkEventMap, ShareOptions, ShellContext, ThemeTokens, TokenRequest, TokenResult, Unsubscribe, WalerSDK, WalerSdkOptions, } from './types.ts';
3
+ /**
4
+ * Builds the SDK and starts the handshake.
5
+ *
6
+ * ```ts
7
+ * const Waler = createSdk({ appId: 'my-app', allowedOrigins: ['https://shell.example.com'] })
8
+ * const { api, context } = await Waler.ready
9
+ * ```
10
+ *
11
+ * The handshake starts here rather than on import, so nothing goes on the wire
12
+ * because a bundler included the module. Inside a frame this uses the iframe
13
+ * bridge; outside one, everything rejects with NOT_IN_SHELL.
14
+ */
15
+ export declare function createSdk(options: WalerSdkOptions): WalerSDK;
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ import { surfaceUrl } from "./browser.js";
2
+ import { selectTransport } from "./bridge/select.js";
3
+ import { createSdkOver } from "./client/sdk.js";
4
+ /**
5
+ * Builds the SDK and starts the handshake.
6
+ *
7
+ * ```ts
8
+ * const Waler = createSdk({ appId: 'my-app', allowedOrigins: ['https://shell.example.com'] })
9
+ * const { api, context } = await Waler.ready
10
+ * ```
11
+ *
12
+ * The handshake starts here rather than on import, so nothing goes on the wire
13
+ * because a bundler included the module. Inside a frame this uses the iframe
14
+ * bridge; outside one, everything rejects with NOT_IN_SHELL.
15
+ */
16
+ export function createSdk(options) {
17
+ return createSdkOver(selectTransport(options), {
18
+ appId: options.appId,
19
+ surfaceUrl: surfaceUrl(),
20
+ });
21
+ }
@@ -0,0 +1 @@
1
+ export declare function newId(): string;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Ids only have to be unique within one page's in-flight requests, so this is not
3
+ * a security primitive and must not be used as one. The fallback exists because
4
+ * `crypto` is unavailable outside a secure context.
5
+ */
6
+ const randomId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
7
+ export function newId() {
8
+ const c = globalThis.crypto;
9
+ if (typeof c === 'object' && c !== null && 'randomUUID' in c) {
10
+ const uuid = c.randomUUID;
11
+ if (typeof uuid === 'function')
12
+ return uuid.call(c);
13
+ }
14
+ return randomId();
15
+ }
@@ -0,0 +1,22 @@
1
+ import type { BridgeEnvelope } from '../types.ts';
2
+ /**
3
+ * Read before parsing. A surface's window receives every postMessage aimed at it,
4
+ * and without a marker readable up front each one would have to be deserialised
5
+ * to discover it was never ours — a parser pointed at input from anywhere.
6
+ */
7
+ export declare const WIRE_PREFIX = "waler:v1:";
8
+ /**
9
+ * A string on every transport, so the prefix above can be checked before parsing
10
+ * and so a bridge that only carries strings needs no second format.
11
+ *
12
+ * No checksum by design: a non-cryptographic one detects neither the corruption
13
+ * that cannot happen here nor any tampering, and carrying one would imply an
14
+ * integrity guarantee this envelope does not have. Origin validation is it.
15
+ */
16
+ export declare function encode(envelope: BridgeEnvelope): string;
17
+ /**
18
+ * `null` for anything that is not ours. Never throws: the caller is a message
19
+ * listener, and an exception there would surface as an unhandled error in the
20
+ * page, triggered by input a stranger controls.
21
+ */
22
+ export declare function decode(raw: unknown): BridgeEnvelope | null;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Read before parsing. A surface's window receives every postMessage aimed at it,
3
+ * and without a marker readable up front each one would have to be deserialised
4
+ * to discover it was never ours — a parser pointed at input from anywhere.
5
+ */
6
+ export const WIRE_PREFIX = 'waler:v1:';
7
+ /**
8
+ * A string on every transport, so the prefix above can be checked before parsing
9
+ * and so a bridge that only carries strings needs no second format.
10
+ *
11
+ * No checksum by design: a non-cryptographic one detects neither the corruption
12
+ * that cannot happen here nor any tampering, and carrying one would imply an
13
+ * integrity guarantee this envelope does not have. Origin validation is it.
14
+ */
15
+ export function encode(envelope) {
16
+ return WIRE_PREFIX + JSON.stringify(envelope);
17
+ }
18
+ const KINDS = new Set(['request', 'response', 'error', 'event']);
19
+ const isFilledString = (value) => typeof value === 'string' && value.length > 0;
20
+ const isOptionalString = (value) => value === undefined || isFilledString(value);
21
+ /**
22
+ * `null` for anything that is not ours. Never throws: the caller is a message
23
+ * listener, and an exception there would surface as an unhandled error in the
24
+ * page, triggered by input a stranger controls.
25
+ */
26
+ export function decode(raw) {
27
+ if (typeof raw !== 'string')
28
+ return null;
29
+ if (!raw.startsWith(WIRE_PREFIX))
30
+ return null;
31
+ let parsed;
32
+ try {
33
+ parsed = JSON.parse(raw.slice(WIRE_PREFIX.length));
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ return isEnvelope(parsed) ? parsed : null;
39
+ }
40
+ function isEnvelope(value) {
41
+ if (typeof value !== 'object' || value === null)
42
+ return false;
43
+ const e = value;
44
+ if (e['v'] !== 1)
45
+ return false;
46
+ if (!isFilledString(e['id']))
47
+ return false;
48
+ if (!isFilledString(e['correlationId']))
49
+ return false;
50
+ if (!KINDS.has(e['kind']))
51
+ return false;
52
+ if (!isOptionalString(e['method']))
53
+ return false;
54
+ if (!isOptionalString(e['event']))
55
+ return false;
56
+ // An error envelope with no body would reject a promise with `undefined`,
57
+ // which from the app's side is indistinguishable from a hang.
58
+ if (e['kind'] === 'error' && !isErrorBody(e['error']))
59
+ return false;
60
+ if (e['kind'] === 'event' && !isFilledString(e['event']))
61
+ return false;
62
+ return true;
63
+ }
64
+ function isErrorBody(value) {
65
+ if (typeof value !== 'object' || value === null)
66
+ return false;
67
+ const body = value;
68
+ return isFilledString(body['code']) && typeof body['message'] === 'string';
69
+ }