diff --git a/babel.config.js b/babel.config.js index 384a0ea5a..02c47968a 100644 --- a/babel.config.js +++ b/babel.config.js @@ -9,7 +9,10 @@ module.exports = { env: { test: { presets: [ - ["@babel/env", { targets: "last 2 versions, ie 11", modules: false }], + [ + "@babel/env", + { targets: "last 2 versions, ie 11", modules: "commonjs" }, + ], "@babel/react", ], }, diff --git a/config/jest/client.config.js b/config/jest/client.config.js index 59055e53b..4e6e28e16 100644 --- a/config/jest/client.config.js +++ b/config/jest/client.config.js @@ -24,7 +24,7 @@ module.exports = { "/config/jest/fileTransform.js", }, transformIgnorePatterns: [ - "[/\\\\]node_modules[/\\\\](?!(fluent)[/\\\\]).+\\.(js|jsx|mjs|ts|tsx)$", + "[/\\\\]node_modules[/\\\\](?!(fluent|react-relay-network-modern)[/\\\\]).+\\.(js|jsx|mjs|ts|tsx)$", ], moduleNameMapper: { "^talk-admin/(.*)$": "/src/core/client/admin/$1", diff --git a/package-lock.json b/package-lock.json index 1da365076..6dde64c72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21995,6 +21995,11 @@ "relay-runtime": "1.7.0-rc.1" } }, + "react-relay-network-modern": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-relay-network-modern/-/react-relay-network-modern-2.4.0.tgz", + "integrity": "sha512-LR/RhHcJclDCVEiwRhlRtf1iltSnbGSxS2rag+bAljMFJ0kOVSYUK3+NDPRbcHLRqbha1FuQXBVfHjjPE6jhMA==" + }, "react-responsive": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-5.0.0.tgz", diff --git a/package.json b/package.json index e02695d7f..357751253 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "passport-strategy": "^1.0.0", "performance-now": "^2.1.0", "permit": "^0.2.4", + "react-relay-network-modern": "^2.4.0", "striptags": "^3.1.1", "subscriptions-transport-ws": "^0.9.12", "tlds": "^1.203.1", diff --git a/src/core/client/framework/lib/bootstrap/createManaged.tsx b/src/core/client/framework/lib/bootstrap/createManaged.tsx index bc1219427..e745c95ca 100644 --- a/src/core/client/framework/lib/bootstrap/createManaged.tsx +++ b/src/core/client/framework/lib/bootstrap/createManaged.tsx @@ -4,7 +4,7 @@ import { noop } from "lodash"; import { Child as PymChild } from "pym.js"; import React, { Component, ComponentType } from "react"; import { Formatter } from "react-timeago"; -import { Environment, Network, RecordSource, Store } from "relay-runtime"; +import { Environment, RecordSource, Store } from "relay-runtime"; import uuid from "uuid/v4"; import { getBrowserInfo } from "talk-framework/lib/browserInfo"; @@ -21,7 +21,7 @@ import { RestClient } from "talk-framework/lib/rest"; import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside"; import { generateBundles, LocalesData, negotiateLanguages } from "../i18n"; -import { createFetch, TokenGetter } from "../network"; +import { createNetwork, TokenGetter } from "../network"; import { PostMessageService } from "../postMessage"; import { TalkContext, TalkContextProvider } from "./TalkContext"; @@ -97,7 +97,7 @@ function createRelayEnvironment() { return ""; }; const environment = new Environment({ - network: Network.create(createFetch(tokenGetter)), + network: createNetwork(tokenGetter), store: new Store(source), }); return { environment, tokenGetter }; diff --git a/src/core/client/framework/lib/errors/graphqlError.ts b/src/core/client/framework/lib/errors/graphqlError.ts deleted file mode 100644 index 1500567b3..000000000 --- a/src/core/client/framework/lib/errors/graphqlError.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface GraphQLErrorItem { - message: string; - locations: Array<{ - line: number; - column: number; - }>; -} - -/** - * Graphql wraps graphql errors at the network layer. - */ -export default class GraphQLError extends Error { - // Original error. - public readonly origin: GraphQLErrorItem[]; - - constructor(origin: GraphQLErrorItem[]) { - super(origin.map(o => o.message).join(" ")); - - // Maintains proper stack trace for where our error was thrown. - if (Error.captureStackTrace) { - Error.captureStackTrace(this, GraphQLError); - } - this.origin = origin; - } -} diff --git a/src/core/client/framework/lib/errors/index.ts b/src/core/client/framework/lib/errors/index.ts index 8372f3429..c1cbae255 100644 --- a/src/core/client/framework/lib/errors/index.ts +++ b/src/core/client/framework/lib/errors/index.ts @@ -1,6 +1,2 @@ -export { default as NetworkError } from "./networkError"; export { default as UnknownServerError } from "./unknownServerError"; export { default as BadUserInputError } from "./badUserInputError"; -export { default as GraphQLError } from "./graphqlError"; - -export * from "./graphqlError"; diff --git a/src/core/client/framework/lib/errors/networkError.ts b/src/core/client/framework/lib/errors/networkError.ts deleted file mode 100644 index eeb87e9d6..000000000 --- a/src/core/client/framework/lib/errors/networkError.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * NetworkError wraps errors at the network layer. - */ -export default class NetworkError extends Error { - // Original error. - public readonly origin: Error; - - constructor(origin: Error) { - // Pass remaining arguments (including vendor specific ones) to parent constructor. - super(origin.message); - - // Maintains proper stack trace for where our error was thrown. - if (Error.captureStackTrace) { - Error.captureStackTrace(this, NetworkError); - } - this.origin = origin; - } -} diff --git a/src/core/client/framework/lib/network/createNetwork.ts b/src/core/client/framework/lib/network/createNetwork.ts new file mode 100644 index 000000000..c37eea89c --- /dev/null +++ b/src/core/client/framework/lib/network/createNetwork.ts @@ -0,0 +1,40 @@ +import { + authMiddleware, + batchMiddleware, + cacheMiddleware, + RelayNetworkLayer, + retryMiddleware, + urlMiddleware, +} from "react-relay-network-modern/es"; + +import customErrorMiddleware from "./customErrorMiddleware"; + +export type TokenGetter = () => string; + +const graphqlURL = "/api/tenant/graphql"; + +export default function createNetwork(tokenGetter: TokenGetter) { + return new RelayNetworkLayer([ + customErrorMiddleware, + cacheMiddleware({ + size: 100, // max 100 requests + ttl: 900000, // 15 minutes + }), + urlMiddleware({ + url: req => Promise.resolve(graphqlURL), + }), + batchMiddleware({ + batchUrl: (requestMap: any) => Promise.resolve(graphqlURL), + batchTimeout: 10, + }), + retryMiddleware({ + fetchTimeout: 15000, + retryDelays: (attempt: number) => Math.pow(2, attempt + 4) * 100, + // or simple array [3200, 6400, 12800, 25600, 51200, 102400, 204800, 409600], + statusCodes: [500, 503, 504], + }), + authMiddleware({ + token: tokenGetter, + }), + ]); +} diff --git a/src/core/client/framework/lib/network/customErrorMiddleware.ts b/src/core/client/framework/lib/network/customErrorMiddleware.ts new file mode 100644 index 000000000..2efaf87d2 --- /dev/null +++ b/src/core/client/framework/lib/network/customErrorMiddleware.ts @@ -0,0 +1,31 @@ +import { Middleware } from "react-relay-network-modern/es"; +import { BadUserInputError, UnknownServerError } from "../errors"; + +function getError(errors: Error[]): Error | null { + if (errors.length > 1 || !(errors[0] as any).extensions) { + // Multiple errors are GraphQL errors. + // TODO: (cvle) Is this assumption correct? + // No extensions == GraphQL error. + // TODO: (cvle) harmonize with server. + return null; + } + const err = errors[0]; + if ((err as any).code === "BAD_USER_INPUT") { + return new BadUserInputError((err as any).extensions); + } + return new UnknownServerError(err.message, (err as any).extensions); +} + +const customErrorMiddleware: Middleware = next => async req => { + const res = await next(req); + if (req.isMutation() && res.errors) { + // Extract custom error. + const error = getError(res.errors); + if (error) { + throw error; + } + } + return res; +}; + +export default customErrorMiddleware; diff --git a/src/core/client/framework/lib/network/fetchQuery.ts b/src/core/client/framework/lib/network/fetchQuery.ts deleted file mode 100644 index bc6b734d3..000000000 --- a/src/core/client/framework/lib/network/fetchQuery.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { FetchFunction } from "relay-runtime"; - -import { - BadUserInputError, - GraphQLError, - NetworkError, - UnknownServerError, -} from "../errors"; - -// Normalize errors. -function getError(errors: Error[]): Error { - if (errors.length > 1) { - // Multiple errors are GraphQL errors. - // TODO: (cvle) Is this assumption correct? - return new GraphQLError(errors as any); - } - const err = errors[0] as Error; - if ((err as any).extensions) { - if ((err as any).code === "BAD_USER_INPUT") { - return new BadUserInputError((err as any).extensions); - } - return new UnknownServerError(err.message, (err as any).extensions); - } - // No extensions == GraphQL error. - // TODO: (cvle) harmonize with server. - return new GraphQLError(errors as any); -} - -export type TokenGetter = () => string; -type CreateFetch = (token?: TokenGetter) => FetchFunction; - -/** - * createFetch returns a simple implementation of the `FetchFunction` - * required by Relay. It'll return a `NetworkError` on failure. - */ -const createFetch: CreateFetch = tokenGetter => async ( - operation, - variables -) => { - const token = tokenGetter && tokenGetter(); - const headers: Record = { - "Content-Type": "application/json", - }; - if (token) { - headers.Authorization = `Bearer ${token}`; - } - try { - const response = await fetch("/api/tenant/graphql", { - method: "POST", - headers, - body: JSON.stringify({ - query: operation.text, - variables, - }), - }); - if (response.status >= 500) { - throw new Error(`${response.status} ${response.statusText}`); - } - const data = await response.json(); - if (data.errors) { - throw getError(data.errors); - } - return data; - } catch (err) { - if (err instanceof TypeError) { - throw new NetworkError(err); - } - throw err; - } -}; - -export default createFetch; diff --git a/src/core/client/framework/lib/network/index.ts b/src/core/client/framework/lib/network/index.ts index 75b935dce..91065bfbd 100644 --- a/src/core/client/framework/lib/network/index.ts +++ b/src/core/client/framework/lib/network/index.ts @@ -1 +1 @@ -export { default as createFetch, TokenGetter } from "./fetchQuery"; +export { default as createNetwork, TokenGetter } from "./createNetwork"; diff --git a/src/core/client/stream/mutations/CreateCommentMutation.ts b/src/core/client/stream/mutations/CreateCommentMutation.ts index 813563185..9ad37a771 100644 --- a/src/core/client/stream/mutations/CreateCommentMutation.ts +++ b/src/core/client/stream/mutations/CreateCommentMutation.ts @@ -24,17 +24,19 @@ function sharedUpdater( store: RecordSourceSelectorProxy, input: CreateCommentInput ) { + updateAsset(store, input); if (input.local) { localUpdate(store, input); } else { update(store, input); } + updateProfile(store, input); } -/** - * update integrates new comment into the CommentConnection. - */ -function update(store: RecordSourceSelectorProxy, input: CreateCommentInput) { +function updateAsset( + store: RecordSourceSelectorProxy, + input: CreateCommentInput +) { // Updating Comment Count const asset = store.get(input.assetID); if (asset) { @@ -45,7 +47,12 @@ function update(store: RecordSourceSelectorProxy, input: CreateCommentInput) { record.setValue(currentCount + 1, "totalVisible"); } } +} +/** + * update integrates new comment into the CommentConnection. + */ +function update(store: RecordSourceSelectorProxy, input: CreateCommentInput) { // Get the payload returned from the server. const payload = store.getRootField("createComment")!; @@ -109,6 +116,26 @@ function localUpdate( } } +/** + * updateProfile integrates new comment into the profile. + */ +function updateProfile( + store: RecordSourceSelectorProxy, + input: CreateCommentInput +) { + // Get the payload returned from the server. + const payload = store.getRootField("createComment")!; + + // Get the edge of the newly created comment. + const newEdge = payload.getLinkedRecord("edge")!; + const newComment = newEdge.getLinkedRecord("node"); + + // TODO: update profile comments connection after we + // integrated pagination. + // tslint:disable-next-line:no-unused-expression + newComment; +} + const mutation = graphql` mutation CreateCommentMutation($input: CreateCommentInput!) { createComment(input: $input) { diff --git a/src/core/client/stream/tabs/profile/queries/ProfileQuery.tsx b/src/core/client/stream/tabs/profile/queries/ProfileQuery.tsx index e98212f77..8eef92f6d 100644 --- a/src/core/client/stream/tabs/profile/queries/ProfileQuery.tsx +++ b/src/core/client/stream/tabs/profile/queries/ProfileQuery.tsx @@ -64,6 +64,10 @@ const ProfileQuery: StatelessComponent = ({ assetID, assetURL, }} + cacheConfig={{ + // TODO: enable caching after mutations are adapted. + force: true, + }} render={render} /> ); diff --git a/src/core/client/stream/test/comments/__snapshots__/postLocalReply.spec.tsx.snap b/src/core/client/stream/test/comments/__snapshots__/postLocalReply.spec.tsx.snap index 10ab06efe..b19151988 100644 --- a/src/core/client/stream/test/comments/__snapshots__/postLocalReply.spec.tsx.snap +++ b/src/core/client/stream/test/comments/__snapshots__/postLocalReply.spec.tsx.snap @@ -1003,7 +1003,7 @@ exports[`post a reply: optimistic response 1`] = ` role="tab" type="button" > - ⁨1⁩ ⁨Comment⁩ + ⁨2⁩ ⁨Comments⁩
  • - ⁨1⁩ ⁨Comment⁩ + ⁨2⁩ ⁨Comments⁩
  • async ( // TODO: rate limit based on the IP address and user agent. // Tenant is guaranteed at this point. - const tenant = req.tenant!; + const tenant = req.talk!.tenant!; // Check to ensure that the local integration has been enabled. if (!tenant.auth.integrations.local.enabled) { @@ -95,7 +95,7 @@ export const logoutHandler = (options: LogoutOptions): RequestHandler => async ( // TODO: rate limit based on the IP address and user agent. // Tenant is guaranteed at this point. - const tenant = req.tenant!; + const tenant = req.talk!.tenant!; // Check to ensure that the local integration has been enabled. if (!tenant.auth.integrations.local.enabled) { diff --git a/src/core/server/app/middleware/context/tenant.ts b/src/core/server/app/middleware/context/tenant.ts new file mode 100644 index 000000000..c52772b6b --- /dev/null +++ b/src/core/server/app/middleware/context/tenant.ts @@ -0,0 +1,51 @@ +import { RequestHandler } from "express-jwt"; +import { Redis } from "ioredis"; +import { Db } from "mongodb"; + +import TenantContext from "talk-server/graph/tenant/context"; +import { TaskQueue } from "talk-server/services/queue"; +import { Request } from "talk-server/types/express"; + +export interface TenantContextMiddlewareOptions { + mongo: Db; + redis: Redis; + queue: TaskQueue; +} + +export const tenantContext = ({ + mongo, + redis, + queue, +}: TenantContextMiddlewareOptions): RequestHandler => ( + req: Request, + res, + next +) => { + if (!req.talk) { + return next(new Error("talk was not set")); + } + + const { tenant, cache } = req.talk; + + if (!cache) { + return next(new Error("cache was not set")); + } + + if (!tenant) { + return next(new Error("tenant was not set")); + } + + req.talk.context = { + tenant: new TenantContext({ + req, + mongo, + redis, + tenant, + user: req.user, + tenantCache: cache.tenant, + queue, + }), + }; + + next(); +}; diff --git a/src/core/server/app/middleware/graphqlBatch.ts b/src/core/server/app/middleware/graphqlBatch.ts new file mode 100644 index 000000000..09a09e4c5 --- /dev/null +++ b/src/core/server/app/middleware/graphqlBatch.ts @@ -0,0 +1,97 @@ +import { RequestHandler, Response } from "express"; + +import logger from "talk-server/logger"; +import { Request } from "talk-server/types/express"; + +function wrapResponse(req: Request, res: Response) { + // If the request is not an array, or has no elements, we should skip it. + if (!Array.isArray(req.body) || req.body.length === 0) { + return res; + } + + // If the request is an array, but it does not have an ID field, then we + // should skip it. + const needsUpgrade = Boolean(typeof req.body[0].id !== "undefined"); + if (!needsUpgrade) { + return res; + } + + // Grab all the existing ID's. + const ids: string[] = req.body.map(({ id }) => id); + + // Save a reference to the old setHeader function. + const setHeader = res.setHeader.bind(res); + + // Capture all the headers that are sent to this, in case we need to use it. + const setHeaders: Record = {}; + res.setHeader = (name: string, value: any) => { + setHeaders[name] = value; + return res; + }; + + // Save a reference to the old write function. + const write = res.write.bind(res); + + // Create a flush function that will be used to flush the response to the + // underlying response. + const flush = (chunk: any, headers: Record = setHeaders) => { + for (const name in headers) { + if (!headers.hasOwnProperty(name)) { + continue; + } + + setHeader(name, headers[name]); + } + + return write(chunk); + }; + + // Override the response writer to parse the response to determine if it needs + // to be rewritten. + res.write = (chunk: string) => { + try { + // If there is no response, forward it, or if we peek at the first + // character and it's not an array opening, then skip it. + if (chunk.length <= 0 || chunk[0] !== "[") { + return flush(chunk); + } + + // Parse the responses, if it's not an array, then skip it. + const responses: object[] | any = JSON.parse(chunk); + if (!Array.isArray(responses) || responses.length === 0) { + return flush(chunk); + } + + // If the length of responses do not equal the length of id's collected, + // then skip it. + if (responses.length !== ids.length) { + return flush(chunk); + } + + // For each of the responses, zip up their id's into the objects, and + // string concat them together to ensure we get the right request. + const gqlResponse = responses.reduce((body: object[], payload, idx) => { + const id = ids[idx]; + body.push({ id, payload }); + return body; + }, []); + + const response = JSON.stringify(gqlResponse); + + return flush(response, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(response, "utf8").toString(), + }); + } catch (err) { + logger.error({ err }, "could not parse chunk as JSON"); + return flush(chunk); + } + }; + + return res; +} + +export const graphqlBatchMiddleware = ( + graphqlRequestHandler: RequestHandler +): RequestHandler => (req: Request, res, next) => + graphqlRequestHandler(req, wrapResponse(req, res), next); diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 865e4d61b..207fb1e92 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -102,8 +102,8 @@ export async function handleSuccessfulLogin( next: NextFunction ) { try { - // Grab the tenant from the request. - const { tenant } = req; + // Talk is guaranteed at this point. + const { tenant } = req.talk!; const options: SigningTokenOptions = {}; diff --git a/src/core/server/app/middleware/passport/strategies/jwt.ts b/src/core/server/app/middleware/passport/strategies/jwt.ts index a979c378c..273bce5d3 100644 --- a/src/core/server/app/middleware/passport/strategies/jwt.ts +++ b/src/core/server/app/middleware/passport/strategies/jwt.ts @@ -100,7 +100,7 @@ export class JWTStrategy extends Strategy { return this.pass(); } - const { tenant } = req; + const { tenant } = req.talk!; if (!tenant) { // TODO: (wyattjoh) log this error, and return a better one? return this.error(new Error("tenant not found")); diff --git a/src/core/server/app/middleware/passport/strategies/local.ts b/src/core/server/app/middleware/passport/strategies/local.ts index 588bcfa03..3b1cc88c1 100644 --- a/src/core/server/app/middleware/passport/strategies/local.ts +++ b/src/core/server/app/middleware/passport/strategies/local.ts @@ -18,7 +18,7 @@ const verifyFactory = (mongo: Db) => async ( // TODO: rate limit based on the IP address and user agent. // The tenant is guaranteed at this point. - const tenant = req.tenant!; + const tenant = req.talk!.tenant!; // Get the user from the database. const user = await retrieveUserWithProfile(mongo, tenant.id, { diff --git a/src/core/server/app/middleware/passport/strategies/oidc.ts b/src/core/server/app/middleware/passport/strategies/oidc.ts index bb0885a54..b810a18f9 100644 --- a/src/core/server/app/middleware/passport/strategies/oidc.ts +++ b/src/core/server/app/middleware/passport/strategies/oidc.ts @@ -265,7 +265,7 @@ export default class OIDCStrategy extends Strategy { } // Grab the tenant out of the request, as we need some more details. - const { tenant } = req; + const { tenant } = req.talk!; if (!tenant) { // TODO: return a better error. return done(new Error("tenant not found")); @@ -336,7 +336,7 @@ export default class OIDCStrategy extends Strategy { } private async lookupStrategy(req: Request) { - const { tenant } = req; + const { tenant } = req.talk!; if (!tenant) { // TODO: return a better error. throw new Error("tenant not found"); diff --git a/src/core/server/app/middleware/tenant.ts b/src/core/server/app/middleware/tenant.ts index 210aeb71a..d04e1e097 100644 --- a/src/core/server/app/middleware/tenant.ts +++ b/src/core/server/app/middleware/tenant.ts @@ -7,14 +7,12 @@ export interface MiddlewareOptions { cache: TenantCache; } -export default (options: MiddlewareOptions): RequestHandler => async ( +export default ({ cache }: MiddlewareOptions): RequestHandler => async ( req: Request, res, next ) => { try { - const { cache } = options; - // Attach the tenant to the request. const tenant = await cache.retrieveByDomain(req.hostname); if (!tenant) { @@ -22,11 +20,15 @@ export default (options: MiddlewareOptions): RequestHandler => async ( return next(new Error("tenant not found")); } - // Attach the tenant cache to the request. - req.tenantCache = cache; - - // Attach the tenant to the request. - req.tenant = tenant; + // Set Talk on the request. + req.talk = { + cache: { + // Attach the tenant cache to the request. + tenant: cache, + }, + // Attach the tenant to the request. + tenant, + }; // Attach the tenant to the view locals. res.locals.tenant = tenant; diff --git a/src/core/server/app/router/api/tenant.ts b/src/core/server/app/router/api/tenant.ts index 7be01b0ca..63bbe121d 100644 --- a/src/core/server/app/router/api/tenant.ts +++ b/src/core/server/app/router/api/tenant.ts @@ -6,6 +6,7 @@ import tenantMiddleware from "talk-server/app/middleware/tenant"; import { RouterOptions } from "talk-server/app/router/types"; import tenantGraphMiddleware from "talk-server/graph/tenant/middleware"; +import { tenantContext } from "talk-server/app/middleware/context/tenant"; import { createNewAuthRouter } from "./auth"; export async function createTenantRouter( @@ -41,12 +42,14 @@ export async function createTenantRouter( // Any users may submit their GraphQL requests with authentication, this // middleware will unpack their user into the request. options.passport.authenticate("jwt", { session: false }), - await tenantGraphMiddleware({ - schema: app.schemas.tenant, - config: app.config, + tenantContext({ mongo: app.mongo, redis: app.redis, queue: app.queue, + }), + await tenantGraphMiddleware({ + schema: app.schemas.tenant, + config: app.config, }) ); diff --git a/src/core/server/graph/tenant/middleware.ts b/src/core/server/graph/tenant/middleware.ts index eef92bca9..b320566ef 100644 --- a/src/core/server/graph/tenant/middleware.ts +++ b/src/core/server/graph/tenant/middleware.ts @@ -1,45 +1,37 @@ import { GraphQLSchema } from "graphql"; -import { Redis } from "ioredis"; -import { Db } from "mongodb"; +// import { graphqlBatchHTTPWrapper } from "react-relay-network-layer"; import { Config } from "talk-common/config"; +import { graphqlBatchMiddleware } from "talk-server/app/middleware/graphqlBatch"; import { graphqlMiddleware } from "talk-server/graph/common/middleware"; -import { TaskQueue } from "talk-server/services/queue"; import { Request } from "talk-server/types/express"; -import TenantContext from "./context"; - export interface TenantGraphQLMiddlewareOptions { schema: GraphQLSchema; config: Config; - mongo: Db; - redis: Redis; - queue: TaskQueue; } -export default async ({ - schema, - config, - mongo, - redis, - queue, -}: TenantGraphQLMiddlewareOptions) => { - return graphqlMiddleware(config, async (req: Request) => { - // Load the tenant and user from the request. - const { tenant, user, tenantCache } = req; +export default async ({ schema, config }: TenantGraphQLMiddlewareOptions) => + graphqlBatchMiddleware( + graphqlMiddleware(config, async (req: Request) => { + if (!req.talk) { + throw new Error("talk was not set"); + } - // Return the graph options. - return { - schema, - context: new TenantContext({ - req, - mongo, - redis, - tenant: tenant!, - user, - tenantCache, - queue, - }), - }; - }); -}; + const { context } = req.talk; + if (!context) { + throw new Error("context was not set"); + } + + const { tenant } = context; + if (!tenant) { + throw new Error("tenant was not set"); + } + + // Return the graph options. + return { + schema, + context: tenant, + }; + }) + ); diff --git a/src/core/server/types/express.ts b/src/core/server/types/express.ts index 595bc904c..264adb165 100644 --- a/src/core/server/types/express.ts +++ b/src/core/server/types/express.ts @@ -1,11 +1,21 @@ import { Request } from "express"; +import TenantContext from "talk-server/graph/tenant/context"; import { Tenant } from "talk-server/models/tenant"; import { User } from "talk-server/models/user"; import TenantCache from "talk-server/services/tenant/cache"; -export interface Request extends Request { - user?: User; +export interface TalkRequest { + cache?: { + tenant: TenantCache; + }; tenant?: Tenant; - tenantCache: TenantCache; + context?: { + tenant?: TenantContext; + }; +} + +export interface Request extends Request { + talk?: TalkRequest; + user?: User; } diff --git a/src/types/react-relay-network-modern.d.ts b/src/types/react-relay-network-modern.d.ts new file mode 100644 index 000000000..a602259aa --- /dev/null +++ b/src/types/react-relay-network-modern.d.ts @@ -0,0 +1,313 @@ +/* tslint:disable */ + +// TODO: send a PR to DefinitelyTyped. + +declare module "react-relay-network-modern/es" { + // TODO: missing typescript types. + // import { QueryResponseCache } from 'relay-runtime'; + + export interface Variables { + [name: string]: any; + } + + export interface FetchOpts { + url?: string; + method: "POST" | "GET"; + headers: { [name: string]: string }; + body: string | FormData; + // Avaliable request modes in fetch options. For details see https://fetch.spec.whatwg.org/#requests + credentials?: "same-origin" | "include" | "omit"; + mode?: "cors" | "websocket" | "navigate" | "no-cors" | "same-origin"; + cache?: + | "default" + | "no-store" + | "reload" + | "no-cache" + | "force-cache" + | "only-if-cached"; + redirect?: "follow" | "error" | "manual"; + [name: string]: any; + } + + export interface ConcreteBatch { + kind: "Batch"; + fragment: any; + id?: string | null; + metadata: { [key: string]: any }; + name: string; + query: any; + text?: string | null; + } + + export interface CacheConfig { + force?: boolean | null; + poll?: number | null; + rerunParamExperimental?: any; + } + + export type FetchResponse = Response; + + export type Uploadable = File | Blob; + export interface UploadableMap { + [key: string]: Uploadable; + } + + export class RelayRequest { + public static lastGenId: number; + public id: string; + public fetchOpts: FetchOpts; + + public operation: ConcreteBatch; + public variables: Variables; + public cacheConfig: CacheConfig; + public uploadables?: UploadableMap | null; + + public getBody(): string | FormData; + public prepareBody(): string | FormData; + public getID(): string; + public getQueryString(): string; + public getVariables(): Variables; + public isMutation(): boolean; + public isFormData(): boolean; + public clone(): RelayRequest; + } + + export class RelayResponse { + public data?: PayloadData | null; + public errors?: any[] | null; + + public ok: any; + public status: number; + public statusText?: string | null; + public headers?: { [name: string]: string } | null; + public url?: string | null; + public text?: string | null; + public json: any; + + public static createFromFetch(res: FetchResponse): Promise; + public static createFromGraphQL(res: { + errors?: any; + data?: any; + }): Promise; + + public processJsonData(json: any): void; + + public clone(): RelayResponse; + + public toString(): string; + } + + export interface PayloadData { + [key: string]: any; + } + + export type QueryPayload = + | { + data?: PayloadData | null; + errors?: any[]; + rerunVariables?: Variables; + } + | RelayResponse; + + // this is workaround should be class from relay-runtime/network/RelayObservable.js + export type RelayObservable = Promise; + // Note: This should accept Subscribable instead of RelayObservable, + // however Flow cannot yet distinguish it from T. + + export type ObservableFromValue = RelayObservable | Promise | T; + + export type FetchHookFunction = ( + operation: ConcreteBatch, + variables: Variables, + cacheConfig: CacheConfig, + uploadables?: UploadableMap | null + ) => void | ObservableFromValue; + + export interface RelayNetworkLayerOpts { + subscribeFn?: SubscribeFunction; + beforeFetch?: FetchHookFunction; + noThrow?: boolean; + } + + export interface Disposable { + dispose(): void; + } + + export type SubscribeFunction = ( + operation: ConcreteBatch, + variables: Variables, + cacheConfig: CacheConfig, + observer: any + ) => RelayObservable | Disposable; + + export type Requests = RelayRequest[]; + + export default class RelayRequestBatch { + public fetchOpts: Partial; + public requests: Requests; + + constructor(requests: Requests); + public setFetchOption(name: string, value: any): void; + public setFetchOptions(opts: Object): void; + public getBody(): string; + public prepareBody(): string; + public getIds(): string[]; + public getID(): string; + public isMutation(): boolean; + public isFormData(): boolean; + public clone(): RelayRequestBatch; + public getVariables(): Variables; + public getQueryString(): string; + } + + export type RelayRequestAny = RelayRequest | RelayRequestBatch; + export type MiddlewareNextFn = ( + req: RelayRequestAny + ) => Promise; + export type Middleware = (next: MiddlewareNextFn) => MiddlewareNextFn; + export type MiddlewareRawNextFn = ( + req: RelayRequestAny + ) => Promise; + + export interface MiddlewareRaw { + isRawMiddleware: true; + (next: MiddlewareRawNextFn): MiddlewareRawNextFn; + } + + export interface MiddlewareSync { + execute: ( + operation: ConcreteBatch, + variables: Variables, + cacheConfig: CacheConfig, + uploadables?: UploadableMap | null + ) => ObservableFromValue; + } + + export class RelayNetworkLayer { + constructor( + middlewares: Array, + opts?: RelayNetworkLayerOpts + ); + } + + export interface AuthMiddlewareOpts { + token?: + | string + | Promise + | ((req: RelayRequestAny) => string | Promise); + tokenRefreshPromise?: ( + req: RelayRequestAny, + res: RelayResponse + ) => string | Promise; + allowEmptyToken?: boolean; + prefix?: string; + header?: string; + } + + export const authMiddleware: (opts?: AuthMiddlewareOpts) => Middleware; + + export interface BatchRequestMap { + [reqId: string]: RequestWrapper; + } + + export interface RequestWrapper { + req: RelayRequest; + completeOk: (res: Object) => void; + completeErr: (e: Error) => void; + done: boolean; + duplicates: RequestWrapper[]; + } + + export interface BatchMiddlewareOpts { + batchUrl?: + | string + | Promise + | ((requestMap: BatchRequestMap) => string | Promise); + batchTimeout?: number; + maxBatchSize?: number; + allowMutations?: boolean; + method?: "POST" | "GET"; + headers?: + | Headers + | Promise + | ((req: RelayRequestBatch) => Headers | Promise); + // Avaliable request modes in fetch options. For details see https://fetch.spec.whatwg.org/#requests + credentials?: FetchOpts["credentials"]; + mode?: FetchOpts["mode"]; + cache?: FetchOpts["cache"]; + redirect?: FetchOpts["redirect"]; + } + + export const batchMiddleware: (opts?: BatchMiddlewareOpts) => Middleware; + + interface CacheMiddlewareOpts { + size?: number; + ttl?: number; + onInit?: (cache: any /* TODO: missing type QueryResponseCache */) => any; + allowMutations?: boolean; + allowFormData?: boolean; + clearOnMutation?: boolean; + } + + export const cacheMiddleware: (opts?: CacheMiddlewareOpts) => Middleware; + + export interface GqlErrorMiddlewareOpts { + logger?: Function; + prefix?: string; + disableServerMiddlewareTip?: boolean; + } + export const errorMiddleware: (opts?: GqlErrorMiddlewareOpts) => Middleware; + + export interface LoggerMiddlewareOpts { + logger?: Function; + } + export const loggerMiddleware: (opts?: LoggerMiddlewareOpts) => Middleware; + + export interface PerfMiddlewareOpts { + logger?: Function; + } + export const performanceMiddleware: (opts?: PerfMiddlewareOpts) => Middleware; + + export interface ProgressOpts { + sizeHeader?: string; + onProgress: (runningTotal: number, totalSize?: number | null) => any; + } + export const progressMiddleware: (opts?: ProgressOpts) => Middleware; + + export type RetryAfterFn = (attempt: number) => number | false; + export type ForceRetryFn = (runNow: Function, delay: number) => any; + export type StatusCheckFn = ( + statusCode: number, + req: RelayRequestAny, + res: RelayResponse + ) => boolean; + + export interface RetryMiddlewareOpts { + fetchTimeout?: number; + retryDelays?: number[] | RetryAfterFn; + statusCodes?: number[] | false | StatusCheckFn; + logger?: Function | false; + allowMutations?: boolean; + allowFormData?: boolean; + forceRetry?: ForceRetryFn | false; + } + export const retryMiddleware: (opts?: RetryMiddlewareOpts) => Middleware; + + export interface UrlMiddlewareOpts { + url: + | string + | Promise + | ((req: RelayRequest) => string | Promise); + method?: "POST" | "GET"; + headers?: + | Headers + | Promise + | ((req: RelayRequest) => Headers | Promise); + // Avaliable request modes in fetch options. For details see https://fetch.spec.whatwg.org/#requests + credentials?: FetchOpts["credentials"]; + mode?: FetchOpts["mode"]; + cache?: FetchOpts["cache"]; + redirect?: FetchOpts["redirect"]; + } + export const urlMiddleware: (opts?: UrlMiddlewareOpts) => Middleware; +}