278 lines
8.6 KiB
TypeScript
278 lines
8.6 KiB
TypeScript
import { contentType } from "jsr:@std/media-types";
|
|
import { ModuleShape, type Shape } from "./hmr/hmr-static.tsx";
|
|
|
|
const keyBundle = "=>";
|
|
const keyTranspile = "->";
|
|
const keyReload = "hmr:id";
|
|
const keysExtension = ["ts", "tsx"];
|
|
const keyReactProxy = `/${keyTranspile}/${import.meta.resolve("./hmr/hmr-listen.tsx")}`
|
|
const extractExtension =(path:string)=>
|
|
{
|
|
const ind = path.lastIndexOf(".");
|
|
return ind === -1 ? "" : path.substring(ind+1);
|
|
}
|
|
|
|
const RootRunning = new URL(`file://${Deno.cwd()}`).toString() + "/";
|
|
const RootSiblings = import.meta.resolve("./");
|
|
|
|
console.log("Root Running:", RootRunning);
|
|
console.log("Root Siblings:", RootSiblings);
|
|
|
|
const bakeConfigPackage:Deno.bundle.Options =
|
|
{
|
|
entrypoints:[""],
|
|
platform: "browser",
|
|
format:"esm",
|
|
write: false,
|
|
minify: false,
|
|
}
|
|
const bakeConfigLocal:Deno.bundle.Options = {...bakeConfigPackage, sourcemap:"inline", inlineImports:false };
|
|
|
|
type FullBakeConfig = {
|
|
key:string,
|
|
bundle?:boolean
|
|
};
|
|
|
|
async function BakeForce(options:FullBakeConfig)
|
|
{
|
|
const key = options.key;
|
|
const type = options.bundle;
|
|
|
|
// If already baking, return the in-flight promise. (Caller may also call Bake Check which handles this.)
|
|
if (BakeCache[key] && typeof (BakeCache[key] as any)?.then === "function")
|
|
{
|
|
return await BakeCache[key] as CachedTranspile | undefined;
|
|
}
|
|
|
|
// Create a fresh config per bake to avoid shared mutation.
|
|
const config = {...(type ? bakeConfigPackage : bakeConfigLocal), entrypoints:[key]};
|
|
console.log("baking", config.entrypoints, "as", key);
|
|
|
|
// store the in-flight promise immediately so concurrent callers reuse it
|
|
const inflight = (async () =>
|
|
{
|
|
try
|
|
{
|
|
const result = await Deno.bundle(config);
|
|
if (result.outputFiles)
|
|
{
|
|
const body = result.outputFiles.map(file=>file.text()).join("\n");
|
|
const save:CachedTranspile = [body, type ? undefined : ModuleShape(body)];
|
|
BakeCache[key] = save; // replace promise with resolved value
|
|
return save;
|
|
}
|
|
}
|
|
catch (e)
|
|
{
|
|
console.log("Bake error", key, e);
|
|
}
|
|
// failed - remove cache entry so next attempt can retry
|
|
delete BakeCache[key];
|
|
return undefined;
|
|
})();
|
|
|
|
BakeCache[key] = inflight;
|
|
return await inflight;
|
|
};
|
|
async function BakeCheck(options:FullBakeConfig)
|
|
{
|
|
const key = options.key || options.path;
|
|
|
|
const lookup = BakeCache[key];
|
|
if(!lookup)
|
|
{
|
|
return await BakeForce(options);
|
|
}
|
|
// if an in-flight promise is stored, await it
|
|
if (typeof (lookup as any)?.then === "function")
|
|
{
|
|
return await lookup as CachedTranspile | undefined;
|
|
}
|
|
return lookup as CachedTranspile;
|
|
}
|
|
type CachedTranspile = [file:string, shape?:Shape]
|
|
// BakeCache may hold a resolved cached tuple or an in-flight Promise that resolves to one.
|
|
const BakeCache:Record<string, CachedTranspile | Promise<CachedTranspile|undefined> | undefined> = {}
|
|
|
|
const denoBody = await fetch(RootRunning+"deno.json").then(resp=>resp.json());
|
|
for(const key in denoBody.imports)
|
|
{
|
|
const value = denoBody.imports[key];
|
|
if(value.startsWith("npm:"))
|
|
{
|
|
denoBody.imports[key] = `/${keyBundle}/${value}`;
|
|
}
|
|
}
|
|
const reactKey = denoBody.compilerOptions.jsxImportSource;
|
|
denoBody.imports["react-original"] = denoBody.imports[reactKey];
|
|
const reactValueModified = `/${keyTranspile}/${import.meta.resolve("./hmr/hmr-react.tsx")}`;
|
|
denoBody.imports[reactKey] = reactValueModified
|
|
denoBody.imports[reactKey+"/jsx-runtime"] = reactValueModified;
|
|
|
|
|
|
console.log(denoBody.imports);
|
|
const importMap = `<script type="importmap">{"imports":${JSON.stringify(denoBody.imports, null, 2)}}</script>`;
|
|
|
|
let htmlPageBody = await fetch(RootRunning+"index.html").then(resp=>resp.text());
|
|
htmlPageBody = htmlPageBody.replace("<head>", "<head>"+importMap);
|
|
const htmlPageHead = {headers:{"content-type":"text/html"}}
|
|
const IndexResponse =()=> new Response(htmlPageBody, htmlPageHead);
|
|
|
|
const JSHead = {headers:{"content-type":"application/javascript"}};
|
|
const JSResponse =(body:string)=>
|
|
{
|
|
const encoder = new TextEncoder();
|
|
const stream = new ReadableStream({
|
|
start(controller)
|
|
{
|
|
controller.enqueue(encoder.encode(body));
|
|
controller.close();
|
|
}
|
|
});
|
|
return new Response(stream, JSHead);
|
|
}
|
|
|
|
|
|
Deno.serve(async(req:Request)=>
|
|
{
|
|
if(req.headers.get("upgrade") == "websocket")
|
|
{
|
|
try
|
|
{
|
|
const { response, socket } = Deno.upgradeWebSocket(req);
|
|
socket.onopen = () => SocketsLive.add(socket);
|
|
socket.onclose = () => SocketsLive.delete(socket);
|
|
socket.onerror = (e) => { console.log("socket error", e); SocketsLive.delete(socket); };
|
|
socket.onmessage = () => {};
|
|
return response;
|
|
}
|
|
catch(e){ console.log("upgradeWebSocket failed", e); }
|
|
}
|
|
|
|
const url = new URL(req.url);
|
|
const parts = url.pathname.split("/").filter(part=>part).map(part=>decodeURIComponent(part));
|
|
|
|
// if there are no path segments, serve index immediately (avoid calling extractExtension on undefined)
|
|
if(parts.length === 0)
|
|
{
|
|
return IndexResponse();
|
|
}
|
|
const lastPart = parts.at(-1) ?? "";
|
|
const extension = extractExtension(lastPart);
|
|
|
|
if(parts[0] == keyBundle)
|
|
{
|
|
console.log("BUNDLE", parts);
|
|
const transpiled = await BakeCheck({
|
|
key: parts.slice(1).join("/"),
|
|
bundle:true
|
|
});
|
|
return JSResponse(transpiled[0]);
|
|
}
|
|
if(parts[0] == keyTranspile)
|
|
{
|
|
console.log("TRANSPILE", parts);
|
|
const transpiled = await BakeCheck({
|
|
key:parts.slice(1).join("/"),
|
|
bundle:false
|
|
});
|
|
return JSResponse(transpiled[0]);
|
|
}
|
|
if(keysExtension.includes(extension))
|
|
{
|
|
console.log("REGULAR", parts);
|
|
const key = "./"+parts.join("/");
|
|
|
|
const [body, [local, foreign]] = await BakeCheck({key, bundle:false});
|
|
if(url.searchParams.has(keyReload))
|
|
{
|
|
return JSResponse(body);
|
|
}
|
|
else
|
|
{
|
|
const params = new URLSearchParams(url.searchParams);
|
|
params.set(keyReload, new Date().getTime().toString());
|
|
|
|
return JSResponse(`
|
|
import {FileListen} from "${keyReactProxy}";
|
|
import * as Import from "${key + "?" + params.toString()}";
|
|
${ local.map(m=>`let proxy_${m} = Import.${m}; export { proxy_${m} as ${m} };`).join("\n") }
|
|
FileListen("${key}", (updatedModule)=>
|
|
{
|
|
${ local.map(m=>`proxy_${m} = updatedModule.${m};`).join("\n\t") }
|
|
});
|
|
${ foreign.join(";\n") }`
|
|
);
|
|
}
|
|
}
|
|
|
|
if(!extension)
|
|
{
|
|
return IndexResponse();
|
|
}
|
|
|
|
return new Response();
|
|
});
|
|
|
|
const SocketsLive = new Set<WebSocket>();
|
|
|
|
const SocketsSend = (inData: string) => {
|
|
for (const socket of Array.from(SocketsLive)) {
|
|
try {
|
|
if (socket.readyState === WebSocket.OPEN) {
|
|
socket.send(inData);
|
|
} else {
|
|
SocketsLive.delete(socket);
|
|
}
|
|
} catch (e) {
|
|
console.log("socket send failed:", e);
|
|
SocketsLive.delete(socket);
|
|
}
|
|
}
|
|
};
|
|
|
|
const Watcher =async()=>
|
|
{
|
|
let blocking = false;
|
|
const cutOff = Deno.cwd().length + 1;
|
|
const filesChanged:Map<string, string> = new Map();
|
|
for await (const event of Deno.watchFs(Deno.cwd()))
|
|
{
|
|
event.paths.forEach( path => filesChanged.set(path, event.kind) );
|
|
if(!blocking)
|
|
{
|
|
blocking = true;
|
|
setTimeout(async()=>
|
|
{
|
|
// filesChanged is a Map, iterate normally
|
|
for (const [path, action] of filesChanged)
|
|
{
|
|
const extension = extractExtension(path);
|
|
|
|
if(keysExtension.includes(extension))
|
|
{
|
|
|
|
const key = path.substring(cutOff).replaceAll("\\", "/");
|
|
const keyAbs = "/"+key;
|
|
const keyRel = "./"+key;
|
|
console.log("File change", path, key);
|
|
if(action != "remove")
|
|
{
|
|
await BakeForce({key:keyRel, bundle:false});
|
|
SocketsSend(keyRel);
|
|
}
|
|
else
|
|
{
|
|
delete BakeCache[keyRel];
|
|
}
|
|
}
|
|
}
|
|
filesChanged.clear();
|
|
blocking = false;
|
|
}
|
|
, 1000);
|
|
}
|
|
}
|
|
}
|
|
|
|
Watcher(); |