Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): allow domain sharding #7061

Draft
wants to merge 3 commits into
base: next
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dev/test-studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"@react-three/fiber": "^8.13.6",
"@sanity/assist": "^3.0.2",
"@sanity/block-tools": "3.49.0",
"@sanity/client": "^6.20.0",
"@sanity/client": "6.20.2-beta.2",
"@sanity/color": "^3.0.0",
"@sanity/google-maps-input": "^4.0.0",
"@sanity/icons": "^3.0.0",
Expand Down
46 changes: 28 additions & 18 deletions dev/test-studio/sanity.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ const sharedSettings = definePlugin({
],
})

export default defineConfig([
const production = defineConfig([
{
name: 'default',
title: 'Test Studio',
Expand Down Expand Up @@ -215,22 +215,6 @@ export default defineConfig([
plugins: [sharedSettings()],
basePath: '/playground-partial-indexing',
},
{
name: 'staging',
title: 'Staging',
subtitle: 'Staging dataset',
projectId: 'exx11uqh',
dataset: 'playground',
plugins: [sharedSettings()],
basePath: '/staging',
apiHost: 'https://api.sanity.work',
auth: {
loginMethod: 'token',
},
unstable_tasks: {
enabled: true,
},
},
{
name: 'custom-components',
title: 'Test Studio',
Expand Down Expand Up @@ -329,4 +313,30 @@ export default defineConfig([
],
basePath: '/presentation',
},
]) as WorkspaceOptions[]
])

const staging = defineConfig([
{
name: 'staging',
title: 'Staging',
subtitle: 'Staging dataset',
projectId: 'exx11uqh',
dataset: 'playground',
plugins: [sharedSettings()],
basePath: '/staging',
apiHost: 'https://api.sanity.work',
allowDomainSharding: true,
auth: {
loginMethod: 'token',
},
unstable_tasks: {
enabled: true,
},
},
])

const studioConfig: WorkspaceOptions[] =
// @ts-expect-error: __SANITY_STAGING__ is a global env variable set by the vite config
typeof __SANITY_STAGING__ !== 'undefined' && __SANITY_STAGING__ ? staging : production

export default studioConfig
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
"@playwright/test": "1.41.2",
"@repo/package.config": "workspace:*",
"@repo/tsconfig": "workspace:*",
"@sanity/client": "^6.20.0",
"@sanity/client": "6.20.2-beta.2",
"@sanity/eslint-config-i18n": "1.0.0",
"@sanity/eslint-config-studio": "^4.0.0",
"@sanity/pkg-utils": "6.9.3",
Expand Down
2 changes: 1 addition & 1 deletion packages/@sanity/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
},
"dependencies": {
"@babel/traverse": "^7.23.5",
"@sanity/client": "^6.20.0",
"@sanity/client": "6.20.2-beta.2",
"@sanity/codegen": "3.49.0",
"@sanity/telemetry": "^0.7.7",
"@sanity/util": "3.49.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/@sanity/migrate/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
},
"dependencies": {
"@bjoerge/mutiny": "^0.5.1",
"@sanity/client": "^6.20.0",
"@sanity/client": "6.20.2-beta.2",
"@sanity/types": "3.49.0",
"@sanity/util": "3.49.0",
"arrify": "^2.0.1",
Expand Down
2 changes: 1 addition & 1 deletion packages/@sanity/types/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"watch": "pkg-utils watch"
},
"dependencies": {
"@sanity/client": "^6.20.0",
"@sanity/client": "6.20.2-beta.2",
"@types/react": "^18.0.25"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/@sanity/util/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@
"watch": "pkg-utils watch"
},
"dependencies": {
"@sanity/client": "^6.20.0",
"@sanity/client": "6.20.2-beta.2",
"@sanity/types": "3.49.0",
"get-random-values-esm": "1.0.2",
"moment": "^2.29.4",
Expand Down
2 changes: 1 addition & 1 deletion packages/@sanity/vision/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
"@repo/package.config": "workspace:*",
"@sanity/block-tools": "workspace:*",
"@sanity/cli": "workspace:*",
"@sanity/client": "^6.20.0",
"@sanity/client": "6.20.2-beta.2",
"@sanity/codegen": "workspace:*",
"@sanity/diff": "workspace:*",
"@sanity/migrate": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion packages/sanity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@
"@sanity/bifur-client": "^0.4.0",
"@sanity/block-tools": "3.49.0",
"@sanity/cli": "3.49.0",
"@sanity/client": "^6.20.0",
"@sanity/client": "6.20.2-beta.2",
"@sanity/color": "^3.0.0",
"@sanity/diff": "3.49.0",
"@sanity/diff-match-patch": "^3.1.1",
Expand Down
18 changes: 16 additions & 2 deletions packages/sanity/src/core/config/prepareConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,22 @@ function getAuthStore(source: SourceOptions): AuthStore {
}

const clientFactory = source.unstable_clientFactory || createClient
const {projectId, dataset, apiHost} = source
return createAuthStore({apiHost, ...source.auth, clientFactory, dataset, projectId})
const {projectId, dataset, apiHost, allowDomainSharding = false} = source

if (allowDomainSharding && source.auth?.loginMethod !== 'token') {
throw new Error(
'Domain sharding is only supported with token-based authentication. Please set `allowDomainSharding: false` or `auth.loginMethod: "token"` in your studio configuration.',
)
}

return createAuthStore({
apiHost,
...source.auth,
clientFactory,
dataset,
projectId,
allowDomainSharding,
})
}

interface ResolveSourceOptions {
Expand Down
14 changes: 14 additions & 0 deletions packages/sanity/src/core/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,20 @@ export interface SourceOptions extends PluginOptions {
*/
apiHost?: string

/**
* If enabled, and the studio cannot detect a modern HTTP version being used for API requests,
* API requests will be spread across multiple hostnames, to avoid browser connection limits.
* This is rarely, if ever, what you want. With HTTP/2, the connection limit is much higher,
* and the overhead of setting up a new connection is much lower. This setting is only useful
* for companies who have an HTTP proxy or similar that prevents HTTP/2 from being used, and
* should generally be avoided.
*
* Note that `auth.loginMethod` _must_ be set to `token` for this to work.
*
* @alpha Warning: This API may be removed at any point and should not be relied on.
*/
allowDomainSharding?: boolean

/**
* Authentication options for this source.
*/
Expand Down
81 changes: 81 additions & 0 deletions packages/sanity/src/core/network/modernHttp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {type SanityClient} from '@sanity/client'

/**
* Checks if the client supports a more modern HTTP protocol than HTTP1.
*
* @param client - The client to use for checking HTTP protocol support.
* @returns Boolean that resolves to `true` if HTTP2 or newer is supported, `false` if _unsupported_, and `undefined` if _unknown_ (eg browser does not have the necessary APIs to determine).
* @internal
*/
export async function supportsModernHttp(client: SanityClient): Promise<boolean | undefined> {
try {
const pingEntry = await getPingResourceTimingEntry(client)

if (
pingEntry &&
'nextHopProtocol' in pingEntry &&
typeof pingEntry.nextHopProtocol === 'string'
) {
// `nextHopProtocol` is a string representing the network protocol used to fetch the resource,
// as identified by the ALPN Protocol ID(RFC7301). < HTTP2 uses eg "http/1.1", while > HTTP2
// uses eg "h2", "h2c" (HTTP/2 over cleartext TCP), "h3" (HTTP/3) etc.
// As we only care about "more modern than HTTP1", we'll just check for "h<digit>" prefix here.
return /^h\d/.test(pingEntry.nextHopProtocol)
}

return undefined
} catch (err) {
return false
}
}

/**
* Perform a request against the `/ping` endpoint, and get a `PerformanceEntry` for it.
* This endpoint allows more timing information to be exposed to browsers, which can tell us things
* such as which HTTP protocol was used, how long it took to resolve DNS, connect, initiate TLS etc.
*
* @param client - The client to use for the request
* @returns A `PerformanceEntry` for the `/ping` request, or `undefined` if the request failed or timed out.
* @internal
*/
async function getPingResourceTimingEntry(
client: SanityClient,
): Promise<PerformanceEntry | undefined> {
if (typeof PerformanceObserver === 'undefined') {
return undefined
}

const tag = 'ping-for-protocol'
return new Promise((resolve) => {
// Try to get resource timing entry for /ping request (allows browser to read network timings)
// If we can't get it within a reasonable time, we'll resolve with `undefined` ("timeout")
let resolved = false
const observer = new PerformanceObserver(function perfObserver(list, obs) {
list.getEntries().forEach((entry) => {
if (entry.name.includes('/ping') && entry.name.includes(tag) && !resolved) {
resolve(entry)
resolved = true
obs.disconnect()
}
})
})
observer.observe({type: 'resource'})

client
.request({
uri: '/ping',
withCredentials: false,
tag,
})
.catch(() => undefined)
// If after 150ms we haven't gotten a timing entry, we'll resolve with `undefined` ("timeout")
.then(() => new Promise((waited) => setTimeout(waited, 150)))
.then(() => {
if (!resolved) {
resolved = true
observer.disconnect()
resolve(undefined)
}
})
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import {
createClient as createSanityClient,
type SanityClient,
} from '@sanity/client'
import {type CurrentUser} from '@sanity/types'
import {isEqual, memoize} from 'lodash'
import {defer} from 'rxjs'
import {distinctUntilChanged, map, shareReplay, startWith, switchMap} from 'rxjs/operators'

import {type AuthConfig} from '../../../config'
import {supportsModernHttp} from '../../../network/modernHttp'
import {CorsOriginError} from '../cors'
import {createBroadcastChannel} from './createBroadcastChannel'
import {createLoginComponent} from './createLoginComponent'
Expand All @@ -16,11 +18,20 @@ import * as storage from './storage'
import {type AuthState, type AuthStore} from './types'
import {isCookielessCompatibleLoginMethod} from './utils/asserters'

/**
* For development purposes - allows forcing domain sharding to be enabled,
* regardless of the browser's support for modern HTTP.
*
* @internal
*/
const FORCE_DOMAIN_SHARDING = false

/** @internal */
export interface AuthStoreOptions extends AuthConfig {
clientFactory?: (options: SanityClientConfig) => SanityClient
projectId: string
dataset: string
allowDomainSharding?: boolean
}

const getStorageKey = (projectId: string) => {
Expand Down Expand Up @@ -69,7 +80,7 @@ const getCurrentUser = async (
broadcastToken: (token: string | null) => void,
) => {
try {
const user = await client.request({
const user = await client.request<CurrentUser>({
uri: '/users/me',
withCredentials: true,
tag: 'users.get-current',
Expand Down Expand Up @@ -121,6 +132,7 @@ export function _createAuthStore({
dataset,
apiHost,
loginMethod = 'dual',
allowDomainSharding,
...providerOptions
}: AuthStoreOptions): AuthStore {
// this broadcast channel receives either a token as a `string` or `null`.
Expand Down Expand Up @@ -169,12 +181,23 @@ export function _createAuthStore({
),
switchMap((client) =>
defer(async (): Promise<AuthState> => {
const currentUser = await getCurrentUser(client, broadcast)
const [currentUser, hasModernHttpSupport] = await Promise.all([
getCurrentUser(client, broadcast),
allowDomainSharding ? supportsModernHttp(client) : Promise.resolve(undefined),
])

return {
currentUser,
client,
authenticated: !!currentUser,
// We want to use the base, unsharded API domain for authentication, but once we know we
// have/don't have a user, we want to switch to the sharded domains for subsequent requests.
// If the user has chosen to use domain sharding _but_ the browser actually _supports_
// modern HTTP, we still want to use unsharded domains, however - thus the extra check.
client:
FORCE_DOMAIN_SHARDING ||
(allowDomainSharding && !hasModernHttpSupport && client.config().token)
? client.withConfig({useDomainSharding: true})
: client,
authenticated: Boolean(currentUser),
}
}),
),
Expand Down
2 changes: 1 addition & 1 deletion perf/tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
},
"dependencies": {
"@playwright/test": "1.41.2",
"@sanity/client": "^6.20.0",
"@sanity/client": "6.20.2-beta.2",
"@sanity/uuid": "^3.0.1",
"dotenv": "^16.0.3",
"execa": "^2.0.0",
Expand Down
Loading
Loading