UNPKG

zapier-platform-cli

Version:

The CLI for managing integrations in Zapier Developer Platform.

404 lines (364 loc) 13.6 kB
const path = require('node:path'); const { BASE_ENDPOINT } = require('../constants'); const { findCorePackageDir, runCommand } = require('./misc'); const { copyZapierWrapper, deleteZapierWrapper } = require('./zapierwrapper'); /** * Wraps Node's http.request() / https.request() so that all requests go via a relay URL. * It decides whether to use the http or https module based on the relay URL's protocol. * * @param {Function} originalHttpRequest - The original http.request function. * @param {Function} originalHttpsRequest - The original https.request function. * @param {string} relayUrl - The base URL to which we relay. (e.g., 'http://my-relay.test') * @param {Object} relayHeaders - Extra headers to add to each request sent to the relay. * @returns {Function} A function with the same signature as http(s).request that relays instead. * * Usage: * const http = require('http'); * const https = require('https'); * * // Replace https.request with our wrapped version: * https.request = wrapHttpRequestFuncWithRelay( * http.request, * https.request, * 'https://my-relay.test', * { 'X-Relayed-By': 'MyRelayProxy' } * ); * * // Now, calling https.request('https://example.com/hello') will actually * // send a request to "https://my-relay.test/example.com/hello" * // with X-Relayed-By header attached. */ function wrapHttpRequestFuncWithRelay( originalHttpRequest, originalHttpsRequest, relayUrl, relayHeaders, ) { const parsedRelayUrl = new URL(relayUrl); // Decide if the relay itself is HTTP or HTTPS const isRelayHttps = parsedRelayUrl.protocol === 'https:'; // Pick which request function to use to talk to the relay const relayRequestFunc = isRelayHttps ? originalHttpsRequest : originalHttpRequest; /** * The actual wrapped request function. * Accepts the same arguments as http(s).request: * (options[, callback]) or (url[, options][, callback]) */ return function wrappedRequest(originalOptions, originalCallback) { let options; let callback; // 1. Normalize arguments (string URL vs. options object) if (typeof originalOptions === 'string') { // Called like request(urlString, [...]) try { const parsedOriginalUrl = new URL(originalOptions); if (typeof originalCallback === 'object') { // request(urlString, optionsObject, callback) options = { ...originalCallback }; callback = arguments[2]; } else { // request(urlString, callback) or request(urlString) options = {}; callback = originalCallback; } // Merge in the URL parts if not explicitly set in options options.protocol = options.protocol || parsedOriginalUrl.protocol; options.hostname = options.hostname || parsedOriginalUrl.hostname; options.port = options.port || parsedOriginalUrl.port; options.path = options.path || parsedOriginalUrl.pathname + parsedOriginalUrl.search; } catch (err) { // If it's not a valid absolute URL, treat it as a path // or re-throw if you prefer strictness. options = {}; callback = originalCallback; options.path = originalOptions; } } else { // Called like request(optionsObject, [callback]) options = { ...originalOptions }; callback = originalCallback; } // 2. Default method and headers if (!options.method) { options.method = 'GET'; } if (!options.headers) { options.headers = {}; } // 3. Decide whether to relay or not // If the request is being sent to the same host:port as our relay, // we do NOT want to relay again. We just call the original request. const targetHost = (options.hostname || options.host) .replaceAll('lcurly-', '{{') .replaceAll('-rcurly', '}}'); const targetPort = options.port ? String(options.port) : ''; const relayHost = parsedRelayUrl.hostname; const relayPort = parsedRelayUrl.port ? String(parsedRelayUrl.port) : ''; const isAlreadyRelay = targetHost === relayHost && // If no port was specified, assume default port comparison as needed (targetPort === relayPort || (!targetPort && !relayPort)); if (isAlreadyRelay) { // Just call the original function; do *not* re-relay const originalFn = options.protocol === 'https:' ? originalHttpsRequest : originalHttpRequest; return originalFn(options, callback); } // 4. Otherwise, build the path we want to relay to let finalHost = targetHost; if (targetPort && targetPort !== '443') { finalHost += `:${targetPort}`; } const combinedPath = `${parsedRelayUrl.pathname}/${finalHost}${options.path}`; // 5. Build final options for the relay request const relayedOptions = { protocol: parsedRelayUrl.protocol, hostname: relayHost, port: relayPort, path: combinedPath, method: options.method, headers: { ...options.headers, ...relayHeaders, }, }; // 6. Make the relay request const relayReq = relayRequestFunc(relayedOptions, callback); return relayReq; }; } /** * Wraps a fetch function so that all requests get relayed to a specified relay URL. * The final relay URL includes: relayUrl + "/" + originalHost + originalPath * * @param {Function} fetchFunc - The original fetch function (e.g., global.fetch). * @param {string} relayUrl - The base URL to which we relay. (e.g. 'https://my-relay.test') * @param {Object} relayHeaders - Extra headers to add to each request sent to the relay. * @returns {Function} A function with the same signature as `fetch(url, options)`. * * Usage: * const wrappedFetch = wrapFetchWithRelay( * fetch, * 'https://my-relay.test', * { 'X-Relayed-By': 'MyRelayProxy' }, * ); * * // Now when you do: * // wrappedFetch('https://example.com/api/user?id=123', { method: 'POST' }); * // it actually sends a request to: * // https://my-relay.test/example.com/api/user?id=123 * // with "X-Relayed-By" header included. */ function wrapFetchWithRelay(fetchFunc, relayUrl, relayHeaders) { const parsedRelayUrl = new URL(relayUrl); return async function wrappedFetch(originalUrl, originalOptions = {}) { // Attempt to parse the originalUrl as an absolute URL const parsedOriginalUrl = new URL(originalUrl); // Build the portion that includes the original host (and port if present) let host = parsedOriginalUrl.hostname; // If there's a port that isn't 443 (for HTTPS) or 80 (for HTTP), append it // (Adjust to your preferences; here we loosely check for 443 only.) if (parsedOriginalUrl.port && parsedOriginalUrl.port.toString() !== '443') { host += `:${parsedOriginalUrl.port}`; } const isAlreadyRelay = parsedOriginalUrl.hostname === parsedRelayUrl.hostname && parsedOriginalUrl.port === parsedRelayUrl.port; if (isAlreadyRelay) { // Just call the original fetch function; do *not* re-relay return fetchFunc(originalUrl, originalOptions); } // Combine the relay's pathname with the "host + path" from the original // For example: relayUrl = http://my-relay.test // => parsedRelayUrl.pathname might be '/' // => combinedPath = '/example.com:8080/some/path' const combinedPath = `${parsedRelayUrl.pathname}/${host}${parsedOriginalUrl.pathname}`; // Merge in the search strings: the relay's own search (if any) plus the original URL's search const finalUrl = `${parsedRelayUrl.origin}${combinedPath}${parsedRelayUrl.search}${parsedOriginalUrl.search}`; // Merge the user's headers with the relayHeaders const mergedHeaders = { ...(originalOptions.headers || {}), ...relayHeaders, }; const finalOptions = { ...originalOptions, headers: mergedHeaders, }; // Call the real fetch with our new URL and merged options return fetchFunc(finalUrl, finalOptions); }; } const loadAppRawUsingImport = async ( appDir, corePackageDir, shouldDeleteWrapper, ) => { const wrapperPath = await copyZapierWrapper(corePackageDir, appDir); let appRaw; try { // zapierwrapper.mjs is only available since zapier-platform-core v17. // And only zapierwrapper.mjs exposes appRaw just for this use case. // Convert path to file:// URL for Windows compatibility with ESM imports const pathForUrl = path.resolve(wrapperPath).replace(/\\/g, '/'); const wrapperUrl = new URL(`file:///${pathForUrl.replace(/^\//, '')}`); appRaw = (await import(wrapperUrl.href)).appRaw; } catch (err) { if (err.name === 'SyntaxError') { // Run a separate process to print the line number of the SyntaxError. // This workaround is needed because `err` doesn't provide the location // info about the SyntaxError. However, if the error is thrown to // Node.js's built-in error handler, it will print the location info. // See: https://github.com/nodejs/node/issues/49441 await runCommand(process.execPath, ['zapierwrapper.js'], { cwd: appDir, }); } throw err; } finally { if (shouldDeleteWrapper) { await deleteZapierWrapper(appDir); } } return appRaw; }; const loadAppRawUsingRequire = (appDir) => { const normalizedPath = path.resolve(appDir); let appRaw = require(normalizedPath); if (appRaw && appRaw.default) { // Node.js 22+ supports using require() to import ESM. // For Node.js < 20.17.0, require() will throw an error on ESM. // https://nodejs.org/api/modules.html#loading-ecmascript-modules-using-require appRaw = appRaw.default; } return appRaw; }; const getLocalAppHandler = async ({ appDir = null, appId = null, deployKey = null, relayAuthenticationId = null, beforeRequest = null, afterResponse = null, shouldDeleteWrapper = true, } = {}) => { appDir = path.resolve(appDir || process.cwd()); const corePackageDir = findCorePackageDir(); let appRaw; try { appRaw = loadAppRawUsingRequire(appDir); } catch (err) { if (err.code === 'MODULE_NOT_FOUND' || err.code === 'ERR_REQUIRE_ESM') { try { appRaw = await loadAppRawUsingImport( appDir, corePackageDir, shouldDeleteWrapper, ); } catch (err) { err.message = 'Your ESM integration requires a compiled `dist/` directory. Run `zapier build` to compile your code first.\n\n' + err.message; throw err; } } else { // err.name === 'SyntaxError' or others throw err; } } if (beforeRequest) { appRaw.beforeRequest = [...(appRaw.beforeRequest || []), ...beforeRequest]; } if (afterResponse) { appRaw.afterResponse = [...afterResponse, ...(appRaw.afterResponse || [])]; } if (appId && deployKey && relayAuthenticationId) { const relayUrl = `${BASE_ENDPOINT}/api/platform/cli/apps/${appId}/relay`; const relayHeaders = { 'x-relay-authentication-id': relayAuthenticationId, 'x-deploy-key': deployKey, }; const http = require('http'); const https = require('https'); const origHttpRequest = http.request; const origHttpsRequest = https.request; http.request = wrapHttpRequestFuncWithRelay( origHttpRequest, origHttpsRequest, relayUrl, relayHeaders, ); https.request = wrapHttpRequestFuncWithRelay( origHttpRequest, origHttpsRequest, relayUrl, relayHeaders, ); global.fetch = wrapFetchWithRelay(global.fetch, relayUrl, relayHeaders); } // Assumes the entry point of zapier-platform-core is index.js const coreEntryPoint = path.join(corePackageDir, 'index.js'); // Convert path to file:// URL for Windows compatibility with ESM imports const pathForUrl = path.resolve(coreEntryPoint).replace(/\\/g, '/'); const coreEntryUrl = new URL(`file:///${pathForUrl.replace(/^\//, '')}`); const zapier = (await import(coreEntryUrl.href)).default; const handler = zapier.createAppHandler(appRaw); if (handler.length === 3) { // < v17: function handler(event, ctx, callback) return (event, ctx, callback) => { event = { ...event, calledFromCli: true, }; return handler(event, ctx, callback); }; } else { // >= v17: async function handler(event, ctx = {}) return async (event, ctx = {}) => { event = { ...event, calledFromCli: true, }; return await handler(event, ctx); }; } }; // Runs a local app command (./index.js) like {command: 'validate'}; const localAppCommand = async (event, appDir, shouldDeleteWrapper = true) => { const handler = await getLocalAppHandler({ appId: event.appId, deployKey: event.deployKey, relayAuthenticationId: event.relayAuthenticationId, beforeRequest: event.beforeRequest, afterResponse: event.afterResponse, appDir, shouldDeleteWrapper, }); if (handler.length === 3) { // < 17: function handler(event, ctx, callback) return new Promise((resolve, reject) => { event = { ...event, calledFromCli: true, }; handler(event, {}, (err, response) => { if (err) { reject(err); } else { resolve(response.results); } }); }); } else { // >= 17: async function handler(event, ctx = {}) const response = await handler(event); return response.results; } }; module.exports = { localAppCommand, };