UNPKG

@microlink/cli

Version:

Interacting with Microlink API from your terminal.

250 lines (207 loc) 7.94 kB
#!/usr/bin/env node 'use strict' require('update-notifier')({ pkg: require('../package.json') }).notify() const getContentType = require('@kikobeats/content-type') const { URLSearchParams } = require('url') const mql = require('@microlink/mql') const prettyMs = require('pretty-ms') const temp = require('temperment') const fs = require('fs') const os = require('os') const { toClipboard, toPlainHeaders } = require('./util') const { gray, green } = require('./colors') const printJson = require('./print-json') const printText = require('./print-text') const exit = require('./exit') const microlinkUrl = () => /^https?:\/\/((?!fonts|geolocation\.)[a-z0-9-]+\.)+microlink\.io/ // Leading-host matcher for the binary's own endpoint (e.g. `microlink-dev` → // `http://localhost:3000`, `microlink-next` → `https://next.microlink.io`). // Each binary sets its own `cli.flags.endpoint`, so the host we strip is driven // by that flag rather than a blanket list shared across every executable. const endpointUrl = endpoint => { if (!endpoint) return null let host try { ;({ host } = new URL(endpoint)) } catch { return null } // Require a host boundary (path/query/fragment or end-of-string) after the // host so a longer host that merely *starts with* it isn't matched (e.g. // endpoint `localhost:3000` must not strip a prefix of `localhost:30001`). const escapedHost = host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') return new RegExp(`^https?://${escapedHost}(?=[/?#]|$)`) } const normalizeInput = (input, endpoint) => { if (!input) return input let normalized = input // Recognize API-shaped input (full endpoint URL, or a bare `?url=`/`url=` // query string) so a bare target URL is left untouched. let isApiInput = /^\??url=/.test(input) || input.startsWith('?') // Host stripping only applies to an actual API request, which always carries a // `url=` query. A bare target URL — even on a microlink.io content host such as // cdn.microlink.io — has none, so its host must survive untouched. if (/(?:^|[?&])url=/.test(input)) { // Strip a canonical `*.microlink.io` host (users paste these into any // binary), plus the binary's own endpoint host when it isn't on microlink.io. const sanitizers = [microlinkUrl()] const endpointRegex = endpointUrl(endpoint) if (endpointRegex) sanitizers.push(endpointRegex) for (const regex of sanitizers) { const next = normalized.replace(regex, '') if (next !== normalized) { isApiInput = true normalized = next } } } if (!isApiInput) return normalized // Drop the leftover path/query separators after the host (e.g. `/?url=…`). normalized = normalized.replace(/^\/+/, '').replace(/^\?/, '') // Lift the `url=` param to the front so the caller's `url=${…}` // reconstruction keeps every other param intact regardless of their order // (e.g. `data.markdown.attr=markdown&embed=markdown&url=…`). const params = normalized.split('&') const urlIndex = params.findIndex(p => p === 'url' || p.startsWith('url=')) if (urlIndex === -1) return normalized const urlValue = params[urlIndex].replace(/^url=?/, '') return [urlValue, ...params.filter((_, index) => index !== urlIndex)].join( '&' ) } const getInput = input => { const collection = input.length === 1 ? input[0].split(os.EOL) : input return collection.map(item => item.trim()).join('') } const toPlainObject = input => Object.fromEntries(new URLSearchParams(input)) const fetch = async (cli, gotOpts) => { const { pretty, copy, json, 'json-full': jsonFull, endpoint, ...flags } = cli.flags const isJson = json || jsonFull const input = getInput(cli.input, endpoint) const { url, ...queryParams } = toPlainObject( `url=${normalizeInput(input, endpoint)}` ) const mqlOpts = { endpoint, ...queryParams, ...flags } const spinner = printText.spinner() const shouldSpin = !isJson && pretty let mergedGotOpts = gotOpts if (Object.keys(cli.headers).length > 0) { mergedGotOpts = { ...gotOpts, headers: { ...gotOpts.headers, ...cli.headers } } } const [requestUrl, requestOptions] = mql.getApiUrl( url, mqlOpts, mergedGotOpts ) try { if (shouldSpin) spinner.start() const start = Date.now() const request = isJson ? mql : mql.buffer const mqlResponse = await request(url, mqlOpts, mergedGotOpts) const duration = Date.now() - start let response = mqlResponse if (isJson) { response = printJson({ requestUrl, requestOptions, response: mqlResponse.response, full: jsonFull, pretty }) } return { response, duration, flags: { copy, pretty, json: isJson } } } catch (error) { error.flags = cli.flags throw error } finally { if (shouldSpin) spinner.stop() } } const render = ({ response, duration, flags }) => { const { headers, requestUrl, url: responseUrl, body } = response if (flags.json) { if (flags.copy) toClipboard(JSON.parse(response), flags) if (!flags.pretty) return console.log(response) return printText.json(JSON.parse(response), { color: true }) } const plainHeaders = toPlainHeaders(headers) const bodyBuffer = Buffer.isBuffer(body) ? body : Buffer.from(body) const bodyText = bodyBuffer.toString() if (!flags.pretty) return console.log(bodyText) const contentType = getContentType(plainHeaders['content-type']) const time = Number.isFinite(duration) ? prettyMs(duration) : 'unknown' const serverTiming = plainHeaders['server-timing'] const id = plainHeaders['x-request-id'] if (bodyText.startsWith('data:')) { const extension = contentType ? contentType.split('/')[1].split(';')[0] : 'png' const filepath = temp.file({ extension }) fs.writeFileSync(filepath, bodyText.split(',')[1], 'base64') printText.image(filepath) } else if (contentType !== 'application/json') { printText.image(bodyBuffer) console.log() } else { const isText = contentType === 'text/plain' const isHtml = contentType === 'text/html' const output = isText || isHtml ? bodyText : JSON.parse(bodyText) printText.json(output, flags) } const edgeCacheStatus = plainHeaders['cf-cache-status'] const unifiedCacheStatus = plainHeaders['x-cache-status'] const cacheStatus = unifiedCacheStatus === 'MISS' && edgeCacheStatus === 'HIT' ? edgeCacheStatus : unifiedCacheStatus const timestamp = Number(plainHeaders['x-timestamp']) const ttl = Number(plainHeaders['x-cache-ttl']) const expires = timestamp + ttl - Date.now() const expiration = prettyMs(expires) const expiredAt = cacheStatus === 'HIT' ? `(${expiration})` : '' const fetchMode = plainHeaders['x-fetch-mode'] const fetchTime = fetchMode && `(${plainHeaders['x-fetch-time']})` const size = Number(plainHeaders['content-length'] || bodyBuffer.length) const uri = requestUrl || responseUrl console.error() console.error( printText.label('success', 'green'), gray(`${printText.bytes(size)} in ${time}`) ) console.error() if (serverTiming) { console.error(' ', printText.keyValue(green('timing'), serverTiming)) } if (cacheStatus) { console.error( ' ', printText.keyValue(green('cache'), `${cacheStatus} ${gray(expiredAt)}`) ) } if (fetchMode) { console.error( ' ', printText.keyValue(green('mode'), `${fetchMode} ${gray(fetchTime)}`) ) } console.error(' ', printText.keyValue(green('uri'), uri)) console.error(' ', printText.keyValue(green('id'), id)) if (flags.copy) { toClipboard(JSON.parse(bodyText), flags) console.error(`\n ${gray('Copied to clipboard!')}`) } } module.exports = (cli, gotOpts = {}) => exit(fetch(cli, gotOpts).then(render), cli) module.exports.normalizeInput = normalizeInput