Skip to content
Merged
2 changes: 2 additions & 0 deletions packages/fresh/src/build_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export interface BuildCache<State = any> {
root: string;
islandRegistry: ServerIslandRegistry;
clientEntry: string;
/** Pathname for the HMR-only chunk (development only). Undefined in production. */
hmrClientEntry?: string;
features: {
errorOverlay: boolean;
};
Expand Down
45 changes: 27 additions & 18 deletions packages/fresh/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,25 +357,34 @@ export class Context<State> {
}
throw err;
} finally {
// Add preload headers
// Add preload headers only when client JS is actually emitted.
const basePath = this.config.basePath;
const runtimeUrl = state.buildCache.clientEntry.startsWith(".")
? state.buildCache.clientEntry.slice(1)
: state.buildCache.clientEntry;
let link = `<${
encodeURI(`${basePath}${runtimeUrl}`)
}>; rel="modulepreload"; as="script"`;
state.islands.forEach((island) => {
const specifier = `${basePath}${
island.file.startsWith(".") ? island.file.slice(1) : island.file
}`;
link += `, <${
encodeURI(specifier)
}>; rel="modulepreload"; as="script"`;
});

if (link !== "") {
headers.append("Link", link);
const linkParts: string[] = [];

if (
state.needsClientRuntime ||
state.buildCache.hmrClientEntry !== undefined
) {
const runtimeUrl = state.buildCache.clientEntry.startsWith(".")
? state.buildCache.clientEntry.slice(1)
: state.buildCache.clientEntry;
linkParts.push(
`<${
encodeURI(`${basePath}${runtimeUrl}`)
}>; rel="modulepreload"; as="script"`,
);
state.islands.forEach((island) => {
const specifier = `${basePath}${
island.file.startsWith(".") ? island.file.slice(1) : island.file
}`;
linkParts.push(
`<${encodeURI(specifier)}>; rel="modulepreload"; as="script"`,
);
});
}

if (linkParts.length > 0) {
headers.append("Link", linkParts.join(", "));
}

renderNonce = state.nonce;
Expand Down
14 changes: 14 additions & 0 deletions packages/fresh/src/dev/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,13 @@ export class Builder<State = any> {
"fresh-runtime": new URL(runtimePath, import.meta.url).href,
};

if (dev) {
entryPoints["fresh-hmr"] = new URL(
"../runtime/client/dev_hmr.ts",
import.meta.url,
).href;
}

const namer = new UniqueNamer();
for (const spec of this.#islandSpecifiers) {
const specName = specToName(spec);
Expand Down Expand Up @@ -353,6 +360,13 @@ export class Builder<State = any> {
buildCache.islandModNameToChunk.get(name)!.browser = pathname;
}

if (dev) {
const hmrChunkName = output.entryToChunk.get("fresh-hmr");
if (hmrChunkName !== undefined) {
buildCache.hmrClientEntry = `${prefix}${hmrChunkName}`;
}
}

for (let i = 0; i < output.files.length; i++) {
const file = output.files[i];
const pathname = `${prefix}${file.path}`;
Expand Down
2 changes: 2 additions & 0 deletions packages/fresh/src/dev/dev_build_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export class MemoryBuildCache<State> implements DevBuildCache<State> {
root: string;
islandRegistry: ServerIslandRegistry = new Map();
clientEntry: string;
hmrClientEntry: string | undefined = undefined;
features = { errorOverlay: false };

constructor(
Expand Down Expand Up @@ -236,6 +237,7 @@ export class DiskBuildCache<State> implements DevBuildCache<State> {
root: string;
islandRegistry: ServerIslandRegistry = new Map();
clientEntry: string = "";
hmrClientEntry: string | undefined = undefined;
features = { errorOverlay: false };

constructor(
Expand Down
135 changes: 1 addition & 134 deletions packages/fresh/src/runtime/client/dev.ts
Original file line number Diff line number Diff line change
@@ -1,136 +1,3 @@
import "preact/debug";
export * from "./mod.ts";
import { IS_BROWSER } from "../shared.ts";

let ws: WebSocket;
let revision = 0;

let reconnectTimer: number;
const backoff = [
// Wait 100ms initially, because we could also be
// disconnected because of a form submit.
100,
150,
200,
250,
300,
350,
400,
450,
500,
500,
605,
750,
1000,
1250,
1500,
1750,
2000,
];
let backoffIdx = 0;
function reconnect() {
if (ws.readyState !== ws.CLOSED) return;

reconnectTimer = setTimeout(() => {
if (backoffIdx === 0) {
// deno-lint-ignore no-console
console.log(
`%c Fresh %c Connection closed. Trying to reconnect...`,
"background-color: #86efac; color: black",
"color: inherit",
);
}
backoffIdx++;

try {
connect();
clearTimeout(reconnectTimer);
} catch (_err) {
reconnect();
}
}, backoff[Math.min(backoffIdx, backoff.length - 1)]);
}

function onOpenWs() {
backoffIdx = 0;
}

function onCloseWs() {
disconnect();
reconnect();
}

function connect() {
const url = new URL("/_frsh/alive", location.origin.replace("http", "ws"));
ws = new WebSocket(
url,
);

ws.addEventListener("open", onOpenWs);
ws.addEventListener("close", onCloseWs);
ws.addEventListener("message", handleMessage);
ws.addEventListener("error", handleError);
}

function disconnect() {
ws.removeEventListener("open", onOpenWs);
ws.removeEventListener("close", onCloseWs);
ws.removeEventListener("message", handleMessage);
ws.removeEventListener("error", handleError);
ws.close();
}

function handleMessage(e: MessageEvent) {
const data = JSON.parse(e.data);
switch (data.type) {
case "initial-state": {
if (revision === 0) {
// deno-lint-ignore no-console
console.log(
`%c Fresh %c Connected to development server.`,
"background-color: #86efac; color: black",
"color: inherit",
);
}

if (revision === 0) {
revision = data.revision;
} else if (revision < data.revision) {
disconnect();
// Needs reload
location.reload();
}
}
}
}

function handleError(e: Event) {
// TODO
// deno-lint-ignore no-explicit-any
if (e && (e as any).code === "ECONNREFUSED") {
setTimeout(connect, 1000);
}
}

if (IS_BROWSER) {
connect();

addEventListener("message", (ev) => {
if (ev.origin !== location.origin) return;
if (typeof ev.data !== "string" || ev.data !== "close-error-overlay") {
return;
}

document.querySelector("#fresh-error-overlay")?.remove();
});

// Disconnect when the tab becomes inactive and re-connect when it
// becomes active again
addEventListener("visibilitychange", () => {
if (document.hidden) {
disconnect();
} else {
connect();
}
});
}
export * from "./dev_hmr.ts";
134 changes: 134 additions & 0 deletions packages/fresh/src/runtime/client/dev_hmr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { IS_BROWSER } from "../shared.ts";

let ws: WebSocket;
let revision = 0;

let reconnectTimer: number;
const backoff = [
// Wait 100ms initially, because we could also be
// disconnected because of a form submit.
100,
150,
200,
250,
300,
350,
400,
450,
500,
500,
605,
750,
1000,
1250,
1500,
1750,
2000,
];
let backoffIdx = 0;
function reconnect() {
if (ws.readyState !== ws.CLOSED) return;

reconnectTimer = setTimeout(() => {
if (backoffIdx === 0) {
// deno-lint-ignore no-console
console.log(
`%c Fresh %c Connection closed. Trying to reconnect...`,
"background-color: #86efac; color: black",
"color: inherit",
);
}
backoffIdx++;

try {
connect();
clearTimeout(reconnectTimer);
} catch (_err) {
reconnect();
}
}, backoff[Math.min(backoffIdx, backoff.length - 1)]);
}

function onOpenWs() {
backoffIdx = 0;
}

function onCloseWs() {
disconnect();
reconnect();
}

function connect() {
const url = new URL("/_frsh/alive", location.origin.replace("http", "ws"));
ws = new WebSocket(
url,
);

ws.addEventListener("open", onOpenWs);
ws.addEventListener("close", onCloseWs);
ws.addEventListener("message", handleMessage);
ws.addEventListener("error", handleError);
}

function disconnect() {
ws.removeEventListener("open", onOpenWs);
ws.removeEventListener("close", onCloseWs);
ws.removeEventListener("message", handleMessage);
ws.removeEventListener("error", handleError);
ws.close();
}

function handleMessage(e: MessageEvent) {
const data = JSON.parse(e.data);
switch (data.type) {
case "initial-state": {
if (revision === 0) {
// deno-lint-ignore no-console
console.log(
`%c Fresh %c Connected to development server.`,
"background-color: #86efac; color: black",
"color: inherit",
);
}

if (revision === 0) {
revision = data.revision;
} else if (revision < data.revision) {
disconnect();
// Needs reload
location.reload();
}
}
}
}

function handleError(e: Event) {
// TODO
// deno-lint-ignore no-explicit-any
if (e && (e as any).code === "ECONNREFUSED") {
setTimeout(connect, 1000);
}
}

if (IS_BROWSER) {
connect();

addEventListener("message", (ev) => {
if (ev.origin !== location.origin) return;
if (typeof ev.data !== "string" || ev.data !== "close-error-overlay") {
return;
}

document.querySelector("#fresh-error-overlay")?.remove();
});

// Disconnect when the tab becomes inactive and re-connect when it
// becomes active again
addEventListener("visibilitychange", () => {
if (document.hidden) {
disconnect();
} else {
connect();
}
});
}
Loading
Loading