diff --git a/client/WebSocketMessages.ts b/client/WebSocketMessages.ts index 7668142d..3044b602 100644 --- a/client/WebSocketMessages.ts +++ b/client/WebSocketMessages.ts @@ -1,5 +1,6 @@ import * as Codec from "tiny-decoders"; +import { NonEmptyArray } from "../src/NonEmptyArray"; import { AbsolutePath, BrowserUiPosition, CompilationMode } from "../src/Types"; const nonNegativeIntCodec = Codec.flatMap(Codec.number, { @@ -114,6 +115,13 @@ export const WebSocketToClientMessage = Codec.taggedUnion("tag", [ tag: Codec.tag("OpenEditorFailed"), error: OpenEditorError, }, + { + tag: Codec.tag("StaticFilesChanged"), + changedFileUrlPaths: NonEmptyArray(Codec.string), + }, + { + tag: Codec.tag("StaticFilesMayHaveChangedWhileDisconnected"), + }, { tag: Codec.tag("StatusChanged"), status: StatusChange, diff --git a/client/client.ts b/client/client.ts index acff2f0d..b856ada9 100644 --- a/client/client.ts +++ b/client/client.ts @@ -1,6 +1,7 @@ import * as Codec from "tiny-decoders"; import { formatDate, formatTime } from "../src/Helpers"; +import { NonEmptyArray } from "../src/NonEmptyArray"; import { runTeaProgram } from "../src/TeaProgram"; import { AbsolutePath, @@ -9,6 +10,7 @@ import { CompilationModeWithProxy, GetNow, } from "../src/Types"; +import { reloadAllCssIfNeeded } from "./css"; import { decodeWebSocketToClientMessage, ErrorLocation, @@ -30,6 +32,9 @@ const HAS_WINDOW = window.window !== undefined; type __ELM_WATCH = { MOCKED_TIMINGS: boolean; WEBSOCKET_TIMEOUT: number; + CHANGED_CSS: Date; + CHANGED_FILE_URL_PATHS: { timestamp: Date; changed: Set }; + ORIGINAL_STYLES: WeakMap; RELOAD_STATUSES: Map; RELOAD_PAGE: (message: string | undefined) => void; TARGET_DATA: Map; @@ -181,6 +186,12 @@ const DEFAULT_ELM_WATCH: __ELM_WATCH = { // Do nothing. }, + CHANGED_CSS: new Date(0), + + CHANGED_FILE_URL_PATHS: { timestamp: new Date(0), changed: new Set() }, + + ORIGINAL_STYLES: new WeakMap(), + RELOAD_STATUSES: new Map(), RELOAD_PAGE: (message) => { @@ -275,13 +286,19 @@ const ORIGINAL_COMPILATION_MODE = // as things change. const ORIGINAL_BROWSER_UI_POSITION = "%ORIGINAL_BROWSER_UI_POSITION%" as BrowserUiPosition; -const WEBSOCKET_PORT = "%WEBSOCKET_PORT%"; +const WEBSOCKET_CONNECTION = "%WEBSOCKET_CONNECTION%"; const CONTAINER_ID = "elm-watch"; const DEBUG = String("%DEBUG%") === "true"; +// Public events: +const ELM_WATCH_CHANGED_FILE_URL_PATHS_EVENT = + "elm-watch:changed-file-url-paths"; +// Internal events: const BROWSER_UI_MOVED_EVENT = "BROWSER_UI_MOVED_EVENT"; const CLOSE_ALL_ERROR_OVERLAYS_EVENT = "CLOSE_ALL_ERROR_OVERLAYS_EVENT"; +const ELM_WATCH_CHANGED_FILE_URL_BATCH_TIME = 10; + // A compilation after moving the browser UI on a big app takes around 700 ms // for me. So more than double that should be plenty. const JUST_CHANGED_BROWSER_UI_POSITION_TIMEOUT = 2000; @@ -324,6 +341,10 @@ type Msg = tag: "PageVisibilityChangedToVisible"; date: Date; } + | { + tag: "ReloadAllCssDone"; + didChange: boolean; + } | { tag: "SleepBeforeReconnectDone"; date: Date; @@ -378,7 +399,6 @@ type UiMsg = type Model = { status: Status; - previousStatusTag: Status["tag"]; compilationMode: CompilationModeWithProxy; browserUiPosition: BrowserUiPosition; lastBrowserUiPositionChangeDate: Date | undefined; @@ -392,6 +412,14 @@ type Cmd = tag: "Eval"; code: string; } + | { + tag: "Flash"; + flashType: FlashType; + } + | { + tag: "HandleStaticFilesChanged"; + changedFileUrlPaths: NonEmptyArray | "AnyFileMayHaveChanged"; + } | { tag: "NoCmd"; } @@ -533,6 +561,19 @@ function BrowserUiPositionWithFallback(value: unknown): BrowserUiPosition { } } +// Removes the information comment added by SimpleStaticFileServer.ts +// so that it does not take space in the element inspector (it is only +// supposed to be read when viewing the source). +function removeElmWatchIndexHtmlComment(): void { + const node = document.firstChild; + if ( + node instanceof Comment && + node.data.trimStart().startsWith("elm-watch debug information:") + ) { + node.remove(); + } +} + function run(): void { let elmCompiledTimestampBeforeReload: number | undefined = undefined; try { @@ -562,6 +603,10 @@ function run(): void { : BrowserUiPositionWithFallback(elements.container.dataset["position"]); const getNow: GetNow = () => new Date(); + if (HAS_WINDOW) { + removeElmWatchIndexHtmlComment(); + } + runTeaProgram({ initMutable: initMutable(getNow, elements), init: init(getNow(), browserUiPosition, elmCompiledTimestampBeforeReload), @@ -576,12 +621,26 @@ function run(): void { const newModel: Model = modelChanged ? { ...updatedModel, - previousStatusTag: model.status.tag, uiExpanded: reloadTrouble ? true : updatedModel.uiExpanded, } : model; const oldErrorOverlay = getErrorOverlay(model.status); const newErrorOverlay = getErrorOverlay(newModel.status); + const statusType = statusToStatusType(newModel.status.tag); + const statusTypeChanged = + statusType !== statusToStatusType(model.status.tag); + const statusFlashType = getStatusFlashType({ + statusType, + statusTypeChanged, + hasReceivedHotReload: + newModel.elmCompiledTimestamp !== INITIAL_ELM_COMPILED_TIMESTAMP, + uiRelatedUpdate: msg.tag === "UiMsg", + errorOverlayVisible: elements !== undefined && !elements.overlay.hidden, + }); + const flashCmd: Array = + statusFlashType === undefined || cmds.some((cmd) => cmd.tag === "Flash") + ? [] + : [{ tag: "Flash", flashType: statusFlashType }]; const allCmds: Array = modelChanged ? [ ...cmds, @@ -592,7 +651,7 @@ function run(): void { }, // This needs to be done before Render, since it depends on whether // the error overlay is visible or not. - newModel.status.tag === newModel.previousStatusTag && + newModel.status.tag === model.status.tag && oldErrorOverlay?.openErrorOverlay === newErrorOverlay?.openErrorOverlay ? { tag: "NoCmd" } @@ -606,11 +665,17 @@ function run(): void { : newErrorOverlay.errors, sendKey: statusToSpecialCaseSendKey(newModel.status), }, - { - tag: "Render", - model: newModel, - manageFocus: msg.tag === "UiMsg", - }, + ...(elements !== undefined || + newModel.status.tag !== model.status.tag + ? [ + { + tag: "Render", + model: newModel, + manageFocus: msg.tag === "UiMsg", + } as const, + ] + : []), + ...flashCmd, model.browserUiPosition === newModel.browserUiPosition ? { tag: "NoCmd" } : { @@ -621,7 +686,7 @@ function run(): void { ? { tag: "TriggerReachedIdleState", reason: "ReloadTrouble" } : { tag: "NoCmd" }, ] - : cmds; + : [...cmds, ...flashCmd]; logDebug(`${msg.tag} (${TARGET_NAME})`, msg, newModel, allCmds); return [newModel, allCmds]; }, @@ -1193,7 +1258,11 @@ function initWebSocket( : window.location.hostname, window.location.protocol === "https:" ? "wss" : "ws", ]; - const url = new URL(`${protocol}://${hostname}:${WEBSOCKET_PORT}/elm-watch`); + const url = new URL( + /^\d+$/.test(WEBSOCKET_CONNECTION) + ? `${protocol}://${hostname}:${WEBSOCKET_CONNECTION}/elm-watch` + : WEBSOCKET_CONNECTION, + ); url.searchParams.set("elmWatchVersion", VERSION); url.searchParams.set("webSocketToken", WEBSOCKET_TOKEN); url.searchParams.set("targetName", TARGET_NAME); @@ -1239,7 +1308,6 @@ const init = ( ): [Model, Array] => { const model: Model = { status: { tag: "Connecting", date, attemptNumber: 1 }, - previousStatusTag: "Idle", compilationMode: ORIGINAL_COMPILATION_MODE, browserUiPosition, lastBrowserUiPositionChangeDate: undefined, @@ -1308,14 +1376,17 @@ function update(msg: Msg, model: Model): [Model, Array] { case "FocusedTab": return [ - // Force a re-render for the “Error” status type, so that the animation plays again. - statusToStatusType(model.status.tag) === "Error" ? { ...model } : model, - // Send these commands regardless of current status: We want to prioritize the target - // due to the focus no matter what, and after waking up on iOS we need to check the - // WebSocket connection no matter what as well. For example, it’s possible to lock - // the phone while Busy, and then we miss the “done” message, which makes us still - // have the Busy status when unlocking the phone. + model, [ + // Replay the error animation. + ...(statusToStatusType(model.status.tag) === "Error" + ? [{ tag: "Flash", flashType: "error" } as const] + : []), + // Send these commands regardless of current status: We want to prioritize the target + // due to the focus no matter what, and after waking up on iOS we need to check the + // WebSocket connection no matter what as well. For example, it’s possible to lock + // the phone while Busy, and then we miss the “done” message, which makes us still + // have the Busy status when unlocking the phone. { tag: "SendMessage", message: { tag: "FocusedTab" }, @@ -1330,6 +1401,12 @@ function update(msg: Msg, model: Model): [Model, Array] { case "PageVisibilityChangedToVisible": return reconnect(model, msg.date, { force: true }); + case "ReloadAllCssDone": + return [ + model, + msg.didChange ? [{ tag: "Flash", flashType: "success" }] : [], + ]; + case "SleepBeforeReconnectDone": return reconnect(model, msg.date, { force: false }); @@ -1518,6 +1595,28 @@ function onWebSocketToClientMessage( ], ]; + case "StaticFilesChanged": + return [ + { ...model, status: { ...model.status, date } }, + [ + { + tag: "HandleStaticFilesChanged", + changedFileUrlPaths: msg.changedFileUrlPaths, + }, + ], + ]; + + case "StaticFilesMayHaveChangedWhileDisconnected": + return [ + { ...model, status: { ...model.status, date } }, + [ + { + tag: "HandleStaticFilesChanged", + changedFileUrlPaths: "AnyFileMayHaveChanged", + }, + ], + ]; + case "StatusChanged": return statusChanged(date, msg.status, model); @@ -1749,6 +1848,72 @@ const runCmd = }); return; + case "Flash": + if (elements !== undefined) { + flash(elements, cmd.flashType); + } + return; + + case "HandleStaticFilesChanged": { + const now = getNow(); + let shouldReloadCss = false; + if (cmd.changedFileUrlPaths === "AnyFileMayHaveChanged") { + shouldReloadCss = true; + } else { + if ( + now.getTime() - + __ELM_WATCH.CHANGED_FILE_URL_PATHS.timestamp.getTime() > + ELM_WATCH_CHANGED_FILE_URL_BATCH_TIME + ) { + __ELM_WATCH.CHANGED_FILE_URL_PATHS = { + timestamp: now, + changed: new Set(), + }; + } + + const justChangedFileUrlPaths = new Set(); + + for (const path of cmd.changedFileUrlPaths) { + if (path.toLowerCase().endsWith(".css")) { + shouldReloadCss = true; + } else if (!__ELM_WATCH.CHANGED_FILE_URL_PATHS.changed.has(path)) { + justChangedFileUrlPaths.add(path); + } + } + + // There might be several targets on the page, all receiving the same + // changed file url paths. This fires the event only once per batch. + // (Every target most likely gets the same paths.) + if (justChangedFileUrlPaths.size > 0) { + for (const path of justChangedFileUrlPaths) { + __ELM_WATCH.CHANGED_FILE_URL_PATHS.changed.add(path); + } + window.dispatchEvent( + new CustomEvent(ELM_WATCH_CHANGED_FILE_URL_PATHS_EVENT, { + detail: justChangedFileUrlPaths, + }), + ); + } + } + + // Same thing here: Every target does not need to reload CSS. + if ( + shouldReloadCss && + now.getTime() - __ELM_WATCH.CHANGED_CSS.getTime() > + ELM_WATCH_CHANGED_FILE_URL_BATCH_TIME && + HAS_WINDOW + ) { + __ELM_WATCH.CHANGED_CSS = now; + reloadAllCssIfNeeded(__ELM_WATCH.ORIGINAL_STYLES) + .then((didChange) => { + dispatch({ tag: "ReloadAllCssDone", didChange }); + }) + .catch(rejectPromise); + } + + return; + } + case "NoCmd": return; @@ -1768,23 +1933,19 @@ const runCmd = targetName: TARGET_NAME, originalCompilationMode: ORIGINAL_COMPILATION_MODE, debugModeToggled: getDebugModeToggled(), - errorOverlayVisible: - elements !== undefined && !elements.overlay.hidden, }; if (elements === undefined) { - if (model.status.tag !== model.previousStatusTag) { - const isError = statusToStatusType(model.status.tag) === "Error"; - const isWebWorker = - typeof (window as unknown as { WorkerGlobalScope: unknown }) - .WorkerGlobalScope !== "undefined" && !isError; - const consoleMethodName = - // `console.info` looks nicer in the browser console for Web Workers. - // On Node.js, we want to always print to stderr. - isError || !isWebWorker ? "error" : "info"; - // eslint-disable-next-line no-console - const consoleMethod = console[consoleMethodName]; - consoleMethod(renderWithoutDomElements(model, info)); - } + const isError = statusToStatusType(model.status.tag) === "Error"; + const isWebWorker = + typeof (window as unknown as { WorkerGlobalScope: unknown }) + .WorkerGlobalScope !== "undefined" && !isError; + const consoleMethodName = + // `console.info` looks nicer in the browser console for Web Workers. + // On Node.js, we want to always print to stderr. + isError || !isWebWorker ? "error" : "info"; + // eslint-disable-next-line no-console + const consoleMethod = console[consoleMethodName]; + consoleMethod(renderWithoutDomElements(model, info)); } else { const { targetRoot } = elements; render(getNow, targetRoot, dispatch, model, info, cmd.manageFocus); @@ -2258,7 +2419,6 @@ type Info = { targetName: string; originalCompilationMode: CompilationModeWithProxy; debugModeToggled: Toggled; - errorOverlayVisible: boolean; }; function renderWithoutDomElements(model: Model, info: Info): string { @@ -2283,7 +2443,6 @@ function render( }, model, info, - manageFocus, ), ); @@ -2306,8 +2465,7 @@ const CLASS = { errorLocationButton: "errorLocationButton", errorTitle: "errorTitle", expandedUiContainer: "expandedUiContainer", - flashError: "flashError", - flashSuccess: "flashSuccess", + flash: "flash", overlay: "overlay", overlayCloseButton: "overlayCloseButton", root: "root", @@ -2317,7 +2475,7 @@ const CLASS = { targetRoot: "targetRoot", }; -function getStatusClass({ +function getStatusFlashType({ statusType, statusTypeChanged, hasReceivedHotReload, @@ -2329,25 +2487,33 @@ function getStatusClass({ hasReceivedHotReload: boolean; uiRelatedUpdate: boolean; errorOverlayVisible: boolean; -}): string | undefined { +}): FlashType | undefined { switch (statusType) { case "Success": - return statusTypeChanged && hasReceivedHotReload - ? CLASS.flashSuccess - : undefined; + return statusTypeChanged && hasReceivedHotReload ? "success" : undefined; case "Error": return errorOverlayVisible ? statusTypeChanged && hasReceivedHotReload - ? CLASS.flashError + ? "error" : undefined : uiRelatedUpdate ? undefined - : CLASS.flashError; + : "error"; case "Waiting": return undefined; } } +type FlashType = "error" | "success"; + +function flash(elements: Elements, flashType: FlashType): void { + for (const element of elements.targetRoot.querySelectorAll( + `.${CLASS.flash}`, + )) { + element.setAttribute("data-flash", flashType); + } +} + const CHEVRON_UP = "▲"; const CHEVRON_DOWN = "▼"; @@ -2517,6 +2683,7 @@ details[open] > summary > .${CLASS.errorTitle}::before { } .${CLASS.root} { + all: initial; --grey: #767676; display: flex; align-items: start; @@ -2648,8 +2815,7 @@ details[open] > summary > .${CLASS.errorTitle}::before { gap: 0.25em; } -.${CLASS.flashError}::before, -.${CLASS.flashSuccess}::before { +[data-flash]::before { content: ""; position: absolute; margin-top: 0.5em; @@ -2662,11 +2828,11 @@ details[open] > summary > .${CLASS.errorTitle}::before { pointer-events: none; } -.${CLASS.flashError}::before { +[data-flash="error"]::before { background-color: #eb0000; } -.${CLASS.flashSuccess}::before { +[data-flash="success"]::before { background-color: #00b600; } @@ -2693,8 +2859,7 @@ details[open] > summary > .${CLASS.errorTitle}::before { } @media (prefers-reduced-motion: reduce) { - .${CLASS.flashError}::before, - .${CLASS.flashSuccess}::before { + [data-flash]::before { transform: translate(-50%, -50%); width: 2em; height: 2em; @@ -2716,7 +2881,6 @@ function view( dispatch: (msg: UiMsg) => void, passedModel: Model, info: Info, - manageFocus: boolean, ): HTMLElement { const model: Model = __ELM_WATCH.MOCKED_TIMINGS ? { @@ -2733,19 +2897,6 @@ function view( ...viewStatus(dispatch, model, info), }; - const statusType = statusToStatusType(model.status.tag); - const statusTypeChanged = - statusType !== statusToStatusType(model.previousStatusTag); - - const statusClass = getStatusClass({ - statusType, - statusTypeChanged, - hasReceivedHotReload: - model.elmCompiledTimestamp !== INITIAL_ELM_COMPILED_TIMESTAMP, - uiRelatedUpdate: manageFocus, - errorOverlayVisible: info.errorOverlayVisible, - }); - return h( HTMLDivElement, { className: CLASS.container }, @@ -2779,24 +2930,18 @@ function view( ), ), compilationModeIcon(model.compilationMode), - icon( - statusData.icon, - statusData.status, - statusClass === undefined - ? {} - : { - className: statusClass, - onanimationend: (event) => { - // The animations are designed to work even without this (they - // stay on the last frame). We also have `pointer-events: none`. - // But remove the absolutely positioned animation element just - // in case. - if (event.currentTarget instanceof HTMLElement) { - event.currentTarget.classList.remove(statusClass); - } - }, - }, - ), + icon(statusData.icon, statusData.status, { + className: CLASS.flash, + onanimationend: (event) => { + // The animations are designed to work even without this (they + // stay on the last frame). We also have `pointer-events: none`. + // But remove the absolutely positioned animation element just + // in case. + if (event.currentTarget instanceof HTMLElement) { + event.currentTarget.removeAttribute("data-flash"); + } + }, + }), h( HTMLTimeElement, { dateTime: model.status.date.toISOString() }, @@ -3268,7 +3413,7 @@ function printWebSocketUrl(url: URL): string { const hostname = url.hostname.endsWith(".localhost") ? "localhost" : url.hostname; - return `${url.protocol}//${hostname}:${url.port}`; + return `${url.protocol}//${hostname}:${url.port}${url.pathname}`; } function viewHttpsInfo(webSocketUrl: URL): Array { @@ -3279,20 +3424,11 @@ function viewHttpsInfo(webSocketUrl: URL): Array { {}, h(HTMLElement, { localName: "strong" }, "Having trouble connecting?"), ), + h(HTMLParagraphElement, {}, "Setting up HTTPS can be a bit tricky."), h( HTMLParagraphElement, {}, - " You might need to ", - h( - HTMLAnchorElement, - { href: new URL(`https://${webSocketUrl.host}/accept`).href }, - "accept elm-watch’s self-signed certificate", - ), - ". ", - ), - h( - HTMLParagraphElement, - {}, + "Read all about ", h( HTMLAnchorElement, { @@ -3300,7 +3436,7 @@ function viewHttpsInfo(webSocketUrl: URL): Array { target: "_blank", rel: "noreferrer", }, - "More information", + "HTTPS with elm-watch", ), ".", ), @@ -3660,7 +3796,6 @@ function renderMockStatuses( webSocketUrl: new URL("ws://localhost:53167"), originalCompilationMode: "standard", debugModeToggled: { tag: "Enabled" }, - errorOverlayVisible: false, }; const mockStatuses: Record< @@ -3844,7 +3979,6 @@ Maybe the JavaScript code running in the browser was compiled with an older vers elements.root.append(targetRoot); const model: Model = { status, - previousStatusTag: status.tag, compilationMode: status.compilationMode ?? "standard", browserUiPosition: "BottomLeft", lastBrowserUiPositionChangeDate: undefined, diff --git a/client/css.ts b/client/css.ts new file mode 100644 index 00000000..c5028b96 --- /dev/null +++ b/client/css.ts @@ -0,0 +1,241 @@ +export async function reloadAllCssIfNeeded( + originalStyles: WeakMap, +): Promise { + const results = await Promise.allSettled( + Array.from(document.styleSheets, (styleSheet) => + reloadCssIfNeeded(originalStyles, styleSheet), + ), + ); + return results.some( + (result) => result.status === "fulfilled" && result.value, + ); +} + +async function reloadCssIfNeeded( + originalStyles: WeakMap, + styleSheet: CSSStyleSheet, +): Promise { + if (styleSheet.href === null) { + return false; + } + + const url = makeUrl(styleSheet.href); + if (url === undefined || url.host !== window.location.host) { + return false; + } + + const response = await fetch(url, { cache: "reload" }); + if (!response.ok) { + return false; + } + + const newCss = await response.text(); + + if (isFirefox() && /@import\b/i.test(newCss)) { + // eslint-disable-next-line no-console + console.warn( + "elm-watch: In Firefox, @import:ed CSS files are not hot reloaded due to over eager caching by Firefox. Style sheet:", + url.href, + ); + } + + const importUrls = isFirefox() ? [] : getAllCssImports(url, styleSheet); + await Promise.allSettled( + importUrls.map((importUrl) => fetch(importUrl, { cache: "reload" })), + ); + const newStyleSheet = await parseCssWithImports(url, newCss); + + return newStyleSheet === undefined + ? false + : updateStyleSheetIfNeeded(originalStyles, styleSheet, newStyleSheet); +} + +// Note: It might seem possible to parse using: +// `const styleSheet = new CSSStyleSheet(); styleSheet.replaceSync(css);` +// However, that does not support `@import`: +// https://github.com/WICG/construct-stylesheets/issues/119#issuecomment-588362382 +// Also, at the time of writing, Safari did not support constructing +// `CSSStyleSheet`. +async function parseCssWithImports( + styleSheetUrl: URL, + css: string, +): Promise { + return new Promise((resolve) => { + const style = document.createElement("style"); + style.media = "print"; + style.textContent = css; + + // Set the base URL for relative URL:s to the URL of the style sheet. + // Otherwise `@import`:s will be relative to the URL of the page instead. + const base = document.createElement("base"); + base.href = styleSheetUrl.href; + + // The "load" event fires when all the CSS has been parsed, including + // `@import`s. Chrome and Safari make `style.sheet` available immediately, + // with `.styleSheet` on `@import` rules set to `null` until it loads, + // while Firefox does not make `style.sheet` available until the "load" + // event. Chrome always fires the "load" event, even if an `@import` fails, + // while Safari fires the "error" event instead. + style.onerror = style.onload = () => { + resolve(style.sheet ?? undefined); + base.remove(); + style.remove(); + }; + + // We use `prepend` instead of `append` here because the first `` element + // in the document seems to win. + document.head.prepend(base, style); + }); +} + +function makeUrl(urlString: string, base?: URL): URL | undefined { + try { + return new URL(urlString, base); + } catch { + return undefined; + } +} + +function getAllCssImports( + styleSheetUrl: URL, + styleSheet: CSSStyleSheet, +): Array { + return Array.from(styleSheet.cssRules).flatMap((rule) => { + if (rule instanceof CSSImportRule && rule.styleSheet !== null) { + const url = makeUrl(rule.href, styleSheetUrl); + if (url !== undefined && url.host === styleSheetUrl.host) { + return [url, ...getAllCssImports(url, rule.styleSheet)]; + } + } + return []; + }); +} + +/** + * This function does nothing if the CSS is unchanged. That’s important because + * reloading the whole `` tag can cause a flash of unstyled content, and + * reset any temporary changes you have done in the inspector. This is run when + * _any_ CSS file changes – which might not even be related to this page – or + * on `visibilitychange` which includes switching between tabs. + * The “diffing” algorithm is very simple: For identical CSS it does nothing. + * For a single changed rule (very common), only that rule is updated. In + * other cases it might replace more rules than strictly needed but it doesn't + * matter. + */ +function updateStyleSheetIfNeeded( + originalStyles: WeakMap, + oldStyleSheet: Pick, + newStyleSheet: Pick, +): boolean { + let changed = false; + const length = Math.min( + oldStyleSheet.cssRules.length, + newStyleSheet.cssRules.length, + ); + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + let index = 0; + for (; index < length; index++) { + const oldRule = oldStyleSheet.cssRules[index]!; + const newRule = newStyleSheet.cssRules[index]!; + if (oldRule instanceof CSSStyleRule && newRule instanceof CSSStyleRule) { + if (oldRule.selectorText !== newRule.selectorText) { + oldRule.selectorText = newRule.selectorText; + changed = true; + } + let originals = originalStyles.get(oldRule); + if (originals === undefined) { + originals = oldRule.style.cssText; + originalStyles.set(oldRule, originals); + } + // We compare the original CSS, not the current CSS, because the current + // CSS might have been changed by the user in the devtools. + if (originals !== newRule.style.cssText) { + oldStyleSheet.deleteRule(index); + oldStyleSheet.insertRule(newRule.cssText, index); + originalStyles.set( + oldStyleSheet.cssRules[index] as CSSStyleRule, + newRule.style.cssText, + ); + changed = true; + } else { + const nestedChanged = updateStyleSheetIfNeeded( + originalStyles, + oldRule, + newRule, + ); + if (nestedChanged) { + changed = true; + // Workaround for Chrome: Nested rules are not updated otherwise. + oldRule.selectorText = oldRule.selectorText; + } + } + } else if ( + oldRule instanceof CSSImportRule && + newRule instanceof CSSImportRule && + oldRule.cssText === newRule.cssText && + // Exclude Firefox since imported style sheets often returned old, cached versions. + !isFirefox() + ) { + const nestedChanged = + oldRule.styleSheet !== null && newRule.styleSheet !== null + ? updateStyleSheetIfNeeded( + originalStyles, + oldRule.styleSheet, + newRule.styleSheet, + ) + : !(oldRule.styleSheet === null && newRule.styleSheet === null); + if (nestedChanged) { + changed = true; + // Workaround for Chrome: Only the first update to the imported style + // sheet is reflected otherwise. + // @ts-expect-error TypeScript says `.media` is readonly, but it’s fine + // to set it. + oldRule.media = oldRule.media; + } + } else if ( + // @media, @supports and @container: + (oldRule instanceof CSSConditionRule && + newRule instanceof CSSConditionRule && + oldRule.conditionText === newRule.conditionText) || + // @layer: + (oldRule instanceof CSSLayerBlockRule && + newRule instanceof CSSLayerBlockRule && + oldRule.name === newRule.name) || + // @page: + (oldRule instanceof CSSPageRule && + newRule instanceof CSSPageRule && + oldRule.selectorText === newRule.selectorText) + ) { + const nestedChanged = updateStyleSheetIfNeeded( + originalStyles, + oldRule, + newRule, + ); + if (nestedChanged) { + changed = true; + } + // The fallback below works for any rule, but is more destructive. + } else if (oldRule.cssText !== newRule.cssText) { + oldStyleSheet.deleteRule(index); + oldStyleSheet.insertRule(newRule.cssText, index); + changed = true; + } + } + while (index < oldStyleSheet.cssRules.length) { + oldStyleSheet.deleteRule(index); + changed = true; + } + for (; index < newStyleSheet.cssRules.length; index++) { + const newRule = newStyleSheet.cssRules[index]!; + oldStyleSheet.insertRule(newRule.cssText, index); + changed = true; + } + /* eslint-enable @typescript-eslint/no-non-null-assertion */ + return changed; +} + +// Note: We can't use the user agent to detect Firefox, because this needs to work +// even when the responsive design mode is enabled (which also swaps the user agent). +function isFirefox(): boolean { + return typeof (window as { scrollMaxX?: number }).scrollMaxX === "number"; +} diff --git a/elm-tooling.json b/elm-tooling.json index 4974e01b..e97de009 100644 --- a/elm-tooling.json +++ b/elm-tooling.json @@ -1,5 +1,6 @@ { "tools": { - "elm": "0.19.1" + "elm": "0.19.1", + "elm-format": "0.8.5" } } diff --git a/example-minimal/README.md b/example-minimal/README.md index 8f61cb58..0e3e1a69 100644 --- a/example-minimal/README.md +++ b/example-minimal/README.md @@ -10,11 +10,11 @@ This is a minimal example of how to use elm-watch. 4. Run `cd example-minimal` to go into this folder. 5. Run `npm ci` to install dependencies for this example. 6. Run `npx elm-watch hot` or `npm start` to start elm-watch for development. -7. Open `index.html` in the browser. +7. The previous command prints a link to elm-watch’s server. Open it in a browser. 8. Edit `src/Main.elm` and watch the browser be automatically updated. 9. Stop `elm-watch`. 10. Run `npx elm-watch make --optimize` or `npm run build` to build for production. (Since this is a minimal example, there’s no minification, just Elm’s `--optimize` mode.) -11. Refresh `index.html` in the browser to try out the production build. +11. Double-click `index.html` to open it straight in a browser to try out the production build. The example uses [elm-tooling] to install Elm and elm-format, but you can of course install them in any way you want. diff --git a/example-minimal/elm-watch.json b/example-minimal/elm-watch.json index 2213d4c7..0f143a90 100644 --- a/example-minimal/elm-watch.json +++ b/example-minimal/elm-watch.json @@ -1,4 +1,5 @@ { + "serve": ".", "targets": { "Main": { "inputs": [ diff --git a/example-minimal/index.html b/example-minimal/index.html index 147cfcf8..f6e7fda3 100644 --- a/example-minimal/index.html +++ b/example-minimal/index.html @@ -4,6 +4,7 @@ example-minimal +
diff --git a/example-minimal/style.css b/example-minimal/style.css new file mode 100644 index 00000000..1c264b2b --- /dev/null +++ b/example-minimal/style.css @@ -0,0 +1,11 @@ +html { + display: flex; + height: 100%; + background-color: #edf; + color: black; +} + +body { + margin: auto; + font-family: system-ui; +} diff --git a/example/README.md b/example/README.md index 75186f4b..a48b1855 100644 --- a/example/README.md +++ b/example/README.md @@ -3,9 +3,9 @@ This is an example that uses: - elm-watch for Elm compilation -- [esbuild] for TypeScript compilation -- [SWC] for minification (`postprocess.js`) -- A little Node.js dev server for routing and proxying, with no dependencies (`dev-server.js`) +- [esbuild] for TypeScript compilation, and for minification +- [SWC] for extra minification ([postprocess.js](./postprocess.js)) +- A custom little Node.js dev server for routing and proxying, with no dependencies ([dev-server.js](./dev-server.js)) - [run-pty] to run the above with just one command - [elm-tooling] to install Elm and elm-format @@ -24,11 +24,11 @@ See also the [minimal elm-watch example][example-minimal]. 5. Run `git submodule update --init` to fetch some real-world Elm apps for demoing. 6. Run `npm ci` to install dependencies for this example. 7. Run `npm start` to start elm-watch, esbuild and the Node.js dev server for development, using run-pty. Alternatively, run `npm run start-advanced` for some extra run-pty goodness (see `run-pty.json` if you’re interested). -8. Visit some Elm app in the browser (see below): Go to http://localhost:8000 (the dev server, recommended), or http://localhost:9000 (raw esbuild server). +8. Visit some Elm app in the browser (see below for which apps are available): Go to http://localhost:8000 (which is the custom dev server). 9. Edit an Elm file in `src/` or `public/submodules/` and watch the browser be automatically updated. 10. Stop elm-watch, esbuild and the dev server. 11. Run `npm run build` to build for production, using elm-watch, esbuild and SWC. -12. Run `npm run try-production` to to try out the production build. It uses esbuild only for serving static files, and the Node.js dev server for proxying. It’s the same URLs as before: http://localhost:8000 and http://localhost:9000. (Note: This command is for trying out the production build locally, not something you’d actually run in production to serve the files.) +12. Run `npm run try-production` to to try out the production build. It uses esbuild only for serving static files, and the Node.js dev server for proxying. It’s the same URL as before: http://localhost:8000. (Note: This command is for trying out the production build locally, not something you’d actually run in production to serve the files.) This example has many Elm apps (many targets in `elm-watch.json`): diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 00000000..169619c7 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,39 @@ +import type * as http from "http"; +import type * as https from "https"; +import type * as stream from "stream"; + +type ReadStream = stream.Readable & { + isTTY: boolean; + isRaw: boolean; + setRawMode: (mode: boolean) => void; +}; + +type WriteStream = stream.Writable & { + isTTY: boolean; + columns?: number; +}; + +type CreateServer = (listeners: { + onRequest: http.RequestListener; + onUpgrade: ( + req: InstanceType, + socket: stream.Duplex, + head: Buffer, + ) => void; +}) => + | ReturnType + | ReturnType; + +declare function elmWatch( + args: Array, + options?: { + cwd?: string; + env?: Record; + stdin?: ReadStream; + stdout?: WriteStream; + stderr?: WriteStream; + createServer?: CreateServer; + }, +): Promise; + +export = elmWatch; diff --git a/package-real.json b/package-real.json index 4248c93d..648d3065 100644 --- a/package-real.json +++ b/package-real.json @@ -6,7 +6,9 @@ "description": "`elm make` in watch mode. Fast and reliable.", "repository": "lydell/elm-watch", "type": "commonjs", - "exports": {}, + "exports": { + ".": "./index.js" + }, "bin": "./index.js", "keywords": ["elm", "watch", "watcher"] } diff --git a/scripts/Build.ts b/scripts/Build.ts index 143b6404..9c6db64f 100644 --- a/scripts/Build.ts +++ b/scripts/Build.ts @@ -45,6 +45,7 @@ export const PROXY_SRC_PATH = path.join(DIR, "client", "proxy.js"); const FILES_TO_COPY: Array = [ { src: "LICENSE" }, + { src: "index.d.ts" }, { src: "elm-watch-node.d.ts" }, { src: "README.md", @@ -139,7 +140,10 @@ exports.proxy = fs.readFileSync(path.join(__dirname, "proxy.js"), "utf8"); switch (path.basename(output.path)) { case "index.js": { const replaced = output.text - .replace(/^[^]+\nmodule.exports = .+\s*/g, "") + .replace( + /^[^]+\nmodule.exports = .+\s*/g, + "module.exports = elmWatchCli;\n", + ) .replace(toModuleRegex, "$1") .replace(/%VERSION%/g, PACKAGE_REAL.version) .trim(); diff --git a/src/Certificate.ts b/src/Certificate.ts deleted file mode 100644 index a6790178..00000000 --- a/src/Certificate.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* -Self-signed SSL certificate. - -Not used for security; only to make `wss:` work on HTTPS pages. - -Created by running: - -openssl req \ - -newkey rsa:4096 \ - -x509 \ - -nodes \ - -keyout server.key \ - -new \ - -out server.crt \ - -subj /CN=test1 \ - -extensions v3_new \ - -config <(cat /System/Library/OpenSSL/openssl.cnf \ - <(printf '[v3_new]\nsubjectAltName=DNS:localhost\nextendedKeyUsage=serverAuth')) \ - -sha256 \ - -days 36500 - -Source: https://stackoverflow.com/a/64309893 -*/ -export const CERTIFICATE = { - key: `-----BEGIN PRIVATE KEY----- -MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQC012uZX87KEVJA -CjTlOoBX7mzfd/T9NxrBlOPhauhGrPyxZ2FmTjZSErtgS96UCUALHyaWIlfmGCtT -XcdZbLFCxNFNuK+kIpXktSO/ZLNnryUTpZFXparAUJpyfO0bjsfJr6jc21gRZFnN -C4pbB/YMDqtQr3o5AS5pqujncUOR0jBHb5EPqlAe4B8LcrB+Hza2wLwRpyGpouhK -Sg1AQDKNx8z0XwcCp1OCpgGniqwDr0QhGWZ4X+T2zivhHag2ZdbfuPYI3se9hl31 -EX9X5OXNVKi+CVOu0CdsrWVMVm665A8oi/pVr+m8xZdivbEmQwNRbYLpEAc1UcE2 -8yHu6PITcB9+i7bKtDMO4ULWFPO6xivXpQqXsA1og8D4yEDHrCzaD7iewPuxaAtT -bJONUMcoNobOD42K2gDs6ZYKzc2ci1GljsXfQ1iO9RSgxpmIwg7T+od5h6clZxwV -7L7YLvi06IXuroXiApXw4ZJa771Nt9xCcH42/AJ9Tka45B/nlF7rqcPPywsjR95t -cac77D8VJbZHj7oulvWdsks/elbs0Q6v0mkmxYu88lMaeXDR2DTaw1lyrF7H0zuY -MBgH/7JRqdQ59zh2iQ3GwAlS8eC4ACVOhMihWsBxNEsk2/WlgBByRDGDX0GTyEGH -yMjPtAi5qcsg7IolWwWqrz4Ik9JibQIDAQABAoICAFVlAA9N2ZU7tq4845t3E5Hy -KYEg4RQNSFovF6ijVgnBuBWBrtGjjy0UYVzolrMq5w4ZaJwunXku4o6cUv1cQRw5 -WmisFGyaVFPKYZYIVFtarlRns4lC1q23oib77O89bgREKaYZAa48x9b2Yx/3U22A -I8+W/U0bzLHjHdXkezbJnTiuaz6NewYJaT97qfH0hV6pBmYDmPbE0ZH7A+TaK8Ud -mx+uG95Z6ypff5qA9hdLVQ2YM/YVukX9N2U3Hu6JCn1clvm7UXCimY2W9J4pnYZn -OsN6HgUHkAZWnuX8To98D9hiuRRrXCFi4MiksJlTvaZe4xlxEyZPc8Ch8N0jLOPL -Yp0RXbtBhE8sFLVPI0DTfXmJluGu91nUBPM5yl/cf4V+FPL7vGE98TDPVdq+ryih -5ebOATi5zW7/WPRBL1A1dtlKvwl06ZsMZ5S6zFHjr4QO2XyJ3VAKwx6lYl5ezPfb -2p9ccq5esjAAl3d0EggtLI2Y5vnMlpWUG2euupNanHhOSGMemtQLrmLTLQEL5WMW -F5nKjKMMutTAHVZlWyPPP/PAXmJBBRS80G7z1+qLOy3pYqGLML5d0H6wduMwyfof -uC16FvW/RmWlhxOiz/RLJi6a8jMhSqZPZ0GVOgsE+3mH4qndv6rI1GKKtuSjA1fo -i8uEfGp3ty5DdGWFSFMVAoIBAQDjbLXbZJE4p5wb3a3Ijb36xZHyruSWOa2DxWue -oPeyeXo/uQq26wuJWNHlaqXGxCH8tJXKKB3tikdBZQrFzok4wHbD2cF1m56aFYQ9 -3LAlheZmmr3LvxqY0hLg+5viIHFywafGQxSt/7qBTizu9F1WQ/XVd4r5mgq9BIre -uunImEfq+7/8Xz3uNHk2kir6WYvhSB+bmge7u4qw4hfrKGvgZkUudTaBQOBByqlB -gk8N9MtFJ7HPbSlpXNfodK+ZLnOgnpfZs27BUEenFHcnTMcX6cJkJsOgeMUuLFoM -a3mJoz0NR3fOiaNIgTfhpe7H6jT75vmcSFVXO7OvnHnbBzj/AoIBAQDLkFOSLc4d -lZWYBehyUEPUgTTSJRNy09TY9uaBLB7E67ue6Ey8SWdaq1YFI9dYj7UGgRalNGX5 -jprli/kOfl+LY3CCq471qMFJWMGaVyzOUmqTfXXuIvDr5N0+/gc1LvejcR1dGOWG -BFFnNgrIqYkgNrBn7qKmHJ+DS4/r6fmGp6LWXB1IZb4A3+0N3AtFYEvATycd6+ho -C64CtXHjOyrD6D6yIV1GSqHofBogW2Lw2cPll6rkwBrjnQQWY4ttFPRMivOA22Kj -5UL5x+O1cAF1gF2rcQjfFbgqxQ4xLgAxs34ZEyTAMlKFRHMEf71PrwSRiVt3qpry -j5JN0qL/61iTAoIBAQC5KVNPEqwhwmUZUv0gojahK6ZOPhKiNMeO13dtqYTB7KGZ -rCCLGQdFhekurgvWru01ABpMgykKs2CcX5XLwwJ6EEkh3/LgvBj/PrFyZHGNu10B -AM+ySR9weOkh//jEvMFhO0ZL52W43NKOYIW473/msmI+sJuX6NEBX+dovCmHRmSX -buy6nxifDl36DjurpKh8fOovF9NgB6s9pHbw4PIju2BsGMaNqbJsHoJ7cYrHxByT -a2Qbi7cBr7Oh8Q7e2rENftIHT03HWoNcBw+UEbCvSYUZYW45AtsXYsjV/9LuOteE -LkHfCLTGXV6P+zdT0N3ekgl8MnA5G8SKIA4eQ90lAoIBAQDKGc5+8O8UPDC7MBJp -e/r7/hOdF6ZJeLp3dhm/4TfjNk+eIvAcd5wfTsAmdkEU8gg+HueGuZEMxWJPyDpL -A3iEgQNxGDbk+th7o50DSM15QiYBrKvq89HRwfVO1xH84VaHdIQ8q70k4yCWofbu -5jL4QpO9fBULapuL1Pdct30/DSwEOovwFuMfJzLJcc/W3xYWJf+mG1MwCXiHw/EA -MvvwaKHmZG2gnfRFRwEBYvnGOc3eIkhOt9N6a6dlOwtwDz/ExqefJTC3m6R1LNmM -h1lLeViGH8E5Cu0/uUiv1wXmUlg9ON5h2xRGr4Cp1ND1TcPxYjfnhQA1FgmhLiEa -iGP1AoIBAFhDurkr/U0DsNGm3Cn2+GlarvoYPsXmLYJnv5yTanfII7XyBGtys2/5 -sH4mEh4hx2wxHR0fDPfu4XZ4/vIMw5gg70gWRqJUeDiPvKPWWZizexNAgUx9ngc3 -MCyA67cZZQ9lk10cNdujm8gjFi+I19iV43kA657IKQSQoptv+XKN4Bfuk6w0mjY1 -XM1ZCpZVO/nhNmQYpgjYOMYEZuUXVwdOZx1LZXDu5kBQO2zstHkWBIKRjxPJ+gCa -vdD/AU3gLNUbUEF2rVx5YcKDQftjAMrZNtJ8GeuUK2Aoi1k5EYMo8fwkQ35UixNo -R54V/WirfICHJ9siZ4WsJk7VRFbvuhk= ------END PRIVATE KEY----- - -`, - - cert: `-----BEGIN CERTIFICATE----- -MIIE2jCCAsKgAwIBAgIJAI+JJie9DC+TMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV -BAMTCWxvY2FsaG9zdDAgFw0yMjEwMDgxMDA3MjlaGA8yMTIyMDkxNDEwMDcyOVow -FDESMBAGA1UEAxMJbG9jYWxob3N0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC -CgKCAgEAtNdrmV/OyhFSQAo05TqAV+5s33f0/TcawZTj4WroRqz8sWdhZk42UhK7 -YEvelAlACx8mliJX5hgrU13HWWyxQsTRTbivpCKV5LUjv2SzZ68lE6WRV6WqwFCa -cnztG47Hya+o3NtYEWRZzQuKWwf2DA6rUK96OQEuaaro53FDkdIwR2+RD6pQHuAf -C3Kwfh82tsC8EachqaLoSkoNQEAyjcfM9F8HAqdTgqYBp4qsA69EIRlmeF/k9s4r -4R2oNmXW37j2CN7HvYZd9RF/V+TlzVSovglTrtAnbK1lTFZuuuQPKIv6Va/pvMWX -Yr2xJkMDUW2C6RAHNVHBNvMh7ujyE3Affou2yrQzDuFC1hTzusYr16UKl7ANaIPA -+MhAx6ws2g+4nsD7sWgLU2yTjVDHKDaGzg+NitoA7OmWCs3NnItRpY7F30NYjvUU -oMaZiMIO0/qHeYenJWccFey+2C74tOiF7q6F4gKV8OGSWu+9TbfcQnB+NvwCfU5G -uOQf55Re66nDz8sLI0febXGnO+w/FSW2R4+6Lpb1nbJLP3pW7NEOr9JpJsWLvPJT -Gnlw0dg02sNZcqxex9M7mDAYB/+yUanUOfc4dokNxsAJUvHguAAlToTIoVrAcTRL -JNv1pYAQckQxg19Bk8hBh8jIz7QIuanLIOyKJVsFqq8+CJPSYm0CAwEAAaMtMCsw -FAYDVR0RBA0wC4IJbG9jYWxob3N0MBMGA1UdJQQMMAoGCCsGAQUFBwMBMA0GCSqG -SIb3DQEBCwUAA4ICAQAhTBoM/lPVF83iI+7bn+gzJyA9DD3sjgTFgM9cqyfWuemg -L/KMKCqaWO3FynGuig4mNArp589zR5+rVyZTa0mxevpxEvK2J+i5sIdzlcNKBJul -WB9UNvluuop/lrC+vugHcKXSUmZeVXhdyaoOEdjz3zzhC9n/ixaLXjl0HzikAt7O -gJgDdk/d4v0ybH67r/ZhT2n2UPWWO6T/H1jc/qNKJmvpHKhia76c4h5nNk+FpLwJ -getTEJPB2cV3kR/mpXkk66WHLi3QK83z6zRkhh4aarYQ1BU7D/W/RpQrfBKJyA5C -rUi4eQXjwdhr/Hs72tnLYQyXhOWL2vvA392eWKoy8WNsewNoOn7nYxAJv5D0zdJb -hernuFyr3CsZfvlPwUZvNI5oEEIu/Rhvhp7qna6Ujh8h95zjiW2khUpBRjno3oew -O6Hj04vqL7wrhO0gXsBfMkg4ECsTVqLGu3fFV2ZeUaDyKrOcNO8+gSAkbWy3LUAh -PoksgZB44l2C7I+B37uxjoN6AweaPv98+AkS/Mg792bFfY/ZF4xNMo8Y+HgSBS5C -xcqzmsQaDOjmf5q4mjxuaZIDxb3slwpR4vaAJqCcMWK8PXqHpTz2msImWhCNnXiy -GqfVp1GwG1kla18ts1d98QWyaWjbdveZp3HMWZzQY1xOzyUX4K4Ejhho5oNl4w== ------END CERTIFICATE----- - -`, -}; diff --git a/src/Compile.ts b/src/Compile.ts index c641c28e..28b8f223 100644 --- a/src/Compile.ts +++ b/src/Compile.ts @@ -29,7 +29,6 @@ import { nonEmptyArrayUniqueBy, } from "./NonEmptyArray"; import { absoluteDirname } from "./PathHelpers"; -import { Port } from "./Port"; import { Postprocess, PostprocessWorkerPool, @@ -57,6 +56,7 @@ import { TargetName, WebSocketToken, } from "./Types"; +import { WebSocketConnection } from "./WebSocketUrl"; export type InstallDependenciesResult = | { tag: "Error" } @@ -575,7 +575,7 @@ export type HandleOutputActionResult = type RunModeWithExtraData = | { tag: "hot"; - webSocketPort: Port; + webSocketConnection: WebSocketConnection; webSocketToken: WebSocketToken; } | { @@ -637,7 +637,7 @@ export async function handleOutputAction({ elmJsonPath: action.elmJsonPath, outputs: mapNonEmptyArray(action.outputs, ({ output }) => output), total, - webSocketPort: runMode.webSocketPort, + webSocketConnection: runMode.webSocketConnection, webSocketToken: runMode.webSocketToken, }); return { tag: "Nothing" }; @@ -957,7 +957,7 @@ function onCompileSuccess( elmCompiledTimestamp, outputState.compilationMode, outputState.browserUiPosition, - runMode.webSocketPort, + runMode.webSocketConnection, runMode.webSocketToken, loggerConfig.debug, ) + newCode, @@ -1143,7 +1143,7 @@ async function postprocessHelper({ elmCompiledTimestamp, outputState.compilationMode, outputState.browserUiPosition, - runMode.webSocketPort, + runMode.webSocketConnection, runMode.webSocketToken, logger.config.debug, ); @@ -1220,7 +1220,7 @@ async function typecheck({ elmJsonPath, outputs, total, - webSocketPort, + webSocketConnection, webSocketToken, }: { env: Env; @@ -1234,7 +1234,7 @@ async function typecheck({ outputState: OutputState; }>; total: number; - webSocketPort: Port; + webSocketConnection: WebSocketConnection; webSocketToken: WebSocketToken; }): Promise { const startTimestamp = getNow().getTime(); @@ -1349,7 +1349,7 @@ async function typecheck({ Buffer.from( Inject.versionedIdentifier( outputPath.targetName, - webSocketPort, + webSocketConnection, webSocketToken, ), ), @@ -1367,7 +1367,7 @@ async function typecheck({ outputPath, getNow().getTime(), outputState.browserUiPosition, - webSocketPort, + webSocketConnection, webSocketToken, logger.config.debug, ), diff --git a/src/ElmWatchJson.ts b/src/ElmWatchJson.ts index 55db8fa0..440b1d1a 100644 --- a/src/ElmWatchJson.ts +++ b/src/ElmWatchJson.ts @@ -15,6 +15,7 @@ import { type ElmWatchJsonPath, markAsElmWatchJsonPath, } from "./Types"; +import { WebSocketUrl } from "./WebSocketUrl"; // First char uppercase: https://github.com/elm/compiler/blob/2860c2e5306cb7093ba28ac7624e8f9eb8cbc867/compiler/src/Parse/Variable.hs#L263-L267 // Rest: https://github.com/elm/compiler/blob/2860c2e5306cb7093ba28ac7624e8f9eb8cbc867/compiler/src/Parse/Variable.hs#L328-L335 @@ -118,6 +119,10 @@ const Config = Codec.fields( targets: Codec.flatMap(Codec.record(Target), TargetRecordHelper), postprocess: Codec.field(NonEmptyArray(Codec.string), { optional: true }), port: Codec.field(Port, { optional: true }), + webSocketUrl: Codec.field(WebSocketUrl("elm-watch.json"), { + optional: true, + }), + serve: Codec.field(Codec.string, { optional: true }), }, { allowExtraFields: false }, ); diff --git a/src/Env.ts b/src/Env.ts index 1179f10a..0343d80c 100644 --- a/src/Env.ts +++ b/src/Env.ts @@ -12,6 +12,18 @@ export const ELM_WATCH_OPEN_EDITOR = "ELM_WATCH_OPEN_EDITOR"; // Type: Check if defined and ignore value. export const ELM_WATCH_EXIT_ON_STDIN_END = "ELM_WATCH_EXIT_ON_STDIN_END"; +// By default, elm-watch’s HTTP server is run on `0.0.0.0` which exposes the +// server on the local network. By setting this variable to `127.0.0.1` you can +// restrict it to just your computer. +// Type: IP address. +export const ELM_WATCH_HOST = "ELM_WATCH_HOST"; + +// This lets you override "webSocketUrl" in elm-watch.json, for cases where you +// need to set it dynamically. For example, you might want to set a certain port +// but keep the host name dynamic (if you run a remote dev container). +// Type: URL starting with ws: or wss:. +export const ELM_WATCH_WEBSOCKET_URL = "ELM_WATCH_WEBSOCKET_URL"; + // If the `ELM_WATCH_OPEN_EDITOR` takes this long, it is killed. // Type: Number. export const __ELM_WATCH_OPEN_EDITOR_TIMEOUT_MS = diff --git a/src/Errors.ts b/src/Errors.ts index f481d7d3..b5ec4eae 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -7,13 +7,20 @@ import * as url from "url"; import * as ElmMakeError from "./ElmMakeError"; import * as ElmWatchJson from "./ElmWatchJson"; -import { ELM_WATCH_OPEN_EDITOR, Env } from "./Env"; +import { + ELM_WATCH_HOST, + ELM_WATCH_OPEN_EDITOR, + ELM_WATCH_WEBSOCKET_URL, + Env, +} from "./Env"; import { bold as boldTerminal, dim as dimTerminal, + escapeHtml, RESET_COLOR, toError, } from "./Helpers"; +import { Host } from "./Host"; import { IS_WINDOWS } from "./IsWindows"; import { DEFAULT_COLUMNS } from "./Logger"; import { @@ -47,6 +54,7 @@ import { UncheckedInputPath, WriteOutputErrorReasonForWriting, } from "./Types"; +import { WebSocketUrl } from "./WebSocketUrl"; function bold(string: string): Piece { return { tag: "Bold", text: string }; @@ -290,27 +298,6 @@ function renderPieceToHtml(piece: Piece, theme: Theme.Theme): string { } } -function escapeHtml(string: string): string { - return string.replace(/[&<>"']/g, (match) => { - switch (match) { - case "&": - return "&"; - case "<": - return "<"; - case ">": - return ">"; - case '"': - return """; - case "'": - return "'"; - /* v8 ignore start */ - default: - return match; - /* v8 ignore stop */ - } - }); -} - // This needs to be kept in sync with `Project.projectHasFilePathThatCanBeOpenedInEditor`. function fancyToPlainErrorLocation( location: FancyErrorLocation, @@ -1354,6 +1341,30 @@ remove "port" from ${elmWatchJson} (which will use an arbitrary available port.) `; } +export function hostNotFound(host: Host, error: Error): ErrorTemplate { + return fancyError("HOST NOT FOUND", { tag: "NoLocation" })` +This environment variable is set: + +${text(ELM_WATCH_HOST)}=${text(host)} + +But that host was not found! + +It’s common to set it to the following to restrict elm-watch’s HTTP server to +your computer: + +${text(ELM_WATCH_HOST)}=127.0.0.1 + +If unset, the default host is 0.0.0.0, which exposes the HTTP server on the +local network. + +Try setting the environment variable to something else, or remove it altogether. + +This is the error message I got: + +${text(error.message)} + `; +} + export function watcherError(error: Error): ErrorTemplate { return fancyError("WATCHER ERROR", { tag: "NoLocation" })` The file watcher encountered an error, which means that it cannot continue. @@ -1369,6 +1380,7 @@ ${text(error.message)} } export function webSocketParamsDecodeError( + webSocketUrl: WebSocketUrl | undefined, error: Codec.DecoderError, urlParams: URLSearchParams, ): string { @@ -1381,10 +1393,36 @@ The URL parameters look like this: ${urlParams.toString()} -The web socket code I generate is supposed to always connect using a correct URL, so something is up here. Maybe the JavaScript code running in the browser was compiled with an older version of elm-watch? If so, try reloading the page. +${ + webSocketUrl === undefined + ? "The web socket code I generate is supposed to always connect using a correct URL, so something is up here." + : webSocketUrlDescription(webSocketUrl) +} + +Or maybe the JavaScript code running in the browser was compiled with an older version of elm-watch? If so, try reloading the page. `; } +function webSocketUrlDescription(webSocketUrl: WebSocketUrl): string { + const description = webSocketUrlSourceDescription(webSocketUrl.source); + return ` +You have configured the web socket URL ${description} to: + +${webSocketUrl.url.href} + +Check if that URL is correct! If it goes to a proxy, make sure the proxy forwards to the correct URL. + `.trim(); +} + +function webSocketUrlSourceDescription(source: WebSocketUrl["source"]): string { + switch (source) { + case "elm-watch.json": + return "in elm-watch.json"; + case "Env": + return `using the ${ELM_WATCH_WEBSOCKET_URL} environment variable`; + } +} + export function webSocketWrongVersion( expectedVersion: string, actualVersion: string, diff --git a/src/Help.ts b/src/Help.ts index b78b5244..03ca2af5 100644 --- a/src/Help.ts +++ b/src/Help.ts @@ -1,7 +1,9 @@ import { EMOJI, emojiWidthFix } from "./Compile"; import { ELM_WATCH_EXIT_ON_STDIN_END, + ELM_WATCH_HOST, ELM_WATCH_OPEN_EDITOR, + ELM_WATCH_WEBSOCKET_URL, NO_COLOR, } from "./Env"; import { bold, dim } from "./Helpers"; @@ -70,6 +72,13 @@ ${bold("Environment variables:")} ${bold(ELM_WATCH_EXIT_ON_STDIN_END)} Exit elm-watch when stdin ends + ${bold(ELM_WATCH_HOST)} + Defaults to 0.0.0.0 (expose on local network) + Set it to 127.0.0.1 to restrict to your computer + + ${bold(ELM_WATCH_WEBSOCKET_URL)} + Override "webSocketUrl" in ${elmWatchJson} dynamically + ${bold("Documentation:")} https://lydell.github.io/elm-watch/ diff --git a/src/Helpers.ts b/src/Helpers.ts index 0d245553..d74e96ef 100644 --- a/src/Helpers.ts +++ b/src/Helpers.ts @@ -108,6 +108,27 @@ export function capitalize(string: string): string { return string.slice(0, 1).toUpperCase() + string.slice(1); } +export function escapeHtml(string: string): string { + return string.replace(/[&<>"']/g, (match) => { + switch (match) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + case "'": + return "'"; + /* v8 ignore start */ + default: + return match; + /* v8 ignore stop */ + } + }); +} + export function silentlyReadIntEnvValue( value: string | undefined, defaultValue: number, diff --git a/src/Host.ts b/src/Host.ts new file mode 100644 index 00000000..15ec1acb --- /dev/null +++ b/src/Host.ts @@ -0,0 +1,13 @@ +import { ELM_WATCH_HOST, Env } from "./Env"; + +export type Host = string & { + readonly Host: never; +}; + +export function markAsHost(string: string): Host { + return string as Host; +} + +export function getHost(env: Env): Host { + return markAsHost(env[ELM_WATCH_HOST] ?? "0.0.0.0"); +} diff --git a/src/Hot.ts b/src/Hot.ts index cffee56e..afa553b8 100644 --- a/src/Hot.ts +++ b/src/Hot.ts @@ -1,8 +1,10 @@ import * as childProcess from "child_process"; import * as chokidar from "chokidar"; import * as fs from "fs"; +import * as os from "os"; import * as path from "path"; import * as Codec from "tiny-decoders"; +import * as url from "url"; import { URLSearchParams } from "url"; import type WebSocket from "ws"; @@ -33,6 +35,7 @@ import { silentlyReadIntEnvValue, toError, } from "./Helpers"; +import { getHost, Host } from "./Host"; import type { Logger, LoggerConfig } from "./Logger"; import { isNonEmptyArray, @@ -58,6 +61,7 @@ import { AbsolutePath, BrowserUiPosition, CompilationMode, + CreateServer, ElmJsonPath, ElmWatchJsonPath, equalsInputPath, @@ -72,14 +76,25 @@ import { WebSocketServer, WebSocketServerMsg, } from "./WebSocketServer"; +import { WebSocketUrl } from "./WebSocketUrl"; type WatcherEventName = "added" | "changed" | "removed"; -type WatcherEvent = { +type WatcherEvent = { tag: "WatcherEvent"; date: Date; eventName: WatcherEventName; - file: AbsolutePath; + file: File; +}; + +type TaggedAbsolutePath = { + tag: "AbsolutePath"; + absolutePath: AbsolutePath; +}; + +type StaticFilesDirPath = { + tag: "StaticFilesDirPath"; + urlPath: string; }; type WebSocketRelatedEvent = @@ -337,6 +352,10 @@ type Cmd = webSocket: WebSocket; message: WebSocketToClientMessage; } + | { + tag: "WebSocketSendAll"; + message: WebSocketToClientMessage; + } | { tag: "WebSocketSendCompileErrorToOutput"; outputPath: OutputPath; @@ -391,6 +410,7 @@ export async function run( env: Env, logger: Logger, getNow: GetNow, + createServer: CreateServer, restartReasons: Array, postprocessWorkerPool: PostprocessWorkerPool, webSocketState: WebSocketState | undefined, @@ -406,6 +426,7 @@ export async function run( env, logger, getNow, + createServer, postprocessWorkerPool, webSocketState, webSocketToken, @@ -457,7 +478,10 @@ export async function watchElmWatchJsonOnce( tag: "WatcherEvent", date: getNow(), eventName, - file: markAsAbsolutePath(absolutePathString), + file: { + tag: "AbsolutePath", + absolutePath: markAsAbsolutePath(absolutePathString), + }, }; watcher .close() @@ -474,6 +498,7 @@ const initMutable = env: Env, logger: Logger, getNow: GetNow, + createServer: CreateServer, postprocessWorkerPool: PostprocessWorkerPool, webSocketState: WebSocketState | undefined, webSocketToken: WebSocketToken, @@ -531,7 +556,13 @@ const initMutable = ); const { - webSocketServer = new WebSocketServer(portChoice, webSocketToken), + webSocketServer = new WebSocketServer( + createServer, + portChoice, + getHost(env), + project.staticFilesDir, + webSocketToken, + ), webSocketConnections = [], } = webSocketState ?? {}; @@ -577,6 +608,20 @@ const initMutable = webSocketServer.listening .then(() => { writeElmWatchStuffJson(mutable, webSocketToken); + // When not running as a TTY the output is a simple log, and it gets + // a bit tedious if the stats are printed after each event. Instead, + // we print it once at startup, and only the server links (connections + // and workers are always 0 at that time). + // This has to be done once the server is ready – we don’t know the + // final port number to print until then. + const isRestart = webSocketState !== undefined; + if (!logger.config.isTTY && !isRestart) { + logger.write( + printStats(logger.config, [ + printServerLinks(mutable.webSocketServer, getHost(env)), + ]), + ); + } }) .catch(rejectPromise); @@ -813,13 +858,31 @@ function update( } case "SleepBeforeNextActionDone": { + const staticFilesDirPaths = model.latestEvents.flatMap((event) => + event.tag === "WatcherEvent" && event.file.tag === "StaticFilesDirPath" + ? [event.file.urlPath] + : [], + ); const [newModel, cmds] = runNextAction(msg.date, project, model); return [ { ...newModel, nextAction: { tag: "NoAction" }, }, - cmds, + [ + ...(isNonEmptyArray(staticFilesDirPaths) + ? [ + { + tag: "WebSocketSendAll", + message: { + tag: "StaticFilesChanged", + changedFileUrlPaths: staticFilesDirPaths, + }, + } as const, + ] + : []), + ...cmds, + ], ]; } @@ -988,6 +1051,17 @@ function update( case "WebSocketConnected": { const result = msg.parseWebSocketConnectRequestUrlResult; + const cssCmd: Array = + project.staticFilesDir === undefined + ? [] + : [ + { + tag: "WebSocketSend", + webSocket: msg.webSocket, + message: { tag: "StaticFilesMayHaveChangedWhileDisconnected" }, + }, + ]; + switch (result.tag) { case "Success": { const [newModel, latestEvent, cmds] = onWebSocketConnected( @@ -1004,7 +1078,7 @@ function update( ...newModel, latestEvents: [...newModel.latestEvents, latestEvent], }, - cmds, + [...cmds, ...cssCmd], ]; } @@ -1035,6 +1109,7 @@ function update( }, }, }, + ...cssCmd, ], ]; } @@ -1059,10 +1134,14 @@ function update( tag: "StatusChanged", status: { tag: "ClientError", - message: webSocketConnectRequestUrlErrorToString(result), + message: webSocketConnectRequestUrlErrorToString( + project.webSocketUrl, + result, + ), }, }, }, + ...cssCmd, ], ]; } @@ -1221,35 +1300,67 @@ function onWatcherEvent( } default: - return absolutePath === getPostprocessElmWatchNodeScriptPath(project) - ? [ - compileNextAction(nextAction), + if (absolutePath === getPostprocessElmWatchNodeScriptPath(project)) { + return [ + compileNextAction(nextAction), + { + ...makeWatcherEvent(eventName, absolutePath, now), + affectsAnyTarget: true, + }, + [ + { + tag: "MarkAsDirty", + outputs: getFlatOutputs(project), + killInstallDependencies: false, + }, + { tag: "RestartWorkers" }, + ], + ]; + } + + if (project.staticFilesDir !== undefined) { + const prefix = project.staticFilesDir + path.sep; + if ( + absolutePath.startsWith(prefix) && + !getFlatOutputs(project).some( + ({ outputPath }) => absolutePath === outputPath.theOutputPath, + ) + ) { + return [ + nextAction, { ...makeWatcherEvent(eventName, absolutePath, now), affectsAnyTarget: true, - }, - [ - { - tag: "MarkAsDirty", - outputs: getFlatOutputs(project), - killInstallDependencies: false, + file: { + tag: "StaticFilesDirPath", + urlPath: url.pathToFileURL( + path.sep + absolutePath.slice(prefix.length), + ).pathname, }, - { tag: "RestartWorkers" }, - ], - ] - : // Ignore other types of files. - undefined; + }, + [], + ]; + } + } + + // Ignore other types of files. + return undefined; } } function onElmFileWatcherEvent( project: Project, - event: WatcherEvent, + event: WatcherEvent, nextAction: NextAction, ): [NextAction, LatestEvent, Array] | undefined { const elmFile = event.file; - if (isElmFileRelatedToElmJsonsErrors(elmFile, project.elmJsonsErrors)) { + if ( + isElmFileRelatedToElmJsonsErrors( + elmFile.absolutePath, + project.elmJsonsErrors, + ) + ) { return makeRestartNextAction(event, project); } @@ -1262,13 +1373,13 @@ function onElmFileWatcherEvent( for (const [outputPath, outputState] of outputs) { if (event.eventName === "removed") { for (const inputPath of outputState.inputs) { - if (equalsInputPath(elmFile, inputPath)) { + if (equalsInputPath(elmFile.absolutePath, inputPath)) { return makeRestartNextAction(event, project); } } } Compile.ensureAllRelatedElmFilePaths(elmJsonPath, outputState); - if (outputState.allRelatedElmFilePaths.has(elmFile)) { + if (outputState.allRelatedElmFilePaths.has(elmFile.absolutePath)) { dirtyOutputs.push({ outputPath, outputState }); } } @@ -1463,7 +1574,10 @@ const runCmd = getNow, runMode: { tag: "hot", - webSocketPort: mutable.webSocketServer.port, + webSocketConnection: mutable.project.webSocketUrl ?? { + tag: "AutomaticUrl", + port: mutable.webSocketServer.port, + }, webSocketToken, }, elmWatchJsonPath: mutable.project.elmWatchJsonPath, @@ -1572,6 +1686,7 @@ const runCmd = loggerConfig: logger.config, date: getNow(), mutable, + host: getHost(env), message: cmd.message, events: filterLatestEvents(cmd.events), hasErrors: isNonEmptyArray(Compile.extractErrors(mutable.project)), @@ -1705,7 +1820,10 @@ const runCmd = const elmWatchJsonChanged = cmd.restartReasons.some((event) => { switch (event.tag) { case "WatcherEvent": - return path.basename(event.file) === "elm-watch.json"; + return ( + event.file.tag === "AbsolutePath" && + path.basename(event.file.absolutePath) === "elm-watch.json" + ); /* v8 ignore start */ default: return false; @@ -1771,6 +1889,12 @@ const runCmd = webSocketSend(cmd.webSocket, cmd.message); return; + case "WebSocketSendAll": + for (const webSocketConnection of mutable.webSocketConnections) { + webSocketSend(webSocketConnection.webSocket, cmd.message); + } + return; + case "WebSocketSendCompileErrorToOutput": Theme.getThemeFromTerminal(logger) .then((theme) => { @@ -1937,6 +2061,19 @@ function onWebSocketServerMsg( return; } + case "HostNotFound": { + const { host } = msg.error; + closeAll(logger, mutable) + .then(() => { + resolvePromise({ + tag: "ExitOnHandledFatalError", + errorTemplate: Errors.hostNotFound(host, msg.error.error), + }); + }) + .catch(rejectPromise); + return; + } + /* v8 ignore start */ case "OtherError": rejectPromise(msg.error.error); @@ -2065,12 +2202,12 @@ function makeWatcherEvent( eventName: WatcherEventName, absolutePath: AbsolutePath, date: Date, -): WatcherEvent { +): WatcherEvent { return { tag: "WatcherEvent", date, eventName, - file: absolutePath, + file: { tag: "AbsolutePath", absolutePath }, }; } @@ -2338,11 +2475,16 @@ function webSocketConnectRequestUrlResultToOutputPath( } function webSocketConnectRequestUrlErrorToString( + webSocketUrl: WebSocketUrl | undefined, error: ParseWebSocketConnectRequestUrlError, ): string { switch (error.tag) { case "ParamsDecodeError": - return Errors.webSocketParamsDecodeError(error.error, error.urlParams); + return Errors.webSocketParamsDecodeError( + webSocketUrl, + error.error, + error.urlParams, + ); case "WrongVersion": return Errors.webSocketWrongVersion( @@ -2780,6 +2922,7 @@ function infoMessageWithTimeline({ loggerConfig, date, mutable, + host, message, events, hasErrors, @@ -2787,13 +2930,14 @@ function infoMessageWithTimeline({ loggerConfig: LoggerConfig; date: Date; mutable: Mutable; + host: Host; message: string; events: Array; hasErrors: boolean; }): string { return [ - "", // Empty line separator. - printStats(loggerConfig, mutable), + loggerConfig.isTTY ? "" : undefined, // Empty line separator. + loggerConfig.isTTY ? printAllStats(loggerConfig, mutable, host) : undefined, "", printTimeline(loggerConfig, events), printMessageWithTimeAndEmoji({ @@ -2833,30 +2977,63 @@ function printMessageWithTimeAndEmoji({ }); } -function printStats(loggerConfig: LoggerConfig, mutable: Mutable): string { +function printAllStats( + loggerConfig: LoggerConfig, + mutable: Mutable, + host: Host, +): string { const numWorkers = mutable.postprocessWorkerPool.getSize(); - return [ - numWorkers > 0 - ? `${dim(`${ELM_WATCH_NODE} workers:`)} ${numWorkers}` - : undefined, - `${dim("web socket connections:")} ${ - mutable.webSocketConnections.length - } ${dim(`(ws://0.0.0.0:${mutable.webSocketServer.port})`)}`, - ] - .flatMap((part) => - part === undefined - ? [] - : Compile.printStatusLine({ - maxWidth: Infinity, - fancy: loggerConfig.fancy, - isTTY: loggerConfig.isTTY, - emojiName: "Stats", - string: part, - }), + return printStats(loggerConfig, [ + printServerLinks(mutable.webSocketServer, host), + `${dim("web socket connections:")} ${mutable.webSocketConnections.length}${ + numWorkers > 0 + ? `${dim(`, ${ELM_WATCH_NODE} workers:`)} ${numWorkers}` + : "" + }`, + ]); +} + +function printStats( + loggerConfig: LoggerConfig, + stats: NonEmptyArray, +): string { + return stats + .map((part) => + Compile.printStatusLine({ + maxWidth: Infinity, + fancy: loggerConfig.fancy, + isTTY: loggerConfig.isTTY, + emojiName: "Stats", + string: part, + }), ) .join("\n"); } +function printServerLinks( + webSocketServer: WebSocketServer, + host: Host, +): string { + const protocol = webSocketServer.isHTTPS ? "https" : "http"; + const { port } = webSocketServer; + + if (host !== "0.0.0.0") { + return `${dim("server:")} ${protocol}://${host}:${port}`; + } + + const networkIps = Object.values(os.networkInterfaces()) + .flatMap((addresses = []) => + addresses.filter( + (address) => address.family === "IPv4" && !address.internal, + ), + ) + .map(({ address }) => address); + + return `${dim("server:")} ${protocol}://localhost:${port}${networkIps + .map((ip) => `${dim(", network:")} ${protocol}://${ip}:${port}`) + .join("")}`; +} + export function printTimeline( loggerConfig: LoggerConfig, events: Array, @@ -2898,7 +3075,12 @@ function printEvent(loggerConfig: LoggerConfig, event: LatestEvent): string { function printEventMessage(event: LatestEvent): string { switch (event.tag) { case "WatcherEvent": - return `${capitalize(event.eventName)} ${event.file}`; + // TODO: Don’t really want to print changes to files in static dir? + return `${capitalize(event.eventName)} ${ + event.file.tag === "AbsolutePath" + ? event.file.absolutePath + : event.file.urlPath + }`; case "WebSocketClosed": return `Web socket disconnected for: ${ diff --git a/src/Inject.ts b/src/Inject.ts index 3d45662a..6380ef29 100644 --- a/src/Inject.ts +++ b/src/Inject.ts @@ -1,8 +1,6 @@ import * as Codec from "tiny-decoders"; import * as ClientCode from "./ClientCode"; -import {} from "./Helpers"; -import { Port } from "./Port"; import { BrowserUiPosition, CompilationMode, @@ -11,6 +9,10 @@ import { TargetName, WebSocketToken, } from "./Types"; +import { + WebSocketConnection, + webSocketConnectionToPrimitive, +} from "./WebSocketUrl"; // This matches full functions, declared either with `function name(` or `var name =`. // NOTE: All function names in the regex must also be mentioned in the @@ -844,7 +846,7 @@ export function proxyFile( outputPath: OutputPath, elmCompiledTimestamp: number, browserUiPosition: BrowserUiPosition, - webSocketPort: Port, + webSocketConnection: WebSocketConnection, webSocketToken: WebSocketToken, debug: boolean, ): string { @@ -853,7 +855,7 @@ export function proxyFile( elmCompiledTimestamp, "proxy", browserUiPosition, - webSocketPort, + webSocketConnection, webSocketToken, debug, ); @@ -869,7 +871,7 @@ export function clientCode( elmCompiledTimestamp: number, compilationMode: CompilationModeWithProxy, browserUiPosition: BrowserUiPosition, - webSocketPort: Port, + webSocketConnection: WebSocketConnection, webSocketToken: WebSocketToken, debug: boolean, ): string { @@ -878,12 +880,17 @@ export function clientCode( INITIAL_ELM_COMPILED_TIMESTAMP: elmCompiledTimestamp.toString(), ORIGINAL_COMPILATION_MODE: compilationMode, ORIGINAL_BROWSER_UI_POSITION: browserUiPosition, - WEBSOCKET_PORT: webSocketPort.toString(), + WEBSOCKET_CONNECTION: + webSocketConnectionToPrimitive(webSocketConnection).toString(), WEBSOCKET_TOKEN: webSocketToken, DEBUG: debug.toString(), }; return ( - versionedIdentifier(outputPath.targetName, webSocketPort, webSocketToken) + + versionedIdentifier( + outputPath.targetName, + webSocketConnection, + webSocketToken, + ) + ClientCode.client.replace( new RegExp(`"%(${Object.keys(replacements).join("|")})%"`, "g"), (_, name: string) => @@ -898,17 +905,17 @@ export function clientCode( // - And it was created by `elm-watch hot`. (`elm-watch make` output does not contain WebSocket stuff). // - And it was created by the same version of `elm-watch`. (Older versions could have bugs.) // - And it has the same target name. (It might have changed, and needs to match.) -// - And it used the same WebSocket port. (Otherwise it will never connect to us.) +// - And it used the same WebSocket url or port. (Otherwise it will never connect to us.) // - And it used the same WebSocket token. (Otherwise we will reject the connection.) export function versionedIdentifier( targetName: TargetName, - webSocketPort: Port, + webSocketConnection: WebSocketConnection, webSocketToken: WebSocketToken, ): string { return `// elm-watch hot ${Codec.JSON.stringify(Codec.unknown, { version: "%VERSION%", targetName, - webSocketPort, + webSocketConnection: webSocketConnectionToPrimitive(webSocketConnection), webSocketToken, })}\n`; } diff --git a/src/Project.ts b/src/Project.ts index 91042bb3..17713198 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -4,7 +4,7 @@ import * as path from "path"; import * as ElmJson from "./ElmJson"; import * as ElmWatchJson from "./ElmWatchJson"; import { ElmWatchStuffJson } from "./ElmWatchStuffJson"; -import { __ELM_WATCH_MAX_PARALLEL, Env } from "./Env"; +import { __ELM_WATCH_MAX_PARALLEL, ELM_WATCH_WEBSOCKET_URL, Env } from "./Env"; import { getSetSingleton, silentlyReadIntEnvValue, toError } from "./Helpers"; import { WalkImportsError } from "./ImportWalker"; import { isNonEmptyArray, NonEmptyArray } from "./NonEmptyArray"; @@ -28,17 +28,21 @@ import { type GetNow, type InputPath, markAsElmJsonPath, + markAsStaticFilesDir, markAsTargetName, type OutputPath, + type StaticFilesDir, type UncheckedInputPath, type WriteOutputErrorReasonForWriting, } from "./Types"; +import { WebSocketUrl } from "./WebSocketUrl"; export type Project = { // Path of the directories containing elm-watch.json, all elm.json, // and all source directories, without duplicates and where each // directory does not contain any other. watchRoots: Set; + staticFilesDir: StaticFilesDir | undefined; elmWatchJsonPath: ElmWatchJsonPath; elmWatchStuffJsonPath: ElmWatchStuffJsonPath; disabledOutputs: Array; @@ -46,6 +50,7 @@ export type Project = { elmJsons: Map>; maxParallel: number; postprocess: Postprocess; + webSocketUrl: WebSocketUrl | undefined; }; // The code base leans towards pure functions, but this data structure is going @@ -423,6 +428,16 @@ export function initProject({ }; } + const staticFilesDir: StaticFilesDir | undefined = + config.serve === undefined + ? undefined + : markAsStaticFilesDir( + absolutePathFromString( + absoluteDirname(elmWatchJsonPath), + config.serve, + ), + ); + const watchRoots = getWatchRoots( elmWatchJsonPath, Array.from(elmJsons.keys()), @@ -438,10 +453,13 @@ export function initProject({ ? { tag: "NoPostprocess" } : { tag: "Postprocess", postprocessArray: config.postprocess }; + const webSocketUrl = getWebSocketUrlFromEnv(env) ?? config.webSocketUrl; + return { tag: "Project", project: { watchRoots, + staticFilesDir, elmWatchJsonPath, elmWatchStuffJsonPath, disabledOutputs, @@ -449,6 +467,7 @@ export function initProject({ elmJsons, maxParallel, postprocess, + webSocketUrl, }, }; } @@ -498,6 +517,23 @@ export function filterSubDirs( ); } +function getWebSocketUrlFromEnv(env: Env): WebSocketUrl | undefined { + const envWebSocketUrlString = env[ELM_WATCH_WEBSOCKET_URL]; + + if (envWebSocketUrlString === undefined) { + return undefined; + } + + const decoderResult = WebSocketUrl("Env").decoder(envWebSocketUrlString); + switch (decoderResult.tag) { + case "Valid": + return decoderResult.value; + case "DecoderError": + // Invalid environment variables are silently ignored. + return undefined; + } +} + type ResolveElmJsonResult = | ElmJsonError | { @@ -696,6 +732,8 @@ export function getPostprocessElmWatchNodeScriptPath( export function projectToDebug(project: Project): unknown { return { watchRoots: Array.from(project.watchRoots), + staticFilesDir: project.staticFilesDir, + webSocketUrl: project.webSocketUrl, elmWatchJson: project.elmWatchJsonPath, elmWatchStuffJson: project.elmWatchStuffJsonPath, maxParallel: project.maxParallel, diff --git a/src/Run.ts b/src/Run.ts index 6199b20e..b4ba2545 100644 --- a/src/Run.ts +++ b/src/Run.ts @@ -16,6 +16,7 @@ import { ELM_WATCH_NODE } from "./PostprocessShared"; import { initProject, projectToDebug } from "./Project"; import { CliArg, + CreateServer, Cwd, ElmWatchJsonPath, ElmWatchStuffDir, @@ -46,6 +47,7 @@ export async function run( env: Env, logger: Logger, getNow: GetNow, + createServer: CreateServer, runMode: RunMode, args: Array, restartReasons: Array, @@ -264,6 +266,7 @@ export async function run( env, logger, getNow, + createServer, restartReasons, postprocessWorkerPool, webSocketState, diff --git a/src/SimpleStaticFileServer.ts b/src/SimpleStaticFileServer.ts new file mode 100644 index 00000000..b290e887 --- /dev/null +++ b/src/SimpleStaticFileServer.ts @@ -0,0 +1,756 @@ +import * as fs from "fs"; +import * as http from "http"; +import * as path from "path"; + +import { escapeHtml, toError } from "./Helpers"; +import { AbsolutePath, markAsAbsolutePath, StaticFilesDir } from "./Types"; + +// Copied from: https://github.com/evanw/esbuild/blob/52110fd09322af7c8ac22e011f64093e53765004/internal/helpers/mime.go#L5-L39 +// Removed markdown – otherwise that causes downloads in Firefox instead of +// opening in the browser as plain text. +const MIME_TYPES: Record = { + // Text + ".css": "text/css; charset=utf-8", + ".htm": "text/html; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".xhtml": "application/xhtml+xml; charset=utf-8", + ".xml": "text/xml; charset=utf-8", + + // Images + ".avif": "image/avif", + ".gif": "image/gif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", + + // Fonts + ".eot": "application/vnd.ms-fontobject", + ".otf": "font/otf", + ".sfnt": "font/sfnt", + ".ttf": "font/ttf", + ".woff": "font/woff", + ".woff2": "font/woff2", + + // Other + ".pdf": "application/pdf", + ".wasm": "application/wasm", + ".webmanifest": "application/manifest+json", +}; + +// Copied from Node.js’ validation code for `setHeader`: +// https://github.com/nodejs/node/blob/f801b58e7753dd5abd492ad2076686f5ec63d897/lib/_http_common.js#L216C1-L216C51 +const HEADER_CHAR_REGEX = /[^\t\x20-\x7e\x80-\xff]/g; + +const DOCS_LINK = "https://lydell.github.io/elm-watch/server/"; +const DOCS_LINK_INDEX_HTML = `${DOCS_LINK}#indexhtml`; + +class Html { + constructor(private escapedHtml: string) {} + + toString(): string { + return this.escapedHtml; + } +} + +function html( + strings: ReadonlyArray, + ...values: Array +): Html { + return new Html( + strings + .flatMap((string, index) => { + const value = values[index] ?? ""; + return [ + string, + value instanceof Html ? value.toString() : escapeHtml(value), + ]; + }) + .join(""), + ); +} + +function baseHtml(faviconEmoji: string, title: string, body: Html): Html { + return html` + + + + + + ${title} – elm-watch + + + + +
${body}
+

+ ℹ️ This is the elm-watch server. +

+ + + `; +} + +function notFoundHtml( + fsPath: FsPath, + statsTag: NotFileStat | "FileWithTrailingSlash", +): Html { + switch (statsTag) { + case "Directory": + return baseHtml( + "📁", + "Directory", + html` +

Directory

+

+ The URL you requested points to a directory. elm-watch only serves + files. +

+

Suggestion: Create an index.html file.

+

+ 👉 + How index.html files work +

+

This is the absolute file path the URL resolves to:

+
${fsPath.theFsPath}
+ `, + ); + + case "NotFound": + return baseHtml( + "❓", + "Not Found", + html` +

404 – Not Found

+

The URL you requested does not point to any existing file.

+ ${getContentType(fsPath) === undefined + ? html` +

+ 👉 + How index.html files work + (for Browser.application programs) +

+ ` + : html` +

+ 👉 + File not found troubleshooting +

+ `} +

This is the absolute file path the URL resolves to:

+
${fsPath.theFsPath}
+ `, + ); + + case "Other": + return baseHtml( + "🚨", + "Unsupported", + html` +

Unsupported file system object

+

+ The URL you requested points to a something that is neither or file + nor a directory. elm-watch only serves files. +

+

This is the absolute file path the URL resolves to:

+
${fsPath.theFsPath}
+ `, + ); + + case "FileWithTrailingSlash": + return baseHtml( + "🚯", + "Trailing slash", + html` +

File with trailing slash

+

+ The URL you requested points to a file, but the URL has a trailing + slash. +

+

Servers typically don't allow trailing slashes on files.

+

Suggestion: Remove the trailing slash from the URL.

+

This is the absolute file path the URL resolves to:

+
${fsPath.theFsPath}
+ `, + ); + } +} + +function staticDirNotFoundHtml( + staticFilesDir: StaticFilesDir, + statsTag: "File" | "NotFound" | "Other", +): Html { + return baseHtml( + "🚨", + "Static files directory not found", + html` +

Static files directory not found

+

+ You have configured a static files directory in elm-watch.json which + resolves to: +

+
${staticFilesDir}
+

${staticFilesDirDescription(statsTag)}

+ `, + ); +} + +function staticFilesDirDescription( + statsTag: "File" | "NotFound" | "Other", +): string { + switch (statsTag) { + case "File": + return "However, that is a file, not a directory!"; + case "NotFound": + return "However, that directory does not exist."; + case "Other": + return "However, that is neither a file nor a directory."; + } +} + +function forbiddenHtml( + staticFilesDir: StaticFilesDir, + forbiddenPath: AbsolutePath, +): Html { + return baseHtml( + "⛔️", + "Forbidden", + html` +

Forbidden

+

+ You have configured a static files directory in elm-watch.json which + resolves to: +

+
${staticFilesDir}
+

+ However, the URL you requested points to a file outside of that + directory: +

+
${forbiddenPath}
+ `, + ); +} + +function indexHtmlInfo( + fsPath: FsPath, + indexFsPath: IndexFsPath, + statsTag: NotFileStat, +): { + headers: Record; + comment: string; +} { + return { + headers: { + "elm-watch-404": fsPath.theFsPath, + "elm-watch-index-html": indexFsPath.theIndexFsPath, + "elm-watch-learn-more": DOCS_LINK_INDEX_HTML, + }, + // If you change the first line, also update the code in client.ts that removes this comment. + comment: ` +`, + }; +} + +function indexHtmlDescription( + fsPath: FsPath, + indexFsPath: IndexFsPath, + statsTag: NotFileStat, +): string { + switch (statsTag) { + case "Directory": + return ` +The URL you requested points to a directory. elm-watch only serves files. + +The closest index.html file was served instead: +${indexFsPath.theIndexFsPath} + +This is the directory: +${fsPath.theFsPath} +`.trim(); + + case "NotFound": + return ` +This response could have been served as a 404 (Not Found), +but was served as 200 (OK) instead, because an index.html file was found. +This is for supporting Browser.application programs. + +If you expected a file to served rather than this HTML, +make sure the URL is correct or that this file exists: +${fsPath.theFsPath} + +This is the closest index.html file, which was served instead: +${indexFsPath.theIndexFsPath} +`.trim(); + + case "Other": + return ` +The URL you requested points to a something that is neither or file +nor a directory. elm-watch only serves files. + +This is the absolute file path the URL resolves to: +${fsPath.theFsPath} + +This is the closest index.html file, which was served instead: +${indexFsPath.theIndexFsPath} +`.trim(); + } +} + +export function staticFileNotEnabledHtml(): Html { + return baseHtml( + "ℹ️", + "Enable static file server?", + html` +

Enable elm-watch static file server?

+

+ If you want, you can enable a simple static file server for your + project. +

+

Add the following to your elm-watch.json file:

+
"serve": "./folder/to/serve/"
+ `, + ); +} + +export function errorHtml(errorMessage: string): Html { + if (errorMessage.includes("\n")) { + const firstRow = errorMessage.split("\n").slice(0, 1).join(""); + return baseHtml( + "🚨", + firstRow, + html`

${firstRow}

+
${errorMessage}
`, + ); + } else { + return baseHtml("🚨", errorMessage, html`

${errorMessage}

`); + } +} + +export function respondHtml( + response: http.ServerResponse, + statusCode: number, + htmlValue: Html, +): void { + const htmlString = htmlValue.toString().trim(); + const contentLength = Buffer.byteLength(htmlString); + if (!response.headersSent) { + writeHead(response, statusCode, { + "Content-Type": "text/html; charset=utf-8", + "Content-Length": contentLength, + }); + response.end(htmlString); + } else { + // This can happen: + // 1. We write the headers for responding. + // 2. Something throws an error, so we never send the intended content. + // 3. We catch that error, and want to respond with a 500 page. + // But the headers (and status code) are already sent, so we can’t + // change them now. Instead, try send the response using the already + // sent headers. + const previousContentLength = response.getHeader("Content-Length"); + if (typeof previousContentLength === "number") { + const newHtmlString = + previousContentLength >= contentLength + ? htmlString + : // Previously set content was shorter – try to extract the main info. + (/
([^]*)<\/main>/.exec(htmlString)?.[1] ?? htmlString); + const newContentLength = Buffer.byteLength(newHtmlString); + response.end( + newHtmlString + + // Pad the content to the previously set content if needed. + " ".repeat(Math.max(0, previousContentLength - newContentLength)), + ); + } else { + // Content-Length not set previously, just try sending the whole thing. + response.end(htmlString); + } + } +} + +function writeHead( + response: http.ServerResponse, + statusCode: number, + headers: Record, +): void { + // Use `.setHeader` rather than passing headers directly to `.writeHead` + // so that they can be read later via `.getHeader`. + for (const [name, value] of Object.entries(headers)) { + response.setHeader(name, value); + } + response.writeHead(statusCode); +} + +// Note: This function may throw file system errors. +export function serveStatic( + staticFilesDir: StaticFilesDir, +): http.RequestListener { + return (request, response) => { + switch (request.method) { + case "HEAD": + case "GET": { + const { url = "/" } = request; + const fsPath = toFsPath(staticFilesDir, url); + + if (fsPath.tag === "Forbidden") { + respondHtml( + response, + 403, + forbiddenHtml(staticFilesDir, fsPath.forbiddenPath), + ); + return; + } + + const stats = statSync(fsPath.theFsPath); + + switch (stats.tag) { + case "File": + if (fsPath.hadTrailingSlash) { + respondHtml( + response, + 404, + notFoundHtml(fsPath, "FileWithTrailingSlash"), + ); + } else { + serveFile(fsPath, stats.size, request, response); + } + return; + + case "NotFound": + case "Other": + case "Directory": { + for (let i = fsPath.segments.length; i >= 0; i--) { + const indexFsPath = toIndexFsPath(staticFilesDir, fsPath, i); + const indexStats = statSync(indexFsPath.theIndexFsPath); + switch (indexStats.tag) { + case "File": { + const info = indexHtmlInfo(fsPath, indexFsPath, stats.tag); + for (const [name, value] of Object.entries(info.headers)) { + response.setHeader( + name, + value.replace(HEADER_CHAR_REGEX, "?"), + ); + } + serveFile( + indexFsPath, + indexStats.size, + request, + response, + info.comment, + ); + return; + } + + case "Directory": + case "Other": + case "NotFound": + break; + } + } + + const staticFilesDirStats = statSync(staticFilesDir); + switch (staticFilesDirStats.tag) { + case "Directory": + respondHtml(response, 404, notFoundHtml(fsPath, stats.tag)); + return; + + case "File": + case "NotFound": + case "Other": + respondHtml( + response, + 404, + staticDirNotFoundHtml( + staticFilesDir, + staticFilesDirStats.tag, + ), + ); + return; + } + } + } + } + + default: + writeHead(response, 405, { Allow: "GET, HEAD" }); + response.end( + errorHtml( + `Unsupported method + +Only GET and HEAD requests are supported. Got: ${request.method ?? "(none)"}`, + ), + ); + return; + } + }; +} + +function getContentType(fsPath: FsPath | IndexFsPath): string | undefined { + return MIME_TYPES[path.extname(toAbsolutePath(fsPath)).toLowerCase()]; +} + +function toAbsolutePath(fsPath: FsPath | IndexFsPath): AbsolutePath { + switch (fsPath.tag) { + case "FsPath": + return fsPath.theFsPath; + case "IndexFsPath": + return fsPath.theIndexFsPath; + } +} + +function serveFile( + fsPath: FsPath | IndexFsPath, + fsSize: number, + request: http.IncomingMessage, + response: http.ServerResponse, + extraContent?: string, +): void { + const contentType = + getContentType(fsPath) ?? + // esbuild defaults to `application/octet-stream`, but if you click a link + // to such a file, it causes a download which clutters your Downloads + // folder. This allows viewing the file instead, and it’s more likely to + // have plain text files than binary files in development repos. + "text/plain; charset=utf-8"; + const contentTypeHeader = { + "Content-Type": contentType, + }; + + switch (request.method) { + case "HEAD": + writeHead(response, 200, contentTypeHeader); + response.end(); + return; + + default: { + const rangeHeader = request.headers.range; + const range = + rangeHeader === undefined ? undefined : parseRangeHeader(rangeHeader); + const readStream = fs.createReadStream(toAbsolutePath(fsPath), range); + readStream.on("error", (error) => { + respondHtml( + response, + 500, + errorHtml(`Failed to read file\n\n${error.message}`), + ); + }); + readStream.on("open", () => { + if (range === undefined) { + writeHead(response, 200, { + ...contentTypeHeader, + "Content-Length": + fsSize + + (extraContent === undefined + ? 0 + : Buffer.byteLength(extraContent)), + }); + if (extraContent !== undefined) { + response.write(extraContent); + } + } else { + writeHead(response, 206, { + ...contentTypeHeader, + "Content-Range": `bytes ${range.start}-${range.end}/${fsSize}`, + "Content-Length": range.end - range.start + 1, + }); + } + }); + readStream.pipe(response, { end: true }); + return; + } + } +} + +type NotFileStat = "Directory" | "NotFound" | "Other"; + +function statSync( + absolutePath: AbsolutePath, +): { tag: "File"; size: number } | { tag: NotFileStat } { + try { + const stats = fs.statSync(absolutePath); + return stats.isFile() + ? { tag: "File", size: stats.size } + : stats.isDirectory() + ? { tag: "Directory" } + : { tag: "Other" }; + } catch (unknownError) { + const error = toError(unknownError); + if ( + error.code === "ENOENT" || // No such file or (parent) directory + error.code === "ENOTDIR" || // Some parent is not a directory + error.code === "ENAMETOOLONG" // Some part of the path is >255 characters + ) { + return { tag: "NotFound" }; + } + throw error; + } +} + +// This only supports what Safari sends when using the `