diff options
author | 2021-08-18 16:40:23 -0700 | |
---|---|---|
committer | 2021-08-18 16:40:23 -0700 | |
commit | abdc26a5fc495092c865f89091bd39242fdb2b07 (patch) | |
tree | 78e71af008a042a73b1dcea8868fe43ce18f67f7 | |
parent | 306c7dda6189521b44253eaf4696eb1ea1b1227f (diff) | |
download | bun-abdc26a5fc495092c865f89091bd39242fdb2b07.tar.gz bun-abdc26a5fc495092c865f89091bd39242fdb2b07.tar.zst bun-abdc26a5fc495092c865f89091bd39242fdb2b07.zip |
Get most of the Next.js router working
Former-commit-id: 3521bd1bb606f164f6ef1cdc4cfaae1663c22891
-rw-r--r-- | demos/hello-next/bun-framework-next/client.development.tsx | 116 | ||||
-rw-r--r-- | demos/hello-next/bun-framework-next/page-loader.ts | 57 | ||||
-rw-r--r-- | demos/hello-next/bun-framework-next/route-loader.ts | 433 | ||||
-rw-r--r-- | demos/hello-next/pages/foo/bar/third.tsx | 13 | ||||
-rw-r--r-- | demos/hello-next/pages/posts/[id].tsx | 18 | ||||
-rw-r--r-- | demos/hello-next/pages/second.tsx | 8 |
6 files changed, 600 insertions, 45 deletions
diff --git a/demos/hello-next/bun-framework-next/client.development.tsx b/demos/hello-next/bun-framework-next/client.development.tsx index aaa505d9c..824171086 100644 --- a/demos/hello-next/bun-framework-next/client.development.tsx +++ b/demos/hello-next/bun-framework-next/client.development.tsx @@ -20,6 +20,7 @@ import Router, { PrivateRouteInfo, } from "next/dist/shared/lib/router/router"; +import * as NextRouteLoader from "next/dist/client/route-loader"; import { isDynamicRoute } from "next/dist/shared/lib/router/utils/is-dynamic"; import { urlQueryToSearchParams, @@ -113,7 +114,7 @@ export let router: Router; let CachedApp: AppComponent, onPerfEntry: (metric: any) => void; export default function boot(EntryPointNamespace, loader) { - _boot(EntryPointNamespace); + _boot(EntryPointNamespace).then(() => {}); } class Container extends React.Component<{ @@ -227,39 +228,98 @@ function AppContainer({ ); } -router = createRouter(page, query, asPath, { - initialProps: hydrateProps, - pageLoader, - App: CachedApp, - Component: CachedComponent, - wrapApp, - err: null, - isFallback: Boolean(isFallback), - subscription: (info, App, scroll) => - render( - Object.assign< - {}, - Omit<RenderRouteInfo, "App" | "scroll">, - Pick<RenderRouteInfo, "App" | "scroll"> - >({}, info, { - App, - scroll, - }) as RenderRouteInfo - ), - locale, - locales, - defaultLocale: "", - domainLocales, - isPreview, -}); +async function _boot(EntryPointNamespace) { + NextRouteLoader.default.getClientBuildManifest = () => Promise.resolve({}); -function _boot(EntryPointNamespace) { const PageComponent = EntryPointNamespace.default; + const appScripts = globalThis.__NEXT_DATA__.pages["/_app"]; + if (appScripts && appScripts.length > 0) { + let appSrc; + for (let asset of appScripts) { + if (!asset.endsWith(".css")) { + appSrc = asset; + break; + } + } + + if (appSrc) { + const AppModule = await import(appSrc); + console.assert( + AppModule.default, + appSrc + " must have a default export'd React component" + ); + + CachedApp = AppModule.default; + } + } + + router = createRouter(page, query, asPath, { + initialProps: hydrateProps, + pageLoader, + App: CachedApp, + Component: CachedComponent, + wrapApp, + err: null, + isFallback: Boolean(isFallback), + subscription: async (info, App, scroll) => { + return render( + Object.assign< + {}, + Omit<RenderRouteInfo, "App" | "scroll">, + Pick<RenderRouteInfo, "App" | "scroll"> + >({}, info, { + App, + scroll, + }) + ); + }, + locale, + locales, + defaultLocale: "", + domainLocales, + isPreview, + }); + ReactDOM.hydrate( + <TopLevelRender + App={CachedApp} + Component={PageComponent} + props={{ pageProps: hydrateProps }} + />, + document.querySelector("#__next") + ); +} + +function TopLevelRender({ App, Component, props, scroll }) { + return ( + <AppContainer scroll={scroll}> + <App Component={Component} {...props}></App> + </AppContainer> + ); +} + +export function render(props) { + ReactDOM.render( + <TopLevelRender {...props} />, + document.querySelector("#__next") + ); +} + +export function renderError(e) { + debugger; + ReactDOM.render( <AppContainer> - <App Component={PageComponent} pageProps={data.props}></App> + <App Component={<div>UH OH!!!!</div>} pageProps={data.props}></App> </AppContainer>, document.querySelector("#__next") ); } + +globalThis.next = { + version: "11.1.0", + router, + emitter, + render, + renderError, +}; diff --git a/demos/hello-next/bun-framework-next/page-loader.ts b/demos/hello-next/bun-framework-next/page-loader.ts index 62cfab583..84e623ab1 100644 --- a/demos/hello-next/bun-framework-next/page-loader.ts +++ b/demos/hello-next/bun-framework-next/page-loader.ts @@ -1,4 +1,6 @@ import NextPageLoader from "next/dist/client/page-loader"; +import getAssetPathFromRoute from "next/dist/shared/lib/router/utils/get-asset-path-from-route"; +import createRouteLoader from "./route-loader"; export default class PageLoader extends NextPageLoader { public routeLoader: RouteLoader; @@ -6,6 +8,8 @@ export default class PageLoader extends NextPageLoader { constructor(_, __, pages) { super(_, __); + // TODO: assetPrefix? + this.routeLoader = createRouteLoader(""); this.pages = pages; } @@ -13,20 +17,47 @@ export default class PageLoader extends NextPageLoader { return Object.keys(this.pages); } - loadPage(route: string): Promise<GoodPageCache> { - return this.routeLoader.loadRoute(route).then((res) => { - if ("component" in res) { - return { - page: res.component, - mod: res.exports, - styleSheets: res.styles.map((o) => ({ - href: o.href, - text: o.content, - })), - }; + async loadPage(route: string): Promise<GoodPageCache> { + try { + const assets = + globalThis.__NEXT_DATA__.pages[route] || + globalThis.__NEXT_DATA__.pages[getAssetPathFromRoute(route)]; + + var src; + console.log(getAssetPathFromRoute(route), assets); + for (let asset of assets) { + if (!asset.endsWith(".css")) { + src = asset; + break; + } } - throw res.error; - }); + + console.assert(src, "Invalid or unknown route passed to loadPage"); + const res = await import(src); + console.log({ res }); + + return { + page: res.default, + mod: res, + __N_SSG: false, + __N_SSP: false, + }; + } catch (err) {} + + // return this.routeLoader.loadRoute(route).then((res) => { + // debugger; + // if ("component" in res) { + // return { + // page: res.component, + // mod: res.exports, + // styleSheets: res.styles.map((o) => ({ + // href: o.href, + // text: o.content, + // })), + // }; + // } + // throw res.error; + // }); } // not used in development! diff --git a/demos/hello-next/bun-framework-next/route-loader.ts b/demos/hello-next/bun-framework-next/route-loader.ts index e69de29bb..887a57544 100644 --- a/demos/hello-next/bun-framework-next/route-loader.ts +++ b/demos/hello-next/bun-framework-next/route-loader.ts @@ -0,0 +1,433 @@ +import { ComponentType } from "react"; +// import { ClientBuildManifest } from "../build/webpack/plugins/build-manifest-plugin"; +import getAssetPathFromRoute from "next/dist/shared/lib/router/utils/get-asset-path-from-route"; +// import { requestIdleCallback } from "./request-idle-callback"; + +const requestIdleCallback = window.requestAnimationFrame; + +// 3.8s was arbitrarily chosen as it's what https://web.dev/interactive +// considers as "Good" time-to-interactive. We must assume something went +// wrong beyond this point, and then fall-back to a full page transition to +// show the user something of value. +const MS_MAX_IDLE_DELAY = 3800; + +declare global { + interface Window { + __BUILD_MANIFEST?: ClientBuildManifest; + __BUILD_MANIFEST_CB?: Function; + } +} + +export interface LoadedEntrypointSuccess { + component: ComponentType; + exports: any; +} +export interface LoadedEntrypointFailure { + error: unknown; +} +export type RouteEntrypoint = LoadedEntrypointSuccess | LoadedEntrypointFailure; + +export interface RouteStyleSheet { + href: string; + content: string; +} + +export interface LoadedRouteSuccess extends LoadedEntrypointSuccess { + styles: RouteStyleSheet[]; +} +export interface LoadedRouteFailure { + error: unknown; +} +export type RouteLoaderEntry = LoadedRouteSuccess | LoadedRouteFailure; + +export type Future<V> = { + resolve: (entrypoint: V) => void; + future: Promise<V>; +}; +function withFuture<T>( + key: string, + map: Map<string, Future<T> | T>, + generator?: () => Promise<T> +): Promise<T> { + let entry: Future<T> | T | undefined = map.get(key); + console.log({ entry }); + if (entry) { + if ("future" in entry) { + return entry.future; + } + return Promise.resolve(entry); + } + + let resolver: (entrypoint: T) => void; + const prom: Promise<T> = new Promise<T>((resolve) => { + resolver = resolve; + }); + map.set(key, (entry = { resolve: resolver!, future: prom })); + + return generator + ? // eslint-disable-next-line no-sequences + generator().then((value) => (resolver(value), value)) + : prom; +} + +export interface RouteLoader { + whenEntrypoint(route: string): Promise<RouteEntrypoint>; + onEntrypoint(route: string, execute: () => unknown): void; + loadRoute(route: string, prefetch?: boolean): Promise<RouteLoaderEntry>; + prefetch(route: string): Promise<void>; +} + +function hasPrefetch(link?: HTMLLinkElement): boolean { + try { + link = document.createElement("link"); + return ( + // detect IE11 since it supports prefetch but isn't detected + // with relList.support + (!!window.MSInputMethodContext && !!(document as any).documentMode) || + link.relList.supports("prefetch") + ); + } catch { + return false; + } +} + +const canPrefetch: boolean = hasPrefetch(); + +function prefetchViaDom( + href: string, + as: string, + link?: HTMLLinkElement +): Promise<any> { + return new Promise<void>((res, rej) => { + if (document.querySelector(`link[rel="prefetch"][href^="${href}"]`)) { + return res(); + } + + link = document.createElement("link"); + + // The order of property assignment here is intentional: + if (as) link!.as = as; + link!.rel = `prefetch`; + link!.crossOrigin = process.env.__NEXT_CROSS_ORIGIN!; + link!.onload = res as any; + link!.onerror = rej; + + // `href` should always be last: + link!.href = href; + + document.head.appendChild(link); + }); +} + +const ASSET_LOAD_ERROR = Symbol("ASSET_LOAD_ERROR"); +// TODO: unexport +export function markAssetError(err: Error): Error { + return Object.defineProperty(err, ASSET_LOAD_ERROR, {}); +} + +export function isAssetError(err?: Error): boolean | undefined { + return err && ASSET_LOAD_ERROR in err; +} + +function appendScript( + src: string, + script?: HTMLScriptElement +): Promise<unknown> { + return new Promise((resolve, reject) => { + script = document.createElement("script"); + + // The order of property assignment here is intentional. + // 1. Setup success/failure hooks in case the browser synchronously + // executes when `src` is set. + script.onload = resolve; + script.onerror = () => + reject(markAssetError(new Error(`Failed to load script: ${src}`))); + + // Bun: Add type module so we can utilize import/export + script.type = "module"; + + // 2. Configure the cross-origin attribute before setting `src` in case the + // browser begins to fetch. + script.crossOrigin = process.env.__NEXT_CROSS_ORIGIN!; + + // 3. Finally, set the source and inject into the DOM in case the child + // must be appended for fetching to start. + script.src = src; + document.body.appendChild(script); + }); +} + +// We wait for pages to be built in dev before we start the route transition +// timeout to prevent an un-necessary hard navigation in development. +let devBuildPromise: Promise<void> | undefined; +let devBuildResolve: (() => void) | undefined; + +if (process.env.NODE_ENV === "development") { + // const { addMessageListener } = require("./dev/error-overlay/eventsource"); + // addMessageListener((event: any) => { + // // This is the heartbeat event + // if (event.data === "\uD83D\uDC93") { + // return; + // } + // const obj = + // typeof event === "string" ? { action: event } : JSON.parse(event.data); + // switch (obj.action) { + // case "built": + // case "sync": + // if (devBuildResolve) { + // devBuildResolve(); + // devBuildResolve = undefined; + // } + // break; + // default: + // break; + // } + // }); +} + +// Resolve a promise that times out after given amount of milliseconds. +function resolvePromiseWithTimeout<T>( + p: Promise<T>, + ms: number, + err: Error +): Promise<T> { + return new Promise((resolve, reject) => { + let cancelled = false; + + p.then((r) => { + // Resolved, cancel the timeout + cancelled = true; + resolve(r); + }).catch(reject); + + // We wrap these checks separately for better dead-code elimination in + // production bundles. + if (process.env.NODE_ENV === "development") { + (devBuildPromise || Promise.resolve()).then(() => { + requestIdleCallback(() => + setTimeout(() => { + if (!cancelled) { + reject(err); + } + }, ms) + ); + }); + } + + if (process.env.NODE_ENV !== "development") { + requestIdleCallback(() => + setTimeout(() => { + if (!cancelled) { + reject(err); + } + }, ms) + ); + } + }); +} + +// TODO: stop exporting or cache the failure +// It'd be best to stop exporting this. It's an implementation detail. We're +// only exporting it for backwards compatibility with the `page-loader`. +// Only cache this response as a last resort if we cannot eliminate all other +// code branches that use the Build Manifest Callback and push them through +// the Route Loader interface. +export function getClientBuildManifest(): Promise<ClientBuildManifest> { + console.log("hiiiiiiiiiiiii"); + if (self.__BUILD_MANIFEST) { + return Promise.resolve(self.__BUILD_MANIFEST); + } + + const onBuildManifest: Promise<ClientBuildManifest> = + new Promise<ClientBuildManifest>((resolve) => { + // Mandatory because this is not concurrent safe: + const cb = self.__BUILD_MANIFEST_CB; + self.__BUILD_MANIFEST_CB = () => { + resolve(self.__BUILD_MANIFEST!); + cb && cb(); + }; + }); + + return resolvePromiseWithTimeout<ClientBuildManifest>( + onBuildManifest, + MS_MAX_IDLE_DELAY, + markAssetError(new Error("Failed to load client build manifest")) + ); +} + +interface RouteFiles { + scripts: string[]; + css: string[]; +} +function getFilesForRoute( + assetPrefix: string, + route: string +): Promise<RouteFiles> { + if (process.env.NODE_ENV === "development") { + return Promise.resolve({ + scripts: [ + encodeURI( + globalThis.__NEXT_DATA.pages[route].filter((k) => !k.endsWith(".css")) + ), + ], + // Styles are handled by `style-loader` in development: + css: [], + }); + } + return getClientBuildManifest().then((manifest) => { + if (!(route in manifest)) { + throw markAssetError(new Error(`Failed to lookup route: ${route}`)); + } + const allFiles = manifest[route].map( + (entry) => assetPrefix + "/_next/" + encodeURI(entry) + ); + return { + scripts: allFiles.filter((v) => v.endsWith(".js")), + css: allFiles.filter((v) => v.endsWith(".css")), + }; + }); +} + +export default function createRouteLoader(assetPrefix: string): RouteLoader { + const entrypoints: Map<string, Future<RouteEntrypoint> | RouteEntrypoint> = + new Map(); + const loadedScripts: Map<string, Promise<unknown>> = new Map(); + const styleSheets: Map<string, Promise<RouteStyleSheet>> = new Map(); + const routes: Map<string, Future<RouteLoaderEntry> | RouteLoaderEntry> = + new Map(); + + function maybeExecuteScript(src: string): Promise<unknown> { + let prom: Promise<unknown> | undefined = loadedScripts.get(src); + if (prom) { + return prom; + } + + // Skip executing script if it's already in the DOM: + if (document.querySelector(`script[src^="${src}"]`)) { + return Promise.resolve(); + } + + loadedScripts.set(src, (prom = appendScript(src))); + return prom; + } + + function fetchStyleSheet(href: string): Promise<RouteStyleSheet> { + let prom: Promise<RouteStyleSheet> | undefined = styleSheets.get(href); + if (prom) { + return prom; + } + + styleSheets.set( + href, + (prom = fetch(href) + .then((res) => { + if (!res.ok) { + throw new Error(`Failed to load stylesheet: ${href}`); + } + return res.text().then((text) => ({ href: href, content: text })); + }) + .catch((err) => { + throw markAssetError(err); + })) + ); + return prom; + } + + return { + whenEntrypoint(route: string) { + return withFuture(route, entrypoints); + }, + onEntrypoint(route: string, execute: () => unknown) { + Promise.resolve(execute) + .then((fn) => fn()) + .then( + (exports: any) => ({ + component: (exports && exports.default) || exports, + exports: exports, + }), + (err) => ({ error: err }) + ) + .then((input: RouteEntrypoint) => { + const old = entrypoints.get(route); + entrypoints.set(route, input); + if (old && "resolve" in old) old.resolve(input); + }); + }, + loadRoute(route: string, prefetch?: boolean) { + return withFuture<RouteLoaderEntry>(route, routes, () => { + if (process.env.NODE_ENV === "development") { + devBuildPromise = new Promise<void>((resolve) => { + devBuildResolve = resolve; + }); + } + + return resolvePromiseWithTimeout( + getFilesForRoute(assetPrefix, route) + .then(({ scripts, css }) => { + return Promise.all([ + entrypoints.has(route) + ? [] + : Promise.all(scripts.map(maybeExecuteScript)), + Promise.all(css.map(fetchStyleSheet)), + ] as const); + }) + .then((res) => { + debugger; + console.log({ res }); + return this.whenEntrypoint(route).then((entrypoint) => { + debugger; + return { + entrypoint, + styles: res[1], + }; + }); + }), + MS_MAX_IDLE_DELAY, + markAssetError(new Error(`Route did not complete loading: ${route}`)) + ) + .then(({ entrypoint, styles }) => { + debugger; + const res: RouteLoaderEntry = Object.assign< + { styles: RouteStyleSheet[] }, + RouteEntrypoint + >({ styles: styles! }, entrypoint); + return "error" in entrypoint ? entrypoint : res; + }) + .catch((err) => { + if (prefetch) { + // we don't want to cache errors during prefetch + throw err; + } + return { error: err }; + }); + }); + }, + prefetch(route: string): Promise<void> { + // https://github.com/GoogleChromeLabs/quicklink/blob/453a661fa1fa940e2d2e044452398e38c67a98fb/src/index.mjs#L115-L118 + // License: Apache 2.0 + let cn; + if ((cn = (navigator as any).connection)) { + // Don't prefetch if using 2G or if Save-Data is enabled. + if (cn.saveData || /2g/.test(cn.effectiveType)) + return Promise.resolve(); + } + return getFilesForRoute(assetPrefix, route) + .then((output) => + Promise.all( + canPrefetch + ? output.scripts.map((script) => prefetchViaDom(script, "script")) + : [] + ) + ) + .then(() => { + requestIdleCallback(() => + this.loadRoute(route, true).catch(() => {}) + ); + }) + .catch( + // swallow prefetch errors + () => {} + ); + }, + }; +} diff --git a/demos/hello-next/pages/foo/bar/third.tsx b/demos/hello-next/pages/foo/bar/third.tsx index 9fbdada30..e1df24bc1 100644 --- a/demos/hello-next/pages/foo/bar/third.tsx +++ b/demos/hello-next/pages/foo/bar/third.tsx @@ -6,10 +6,19 @@ export default function Baz({}) { <h1>Third</h1> <ul> <li> - <a href="/">Root page</a> + <Link href="/"> + <a>Root page</a> + </Link> </li> <li> - <a href="/second">Second page</a> + <Link href="/second"> + <a>Second page</a> + </Link> + </li> + <li> + <Link href="/posts/123"> + <a>Post page 123</a> + </Link> </li> </ul> </div> diff --git a/demos/hello-next/pages/posts/[id].tsx b/demos/hello-next/pages/posts/[id].tsx new file mode 100644 index 000000000..26cb704f6 --- /dev/null +++ b/demos/hello-next/pages/posts/[id].tsx @@ -0,0 +1,18 @@ +import { useRouter } from "next/router"; +import Link from "next/link"; + +export default function Post({}) { + const router = useRouter(); + return ( + <div style={{ padding: 16 }}> + <h1>Post: {router.query.id}</h1> + <ul> + <li> + <Link href="/"> + <a>Root page</a> + </Link> + </li> + </ul> + </div> + ); +} diff --git a/demos/hello-next/pages/second.tsx b/demos/hello-next/pages/second.tsx index ae5fd5ec8..8cb0daa89 100644 --- a/demos/hello-next/pages/second.tsx +++ b/demos/hello-next/pages/second.tsx @@ -7,10 +7,14 @@ export default function Second({}) { <ul> <li> - <a href="/">Root page</a> + <Link href="/"> + <a>Root page</a> + </Link> </li> <li> - <a href="/foo/bar/third">Third page</a> + <Link href="/foo/bar/third"> + <a>Third page</a> + </Link> </li> </ul> </div> |