diff --git a/lib/api/src/modules/refs.ts b/lib/api/src/modules/refs.ts index 99f87170ddd0..c8f10f78b429 100644 --- a/lib/api/src/modules/refs.ts +++ b/lib/api/src/modules/refs.ts @@ -36,7 +36,7 @@ export interface ComposedRef { id: string; title?: string; url: string; - type?: 'auto-inject' | 'unknown' | 'lazy'; + type?: 'auto-inject' | 'unknown' | 'lazy' | 'server-checked'; stories: StoriesHash; versions?: Versions; loginUrl?: string; @@ -44,16 +44,13 @@ export interface ComposedRef { ready?: boolean; error?: any; } -export interface ComposedRefUpdate { - title?: string; - type?: 'auto-inject' | 'unknown' | 'lazy'; - stories?: StoriesHash; - versions?: Versions; - loginUrl?: string; - version?: string; - ready?: boolean; - error?: any; -} + +export type ComposedRefUpdate = Partial< + Pick< + ComposedRef, + 'title' | 'type' | 'stories' | 'versions' | 'loginUrl' | 'version' | 'ready' | 'error' + > +>; export type Refs = Record; export type RefId = string; @@ -62,16 +59,6 @@ export type RefUrl = string; // eslint-disable-next-line no-useless-escape const findFilename = /(\/((?:[^\/]+?)\.[^\/]+?)|\/)$/; -const allSettled = (promises: Promise[]): Promise<(Response | false)[]> => - Promise.all( - promises.map((promise) => - promise.then( - (r) => (r.ok ? r : (false as const)), - () => false as const - ) - ) - ); - export const getSourceType = (source: string, refId: string) => { const { origin: localOrigin, pathname: localPathname } = location; const { origin: sourceOrigin, pathname: sourcePathname } = new URL(source); @@ -98,6 +85,15 @@ const addRefIds = (input: StoriesHash, ref: ComposedRef): StoriesHash => { }, {} as StoriesHash); }; +const handle = async (request: Response | false): Promise => { + if (request) { + return Promise.resolve(request) + .then((response) => (response.ok ? response.json() : {})) + .catch((error) => ({ error })); + } + return {}; +}; + const map = ( input: StoriesRaw, ref: ComposedRef, @@ -135,40 +131,37 @@ export const init: ModuleFn = ({ store, provider, fullAPI }, { runCheck = true } }); }, checkRef: async (ref) => { - const { id, url, version } = ref; + const { id, url, version, type } = ref; + const isPublic = type === 'server-checked'; + + // ref's type starts as either 'unknown' or 'server-checked' + // "server-checked" happens when we were able to verify the storybook is accessible from node (without cookies) + // "unknown" happens if the request was declined of failed (this can happen because the storybook doesn't exists or authentication is required) + // + // we then make a request for stories.json + // + // if this request fails when storybook is server-checked we mark the ref as "auto-inject", this is a fallback mechanism for local storybook, legacy storybooks, and storybooks that lack stories.json + // if the request fails with type "unknown" we give up and show an error + // if the request succeeds we set the ref to 'lazy' type, and show the stories in the sidebar without injecting the iframe first + // + // then we fetch metadata if the above fetch succeeded const loadedData: { error?: Error; stories?: StoriesRaw; loginUrl?: string } = {}; const query = version ? `?version=${version}` : ''; + const credentials = isPublic ? 'omit' : 'include'; + + // In theory the `/iframe.html` could be private and the `stories.json` could not exist, but in practice + // the only private servers we know about (Chromatic) always include `stories.json`. So we can tell + // if the ref actually exists by simply checking `stories.json` w/ credentials. - const [included, omitted, iframe] = await allSettled([ - fetch(`${url}/stories.json${query}`, { - headers: { - Accept: 'application/json', - }, - credentials: 'include', - }), - fetch(`${url}/stories.json${query}`, { - headers: { - Accept: 'application/json', - }, - credentials: 'omit', - }), - fetch(`${url}/iframe.html${query}`, { - mode: 'no-cors', - credentials: 'omit', - }), - ]); - - const handle = async (request: Response | false): Promise => { - if (request) { - return Promise.resolve(request) - .then((response) => (response.ok ? response.json() : {})) - .catch((error) => ({ error })); - } - return {}; - }; - - if (!included && !omitted && !iframe) { + const storiesFetch = await fetch(`${url}/stories.json${query}`, { + headers: { + Accept: 'application/json', + }, + credentials, + }); + + if (!storiesFetch.ok && !isPublic) { loadedData.error = { message: dedent` Error: Loading of ref failed @@ -182,11 +175,9 @@ export const init: ModuleFn = ({ store, provider, fullAPI }, { runCheck = true } Please check your dev-tools network tab. `, } as Error; - } else if (omitted || included) { - const credentials = included ? 'include' : 'omit'; - + } else if (storiesFetch.ok) { const [stories, metadata] = await Promise.all([ - included ? handle(included) : handle(omitted), + handle(storiesFetch), handle( fetch(`${url}/metadata.json${query}`, { headers: { @@ -244,11 +235,6 @@ export const init: ModuleFn = ({ store, provider, fullAPI }, { runCheck = true } const initialState: SubState['refs'] = refs; - Object.values(refs).forEach((r) => { - // eslint-disable-next-line no-param-reassign - r.type = 'unknown'; - }); - if (runCheck) { Object.entries(refs).forEach(([k, v]) => { api.checkRef(v as SetRefData); diff --git a/lib/api/src/tests/refs.test.js b/lib/api/src/tests/refs.test.js index 2c1a817339ac..d578afbae1c3 100644 --- a/lib/api/src/tests/refs.test.js +++ b/lib/api/src/tests/refs.test.js @@ -146,22 +146,6 @@ describe('Refs API', () => { }, }, ], - Array [ - "https://example.com/stories.json", - Object { - "credentials": "omit", - "headers": Object { - "Accept": "application/json", - }, - }, - ], - Array [ - "https://example.com/iframe.html", - Object { - "credentials": "omit", - "mode": "no-cors", - }, - ], ] `); }); @@ -191,22 +175,6 @@ describe('Refs API', () => { }, }, ], - Array [ - "https://example.com/stories.json?version=2.1.3-rc.2", - Object { - "credentials": "omit", - "headers": Object { - "Accept": "application/json", - }, - }, - ], - Array [ - "https://example.com/iframe.html?version=2.1.3-rc.2", - Object { - "credentials": "omit", - "mode": "no-cors", - }, - ], ] `); }); @@ -253,22 +221,6 @@ describe('Refs API', () => { }, }, ], - Array [ - "https://example.com/stories.json", - Object { - "credentials": "omit", - "headers": Object { - "Accept": "application/json", - }, - }, - ], - Array [ - "https://example.com/iframe.html", - Object { - "credentials": "omit", - "mode": "no-cors", - }, - ], ] `); @@ -343,22 +295,6 @@ describe('Refs API', () => { }, }, ], - Array [ - "https://example.com/stories.json", - Object { - "credentials": "omit", - "headers": Object { - "Accept": "application/json", - }, - }, - ], - Array [ - "https://example.com/iframe.html", - Object { - "credentials": "omit", - "mode": "no-cors", - }, - ], Array [ "https://example.com/metadata.json", Object { @@ -436,32 +372,6 @@ describe('Refs API', () => { }, }, ], - Array [ - "https://example.com/stories.json", - Object { - "credentials": "omit", - "headers": Object { - "Accept": "application/json", - }, - }, - ], - Array [ - "https://example.com/iframe.html", - Object { - "credentials": "omit", - "mode": "no-cors", - }, - ], - Array [ - "https://example.com/metadata.json", - Object { - "cache": "no-cache", - "credentials": "omit", - "headers": Object { - "Accept": "application/json", - }, - }, - ], ] `); @@ -469,9 +379,18 @@ describe('Refs API', () => { Object { "refs": Object { "fake": Object { - "error": undefined, + "error": Object { + "message": "Error: Loading of ref failed + at fetch (lib/api/src/modules/refs.ts) + + URL: https://example.com + + We weren't able to load the above URL, + it's possible a CORS error happened. + + Please check your dev-tools network tab.", + }, "id": "fake", - "loginUrl": "https://example.com/login", "ready": false, "stories": undefined, "title": "Fake", @@ -530,22 +449,6 @@ describe('Refs API', () => { }, }, ], - Array [ - "https://example.com/stories.json", - Object { - "credentials": "omit", - "headers": Object { - "Accept": "application/json", - }, - }, - ], - Array [ - "https://example.com/iframe.html", - Object { - "credentials": "omit", - "mode": "no-cors", - }, - ], Array [ "https://example.com/metadata.json", Object { @@ -580,7 +483,7 @@ describe('Refs API', () => { `); }); - it('checks refs (cors)', async () => { + it('checks refs (serverside-success)', async () => { // given const { api } = initRefs({ provider, store }, { runCheck: false }); @@ -591,6 +494,63 @@ describe('Refs API', () => { throw new Error('Failed to fetch'); }, }, + { + ok: true, + response: async () => { + throw new Error('not ok'); + }, + }, + { + ok: false, + response: async () => { + throw new Error('Failed to fetch'); + }, + } + ); + + await api.checkRef({ + id: 'fake', + url: 'https://example.com', + title: 'Fake', + type: 'server-checked', + }); + + expect(fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "https://example.com/stories.json", + Object { + "credentials": "omit", + "headers": Object { + "Accept": "application/json", + }, + }, + ], + ] + `); + + expect(store.setState.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "refs": Object { + "fake": Object { + "error": undefined, + "id": "fake", + "ready": false, + "stories": undefined, + "title": "Fake", + "type": "auto-inject", + "url": "https://example.com", + }, + }, + } + `); + }); + + it('checks refs (serverside-fail)', async () => { + // given + const { api } = initRefs({ provider, store }, { runCheck: false }); + + setupResponses( { ok: false, response: async () => { @@ -615,6 +575,7 @@ describe('Refs API', () => { id: 'fake', url: 'https://example.com', title: 'Fake', + type: 'unknown', }); expect(fetch.mock.calls).toMatchInlineSnapshot(` @@ -629,21 +590,15 @@ describe('Refs API', () => { }, ], Array [ - "https://example.com/stories.json", + "https://example.com/metadata.json", Object { - "credentials": "omit", + "cache": "no-cache", + "credentials": "include", "headers": Object { "Accept": "application/json", }, }, ], - Array [ - "https://example.com/iframe.html", - Object { - "credentials": "omit", - "mode": "no-cors", - }, - ], ] `); @@ -651,7 +606,7 @@ describe('Refs API', () => { Object { "refs": Object { "fake": Object { - "error": undefined, + "error": [Error: not ok], "id": "fake", "ready": false, "stories": undefined, diff --git a/lib/core/src/server/manager/manager-config.js b/lib/core/src/server/manager/manager-config.js index af878ddb5319..389aecec7629 100644 --- a/lib/core/src/server/manager/manager-config.js +++ b/lib/core/src/server/manager/manager-config.js @@ -2,6 +2,7 @@ import path from 'path'; import fs from 'fs-extra'; import findUp from 'find-up'; import resolveFrom from 'resolve-from'; +import fetch from 'node-fetch'; import { logger } from '@storybook/node-logger'; @@ -36,6 +37,12 @@ const getAutoRefs = async (options) => { return list.filter(Boolean); }; +const checkRef = (url) => + fetch(`${url}/iframe.html`).then( + ({ ok }) => ok, + () => false + ); + const stripTrailingSlash = (url) => url.replace(/\/$/, ''); const toTitle = (input) => { @@ -91,6 +98,15 @@ async function getManagerWebpackConfig(options, presets) { } if (autoRefs || definedRefs) { entries.push(path.resolve(path.join(options.configDir, `generated-refs.js`))); + + // verify the refs are publicly reachable, if they are not we'll require stories.json at runtime, otherwise the ref won't work + await Promise.all( + Object.entries(refs).map(async ([k, value]) => { + const ok = checkRef(value.url); + + refs[k] = { ...value, type: ok ? 'server-checked' : 'unknown' }; + }) + ); } return presets.apply('managerWebpack', {}, { ...options, babelOptions, entries, refs });