From 9a168473a813e42ab2dc8ae7f21a58a760b712da Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Tue, 14 Mar 2023 19:44:31 -0700 Subject: [PATCH] Add initial `@vercel/remix` package (#14) --- package.json | 3 +- packages/vercel-remix/README.md | 13 ++ packages/vercel-remix/edge/crypto.ts | 53 ++++++++ packages/vercel-remix/edge/entry.server.ts | 27 ++++ packages/vercel-remix/edge/implementations.ts | 13 ++ packages/vercel-remix/edge/index.ts | 67 ++++++++++ packages/vercel-remix/edge/server.ts | 21 ++++ packages/vercel-remix/entry.server.ts | 81 ++++++++++++ packages/vercel-remix/globals.ts | 2 + packages/vercel-remix/index.ts | 67 ++++++++++ packages/vercel-remix/jest.config.js | 5 + packages/vercel-remix/package.json | 47 +++++++ packages/vercel-remix/rollup.config.js | 13 ++ packages/vercel-remix/server.ts | 119 ++++++++++++++++++ packages/vercel-remix/tsconfig.json | 18 +++ rollup.utils.js | 19 ++- scripts/copy-build-to-dist.mjs | 9 +- tsconfig.json | 3 +- yarn.lock | 5 + 19 files changed, 577 insertions(+), 8 deletions(-) create mode 100644 packages/vercel-remix/README.md create mode 100644 packages/vercel-remix/edge/crypto.ts create mode 100644 packages/vercel-remix/edge/entry.server.ts create mode 100644 packages/vercel-remix/edge/implementations.ts create mode 100644 packages/vercel-remix/edge/index.ts create mode 100644 packages/vercel-remix/edge/server.ts create mode 100644 packages/vercel-remix/entry.server.ts create mode 100644 packages/vercel-remix/globals.ts create mode 100644 packages/vercel-remix/index.ts create mode 100644 packages/vercel-remix/jest.config.js create mode 100644 packages/vercel-remix/package.json create mode 100644 packages/vercel-remix/rollup.config.js create mode 100644 packages/vercel-remix/server.ts create mode 100644 packages/vercel-remix/tsconfig.json diff --git a/package.json b/package.json index dcc5614d0b9..35a993cf6cb 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "packages/remix-serve", "packages/remix-server-runtime", "packages/remix-testing", - "packages/remix-vercel" + "packages/remix-vercel", + "packages/vercel-remix" ], "scripts": { "bug-report-test": "playwright test --config ./integration/playwright.config.ts integration/bug-report-test.ts", diff --git a/packages/vercel-remix/README.md b/packages/vercel-remix/README.md new file mode 100644 index 00000000000..40685a7476f --- /dev/null +++ b/packages/vercel-remix/README.md @@ -0,0 +1,13 @@ +# Welcome to Remix! + +[Remix](https://remix.run) is a web framework that helps you build better websites with React. + +To get started, open a new shell and run: + +```sh +npx create-remix@latest +``` + +Then follow the prompts you see in your terminal. + +For more information about Remix, [visit remix.run](https://remix.run)! diff --git a/packages/vercel-remix/edge/crypto.ts b/packages/vercel-remix/edge/crypto.ts new file mode 100644 index 00000000000..c520a286f0a --- /dev/null +++ b/packages/vercel-remix/edge/crypto.ts @@ -0,0 +1,53 @@ +import type { SignFunction, UnsignFunction } from "@remix-run/server-runtime"; + +const encoder = new TextEncoder(); + +export const sign: SignFunction = async (value, secret) => { + let key = await createKey(secret, ["sign"]); + let data = encoder.encode(value); + let signature = await crypto.subtle.sign("HMAC", key, data); + let hash = btoa(String.fromCharCode(...new Uint8Array(signature))).replace( + /=+$/, + "" + ); + + return value + "." + hash; +}; + +export const unsign: UnsignFunction = async (signed, secret) => { + let index = signed.lastIndexOf("."); + let value = signed.slice(0, index); + let hash = signed.slice(index + 1); + + let key = await createKey(secret, ["verify"]); + let data = encoder.encode(value); + let signature = byteStringToUint8Array(atob(hash)); + let valid = await crypto.subtle.verify("HMAC", key, signature, data); + + return valid ? value : false; +}; + +async function createKey( + secret: string, + usages: CryptoKey["usages"] +): Promise { + let key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + usages + ); + + return key; +} + +function byteStringToUint8Array(byteString: string): Uint8Array { + let array = new Uint8Array(byteString.length); + + for (let i = 0; i < byteString.length; i++) { + array[i] = byteString.charCodeAt(i); + } + + return array; +} diff --git a/packages/vercel-remix/edge/entry.server.ts b/packages/vercel-remix/edge/entry.server.ts new file mode 100644 index 00000000000..273b17027ab --- /dev/null +++ b/packages/vercel-remix/edge/entry.server.ts @@ -0,0 +1,27 @@ +import isbot from 'isbot'; +import { renderToReadableStream } from 'react-dom/server'; + +export async function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixServer: JSX.Element +) { + let body = await renderToReadableStream(remixServer, { + signal: request.signal, + onError(error) { + console.error(error); + responseStatusCode = 500; + }, + }); + + if (isbot(request.headers.get('user-agent'))) { + await body.allReady; + } + + responseHeaders.set('Content-Type', 'text/html'); + return new Response(body, { + headers: responseHeaders, + status: responseStatusCode, + }); +} diff --git a/packages/vercel-remix/edge/implementations.ts b/packages/vercel-remix/edge/implementations.ts new file mode 100644 index 00000000000..4c5bf0e639d --- /dev/null +++ b/packages/vercel-remix/edge/implementations.ts @@ -0,0 +1,13 @@ +import { + createCookieFactory, + createCookieSessionStorageFactory, + createSessionStorageFactory, + } from "@remix-run/server-runtime"; + + import { sign, unsign } from "./crypto"; + + export const createCookie = createCookieFactory({ sign, unsign }); + export const createCookieSessionStorage = + createCookieSessionStorageFactory(createCookie); + export const createSessionStorage = createSessionStorageFactory(createCookie); + \ No newline at end of file diff --git a/packages/vercel-remix/edge/index.ts b/packages/vercel-remix/edge/index.ts new file mode 100644 index 00000000000..2fe4ec767cb --- /dev/null +++ b/packages/vercel-remix/edge/index.ts @@ -0,0 +1,67 @@ +export { + createCookie, + createCookieSessionStorage, + createSessionStorage, +} from './implementations'; + +export { + createRequestHandler, + createSession, + defer, + isCookie, + isSession, + json, + MaxPartSizeExceededError, + redirect, + unstable_composeUploadHandlers, + unstable_createMemoryUploadHandler, + unstable_parseMultipartFormData, +} from '@remix-run/server-runtime'; + +export type { + ActionArgs, + ActionFunction, + AppData, + AppLoadContext, + Cookie, + CookieOptions, + CookieParseOptions, + CookieSerializeOptions, + CookieSignatureOptions, + DataFunctionArgs, + EntryContext, + ErrorBoundaryComponent, + HandleDataRequestFunction, + HandleDocumentRequestFunction, + HeadersFunction, + HtmlLinkDescriptor, + HtmlMetaDescriptor, + V2_HtmlMetaDescriptor, + JsonFunction, + LinkDescriptor, + LinksFunction, + LoaderArgs, + LoaderFunction, + MemoryUploadHandlerFilterArgs, + MemoryUploadHandlerOptions, + MetaDescriptor, + MetaFunction, + V2_MetaFunction, + PageLinkDescriptor, + RequestHandler, + RouteComponent, + RouteHandle, + SerializeFrom, + ServerBuild, + ServerEntryModule, + Session, + SessionData, + SessionIdStorageStrategy, + SessionStorage, + SignFunction, + TypedDeferredData, + TypedResponse, + UnsignFunction, + UploadHandler, + UploadHandlerPart, +} from '@remix-run/server-runtime'; diff --git a/packages/vercel-remix/edge/server.ts b/packages/vercel-remix/edge/server.ts new file mode 100644 index 00000000000..0335d83cbc4 --- /dev/null +++ b/packages/vercel-remix/edge/server.ts @@ -0,0 +1,21 @@ +import type { ServerBuild, AppLoadContext, RequestHandler } from "@remix-run/server-runtime"; +import { createRequestHandler as createRemixRequestHandler } from "@remix-run/server-runtime"; + +export type GetLoadContextFunction = (req: Request) => AppLoadContext; + +export function createRequestHandler({ + build, + getLoadContext, + mode, +}: { + build: ServerBuild; + getLoadContext?: GetLoadContextFunction; + mode?: string; +}): RequestHandler { + let handleRequest = createRemixRequestHandler(build, mode); + + return (request) => { + let loadContext = getLoadContext?.(request); + return handleRequest(request, loadContext); + }; +} diff --git a/packages/vercel-remix/entry.server.ts b/packages/vercel-remix/entry.server.ts new file mode 100644 index 00000000000..d3e6dbc9a4e --- /dev/null +++ b/packages/vercel-remix/entry.server.ts @@ -0,0 +1,81 @@ +import { PassThrough } from 'stream'; +import { renderToPipeableStream } from 'react-dom/server'; +import { Response } from '@remix-run/node'; +import isbot from 'isbot'; + +const ABORT_DELAY = 5000; + +export function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixServer: JSX.Element +) { + // If the request is from a bot, we want to wait for the full + // response to render before sending it to the client. This + // ensures that bots can see the full page content. + if (isbot(request.headers.get('user-agent'))) { + return serveTheBots(responseStatusCode, responseHeaders, remixServer); + } + + return serveBrowsers(responseStatusCode, responseHeaders, remixServer); +} + +function serveTheBots( + responseStatusCode: number, + responseHeaders: Headers, + remixServer: JSX.Element +) { + return new Promise((resolve, reject) => { + let { pipe, abort } = renderToPipeableStream(remixServer, { + // Use onAllReady to wait for the entire document to be ready + onAllReady() { + responseHeaders.set('Content-Type', 'text/html'); + let body = new PassThrough(); + pipe(body); + resolve( + new Response(body, { + status: responseStatusCode, + headers: responseHeaders, + }) + ); + }, + onShellError(err) { + reject(err); + }, + }); + setTimeout(abort, ABORT_DELAY); + }); +} + +function serveBrowsers( + responseStatusCode: number, + responseHeaders: Headers, + remixServer: JSX.Element +) { + return new Promise((resolve, reject) => { + let didError = false; + let { pipe, abort } = renderToPipeableStream(remixServer, { + // use onShellReady to wait until a suspense boundary is triggered + onShellReady() { + responseHeaders.set('Content-Type', 'text/html'); + let body = new PassThrough(); + pipe(body); + resolve( + new Response(body, { + status: didError ? 500 : responseStatusCode, + headers: responseHeaders, + }) + ); + }, + onShellError(err) { + reject(err); + }, + onError(err) { + didError = true; + console.error(err); + }, + }); + setTimeout(abort, ABORT_DELAY); + }); +} diff --git a/packages/vercel-remix/globals.ts b/packages/vercel-remix/globals.ts new file mode 100644 index 00000000000..917305ac938 --- /dev/null +++ b/packages/vercel-remix/globals.ts @@ -0,0 +1,2 @@ +import { installGlobals } from "@remix-run/node"; +installGlobals(); diff --git a/packages/vercel-remix/index.ts b/packages/vercel-remix/index.ts new file mode 100644 index 00000000000..e7951674d6a --- /dev/null +++ b/packages/vercel-remix/index.ts @@ -0,0 +1,67 @@ +export { + createCookie, + createCookieSessionStorage, + createSessionStorage, +} from "@remix-run/node"; + +export { + createRequestHandler, + createSession, + defer, + isCookie, + isSession, + json, + MaxPartSizeExceededError, + redirect, + unstable_composeUploadHandlers, + unstable_createMemoryUploadHandler, + unstable_parseMultipartFormData, +} from "@remix-run/server-runtime"; + +export type { + ActionArgs, + ActionFunction, + AppData, + AppLoadContext, + Cookie, + CookieOptions, + CookieParseOptions, + CookieSerializeOptions, + CookieSignatureOptions, + DataFunctionArgs, + EntryContext, + ErrorBoundaryComponent, + HandleDataRequestFunction, + HandleDocumentRequestFunction, + HeadersFunction, + HtmlLinkDescriptor, + HtmlMetaDescriptor, + V2_HtmlMetaDescriptor, + JsonFunction, + LinkDescriptor, + LinksFunction, + LoaderArgs, + LoaderFunction, + MemoryUploadHandlerFilterArgs, + MemoryUploadHandlerOptions, + MetaDescriptor, + MetaFunction, + V2_MetaFunction, + PageLinkDescriptor, + RequestHandler, + RouteComponent, + RouteHandle, + SerializeFrom, + ServerBuild, + ServerEntryModule, + Session, + SessionData, + SessionIdStorageStrategy, + SessionStorage, + SignFunction, + TypedDeferredData, + TypedResponse, + UnsignFunction, + UploadHandler, + UploadHandlerPart, +} from "@remix-run/server-runtime"; diff --git a/packages/vercel-remix/jest.config.js b/packages/vercel-remix/jest.config.js new file mode 100644 index 00000000000..7b30fe2479c --- /dev/null +++ b/packages/vercel-remix/jest.config.js @@ -0,0 +1,5 @@ +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + ...require("../../jest/jest.config.shared"), + displayName: "vercel", +}; diff --git a/packages/vercel-remix/package.json b/packages/vercel-remix/package.json new file mode 100644 index 00000000000..a0bf2c0ee9f --- /dev/null +++ b/packages/vercel-remix/package.json @@ -0,0 +1,47 @@ +{ + "name": "@vercel/remix", + "version": "1.14.1", + "description": "Isomorphic runtime + adapter for Remix on Vercel", + "repository": { + "type": "git", + "url": "https://github.com/vercel/remix", + "directory": "packages/vercel-remix" + }, + "license": "MIT", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "browser": "dist/edge/index.js", + "exports": { + ".": { + "browser": "./dist/edge/index.js", + "default": "./dist/index.js" + }, + "./server": { + "browser": "./dist/edge/server.js", + "default": "./dist/server.js" + }, + "./entry.server": { + "browser": "./dist/edge/entry.server.js", + "default": "./dist/entry.server.js" + }, + "./package.json": "./package.json" + }, + "dependencies": { + "@remix-run/node": "1.14.1", + "@remix-run/server-runtime": "1.14.1", + "isbot": "3.6.6" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + }, + "engines": { + "node": ">=14" + }, + "files": [ + "dist/", + "CHANGELOG.md", + "LICENSE.md", + "README.md" + ] +} diff --git a/packages/vercel-remix/rollup.config.js b/packages/vercel-remix/rollup.config.js new file mode 100644 index 00000000000..b0ed9da4f30 --- /dev/null +++ b/packages/vercel-remix/rollup.config.js @@ -0,0 +1,13 @@ +const { getAdapterConfig } = require("../../rollup.utils"); + +/** @returns {import("rollup").RollupOptions[]} */ +module.exports = function rollup() { + return [ + getAdapterConfig("vercel-remix"), + getAdapterConfig("vercel-remix", "server.ts"), + getAdapterConfig("vercel-remix", "entry.server.ts"), + getAdapterConfig("vercel-remix", "edge/index.ts"), + getAdapterConfig("vercel-remix", "edge/server.ts"), + getAdapterConfig("vercel-remix", "edge/entry.server.ts"), + ]; +}; diff --git a/packages/vercel-remix/server.ts b/packages/vercel-remix/server.ts new file mode 100644 index 00000000000..355aebccc1a --- /dev/null +++ b/packages/vercel-remix/server.ts @@ -0,0 +1,119 @@ +import "./globals"; + +import type { IncomingMessage, IncomingHttpHeaders, ServerResponse } from 'http'; +import type { + AppLoadContext, + ServerBuild, + RequestInit as NodeRequestInit, + Response as NodeResponse, +} from "@remix-run/node"; +import { + AbortController as NodeAbortController, + createRequestHandler as createRemixRequestHandler, + Headers as NodeHeaders, + Request as NodeRequest, + writeReadableStreamToWritable, +} from "@remix-run/node"; + +/** + * A function that returns the value to use as `context` in route `loader` and + * `action` functions. + * + * You can think of this as an escape hatch that allows you to pass + * environment/platform-specific values through to your loader/action. + */ +export type GetLoadContextFunction = (req: NodeRequest) => AppLoadContext; + +export type RequestHandler = ( + req: IncomingMessage, + res: ServerResponse +) => Promise; + +/** + * Returns a request handler for Vercel's Node.js runtime that serves the + * response using Remix. + */ +export function createRequestHandler({ + build, + getLoadContext, + mode = process.env.NODE_ENV, +}: { + build: ServerBuild; + getLoadContext?: GetLoadContextFunction; + mode?: string; +}): RequestHandler { + let handleRequest = createRemixRequestHandler(build, mode); + + return async (req, res) => { + let request = createRemixRequest(req, res); + let loadContext = getLoadContext?.(request); + + let response = (await handleRequest(request, loadContext)) as NodeResponse; + + await sendRemixResponse(res, response); + }; +} + +function createRemixHeaders(requestHeaders: IncomingHttpHeaders): NodeHeaders { + let headers = new NodeHeaders(); + + for (let key in requestHeaders) { + let header = requestHeaders[key]!; + // set-cookie is an array (maybe others) + if (Array.isArray(header)) { + for (let value of header) { + headers.append(key, value); + } + } else { + headers.append(key, header); + } + } + + return headers; +} + +function createRemixRequest( + req: IncomingMessage, + res: ServerResponse +): NodeRequest { + let host = req.headers["x-forwarded-host"] || req.headers["host"]; + let protocol = req.headers["x-forwarded-proto"] || "https"; + let url = new URL(`${protocol}://${host}${req.url}`); + + // Abort action/loaders once we can no longer write a response + let controller = new NodeAbortController(); + res.on("close", () => controller.abort()); + + let init: NodeRequestInit = { + method: req.method, + headers: createRemixHeaders(req.headers), + // Cast until reason/throwIfAborted added + // https://github.com/mysticatea/abort-controller/issues/36 + signal: controller.signal as NodeRequestInit["signal"], + }; + + if (req.method !== "GET" && req.method !== "HEAD") { + init.body = req; + } + + return new NodeRequest(url.href, init); +} + +async function sendRemixResponse( + res: ServerResponse, + nodeResponse: NodeResponse +): Promise { + res.statusMessage = nodeResponse.statusText; + let multiValueHeaders = nodeResponse.headers.raw(); + res.writeHead( + nodeResponse.status, + nodeResponse.statusText, + multiValueHeaders + ); + + if (nodeResponse.body) { + await writeReadableStreamToWritable(nodeResponse.body, res); + } else { + res.end(); + } +} diff --git a/packages/vercel-remix/tsconfig.json b/packages/vercel-remix/tsconfig.json new file mode 100644 index 00000000000..57aa1723f71 --- /dev/null +++ b/packages/vercel-remix/tsconfig.json @@ -0,0 +1,18 @@ +{ + "include": ["**/*.ts"], + "exclude": ["dist", "__tests__", "node_modules"], + "compilerOptions": { + "lib": ["ES2019", "DOM.Iterable"], + "target": "ES2019", + "skipLibCheck": true, + "composite": true, + + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "strict": true, + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": ".", + "outDir": "../../build/node_modules/@vercel/remix/dist" + } +} diff --git a/rollup.utils.js b/rollup.utils.js index d8c352620a9..6807a11cfa4 100644 --- a/rollup.utils.js +++ b/rollup.utils.js @@ -5,6 +5,7 @@ const fs = require("fs"); const fse = require("fs-extra"); const nodeResolve = require("@rollup/plugin-node-resolve").default; const path = require("path"); +const { dirname } = require("path"); const REPO_ROOT_DIR = __dirname; @@ -34,10 +35,11 @@ if (process.env.REMIX_LOCAL_BUILD_DIRECTORY) { * @param {boolean} [executable] */ function createBanner(packageName, version, executable = false) { + let owner = packageName.startsWith('@vercel/') ? 'Vercel, Inc.' : 'Remix Software Inc.'; let banner = `/** * ${packageName} v${version} * - * Copyright (c) Remix Software Inc. + * Copyright (c) ${owner} * * This source code is licensed under the MIT license found in the * LICENSE.md file in the root directory of this source tree. @@ -135,19 +137,25 @@ function copyToPlaygrounds() { * @param {RemixAdapter} adapterName * @returns {import("rollup").RollupOptions} */ -function getAdapterConfig(adapterName) { +function getAdapterConfig(adapterName, entrypoint = 'index.ts') { /** @type {`@remix-run/${RemixAdapter}`} */ let packageName = `@remix-run/${adapterName}`; let sourceDir = `packages/remix-${adapterName}`; + + if (adapterName === 'vercel-remix') { + packageName = `@vercel/remix`; + sourceDir = 'packages/vercel-remix'; + } + let outputDir = getOutputDir(packageName); - let outputDist = path.join(outputDir, "dist"); + let outputDist = path.join(outputDir, "dist", dirname(entrypoint)); let version = getVersion(sourceDir); return { external(id) { return isBareModuleId(id); }, - input: `${sourceDir}/index.ts`, + input: `${sourceDir}/${entrypoint}`, output: { banner: createBanner(packageName, version), dir: outputDist, @@ -416,6 +424,9 @@ function getOutputDir(packageName) { * @param {string} packageName */ function getPackageDirname(packageName) { + if (packageName === '@vercel/remix') { + return 'vercel-remix'; + } let scope = "@remix-run/"; return packageName.startsWith(scope) ? `remix-${packageName.slice(scope.length)}` diff --git a/scripts/copy-build-to-dist.mjs b/scripts/copy-build-to-dist.mjs index 1b0bcb8e523..185268dad80 100644 --- a/scripts/copy-build-to-dist.mjs +++ b/scripts/copy-build-to-dist.mjs @@ -33,7 +33,11 @@ async function copyBuildToDist() { build: buildDir, src: path.join( PACKAGES_PATH, - parentDir === "@remix-run" ? `remix-${dirName}` : dirName + parentDir === "@remix-run" + ? `remix-${dirName}` + : parentDir === "@vercel" + ? `vercel-${dirName}` + : dirName ), }; }); @@ -138,10 +142,11 @@ async function getPackageBuildPaths(moduleRootDir) { if (!(await fse.stat(moduleDir)).isDirectory()) { continue; } - if (path.basename(moduleDir) === "@remix-run") { + if (path.basename(moduleDir) === "@remix-run" || path.basename(moduleDir) === '@vercel') { packageBuilds.push(...(await getPackageBuildPaths(moduleDir))); } else if ( /node_modules\/@remix-run\//.test(moduleDir) || + /node_modules\/@vercel\//.test(moduleDir) || /node_modules\/create-remix/.test(moduleDir) || /node_modules\/remix/.test(moduleDir) ) { diff --git a/tsconfig.json b/tsconfig.json index 9ac1dcf8c4a..f8b7404a1a8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,7 @@ { "path": "packages/remix-serve" }, { "path": "packages/remix-server-runtime" }, { "path": "packages/remix-testing" }, - { "path": "packages/remix-vercel" } + { "path": "packages/remix-vercel" }, + { "path": "packages/vercel-remix" } ] } diff --git a/yarn.lock b/yarn.lock index 1b083028c10..9bd07feacfd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7780,6 +7780,11 @@ isarray@^1.0.0, isarray@~1.0.0: resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= +isbot@3.6.6: + version "3.6.6" + resolved "https://registry.npmjs.org/isbot/-/isbot-3.6.6.tgz#3ae163dee8dc158322c025f9105d16b050e97192" + integrity sha512-98aGl1Spbx1led422YFrusDJ4ZutSNOymb2avZ2V4BCCjF3MqAF2k+J2zoaLYahubaFkb+3UyvbVDVlk/Ngrew== + isbot@^3.5.1: version "3.5.1" resolved "https://registry.npmjs.org/isbot/-/isbot-3.5.1.tgz"