aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Jack Hanford <jackhanford@gmail.com> 2021-08-18 16:40:23 -0700
committerGravatar Jack Hanford <jackhanford@gmail.com> 2021-08-18 16:40:23 -0700
commitabdc26a5fc495092c865f89091bd39242fdb2b07 (patch)
tree78e71af008a042a73b1dcea8868fe43ce18f67f7
parent306c7dda6189521b44253eaf4696eb1ea1b1227f (diff)
downloadbun-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.tsx116
-rw-r--r--demos/hello-next/bun-framework-next/page-loader.ts57
-rw-r--r--demos/hello-next/bun-framework-next/route-loader.ts433
-rw-r--r--demos/hello-next/pages/foo/bar/third.tsx13
-rw-r--r--demos/hello-next/pages/posts/[id].tsx18
-rw-r--r--demos/hello-next/pages/second.tsx8
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>