diff --git a/package-lock.json b/package-lock.json index 7915478..31ff755 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "MIT", "workspaces": [ "packages/explorer", - "packages/sandbox" + "packages/sandbox", + "packages/embed-helpers" ], "devDependencies": { "@babel/core": "^7.18.5", @@ -64,6 +65,10 @@ "node": ">=6.0.0" } }, + "node_modules/@apollo/embed-helpers": { + "resolved": "packages/embed-helpers", + "link": true + }, "node_modules/@apollo/explorer": { "resolved": "packages/explorer", "link": true @@ -9964,9 +9969,24 @@ "zen-observable": "0.8.15" } }, + "packages/embed-helpers": { + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@types/whatwg-mimetype": "^3.0.0", + "graphql-ws": "^5.9.0", + "subscriptions-transport-ws": "^0.11.0", + "whatwg-mimetype": "^3.0.0", + "zen-observable-ts": "^1.1.0" + }, + "engines": { + "node": ">=12.0", + "npm": ">=7.0" + } + }, "packages/explorer": { "name": "@apollo/explorer", - "version": "3.1.1", + "version": "3.2.0", "license": "MIT", "dependencies": { "@types/whatwg-mimetype": "^3.0.0", @@ -9998,7 +10018,7 @@ }, "packages/sandbox": { "name": "@apollo/sandbox", - "version": "2.1.1", + "version": "2.2.0", "license": "MIT", "dependencies": { "@types/whatwg-mimetype": "^3.0.0", @@ -10040,6 +10060,16 @@ "@jridgewell/trace-mapping": "^0.3.9" } }, + "@apollo/embed-helpers": { + "version": "file:packages/embed-helpers", + "requires": { + "@types/whatwg-mimetype": "^3.0.0", + "graphql-ws": "^5.9.0", + "subscriptions-transport-ws": "^0.11.0", + "whatwg-mimetype": "^3.0.0", + "zen-observable-ts": "^1.1.0" + } + }, "@apollo/explorer": { "version": "file:packages/explorer", "requires": { diff --git a/package.json b/package.json index b312559..f0476f6 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,8 @@ }, "workspaces": [ "packages/explorer", - "packages/sandbox" + "packages/sandbox", + "packages/embed-helpers" ], "devDependencies": { "@babel/core": "^7.18.5", diff --git a/packages/embed-helpers/createRollupConfig.js b/packages/embed-helpers/createRollupConfig.js new file mode 100644 index 0000000..bb02daf --- /dev/null +++ b/packages/embed-helpers/createRollupConfig.js @@ -0,0 +1,38 @@ +import typescript from '@rollup/plugin-typescript'; +import babel from '@rollup/plugin-babel'; +import json from '@rollup/plugin-json'; + +export function createRollupConfig(options) { + return { + input: { + index: 'src/index.ts', + }, + output: { + format: options.format, + freeze: false, + esModule: true, + name: 'embed-helpers', + exports: 'named', + sourcemap: false, + dir: `./dist`, + entryFileNames: + // All of our esm files have .mjs extensions + options.format === 'esm' ? '[name].mjs' : '[name].production.min.cjs', + ...(options.format === 'cjs' && { + chunkFileNames: '[name].production.min.cjs', + }), + }, + external: ['use-deep-compare-effect', 'react'], + plugins: [ + typescript({ + tsconfig: './tsconfig.json', + }), + babel({ + exclude: 'node_modules/**', + extensions: ['.js', '.jsx', '.ts', '.tsx'], + babelHelpers: 'bundled', + }), + json(), + ], + }; +} diff --git a/packages/embed-helpers/package.json b/packages/embed-helpers/package.json new file mode 100644 index 0000000..72d9064 --- /dev/null +++ b/packages/embed-helpers/package.json @@ -0,0 +1,62 @@ +{ + "name": "@apollo/embed-helpers", + "version": "0.0.0", + "author": "packages@apollographql.com", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/apollographql/embeddable-explorer" + }, + "homepage": "https://github.com/apollographql/embeddable-explorer/embed-helpers#readme", + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "typings": "dist/src/index.d.ts", + "engines": { + "node": ">=12.0", + "npm": ">=7.0" + }, + "volta": { + "node": "16.13.0", + "npm": "8.3.1" + }, + "scripts": { + "build": "npm run build:cjs-esm", + "build:cjs-esm": "rm -rf dist && rollup -c rollup.cjs-esm.config.js && cp src/index.cjs dist/index.cjs", + "lint": "eslint --ext .js,.jsx,.ts,.tsx .", + "size": "size-limit", + "analyze": "size-limit --why", + "typescript:check": "tsc --noEmit", + "prettier:check": "prettier --check .", + "prettier:fix": "prettier --write ." + }, + "husky": { + "hooks": { + "pre-commit": "npm run lint" + } + }, + "prettier": { + "printWidth": 80, + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "endOfLine": "auto" + }, + "size-limit": [ + { + "path": "dist/index.production.min.js", + "limit": "10 KB" + }, + { + "path": "dist/index.mjs", + "limit": "10 KB" + } + ], + "dependencies": { + "@types/whatwg-mimetype": "^3.0.0", + "graphql-ws": "^5.9.0", + "subscriptions-transport-ws": "^0.11.0", + "whatwg-mimetype": "^3.0.0", + "zen-observable-ts": "^1.1.0" + } + } + \ No newline at end of file diff --git a/packages/embed-helpers/rollup.cjs-esm.config.js b/packages/embed-helpers/rollup.cjs-esm.config.js new file mode 100644 index 0000000..c7a1ba0 --- /dev/null +++ b/packages/embed-helpers/rollup.cjs-esm.config.js @@ -0,0 +1,10 @@ +import { createRollupConfig } from './createRollupConfig'; + +export default [ + createRollupConfig({ + format: 'cjs', + }), + createRollupConfig({ + format: 'esm', + }), +]; diff --git a/packages/embed-helpers/src/constants.ts b/packages/embed-helpers/src/constants.ts new file mode 100644 index 0000000..23b8ef7 --- /dev/null +++ b/packages/embed-helpers/src/constants.ts @@ -0,0 +1,43 @@ +export const EMBEDDABLE_SANDBOX_URL = (__testLocal__ = false) => + __testLocal__ + ? 'https://embed.apollo.local:3000/sandbox/explorer' + : 'https://sandbox.embed.apollographql.com/sandbox/explorer'; + +export const EMBEDDABLE_EXPLORER_URL = (__testLocal__ = false) => + __testLocal__ + ? 'https://embed.apollo.local:3000' + : 'https://explorer.embed.apollographql.com'; + +// Message types for Explorer state +export const EXPLORER_LISTENING_FOR_SCHEMA = 'ExplorerListeningForSchema'; +export const EXPLORER_LISTENING_FOR_STATE = 'ExplorerListeningForState'; +export const SET_OPERATION = 'SetOperation'; +export const SCHEMA_ERROR = 'SchemaError'; +export const SCHEMA_RESPONSE = 'SchemaResponse'; + +// Message types for queries and mutations +export const EXPLORER_QUERY_MUTATION_REQUEST = 'ExplorerRequest'; +export const EXPLORER_QUERY_MUTATION_RESPONSE = 'ExplorerResponse'; +export const TRACE_KEY = 'ftv1'; + +// Message types for subscriptions +export const EXPLORER_SUBSCRIPTION_REQUEST = 'ExplorerSubscriptionRequest'; +export const EXPLORER_SUBSCRIPTION_RESPONSE = 'ExplorerSubscriptionResponse'; +export const EXPLORER_SUBSCRIPTION_TERMINATION = + 'ExplorerSubscriptionTermination'; +export const EXPLORER_SET_SOCKET_ERROR = 'ExplorerSetSocketError'; +export const EXPLORER_SET_SOCKET_STATUS = 'ExplorerSetSocketStatus'; +export const IFRAME_DOM_ID = (uniqueId: number) => `apollo-embed-${uniqueId}`; + +// Message types for authentication +export const EXPLORER_LISTENING_FOR_HANDSHAKE = 'ExplorerListeningForHandshake'; +export const HANDSHAKE_RESPONSE = 'HandshakeResponse'; +export const SET_PARTIAL_AUTHENTICATION_TOKEN_FOR_PARENT = + 'SetPartialAuthenticationTokenForParent'; +export const TRIGGER_LOGOUT_IN_PARENT = 'TriggerLogoutInParent'; +export const EXPLORER_LISTENING_FOR_PARTIAL_TOKEN = + 'ExplorerListeningForPartialToken'; +export const PARTIAL_AUTHENTICATION_TOKEN_RESPONSE = + 'PartialAuthenticationTokenResponse'; +export const INTROSPECTION_QUERY_WITH_HEADERS = 'IntrospectionQueryWithHeaders'; +export const PARENT_LOGOUT_SUCCESS = 'ParentLogoutSuccess'; diff --git a/packages/embed-helpers/src/constructMultipartForm.ts b/packages/embed-helpers/src/constructMultipartForm.ts new file mode 100644 index 0000000..f017b70 --- /dev/null +++ b/packages/embed-helpers/src/constructMultipartForm.ts @@ -0,0 +1,102 @@ +import type { JSONValue } from './types'; + +export type FileVariable = { + variableKey: string; + files: { arrayBuffer: ArrayBuffer; fileName: string }[]; + isMultiFile: boolean; +}; + +// https://github.com/apollographql/apollo-client/blob/cbcf951256b22553bdb065dfa0d32c0a4ca804d3/src/link/http/serializeFetchParameter.ts +export const serializeFetchParameter = (p: any, label: string) => { + let serialized; + try { + serialized = JSON.stringify(p); + } catch (e) { + const parseError = new Error( + `Network request failed. ${label} is not serializable` + ); + throw parseError; + } + return serialized; +}; + +export const constructMultipartForm = async ({ + fileVariables: inputtedFileVariables, + requestBody, +}: { + fileVariables: FileVariable[]; + requestBody: { + operationName?: string; + query: string; + variables?: Record; + }; +}) => { + const fileVariables: { + variableKey: string; + files: File[]; + isMultiFile: boolean; + }[] = inputtedFileVariables.map((fileVariable) => ({ + ...fileVariable, + files: fileVariable.files.map( + ({ arrayBuffer, fileName }) => + new File([new Blob([arrayBuffer])], fileName) + ), + })); + + // the map element of a FormData maps indices to a single item array of variable names + // as seen here https://github.com/jaydenseric/graphql-multipart-request-spec#file-list + const map: Record = {}; + let i = 0; + // map must be the first thing in the form, followed by the files + // other wise you get the error: + // Misordered multipart fields; files should follow ‘map’ (https://github.com/jaydenseric/graphql-multipart-request-spec). + const filesToAppend: [string, File, string][] = []; + // variables are added to the operation body with null values, the variable + // name is used to match them to files uploaded in the later part of the request + // according to the spec https://github.com/jaydenseric/graphql-multipart-request-spec + let variablesWithNullsForFiles: + | Record + | undefined = requestBody.variables; + fileVariables.forEach( + ({ files, variableKey, isMultiFile }, fileVariableIndex) => { + if (files?.length) { + variablesWithNullsForFiles = { + ...variablesWithNullsForFiles, + [variableKey]: isMultiFile + ? new Array(files.length).fill(null) + : null, + }; + Array.from(files).forEach((file) => { + map[i] = [ + `${ + fileVariables.length > 1 ? `${fileVariableIndex}.` : '' + }variables.${variableKey}${isMultiFile ? `.${i}` : ''}`, + ]; + // in the request, there is expected to be a number appended that corresponds to each file + // https://github.com/jaydenseric/graphql-multipart-request-spec#file-list + filesToAppend.push([i.toString(), file, file.name]); + i++; + }); + } + } + ); + const form = new FormData(); + form.append( + 'operations', + serializeFetchParameter( + { + query: requestBody.query, + operationName: requestBody.operationName, + variables: variablesWithNullsForFiles, + }, + 'Payload' + ) + ); + + form.append('map', JSON.stringify(map)); + filesToAppend.forEach((item) => { + form.append(item[0], item[1], item[2]); + }); + + return form; +}; diff --git a/packages/embed-helpers/src/defaultHandleRequest.ts b/packages/embed-helpers/src/defaultHandleRequest.ts new file mode 100644 index 0000000..a0d7b86 --- /dev/null +++ b/packages/embed-helpers/src/defaultHandleRequest.ts @@ -0,0 +1,20 @@ +import type { HandleRequest } from './postMessageRelayHelpers'; + +export const defaultHandleRequest = ({ + legacyIncludeCookies, +}: { + legacyIncludeCookies?: boolean; +}): HandleRequest => { + const handleRequestWithCookiePref: HandleRequest = (endpointUrl, options) => + fetch(endpointUrl, { + ...options, + ...(legacyIncludeCookies + ? { credentials: 'include' } + : // if the user doesn't pass this value then we should use the credentials option sent from the + // studio postMessage request. otherwise this would overwrite it. + legacyIncludeCookies !== undefined + ? { credentials: 'omit' } + : {}), + }); + return handleRequestWithCookiePref; +}; diff --git a/packages/embed-helpers/src/index.cjs b/packages/embed-helpers/src/index.cjs new file mode 100644 index 0000000..9a2f8da --- /dev/null +++ b/packages/embed-helpers/src/index.cjs @@ -0,0 +1,2 @@ +'use strict'; +module.exports = require('./index.production.min.cjs'); diff --git a/packages/embed-helpers/src/index.ts b/packages/embed-helpers/src/index.ts new file mode 100644 index 0000000..3999193 --- /dev/null +++ b/packages/embed-helpers/src/index.ts @@ -0,0 +1,61 @@ +import { + EMBEDDABLE_EXPLORER_URL, + EMBEDDABLE_SANDBOX_URL, + EXPLORER_LISTENING_FOR_HANDSHAKE, + EXPLORER_LISTENING_FOR_SCHEMA, + EXPLORER_LISTENING_FOR_STATE, + EXPLORER_QUERY_MUTATION_REQUEST, + EXPLORER_SUBSCRIPTION_REQUEST, + HANDSHAKE_RESPONSE, + IFRAME_DOM_ID, + INTROSPECTION_QUERY_WITH_HEADERS, + SCHEMA_RESPONSE, +} from './constants'; + +import { constructMultipartForm } from './constructMultipartForm'; +import { defaultHandleRequest } from './defaultHandleRequest'; +import { + ExplorerSubscriptionResponse, + HandleRequest, + IncomingEmbedMessage, + OutgoingEmbedMessage, + executeIntrospectionRequest, + executeOperation, + handleAuthenticationPostMessage, + sendPostMessageToEmbed, +} from './postMessageRelayHelpers'; +import { readMultipartWebStream } from './readMultipartWebStream'; + +import { executeSubscription } from './subscriptionPostMessageRelayHelpers'; +import type { JSONValue, JSONObject } from './types'; + +export { + EMBEDDABLE_EXPLORER_URL, + EMBEDDABLE_SANDBOX_URL, + IFRAME_DOM_ID, + EXPLORER_LISTENING_FOR_HANDSHAKE, + EXPLORER_LISTENING_FOR_SCHEMA, + EXPLORER_LISTENING_FOR_STATE, + EXPLORER_QUERY_MUTATION_REQUEST, + EXPLORER_SUBSCRIPTION_REQUEST, + HANDSHAKE_RESPONSE, + INTROSPECTION_QUERY_WITH_HEADERS, + SCHEMA_RESPONSE, + constructMultipartForm, + defaultHandleRequest, + executeIntrospectionRequest, + executeOperation, + executeSubscription, + handleAuthenticationPostMessage, + readMultipartWebStream, + sendPostMessageToEmbed, +}; + +export type { + JSONObject, + JSONValue, + HandleRequest, + ExplorerSubscriptionResponse, + IncomingEmbedMessage, + OutgoingEmbedMessage, +}; diff --git a/packages/embed-helpers/src/postMessageRelayHelpers.ts b/packages/embed-helpers/src/postMessageRelayHelpers.ts new file mode 100644 index 0000000..8cfcc09 --- /dev/null +++ b/packages/embed-helpers/src/postMessageRelayHelpers.ts @@ -0,0 +1,649 @@ +import type { + ExecutionResult, + GraphQLError, + IntrospectionQuery, +} from 'graphql'; +import { + PARTIAL_AUTHENTICATION_TOKEN_RESPONSE, + EXPLORER_QUERY_MUTATION_RESPONSE, + HANDSHAKE_RESPONSE, + SCHEMA_ERROR, + SCHEMA_RESPONSE, + SET_PARTIAL_AUTHENTICATION_TOKEN_FOR_PARENT, + EXPLORER_LISTENING_FOR_PARTIAL_TOKEN, + PARENT_LOGOUT_SUCCESS, + TRIGGER_LOGOUT_IN_PARENT, + EXPLORER_SUBSCRIPTION_RESPONSE, + EXPLORER_SET_SOCKET_ERROR, + EXPLORER_SET_SOCKET_STATUS, + TRACE_KEY, + EXPLORER_LISTENING_FOR_HANDSHAKE, + EXPLORER_QUERY_MUTATION_REQUEST, + EXPLORER_SUBSCRIPTION_REQUEST, + EXPLORER_SUBSCRIPTION_TERMINATION, + EXPLORER_LISTENING_FOR_SCHEMA, + INTROSPECTION_QUERY_WITH_HEADERS, +} from './constants'; +import MIMEType from 'whatwg-mimetype'; +import { readMultipartWebStream } from './readMultipartWebStream'; +import type { JSONObject, JSONValue } from './types'; +import type { ObjMap } from 'graphql/jsutils/ObjMap'; +import type { + GraphQLSubscriptionLibrary, + HTTPMultipartClient, +} from './subscriptionPostMessageRelayHelpers'; +import { constructMultipartForm, FileVariable } from './constructMultipartForm'; + +export type HandleRequest = ( + endpointUrl: string, + options: Omit & { headers: Record } +) => Promise; + +export type SocketStatus = 'disconnected' | 'connecting' | 'connected'; + +// Helper function that adds content-type: application/json +// to each request's headers if not present +function getHeadersWithContentType( + headers: Record | undefined +) { + const headersWithContentType = headers ?? {}; + if ( + Object.keys(headersWithContentType).every( + (key) => key.toLowerCase() !== 'content-type' + ) + ) { + headersWithContentType['content-type'] = 'application/json'; + } + return headersWithContentType; +} + +export function sendPostMessageToEmbed({ + message, + embeddedIFrameElement, + embedUrl, +}: { + message: OutgoingEmbedMessage; + embeddedIFrameElement: HTMLIFrameElement; + embedUrl: string; +}) { + embeddedIFrameElement?.contentWindow?.postMessage(message, embedUrl); +} + +export type ResponseError = { + message: string; + stack?: string; +}; + +export interface ResponseData { + data?: Record | JSONValue | ObjMap; + path?: Array; + errors?: readonly GraphQLError[]; + extensions?: { [TRACE_KEY]?: string }; +} +type ExplorerResponse = ResponseData & { + incremental?: Array< + ResponseData & { path: NonNullable } + >; + error?: ResponseError; + status?: number; + headers?: Record | Record[]; + hasNext?: boolean; + size?: number; +}; + +// https://apollographql.quip.com/mkWRAJfuxa7L/Multipart-subscriptions-protocol-spec +interface MultipartSubscriptionResponse { + data: { + errors?: Array; + payload: + | (ResponseData & { + error?: { message: string; stack?: string }; + }) + | null; + }; + headers?: Record | Record[]; + size: number; + status?: number; +} + +export type ExplorerSubscriptionResponse = + // websocket response + | { + data?: ExecutionResult; + error?: Error; + errors?: GraphQLError[]; + } + // http multipart response options below + | MultipartSubscriptionResponse + | { + data: null; + // this only exists in the PM MultipartSubscriptionResponse + // type, not in the one in explorer, because we want to send + // caught errors like CORS errors through to the embed + error?: ResponseError; + status?: number; + headers?: Record | Record[]; + }; + +export type OutgoingEmbedMessage = + | { + name: typeof SCHEMA_ERROR; + error?: string; + errors?: Array; + operationId: string; + } + | { + name: typeof SCHEMA_RESPONSE; + schema: IntrospectionQuery | string | undefined; + operationId: string; + } + | { + name: typeof HANDSHAKE_RESPONSE; + graphRef?: string; + inviteToken?: string; + accountId?: string; + parentHref?: string; + } + | { + name: typeof PARTIAL_AUTHENTICATION_TOKEN_RESPONSE; + partialToken?: string; + } + | { + name: typeof EXPLORER_QUERY_MUTATION_RESPONSE; + operationId: string; + response: ExplorerResponse; + } + | { + name: typeof EXPLORER_SUBSCRIPTION_RESPONSE; + operationId: string; + response: ExplorerSubscriptionResponse; + } + | { + name: typeof EXPLORER_SET_SOCKET_ERROR; + error: Error | undefined; + } + | { + name: typeof EXPLORER_SET_SOCKET_STATUS; + status: SocketStatus; + } + | { + name: typeof PARENT_LOGOUT_SUCCESS; + }; + +export type IncomingEmbedMessage = + | MessageEvent<{ + name: typeof EXPLORER_LISTENING_FOR_HANDSHAKE; + }> + | MessageEvent<{ + name: typeof EXPLORER_QUERY_MUTATION_REQUEST; + operationId: string; + operationName?: string; + operation: string; + variables?: Record; + headers?: Record; + // TODO (evan, 2023-02): We should make includeCookies non-optional in a few months to account for service workers refreshing + includeCookies?: boolean; + // This is required for Sandbox, but optional for Explorer b/c we support querying with + // the `endpointUrl` config option still to be backwards compat + endpointUrl?: string; + fileVariables?: FileVariable[]; + }> + | MessageEvent<{ + name: typeof EXPLORER_SUBSCRIPTION_REQUEST; + operationId: string; + operation: string; + variables?: Record; + operationName?: string; + headers?: Record; + subscriptionUrl: string; + protocol: GraphQLSubscriptionLibrary; + // only used for multipart protocol + httpMultipartParams: { + includeCookies: boolean | undefined; + }; + }> + | MessageEvent<{ + name: typeof EXPLORER_SUBSCRIPTION_TERMINATION; + operationId: string; + }> + | MessageEvent<{ + name: typeof EXPLORER_LISTENING_FOR_SCHEMA; + }> + | MessageEvent<{ + name: typeof SET_PARTIAL_AUTHENTICATION_TOKEN_FOR_PARENT; + localStorageKey: string; + partialToken: string; + }> + | MessageEvent<{ + name: typeof TRIGGER_LOGOUT_IN_PARENT; + localStorageKey: string; + }> + | MessageEvent<{ + name: typeof EXPLORER_LISTENING_FOR_PARTIAL_TOKEN; + localStorageKey?: string; + }> + | MessageEvent<{ + name: typeof INTROSPECTION_QUERY_WITH_HEADERS; + introspectionRequestBody: string; + introspectionRequestHeaders: Record; + // TODO (evan, 2023-02): We should make includeCookies non-optional in a few months to account for service workers refreshing + includeCookies?: boolean; + sandboxEndpointUrl?: string; + operationId: string; + }>; + +export async function executeOperation({ + endpointUrl, + handleRequest, + headers, + includeCookies, + operationId, + operation, + operationName, + variables, + fileVariables, + embeddedIFrameElement, + embedUrl, + isMultipartSubscription, + multipartSubscriptionClient, +}: { + endpointUrl: string; + handleRequest: HandleRequest; + headers?: Record; + includeCookies?: boolean; + operationId: string; + operation: string; + operationName: string | undefined; + variables?: Record; + fileVariables?: FileVariable[] | undefined; + embeddedIFrameElement: HTMLIFrameElement; + embedUrl: string; + isMultipartSubscription: boolean; + multipartSubscriptionClient?: HTTPMultipartClient; +}) { + const requestBody = { + query: operation, + variables, + operationName, + }; + let promise: Promise; + if (fileVariables && fileVariables.length > 0) { + const form = await constructMultipartForm({ + fileVariables, + requestBody, + }); + + promise = handleRequest(endpointUrl, { + method: 'POST', + headers: headers ?? {}, + body: form, + ...(includeCookies ? { credentials: 'include' } : {}), + }); + } else { + promise = handleRequest(endpointUrl, { + method: 'POST', + headers: getHeadersWithContentType(headers), + body: JSON.stringify(requestBody), + ...(!!includeCookies + ? { credentials: 'include' } + : { credentials: 'omit' }), + }); + } + promise + .then(async (response) => { + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + const contentType = response.headers?.get('content-type'); + const mimeType = contentType && new MIMEType(contentType); + if ( + mimeType && + mimeType.type === 'multipart' && + mimeType.subtype === 'mixed' + ) { + multipartSubscriptionClient?.emit('connected'); + const { observable, closeReadableStream } = readMultipartWebStream( + response, + mimeType + ); + + let isFirst = true; + + const observableSubscription = observable.subscribe({ + next(data) { + // if shouldTerminate is true, we got a server error + // we handle this in Explorer, but we need to disconnect from + // the readableStream & subscription here + if ('payload' in data.data) { + if ('shouldTerminate' in data && data.shouldTerminate) { + observableSubscription.unsubscribe(); + closeReadableStream(); + // the status being disconnected will be handled in the Explorer + // but we send a pm just in case + sendPostMessageToEmbed({ + message: { + name: EXPLORER_SET_SOCKET_STATUS, + status: 'disconnected', + }, + embeddedIFrameElement, + embedUrl, + }); + } + sendPostMessageToEmbed({ + message: { + name: EXPLORER_SUBSCRIPTION_RESPONSE, + // Include the same operation ID in the response message's name + // so the Explorer knows which operation it's associated with + operationId, + response: { + data: data.data, + status: response.status, + headers: isFirst + ? [ + responseHeaders, + ...(Array.isArray(data.headers) + ? data.headers + : data.headers + ? [data.headers] + : []), + ] + : data.headers, + size: data.size, + }, + }, + embeddedIFrameElement, + embedUrl, + }); + } else { + sendPostMessageToEmbed({ + message: { + name: EXPLORER_QUERY_MUTATION_RESPONSE, + // Include the same operation ID in the response message's name + // so the Explorer knows which operation it's associated with + operationId, + response: { + incremental: data.data.incremental, + data: data.data.data, + errors: data.data.errors, + extensions: data.data.extensions, + path: data.data.path, + status: response.status, + headers: isFirst + ? [ + responseHeaders, + ...(Array.isArray(data.headers) + ? data.headers + : data.headers + ? [data.headers] + : []), + ] + : data.headers, + hasNext: true, + size: data.size, + }, + }, + embeddedIFrameElement, + embedUrl, + }); + } + isFirst = false; + }, + error(err: unknown) { + const error = + err && + typeof err === 'object' && + 'message' in err && + typeof err.message === 'string' + ? { + message: err.message, + ...('stack' in err && typeof err.stack === 'string' + ? { stack: err.stack } + : {}), + } + : undefined; + sendPostMessageToEmbed({ + message: { + name: isMultipartSubscription + ? EXPLORER_SUBSCRIPTION_RESPONSE + : EXPLORER_QUERY_MUTATION_RESPONSE, + // Include the same operation ID in the response message's name + // so the Explorer knows which operation it's associated with + operationId, + response: { + data: null, + error, + ...(!isMultipartSubscription ? { hasNext: false } : {}), + }, + }, + embeddedIFrameElement, + embedUrl, + }); + }, + complete() { + sendPostMessageToEmbed({ + message: { + name: isMultipartSubscription + ? EXPLORER_SUBSCRIPTION_RESPONSE + : EXPLORER_QUERY_MUTATION_RESPONSE, + // Include the same operation ID in the response message's name + // so the Explorer knows which operation it's associated with + operationId, + response: { + data: null, + status: response.status, + headers: isFirst ? responseHeaders : undefined, + ...(!isMultipartSubscription ? { hasNext: false } : {}), + }, + }, + embeddedIFrameElement, + embedUrl, + }); + }, + }); + if (multipartSubscriptionClient) { + multipartSubscriptionClient.stopListeningCallback = () => { + closeReadableStream(); + observableSubscription.unsubscribe(); + }; + } + } else { + const json = await response.json(); + + // if we didn't get the mime type multi part response, + // something went wrong with this multipart subscription + multipartSubscriptionClient?.emit('error'); + multipartSubscriptionClient?.emit('disconnected'); + sendPostMessageToEmbed({ + message: { + name: isMultipartSubscription + ? EXPLORER_SUBSCRIPTION_RESPONSE + : EXPLORER_QUERY_MUTATION_RESPONSE, + // Include the same operation ID in the response message's name + // so the Explorer knows which operation it's associated with + operationId, + response: { + ...json, + status: response.status, + headers: responseHeaders, + hasNext: false, + }, + }, + embeddedIFrameElement, + embedUrl, + }); + } + }) + .catch((err) => { + multipartSubscriptionClient?.emit('error', err); + multipartSubscriptionClient?.emit('disconnected'); + const error = + err && + typeof err === 'object' && + 'message' in err && + typeof err.message === 'string' + ? { + message: err.message, + ...('stack' in err && typeof err.stack === 'string' + ? { stack: err.stack } + : {}), + } + : undefined; + sendPostMessageToEmbed({ + message: { + name: isMultipartSubscription + ? EXPLORER_SUBSCRIPTION_RESPONSE + : EXPLORER_QUERY_MUTATION_RESPONSE, + // Include the same operation ID in the response message's name + // so the Explorer knows which operation it's associated with + operationId, + response: { + data: null, + error, + ...(!isMultipartSubscription ? { hasNext: false } : {}), + }, + }, + embeddedIFrameElement, + embedUrl, + }); + }); +} + +export async function executeIntrospectionRequest({ + endpointUrl, + headers, + includeCookies, + introspectionRequestBody, + embeddedIFrameElement, + embedUrl, + handleRequest, + operationId, +}: { + endpointUrl: string; + embeddedIFrameElement: HTMLIFrameElement; + headers?: Record; + includeCookies?: boolean; + introspectionRequestBody: string; + embedUrl: string; + handleRequest: HandleRequest; + operationId: string; +}) { + const { query, operationName } = JSON.parse(introspectionRequestBody) as { + query: string; + operationName: string; + }; + return handleRequest(endpointUrl, { + method: 'POST', + headers: getHeadersWithContentType(headers), + body: JSON.stringify({ + query, + operationName, + }), + ...(!!includeCookies + ? { credentials: 'include' } + : { credentials: 'omit' }), + }) + .then((response) => response.json()) + .then((response) => { + if (response.errors && response.errors.length) { + sendPostMessageToEmbed({ + message: { + name: SCHEMA_ERROR, + errors: response.errors, + operationId, + }, + embeddedIFrameElement, + embedUrl, + }); + } + sendPostMessageToEmbed({ + message: { + name: SCHEMA_RESPONSE, + schema: response.data, + operationId, + }, + embeddedIFrameElement, + embedUrl, + }); + }) + .catch((error) => { + sendPostMessageToEmbed({ + message: { + name: SCHEMA_ERROR, + error: error, + operationId, + }, + embeddedIFrameElement, + embedUrl, + }); + }); +} + +export const handleAuthenticationPostMessage = ({ + event, + embeddedIFrameElement, + embedUrl, +}: { + event: IncomingEmbedMessage; + embeddedIFrameElement: HTMLIFrameElement; + embedUrl: string; +}) => { + const { data } = event; + // When the embed authenticates, save the partial token in local storage + if (data.name === SET_PARTIAL_AUTHENTICATION_TOKEN_FOR_PARENT) { + const partialEmbedApiKeysString = window.localStorage.getItem( + 'apolloStudioEmbeddedExplorerEncodedApiKey' + ); + const partialEmbedApiKeys = partialEmbedApiKeysString + ? JSON.parse(partialEmbedApiKeysString) + : {}; + partialEmbedApiKeys[data.localStorageKey] = data.partialToken; + window.localStorage.setItem( + 'apolloStudioEmbeddedExplorerEncodedApiKey', + JSON.stringify(partialEmbedApiKeys) + ); + } + + // When the embed logs out, remove the partial token in local storage + if (data.name === TRIGGER_LOGOUT_IN_PARENT) { + const partialEmbedApiKeysString = window.localStorage.getItem( + 'apolloStudioEmbeddedExplorerEncodedApiKey' + ); + const partialEmbedApiKeys = partialEmbedApiKeysString + ? JSON.parse(partialEmbedApiKeysString) + : {}; + delete partialEmbedApiKeys[data.localStorageKey]; + window.localStorage.setItem( + 'apolloStudioEmbeddedExplorerEncodedApiKey', + JSON.stringify(partialEmbedApiKeys) + ); + sendPostMessageToEmbed({ + message: { name: PARENT_LOGOUT_SUCCESS }, + embeddedIFrameElement, + embedUrl, + }); + } + + if ( + data.name === EXPLORER_LISTENING_FOR_PARTIAL_TOKEN && + data.localStorageKey + ) { + const partialEmbedApiKeysString = window.localStorage.getItem( + 'apolloStudioEmbeddedExplorerEncodedApiKey' + ); + const partialEmbedApiKeys = partialEmbedApiKeysString + ? JSON.parse(partialEmbedApiKeysString) + : {}; + if (partialEmbedApiKeys && partialEmbedApiKeys[data.localStorageKey]) { + sendPostMessageToEmbed({ + message: { + name: PARTIAL_AUTHENTICATION_TOKEN_RESPONSE, + partialToken: partialEmbedApiKeys[data.localStorageKey], + }, + embeddedIFrameElement, + embedUrl, + }); + } + } +}; diff --git a/packages/embed-helpers/src/readMultipartWebStream.ts b/packages/embed-helpers/src/readMultipartWebStream.ts new file mode 100644 index 0000000..e33cab6 --- /dev/null +++ b/packages/embed-helpers/src/readMultipartWebStream.ts @@ -0,0 +1,174 @@ +import { Observable } from 'zen-observable-ts'; +import type { GraphQLError } from 'graphql'; +import type MIMEType from 'whatwg-mimetype'; +import type { ResponseData } from './postMessageRelayHelpers'; + +export interface MultipartResponse { + data: ResponseData & { + incremental?: Array< + ResponseData & { path: NonNullable } + >; + error?: { message: string; stack?: string }; + hasNext?: boolean; + }; + headers?: Record | Record[]; + size: number; +} + +// https://apollographql.quip.com/mkWRAJfuxa7L/Multipart-subscriptions-protocol-spec +export interface MultipartSubscriptionResponse { + data: { + errors?: Array; + payload: + | (ResponseData & { + error?: { message: string; stack?: string }; + }) + | null; + }; + headers?: Record | Record[]; + size: number; + // True if --graphql-- message boundary is in the response + shouldTerminate?: boolean; +} + +export function readMultipartWebStream(response: Response, mimeType: MIMEType) { + if (response.body === null) { + throw new Error('Missing body'); + } else if (typeof response.body.tee !== 'function') { + // not sure if we actually need this check in explorer? + throw new Error( + 'Streaming bodies not supported by provided fetch implementation' + ); + } + + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + + const messageBoundary = `--${mimeType.parameters.get('boundary') || '-'}`; + const subscriptionTerminationMessageBoundary = '--graphql--'; + + const reader = response.body.getReader(); + return { + closeReadableStream: () => reader.cancel(), + observable: new Observable< + MultipartResponse | MultipartSubscriptionResponse + >((observer) => { + function readMultipartStream() { + reader + .read() + .then((iteration) => { + if (iteration.done) { + observer.complete?.(); + return; + } + + const chunk = decoder.decode(iteration.value); + buffer += chunk; + + let boundaryIndex = buffer.indexOf(messageBoundary); + while (boundaryIndex > -1) { + const message = buffer.slice(0, boundaryIndex); + buffer = buffer.slice(boundaryIndex + messageBoundary.length); + + if (message.trim()) { + const messageStartIndex = message.indexOf('\r\n\r\n'); + + const chunkHeaders = Object.fromEntries( + message + .slice(0, messageStartIndex) + .split('\n') + .map((line) => { + const i = line.indexOf(':'); + if (i > -1) { + const name = line.slice(0, i).trim(); + const value = line.slice(i + 1).trim(); + return [name, value] as const; + } else { + return null; + } + }) + .filter((h): h is NonNullable => !!h) + ); + + if ( + chunkHeaders['content-type'] + ?.toLowerCase() + .indexOf('application/json') === -1 + ) { + throw new Error('Unsupported patch content type'); + } + + const bodyText = message.slice(messageStartIndex); + try { + observer.next?.({ + data: JSON.parse(bodyText), + headers: chunkHeaders, + size: chunk.length, + ...(chunk.indexOf(subscriptionTerminationMessageBoundary) > + -1 + ? { shouldTerminate: true } + : {}), + }); + } catch (err) { + // const parseError = err as ServerParseError; + // parseError.name = 'ServerParseError'; + // parseError.response = response; + // parseError.statusCode = response.status; + // parseError.bodyText = bodyText; + throw err; + } + } + + boundaryIndex = buffer.indexOf(messageBoundary); + } + + readMultipartStream(); + }) + .catch((err) => { + if (err.name === 'AbortError') return; + // if it is a network error, BUT there is graphql result info fire + // the next observer before calling error this gives apollo-client + // (and react-apollo) the `graphqlErrors` and `networkErrors` to + // pass to UI this should only happen if we *also* have data as + // part of the response key per the spec + if (err.result && err.result.errors && err.result.data) { + // if we don't call next, the UI can only show networkError + // because AC didn't get any graphqlErrors this is graphql + // execution result info (i.e errors and possibly data) this is + // because there is no formal spec how errors should translate to + // http status codes. So an auth error (401) could have both data + // from a public field, errors from a private field, and a status + // of 401 + // { + // user { // this will have errors + // firstName + // } + // products { // this is public so will have data + // cost + // } + // } + // + // the result of above *could* look like this: + // { + // data: { products: [{ cost: "$10" }] }, + // errors: [{ + // message: 'your session has timed out', + // path: [] + // }] + // } + // status code of above would be a 401 + // in the UI you want to show data where you can, errors as data where you can + // and use correct http status codes + observer.next?.({ + data: err.result, + size: Infinity, + }); + } + + observer.error?.(err); + }); + } + readMultipartStream(); + }), + }; +} diff --git a/packages/embed-helpers/src/subscriptionPostMessageRelayHelpers.ts b/packages/embed-helpers/src/subscriptionPostMessageRelayHelpers.ts new file mode 100644 index 0000000..03efc8d --- /dev/null +++ b/packages/embed-helpers/src/subscriptionPostMessageRelayHelpers.ts @@ -0,0 +1,447 @@ +import EventEmitter from 'eventemitter3'; +import type { ExecutionResult } from 'graphql'; +import { Client, createClient as createGraphQLWSClient } from 'graphql-ws'; +import { + Observer, + OperationOptions, + SubscriptionClient as TransportSubscriptionClient, +} from 'subscriptions-transport-ws'; +import { + EXPLORER_SET_SOCKET_ERROR, + EXPLORER_SET_SOCKET_STATUS, + EXPLORER_SUBSCRIPTION_RESPONSE, + EXPLORER_SUBSCRIPTION_TERMINATION, +} from './constants'; +import { + executeOperation, + HandleRequest, + sendPostMessageToEmbed, + SocketStatus, +} from './postMessageRelayHelpers'; +import type { JSONObject } from './types'; + +export type GraphQLSubscriptionLibrary = + | 'subscriptions-transport-ws' + | 'graphql-ws' + | 'http-multipart'; + +// @see https://www.typescriptlang.org/docs/handbook/advanced-types.html#exhaustiveness-checking +function assertUnreachable(x: never): never { + throw new Error(`Didn't expect to get here ${x}`); +} + +type HTTPMultipartParams = { + includeCookies?: boolean; + handleRequest: HandleRequest; +}; + +export type HTTPMultipartClient = EventEmitter< + 'connected' | 'error' | 'disconnected' +> & { + stopListeningCallback: (() => void) | undefined; +}; + +class SubscriptionClient { + protocol: Protocol; + unsubscribeFunctions: Array<() => void> = []; + url: string; + headers: Record | undefined; + // Private variables + private _multipartClient: HTTPMultipartClient | undefined; + private _graphWsClient: Client | undefined; + private _transportSubscriptionClient: undefined | TransportSubscriptionClient; + + constructor( + url: string, + headers: Record | undefined, + protocol: Protocol + ) { + this.protocol = protocol; + this.url = url; + this.headers = headers; + } + + public get graphWsClient(): Client { + const client = + this._graphWsClient ?? + createGraphQLWSClient({ + url: this.url, + lazy: true, + connectionParams: this.headers ?? {}, + keepAlive: 10_000, + }); + this._graphWsClient = client; + return client; + } + + public get transportSubscriptionClient(): TransportSubscriptionClient { + const client = + this._transportSubscriptionClient ?? + new TransportSubscriptionClient(this.url, { + reconnect: true, + lazy: true, + connectionParams: this.headers ?? {}, + }); + this._transportSubscriptionClient = client; + return client; + } + + public get multipartClient(): HTTPMultipartClient { + const client = + this._multipartClient ?? + Object.assign( + new EventEmitter<'connected' | 'error' | 'disconnected'>(), + { + stopListeningCallback: undefined, + } + ); + this._multipartClient = client; + return client; + } + + onConnected(callback: () => void) { + if (this.protocol === 'http-multipart') { + this.multipartClient.on('connected', callback); + return () => this.multipartClient.off('connected', callback); + } + if (this.protocol === 'graphql-ws') { + return this.graphWsClient.on('connected', callback); + } + if (this.protocol === 'subscriptions-transport-ws') { + return this.transportSubscriptionClient.onConnected(callback); + } + assertUnreachable(this.protocol); + } + onConnecting(callback: () => void) { + if (this.protocol === 'http-multipart') { + return; + } + if (this.protocol === 'graphql-ws') { + return this.graphWsClient.on('connecting', callback); + } + if (this.protocol === 'subscriptions-transport-ws') { + return this.transportSubscriptionClient.onConnecting(callback); + } + assertUnreachable(this.protocol); + } + onError(callback: (e: Error) => void) { + if (this.protocol === 'http-multipart') { + this.multipartClient.on('error', callback); + return () => this.multipartClient.off('error', callback); + } + if (this.protocol === 'graphql-ws') { + return this.graphWsClient.on('error', (error: unknown) => + callback(error as Error) + ); + } + if (this.protocol === 'subscriptions-transport-ws') { + return this.transportSubscriptionClient.onError((e: Error) => + callback(e) + ); + } + assertUnreachable(this.protocol); + } + onReconnecting(callback: () => void) { + if (this.protocol === 'http-multipart') { + return; + } + if (this.protocol === 'graphql-ws') { + return; + } + if (this.protocol === 'subscriptions-transport-ws') { + return this.transportSubscriptionClient.onReconnecting(callback); + } + assertUnreachable(this.protocol); + } + onReconnected(callback: () => void) { + if (this.protocol === 'http-multipart') { + return; + } + if (this.protocol === 'graphql-ws') { + return; + } + if (this.protocol === 'subscriptions-transport-ws') { + return this.transportSubscriptionClient.onReconnected(callback); + } + assertUnreachable(this.protocol); + } + onDisconnected(callback: () => void) { + if (this.protocol === 'http-multipart') { + this.multipartClient.on('disconnected', callback); + return () => this.multipartClient.off('disconnected', callback); + } + if (this.protocol === 'graphql-ws') { + return this.graphWsClient.on('closed', callback); + } + if (this.protocol === 'subscriptions-transport-ws') { + return this.transportSubscriptionClient.onDisconnected(callback); + } + assertUnreachable(this.protocol); + } + dispose() { + if (this.protocol === 'http-multipart') { + this.multipartClient.stopListeningCallback?.(); + return; + } + if (this.protocol === 'graphql-ws') { + return this.graphWsClient.dispose(); + } + if (this.protocol === 'subscriptions-transport-ws') { + return this.transportSubscriptionClient.close(); + } + assertUnreachable(this.protocol); + } + + request( + params: OperationOptions & { + query: string; + variables: Record | undefined; + operationName: string | undefined; + httpMultipartParams?: HTTPMultipartParams; + embeddedIFrameElement: HTMLIFrameElement; + embedUrl: string; + operationId: string; + } + ) { + return { + subscribe: async ( + subscribeParams: Observer>> + ) => { + if (this.protocol === 'http-multipart' && params.httpMultipartParams) { + // we only use subscribeParams for websockets, for http multipart subs + // we do all responding in executeOperation, since this is where we set + // up the Observable + await executeOperation({ + operation: params.query, + operationName: params.operationName, + variables: params.variables, + headers: this.headers ?? {}, + includeCookies: params.httpMultipartParams?.includeCookies ?? false, + endpointUrl: this.url, + embeddedIFrameElement: params.embeddedIFrameElement, + embedUrl: params.embedUrl, + operationId: params.operationId, + handleRequest: params.httpMultipartParams?.handleRequest, + isMultipartSubscription: true, + multipartSubscriptionClient: this.multipartClient, + }); + } + if (this.protocol === 'graphql-ws') { + this.unsubscribeFunctions.push( + this.graphWsClient.subscribe(params, { + ...subscribeParams, + next: (data) => + subscribeParams.next?.(data as Record), + error: (error) => subscribeParams.error?.(error as Error), + complete: () => {}, + }) + ); + } + if (this.protocol === 'subscriptions-transport-ws') { + return this.transportSubscriptionClient + .request(params) + .subscribe(subscribeParams); + } else { + return undefined; + } + }, + }; + } + + unsubscribeAll() { + if (this.protocol === 'http-multipart') { + this.multipartClient.stopListeningCallback?.(); + } + if (this.protocol === 'graphql-ws') { + this.unsubscribeFunctions.forEach((off) => { + off(); + }); + this.unsubscribeFunctions = []; + } + + if (this.protocol === 'subscriptions-transport-ws') { + return this.transportSubscriptionClient.unsubscribeAll(); + } + } +} + +function setParentSocketError({ + error, + embeddedIFrameElement, + embedUrl, +}: { + error: Error | undefined; + embeddedIFrameElement: HTMLIFrameElement; + embedUrl: string; +}) { + sendPostMessageToEmbed({ + message: { + name: EXPLORER_SET_SOCKET_ERROR, + error, + }, + embeddedIFrameElement, + embedUrl, + }); +} + +function setParentSocketStatus({ + status, + embeddedIFrameElement, + embedUrl, +}: { + status: SocketStatus; + embeddedIFrameElement: HTMLIFrameElement; + embedUrl: string; +}) { + sendPostMessageToEmbed({ + message: { + name: EXPLORER_SET_SOCKET_STATUS, + status, + }, + embeddedIFrameElement, + embedUrl, + }); +} + +export function executeSubscription({ + operation, + operationName, + variables, + headers, + embeddedIFrameElement, + operationId, + embedUrl, + subscriptionUrl, + protocol, + httpMultipartParams, +}: { + operation: string; + operationId: string; + embeddedIFrameElement: HTMLIFrameElement; + operationName: string | undefined; + variables?: Record; + headers?: Record; + embedUrl: string; + subscriptionUrl: string; + protocol: GraphQLSubscriptionLibrary; + httpMultipartParams: HTTPMultipartParams; +}) { + const client = new SubscriptionClient( + subscriptionUrl, + headers ?? {}, + protocol + ); + + const checkForSubscriptionTermination = (event: MessageEvent) => { + if (event.data.name === EXPLORER_SUBSCRIPTION_TERMINATION) { + client.unsubscribeAll(); + window.removeEventListener('message', checkForSubscriptionTermination); + } + }; + + window.addEventListener('message', checkForSubscriptionTermination); + + client.onError((e: Error) => + setParentSocketError({ + error: JSON.parse(JSON.stringify(e)), + embeddedIFrameElement, + embedUrl, + }) + ); + client.onConnected(() => { + setParentSocketError({ + error: undefined, + embeddedIFrameElement, + embedUrl, + }); + setParentSocketStatus({ + status: 'connected', + embeddedIFrameElement, + embedUrl, + }); + }); + client.onReconnected(() => { + setParentSocketError({ + error: undefined, + embeddedIFrameElement, + embedUrl, + }); + setParentSocketStatus({ + status: 'connected', + embeddedIFrameElement, + embedUrl, + }); + }); + client.onConnecting(() => + setParentSocketStatus({ + status: 'connecting', + embeddedIFrameElement, + embedUrl, + }) + ); + client.onReconnecting(() => + setParentSocketStatus({ + status: 'connecting', + embeddedIFrameElement, + embedUrl, + }) + ); + client.onDisconnected(() => + setParentSocketStatus({ + status: 'disconnected', + embeddedIFrameElement, + embedUrl, + }) + ); + + client + .request({ + query: operation, + variables: variables ?? {}, + operationName, + embeddedIFrameElement, + embedUrl, + httpMultipartParams, + operationId, + }) + .subscribe( + // we only use these callbacks for websockets, for http multipart subs + // we do all responding in executeOperation, since this is where we set + // up the Observable + { + next(data) { + sendPostMessageToEmbed({ + message: { + name: EXPLORER_SUBSCRIPTION_RESPONSE, + // Include the same operation ID in the response message's name + // so the Explorer knows which operation it's associated with + operationId, + // we use different versions of graphql in Explorer & here, + // Explorer expects an Object, which is what this is in reality + response: { data: data as JSONObject }, + }, + embeddedIFrameElement, + embedUrl, + }); + }, + error: (error) => { + sendPostMessageToEmbed({ + message: { + name: EXPLORER_SUBSCRIPTION_RESPONSE, + // Include the same operation ID in the response message's name + // so the Explorer knows which operation it's associated with + operationId, + response: { error: JSON.parse(JSON.stringify(error)) }, + }, + embeddedIFrameElement, + embedUrl, + }); + }, + } + ); + + return { + dispose: () => + window.removeEventListener('message', checkForSubscriptionTermination), + }; +} diff --git a/packages/embed-helpers/src/types.ts b/packages/embed-helpers/src/types.ts new file mode 100644 index 0000000..3787598 --- /dev/null +++ b/packages/embed-helpers/src/types.ts @@ -0,0 +1,3 @@ +export type JSONPrimitive = boolean | null | string | number; +export type JSONObject = { [key in string]?: JSONValue }; +export type JSONValue = JSONPrimitive | JSONValue[] | JSONObject; diff --git a/packages/embed-helpers/tsconfig.json b/packages/embed-helpers/tsconfig.json new file mode 100644 index 0000000..24034b9 --- /dev/null +++ b/packages/embed-helpers/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "src/index.ts", "buildHelpers"], + "compilerOptions": { + "outDir": "dist", + "target": "es6", + "rootDir": "." + } +} \ No newline at end of file diff --git a/packages/explorer/package.json b/packages/explorer/package.json index a8b2308..2cb16fb 100644 --- a/packages/explorer/package.json +++ b/packages/explorer/package.json @@ -5,9 +5,9 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/apollographql/embeddable-explorer" + "url": "https://github.com/apollographql/embeddable-explorer/explorer" }, - "homepage": "https://github.com/apollographql/embeddable-explorer#readme", + "homepage": "https://github.com/apollographql/embeddable-explorer/explorer#readme", "main": "dist/index.cjs", "module": "dist/index.mjs", "typings": "dist/src/index.d.ts", diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index 0c24aab..9997379 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -5,9 +5,9 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/apollographql/embeddable-explorer" + "url": "https://github.com/apollographql/embeddable-explorer/sandbox" }, - "homepage": "https://github.com/apollographql/embeddable-explorer#readme", + "homepage": "https://github.com/apollographql/embeddable-explorer/sandbox#readme", "main": "dist/index.cjs", "module": "dist/index.mjs", "typings": "dist/src/index.d.ts",