From 56c9ad8d7b050adbb26ef46e7a8e02107fa95fc3 Mon Sep 17 00:00:00 2001 From: Tobias Koppers Date: Wed, 30 Aug 2023 14:55:55 +0200 Subject: [PATCH] Turbopack: Implement HMR in next-api (#54772) by @jridgewell: ### What? This integrates Turbopack's HRM protocol within the existing HMR WebSocket, and implements the server-side of the Next's and Turbopack's protocols for use in next-api. ### Why? HMR makes the development experience. ### How? The (new) Turbopack HMR protocol allows reusing the existing `sendMessage` and `addMessageListener` API's already used by our HMR singleton in Pages. (App apparently doesn't have a per-chunk HMR, only per-page, so it's HMR signals are describe below.) For the next-api server-side, I implemented the following events: - `reloadPage`, for Pages when `_document` changes - `middlewareChanges` when middleware is added/deleted, or modified - `serverOnlyChanges` for Pages when a loaded page's server graph changes - `serverComponentChanges` for App when a loaded app page's server graph changes We reuse the already implemented `addedPage`, `removedPage`, and `devPagesManifestUpdate` (done via webpack, so we should eventually port that over to the Turbopack listeners). I don't know exactly where `built`, `building`, and `sync` should be integrated, so they're just not sent currently. Additionally, the client-sent events aren't implemented in the new HMR server. Depends on https://github.com/vercel/turbo/pull/5814 Closes WEB-1453 --------- Co-authored-by: Justin Ridgewell --- .../crates/next-api/src/middleware.rs | 2 +- .../next-swc/crates/next-api/src/project.rs | 4 +- .../next-core/src/next_client/context.rs | 2 +- .../crates/next-core/src/next_import_map.rs | 34 ++-- packages/next/package.json | 1 + packages/next/src/build/swc/index.ts | 26 ++- packages/next/src/client/dev/amp-dev.ts | 10 +- .../next/src/client/dev/dev-build-watcher.ts | 16 +- .../dev/error-overlay/hot-dev-client.ts | 11 +- .../src/client/dev/error-overlay/websocket.ts | 16 +- .../next/src/client/next-dev-turbopack.ts | 27 ++- packages/next/src/client/next-dev.ts | 90 +-------- packages/next/src/client/page-bootstrap.ts | 85 ++++++++ .../src/server/lib/router-utils/setup-dev.ts | 191 +++++++++++++++++- pnpm-lock.yaml | 5 +- 15 files changed, 363 insertions(+), 157 deletions(-) create mode 100644 packages/next/src/client/page-bootstrap.ts diff --git a/packages/next-swc/crates/next-api/src/middleware.rs b/packages/next-swc/crates/next-api/src/middleware.rs index 5c353cdb1ae5e..99e819f060f6d 100644 --- a/packages/next-swc/crates/next-api/src/middleware.rs +++ b/packages/next-swc/crates/next-api/src/middleware.rs @@ -214,6 +214,6 @@ impl Endpoint for MiddlewareEndpoint { #[turbo_tasks::function] fn client_changed(self: Vc) -> Vc { - Completion::new() + Completion::immutable() } } diff --git a/packages/next-swc/crates/next-api/src/project.rs b/packages/next-swc/crates/next-api/src/project.rs index c0ce87cf01317..8608e3546a218 100644 --- a/packages/next-swc/crates/next-api/src/project.rs +++ b/packages/next-swc/crates/next-api/src/project.rs @@ -586,7 +586,7 @@ impl Project { Ok(self .await? .versioned_content_map - .get(self.client_root().join(identifier))) + .get(self.client_relative_path().join(identifier))) } #[turbo_tasks::function] @@ -635,7 +635,7 @@ impl Project { Ok(self .await? .versioned_content_map - .keys_in_path(self.client_root())) + .keys_in_path(self.client_relative_path())) } } diff --git a/packages/next-swc/crates/next-core/src/next_client/context.rs b/packages/next-swc/crates/next-core/src/next_client/context.rs index 488526faf3de0..c09aea8f00793 100644 --- a/packages/next-swc/crates/next-core/src/next_client/context.rs +++ b/packages/next-swc/crates/next-core/src/next_client/context.rs @@ -136,7 +136,7 @@ pub async fn get_client_resolve_options_context( let next_client_import_map = get_next_client_import_map(project_path, ty, next_config, execution_context); let next_client_fallback_import_map = get_next_client_fallback_import_map(ty); - let next_client_resolved_map = get_next_client_resolved_map(project_path, project_path); + let next_client_resolved_map = get_next_client_resolved_map(project_path, project_path, mode); let module_options_context = ResolveOptionsContext { enable_node_modules: Some(project_path.root().resolve().await?), custom_conditions: vec![mode.node_env().to_string()], diff --git a/packages/next-swc/crates/next-core/src/next_import_map.rs b/packages/next-swc/crates/next-core/src/next_import_map.rs index 485e45c23126d..b012b4fc739d4 100644 --- a/packages/next-swc/crates/next-core/src/next_import_map.rs +++ b/packages/next-swc/crates/next-core/src/next_import_map.rs @@ -309,22 +309,28 @@ pub async fn get_next_edge_import_map( pub fn get_next_client_resolved_map( context: Vc, root: Vc, + mode: NextMode, ) -> Vc { - let glob_mappings = vec![ - // Temporary hack to replace the hot reloader until this is passable by props in next.js - ( - context.root(), - Glob::new( - "**/next/dist/client/components/react-dev-overlay/hot-reloader-client.js" - .to_string(), + let glob_mappings = if mode == NextMode::Development { + vec![] + } else { + vec![ + // Temporary hack to replace the hot reloader until this is passable by props in + // next.js + ( + context.root(), + Glob::new( + "**/next/dist/client/components/react-dev-overlay/hot-reloader-client.js" + .to_string(), + ), + ImportMapping::PrimaryAlternative( + "@vercel/turbopack-next/dev/hot-reloader.tsx".to_string(), + Some(root), + ) + .into(), ), - ImportMapping::PrimaryAlternative( - "@vercel/turbopack-next/dev/hot-reloader.tsx".to_string(), - Some(root), - ) - .into(), - ), - ]; + ] + }; ResolvedMap { by_glob: glob_mappings, } diff --git a/packages/next/package.json b/packages/next/package.json index 91f473063319e..e67f088b8448b 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -192,6 +192,7 @@ "@types/ws": "8.2.0", "@vercel/ncc": "0.34.0", "@vercel/nft": "0.22.6", + "@vercel/turbopack-ecmascript-runtime": "https://gitpkg-fork.vercel.sh/vercel/turbo/crates/turbopack-ecmascript-runtime/js?turbopack-230829.2", "acorn": "8.5.0", "ajv": "8.11.0", "amphtml-validator": "1.0.35", diff --git a/packages/next/src/build/swc/index.ts b/packages/next/src/build/swc/index.ts index a9c0545e27558..949bab7c1032d 100644 --- a/packages/next/src/build/swc/index.ts +++ b/packages/next/src/build/swc/index.ts @@ -475,7 +475,7 @@ export type TurbopackResult = T & { diagnostics: Diagnostics[] } -interface Middleware { +export interface Middleware { endpoint: Endpoint } @@ -500,7 +500,7 @@ export interface UpdateInfo { tasks: number } -enum ServerClientChangeType { +export enum ServerClientChangeType { Server = 'Server', Client = 'Client', Both = 'Both', @@ -550,7 +550,7 @@ export interface Endpoint { * After changed() has been awaited it will listen to changes. * The async iterator will yield for each change. */ - changed(): Promise> + changed(): Promise>> } interface EndpointConfig { @@ -593,6 +593,8 @@ function bindingToApi(binding: any, _wasm: boolean) { callback: (err: Error, value: T) => void ) => Promise<{ __napiType: 'RootTask' }> + const cancel = new (class Cancel extends Error {})() + /** * Utility function to ensure all variants of an enum are handled. */ @@ -635,6 +637,7 @@ function bindingToApi(binding: any, _wasm: boolean) { reject: (error: Error) => void } | undefined + let canceled = false // The native function will call this every time it emits a new result. We // either need to notify a waiting consumer, or buffer the new result until @@ -652,10 +655,10 @@ function bindingToApi(binding: any, _wasm: boolean) { } } - return (async function* () { + const iterator = (async function* () { const task = await withErrorCause(() => nativeFunction(emitResult)) try { - while (true) { + while (!canceled) { if (buffer.length > 0) { const item = buffer.shift()! if (item.err) throw item.err @@ -667,10 +670,19 @@ function bindingToApi(binding: any, _wasm: boolean) { }) } } + } catch (e) { + if (e === cancel) return + throw e } finally { binding.rootTaskDispose(task) } })() + iterator.return = async () => { + canceled = true + if (waiting) waiting.reject(cancel) + return { value: undefined, done: true } as IteratorReturnResult + } + return iterator } /** @@ -908,6 +920,10 @@ function bindingToApi(binding: any, _wasm: boolean) { ) ) + // The subscriptions will emit always emit once, which is the initial + // computation. This is not a change, so swallow it. + await Promise.all([serverSubscription.next(), clientSubscription.next()]) + return (async function* () { try { while (true) { diff --git a/packages/next/src/client/dev/amp-dev.ts b/packages/next/src/client/dev/amp-dev.ts index 3e39e3e025476..1d77dbe5bc7bd 100644 --- a/packages/next/src/client/dev/amp-dev.ts +++ b/packages/next/src/client/dev/amp-dev.ts @@ -77,14 +77,8 @@ async function tryApplyUpdates() { } } -addMessageListener((event) => { - if (event.data === '\uD83D\uDC93') { - return - } - +addMessageListener((message) => { try { - const message = JSON.parse(event.data) - // actions which are not related to amp-dev if ( message.action === 'serverError' || @@ -102,7 +96,7 @@ addMessageListener((event) => { } } catch (err: any) { console.warn( - '[HMR] Invalid message: ' + event.data + '\n' + (err?.stack ?? '') + '[HMR] Invalid message: ' + message + '\n' + (err?.stack ?? '') ) } }) diff --git a/packages/next/src/client/dev/dev-build-watcher.ts b/packages/next/src/client/dev/dev-build-watcher.ts index b158b536cbda8..f107d65bbf460 100644 --- a/packages/next/src/client/dev/dev-build-watcher.ts +++ b/packages/next/src/client/dev/dev-build-watcher.ts @@ -5,7 +5,7 @@ type VerticalPosition = 'top' | 'bottom' type HorizonalPosition = 'left' | 'right' export default function initializeBuildWatcher( - toggleCallback: (cb: (event: string | { data: string }) => void) => void, + toggleCallback: (cb: (obj: Record) => void) => void, position = 'bottom-right' ) { const shadowHost = document.createElement('div') @@ -53,21 +53,13 @@ export default function initializeBuildWatcher( // Handle events - addMessageListener((event) => { - // This is the heartbeat event - if (event.data === '\uD83D\uDC93') { - return - } - + addMessageListener((obj) => { try { - handleMessage(event) + handleMessage(obj) } catch {} }) - function handleMessage(event: string | { data: string }) { - const obj = - typeof event === 'string' ? { action: event } : JSON.parse(event.data) - + function handleMessage(obj: Record) { // eslint-disable-next-line default-case switch (obj.action) { case 'building': diff --git a/packages/next/src/client/dev/error-overlay/hot-dev-client.ts b/packages/next/src/client/dev/error-overlay/hot-dev-client.ts index f3d545ec924ad..06ff52f8b2674 100644 --- a/packages/next/src/client/dev/error-overlay/hot-dev-client.ts +++ b/packages/next/src/client/dev/error-overlay/hot-dev-client.ts @@ -63,15 +63,14 @@ let customHmrEventHandler: any export default function connect() { register() - addMessageListener((event) => { + addMessageListener((payload) => { try { - const payload = JSON.parse(event.data) - if (!('action' in payload)) return - - processMessage(payload) + if ('action' in payload) { + processMessage(payload) + } } catch (err: any) { console.warn( - '[HMR] Invalid message: ' + event.data + '\n' + (err?.stack ?? '') + '[HMR] Invalid message: ' + payload + '\n' + (err?.stack ?? '') ) } }) diff --git a/packages/next/src/client/dev/error-overlay/websocket.ts b/packages/next/src/client/dev/error-overlay/websocket.ts index c29adf7299241..efad62a99b6b1 100644 --- a/packages/next/src/client/dev/error-overlay/websocket.ts +++ b/packages/next/src/client/dev/error-overlay/websocket.ts @@ -1,5 +1,7 @@ +type WebSocketMessage = Record + let source: WebSocket -const eventCallbacks: ((event: any) => void)[] = [] +const eventCallbacks: ((msg: WebSocketMessage) => void)[] = [] let lastActivity = Date.now() function getSocketProtocol(assetPrefix: string): string { @@ -13,7 +15,7 @@ function getSocketProtocol(assetPrefix: string): string { return protocol === 'http:' ? 'ws' : 'wss' } -export function addMessageListener(cb: (event: any) => void) { +export function addMessageListener(cb: (msg: WebSocketMessage) => void) { eventCallbacks.push(cb) } @@ -39,11 +41,17 @@ export function connectHMR(options: { lastActivity = Date.now() } - function handleMessage(event: any) { + function handleMessage(event: MessageEvent) { lastActivity = Date.now() + // webpack's heartbeat event. + if (event.data === '\uD83D\uDC93') { + return + } + + const msg = JSON.parse(event.data) eventCallbacks.forEach((cb) => { - cb(event) + cb(msg) }) } diff --git a/packages/next/src/client/next-dev-turbopack.ts b/packages/next/src/client/next-dev-turbopack.ts index de54f55331d77..efa5ce70dda21 100644 --- a/packages/next/src/client/next-dev-turbopack.ts +++ b/packages/next/src/client/next-dev-turbopack.ts @@ -1,8 +1,12 @@ // TODO: Remove use of `any` type. -import { initialize, hydrate, version, router, emitter } from './' -import { displayContent } from './dev/fouc' +import { initialize, version, router, emitter } from './' +import initWebpackHMR from './dev/webpack-hot-middleware-client' import './setup-hydration-warning' +import { pageBootrap } from './page-bootstrap' +import { addMessageListener, sendMessage } from './dev/error-overlay/websocket' +//@ts-expect-error requires "moduleResolution": "node16" in tsconfig.json and not .ts extension +import { connect } from '@vercel/turbopack-ecmascript-runtime/dev/client/hmr-client.ts' window.next = { version: `${version}-turbo`, @@ -17,11 +21,10 @@ window.next = { // for the page loader declare let __turbopack_load__: any +const webpackHMR = initWebpackHMR() initialize({ // TODO the prop name is confusing as related to webpack - webpackHMR: { - onUnrecoverableError() {}, - }, + webpackHMR, }) .then(({ assetPrefix }) => { // for the page loader @@ -51,7 +54,19 @@ initialize({ ) } - return hydrate({ beforeRender: displayContent }).then(() => {}) + connect({ + addMessageListener(cb: (msg: Record) => void) { + addMessageListener((msg) => { + // Only call Turbopack's message listener for turbopack messages + if (msg.type?.startsWith('turbopack-')) { + cb(msg) + } + }) + }, + sendMessage, + }) + + return pageBootrap(assetPrefix) }) .catch((err) => { console.error('Error was not caught', err) diff --git a/packages/next/src/client/next-dev.ts b/packages/next/src/client/next-dev.ts index 23335e3452166..275ed7bbf6e9b 100644 --- a/packages/next/src/client/next-dev.ts +++ b/packages/next/src/client/next-dev.ts @@ -1,15 +1,8 @@ // TODO: Remove use of `any` type. import './webpack' -import { initialize, hydrate, version, router, emitter } from './' -import initOnDemandEntries from './dev/on-demand-entries-client' +import { initialize, version, router, emitter } from './' import initWebpackHMR from './dev/webpack-hot-middleware-client' -import initializeBuildWatcher from './dev/dev-build-watcher' -import { displayContent } from './dev/fouc' -import { connectHMR, addMessageListener } from './dev/error-overlay/websocket' -import { - assign, - urlQueryToSearchParams, -} from '../shared/lib/router/utils/querystring' +import { pageBootrap } from './page-bootstrap' import './setup-hydration-warning' @@ -25,84 +18,7 @@ window.next = { const webpackHMR = initWebpackHMR() initialize({ webpackHMR }) .then(({ assetPrefix }) => { - connectHMR({ assetPrefix, path: '/_next/webpack-hmr' }) - - return hydrate({ beforeRender: displayContent }).then(() => { - initOnDemandEntries() - - let buildIndicatorHandler: any = () => {} - - function devPagesHmrListener(event: any) { - let payload - try { - payload = JSON.parse(event.data) - } catch {} - if (payload.event === 'server-error' && payload.errorJSON) { - const { stack, message } = JSON.parse(payload.errorJSON) - const error = new Error(message) - error.stack = stack - throw error - } else if (payload.action === 'reloadPage') { - window.location.reload() - } else if (payload.action === 'devPagesManifestUpdate') { - fetch( - `${assetPrefix}/_next/static/development/_devPagesManifest.json` - ) - .then((res) => res.json()) - .then((manifest) => { - window.__DEV_PAGES_MANIFEST = manifest - }) - .catch((err) => { - console.log(`Failed to fetch devPagesManifest`, err) - }) - } else if (payload.event === 'middlewareChanges') { - return window.location.reload() - } else if (payload.event === 'serverOnlyChanges') { - const { pages } = payload - - // Make sure to reload when the dev-overlay is showing for an - // API route - if (pages.includes(router.query.__NEXT_PAGE)) { - return window.location.reload() - } - - if (!router.clc && pages.includes(router.pathname)) { - console.log('Refreshing page data due to server-side change') - - buildIndicatorHandler('building') - - const clearIndicator = () => buildIndicatorHandler('built') - - router - .replace( - router.pathname + - '?' + - String( - assign( - urlQueryToSearchParams(router.query), - new URLSearchParams(location.search) - ) - ), - router.asPath, - { scroll: false } - ) - .catch(() => { - // trigger hard reload when failing to refresh data - // to show error overlay properly - location.reload() - }) - .finally(clearIndicator) - } - } - } - addMessageListener(devPagesHmrListener) - - if (process.env.__NEXT_BUILD_INDICATOR) { - initializeBuildWatcher((handler: any) => { - buildIndicatorHandler = handler - }, process.env.__NEXT_BUILD_INDICATOR_POSITION) - } - }) + return pageBootrap(assetPrefix) }) .catch((err) => { console.error('Error was not caught', err) diff --git a/packages/next/src/client/page-bootstrap.ts b/packages/next/src/client/page-bootstrap.ts new file mode 100644 index 0000000000000..4163ffa8f4096 --- /dev/null +++ b/packages/next/src/client/page-bootstrap.ts @@ -0,0 +1,85 @@ +import { hydrate, router } from './' +import initOnDemandEntries from './dev/on-demand-entries-client' +import initializeBuildWatcher from './dev/dev-build-watcher' +import { displayContent } from './dev/fouc' +import { connectHMR, addMessageListener } from './dev/error-overlay/websocket' +import { + assign, + urlQueryToSearchParams, +} from '../shared/lib/router/utils/querystring' + +export function pageBootrap(assetPrefix: string) { + connectHMR({ assetPrefix, path: '/_next/webpack-hmr' }) + + return hydrate({ beforeRender: displayContent }).then(() => { + initOnDemandEntries() + + let buildIndicatorHandler: (obj: Record) => void = () => {} + + function devPagesHmrListener(payload: any) { + if (payload.event === 'server-error' && payload.errorJSON) { + const { stack, message } = JSON.parse(payload.errorJSON) + const error = new Error(message) + error.stack = stack + throw error + } else if (payload.action === 'reloadPage') { + window.location.reload() + } else if (payload.action === 'devPagesManifestUpdate') { + fetch(`${assetPrefix}/_next/static/development/_devPagesManifest.json`) + .then((res) => res.json()) + .then((manifest) => { + window.__DEV_PAGES_MANIFEST = manifest + }) + .catch((err) => { + console.log(`Failed to fetch devPagesManifest`, err) + }) + } else if (payload.event === 'middlewareChanges') { + return window.location.reload() + } else if (payload.event === 'serverOnlyChanges') { + const { pages } = payload + + // Make sure to reload when the dev-overlay is showing for an + // API route + if (pages.includes(router.query.__NEXT_PAGE)) { + return window.location.reload() + } + + if (!router.clc && pages.includes(router.pathname)) { + console.log('Refreshing page data due to server-side change') + + buildIndicatorHandler({ action: 'building' }) + + const clearIndicator = () => + buildIndicatorHandler({ action: 'built' }) + + router + .replace( + router.pathname + + '?' + + String( + assign( + urlQueryToSearchParams(router.query), + new URLSearchParams(location.search) + ) + ), + router.asPath, + { scroll: false } + ) + .catch(() => { + // trigger hard reload when failing to refresh data + // to show error overlay properly + location.reload() + }) + .finally(clearIndicator) + } + } + } + addMessageListener(devPagesHmrListener) + + if (process.env.__NEXT_BUILD_INDICATOR) { + initializeBuildWatcher((handler: any) => { + buildIndicatorHandler = handler + }, process.env.__NEXT_BUILD_INDICATOR_POSITION) + } + }) +} diff --git a/packages/next/src/server/lib/router-utils/setup-dev.ts b/packages/next/src/server/lib/router-utils/setup-dev.ts index 3584023884ebf..396508617d2d5 100644 --- a/packages/next/src/server/lib/router-utils/setup-dev.ts +++ b/packages/next/src/server/lib/router-utils/setup-dev.ts @@ -1,10 +1,13 @@ import type { NextConfigComplete } from '../../config-shared' -import type { +import { Endpoint, Route, TurbopackResult, WrittenEndpoint, + ServerClientChangeType, } from '../../../build/swc' +import type { Socket } from 'net' +import ws from 'next/dist/compiled/ws' import fs from 'fs' import url from 'url' @@ -91,6 +94,8 @@ import type { RenderWorkers } from '../router-server' import { pathToRegexp } from 'next/dist/compiled/path-to-regexp' import { NextJsHotReloaderInterface } from '../../dev/hot-reloader-types' +const wsServer = new ws.Server({ noServer: true }) + type SetupOpts = { renderWorkers: RenderWorkers dir: string @@ -181,6 +186,8 @@ async function startWatcher(opts: SetupOpts) { }) const iter = project.entrypointsSubscribe() const curEntries: Map = new Map() + let changeSubscriptions: Map> = new Map() + let prevMiddleware: boolean | undefined = undefined const globalEntries: { app: Endpoint | undefined document: Endpoint | undefined @@ -283,6 +290,11 @@ async function startWatcher(opts: SetupOpts) { const pagesManifests = new Map() const appPathsManifests = new Map() const middlewareManifests = new Map() + const clientToHmrSubscription = new Map< + ws, + Map> + >() + const clients = new Set() async function loadMiddlewareManifest( pageName: string, @@ -328,6 +340,25 @@ async function startWatcher(opts: SetupOpts) { ) } + async function changeSubscription( + page: string, + endpoint: Endpoint | undefined, + makePayload: ( + page: string, + change: ServerClientChangeType + ) => object | void + ) { + if (!endpoint || changeSubscriptions.has(page)) return + + const changed = await endpoint.changed() + changeSubscriptions.set(page, changed) + + for await (const change of changed) { + const payload = makePayload(page, change.change) + if (payload) hotReloader.send(payload) + } + } + try { async function handleEntries() { for await (const entrypoints of iter) { @@ -358,10 +389,35 @@ async function startWatcher(opts: SetupOpts) { } } - if (entrypoints.middleware) { + for (const [pathname, subscription] of changeSubscriptions) { + if (pathname === '') { + // middleware is handled below + continue + } + + if (!curEntries.has(pathname)) { + subscription.return?.() + changeSubscriptions.delete(pathname) + } + } + + const { middleware } = entrypoints + // We check for explicit true/false, since it's initialized to + // undefined during the first loop (middlewareChanges event is + // unnecessary during the first serve) + if (prevMiddleware === true && !middleware) { + // Went from middleware to no middleware + hotReloader.send({ event: 'middlewareChanges' }) + changeSubscriptions.get('')?.return?.() + changeSubscriptions.delete('') + } else if (prevMiddleware === false && middleware) { + // Went from no middleware to middleware + hotReloader.send({ event: 'middlewareChanges' }) + } + if (middleware) { await processResult( 'middleware', - await entrypoints.middleware.endpoint.writeToDisk() + await middleware.endpoint.writeToDisk() ) await loadMiddlewareManifest('middleware', 'middleware') serverFields.actualMiddlewareFile = 'middleware' @@ -371,10 +427,16 @@ async function startWatcher(opts: SetupOpts) { matchers: middlewareManifests.get('middleware')?.middleware['/'].matchers, } + + changeSubscription('', middleware.endpoint, () => { + return { event: 'middlewareChanges' } + }) + prevMiddleware = true } else { middlewareManifests.delete('middleware') serverFields.actualMiddlewareFile = undefined serverFields.middleware = undefined + prevMiddleware = false } await propagateToWorkers( 'actualMiddlewareFile', @@ -588,6 +650,27 @@ async function startWatcher(opts: SetupOpts) { ) } + async function subscribeToHmrEvents(id: string, client: ws) { + let mapping = clientToHmrSubscription.get(client) + if (mapping === undefined) { + mapping = new Map() + clientToHmrSubscription.set(client, mapping) + } + if (mapping.has(id)) return + + const subscription = project.hmrEvents(id) + mapping.set(id, subscription) + for await (const data of subscription) { + hotReloader.send({ type: 'turbopack-message', data }) + } + } + + function unsubscribeToHmrEvents(id: string, client: ws) { + const mapping = clientToHmrSubscription.get(client) + const subscription = mapping?.get(id) + subscription?.return!() + } + // Write empty manifests await mkdir(path.join(distDir, 'server'), { recursive: true }) await mkdir(path.join(distDir, 'static/development'), { recursive: true }) @@ -635,6 +718,80 @@ async function startWatcher(opts: SetupOpts) { return { finished: undefined } }, + onHMR(req: IncomingMessage, socket: Socket, head: Buffer) { + wsServer.handleUpgrade(req, socket, head, (client) => { + clients.add(client) + client.on('close', () => clients.delete(client)) + + // server sends: + // - Middleware HMR: + // - { action: 'building' } + // - { action: 'sync', hash, errors, warnings, versionInfo } + // - { action: 'built', hash } + + client.addEventListener('message', ({ data }) => { + const parsedData = JSON.parse( + typeof data !== 'string' ? data.toString() : data + ) + + // Next.js messages + switch (parsedData.event) { + case 'ping': { + // const result = parsedData.appDirRoute + // ? handleAppDirPing(parsedData.tree) + // : handlePing(parsedData.page) + const result = { success: true } + hotReloader.send({ + ...result, + [parsedData.appDirRoute ? 'action' : 'event']: 'pong', + }) + break + } + + case 'client-error': // { errorCount, clientId } + case 'client-warning': // { warningCount, clientId } + case 'client-success': // { clientId } + case 'server-component-reload-page': // { clientId } + case 'client-reload-page': // { clientId } + case 'client-full-reload': // { stackTrace, hadRuntimeError } + // TODO + break + + default: + // Might be a Turbopack message... + if (!parsedData.type) { + throw new Error(`unrecognized HMR message "${data}"`) + } + } + + // Turbopack messages + switch (parsedData.type) { + case 'turbopack-subscribe': + subscribeToHmrEvents(parsedData.path, client) + break + + case 'turbopack-unsubscribe': + unsubscribeToHmrEvents(parsedData.path, client) + break + + default: + throw new Error(`unrecognized Turbopack HMR message "${data}"`) + } + }) + + client.send(JSON.stringify({ type: 'turbopack-connected' })) + }) + }, + + send(action: string | object, ...data: any[]) { + const payload = JSON.stringify( + typeof action === 'string' ? { action, data } : action + ) + for (const client of clients) { + client.send(payload) + } + }, + setHmrServerError(_error) { // Not implemented yet. }, @@ -647,15 +804,9 @@ async function startWatcher(opts: SetupOpts) { async stop() { // Not implemented yet. }, - send(_action, ..._args) { - // Not implemented yet. - }, async getCompilationErrors(_page) { return [] }, - onHMR(_req, _socket, _head) { - // Not implemented yet. - }, invalidate(/* Unused parameter: { reloadAfterInvalidation } */) { // Not implemented yet. }, @@ -681,6 +832,9 @@ async function startWatcher(opts: SetupOpts) { '_document', await globalEntries.document?.writeToDisk() ) + changeSubscription('_document', globalEntries?.document, () => { + return { action: 'reloadPage' } + }) await loadPagesManifest('_document') await processResult(page, await globalEntries.error?.writeToDisk()) @@ -723,12 +877,23 @@ async function startWatcher(opts: SetupOpts) { '_document', await globalEntries.document?.writeToDisk() ) + changeSubscription('_document', globalEntries?.document, () => { + return { action: 'reloadPage' } + }) await loadPagesManifest('_document') const writtenEndpoint = await processResult( page, await route.htmlEndpoint.writeToDisk() ) + changeSubscription(page, route.htmlEndpoint, (pageName, change) => { + switch (change) { + case ServerClientChangeType.Server: + case ServerClientChangeType.Both: + return { event: 'serverOnlyChanges', pages: [pageName] } + default: + } + }) const type = writtenEndpoint?.type @@ -774,6 +939,14 @@ async function startWatcher(opts: SetupOpts) { } case 'app-page': { await processResult(page, await route.htmlEndpoint.writeToDisk()) + changeSubscription(page, route.htmlEndpoint, (_page, change) => { + switch (change) { + case ServerClientChangeType.Server: + case ServerClientChangeType.Both: + return { action: 'serverComponentChanges' } + default: + } + }) await loadAppBuildManifest(page) await loadBuildManifest(page, 'app') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b69c0a14a2c14..999f835590554 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1057,6 +1057,9 @@ importers: '@vercel/nft': specifier: 0.22.6 version: 0.22.6 + '@vercel/turbopack-ecmascript-runtime': + specifier: https://gitpkg-fork.vercel.sh/vercel/turbo/crates/turbopack-ecmascript-runtime/js?turbopack-230829.2 + version: '@gitpkg-fork.vercel.sh/vercel/turbo/crates/turbopack-ecmascript-runtime/js?turbopack-230829.2(react-refresh@0.12.0)(webpack@5.86.0)' acorn: specifier: 8.5.0 version: 8.5.0 @@ -6867,7 +6870,6 @@ packages: dependencies: react-refresh: 0.12.0 webpack: 5.86.0(@swc/core@1.3.55) - dev: false /@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3: resolution: {integrity: sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==} @@ -27196,7 +27198,6 @@ packages: transitivePeerDependencies: - react-refresh - webpack - dev: false '@gitpkg-fork.vercel.sh/vercel/turbo/crates/turbopack-node/js?turbopack-230829.2': resolution: {tarball: https://gitpkg-fork.vercel.sh/vercel/turbo/crates/turbopack-node/js?turbopack-230829.2}