246 lines
7.5 KiB
TypeScript
246 lines
7.5 KiB
TypeScript
import { contentType } from "jsr:@std/media-types";
|
|
import { ModuleProxy } from "./hmr/hmr-static.tsx";
|
|
|
|
const keyBundle = encodeURI(">");
|
|
const keyAdjacent = encodeURI("^");
|
|
const keyReload = "reload";
|
|
const keysExtension = ["ts", "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(RootRunning);
|
|
console.log(RootSiblings);
|
|
|
|
const bakeConfigPackage:Deno.bundle.Options =
|
|
{
|
|
entrypoints:[""],
|
|
platform: "browser",
|
|
format:"esm",
|
|
write: false,
|
|
minify: true,
|
|
}
|
|
const bakeConfigLocal:Deno.bundle.Options = {...bakeConfigPackage, minify:false, sourcemap:"inline", inlineImports:false };
|
|
async function BakeForce(path:string, type?:"package")
|
|
{
|
|
// If already baking, return the in-flight promise. (Caller may also call BakeCheck which handles this.)
|
|
if (BakeCache[path] && typeof (BakeCache[path] as any)?.then === "function")
|
|
{
|
|
return await BakeCache[path] as CachedTranspile | undefined;
|
|
}
|
|
|
|
// Create a fresh config per bake to avoid shared mutation.
|
|
const config = {...(type ? bakeConfigPackage : bakeConfigLocal)};
|
|
config.entrypoints = [...config.entrypoints];
|
|
config.entrypoints[0] = type ? path : RootRunning+path;
|
|
console.log("baking", config.entrypoints);
|
|
|
|
// 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 ? "" : ModuleProxy(body, path)];
|
|
BakeCache[path] = save; // replace promise with resolved value
|
|
return save;
|
|
}
|
|
}
|
|
catch (e)
|
|
{
|
|
console.log("BakeForce error for", path, e);
|
|
}
|
|
// failed - remove cache entry so next attempt can retry
|
|
delete BakeCache[path];
|
|
return undefined;
|
|
})();
|
|
|
|
BakeCache[path] = inflight;
|
|
return await inflight;
|
|
};
|
|
async function BakeCheck(path:string, type?:"package")
|
|
{
|
|
const lookup = BakeCache[path];
|
|
if(!lookup)
|
|
{
|
|
return await BakeForce(path, type);
|
|
}
|
|
// 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, profile:string]
|
|
// 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] = "/>/"+value;
|
|
}
|
|
}
|
|
|
|
denoBody.imports["react-original"] = denoBody.imports[denoBody.compilerOptions.jsxImportSource];
|
|
denoBody.imports["react"] = "/^/hmr/hmr-react.tsx";
|
|
|
|
console.log(denoBody.imports);
|
|
|
|
const importMap = `<script type="importmap">{"imports":${JSON.stringify(denoBody.imports)}}</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.onmessage = () => {};
|
|
socket.onerror = (e) => {console.log("Socket errored:", e);SocketsLive.delete(socket);}
|
|
return response;
|
|
}
|
|
catch(e){ console.log("Socket errored:", e); }
|
|
}
|
|
|
|
const url = new URL(req.url);
|
|
const parts = url.pathname.split("/").filter(part=>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)
|
|
{
|
|
const proxiedPath = parts.slice(1).join("/");
|
|
const transpiled = await BakeCheck(proxiedPath, "package");
|
|
return JSResponse(transpiled[0]);
|
|
}
|
|
if(parts[0] == keyAdjacent)
|
|
{
|
|
const proxiedPath = "/"+parts.slice(1).join("/");
|
|
const transpiled = await BakeCheck(proxiedPath);
|
|
return JSResponse(transpiled[0]);
|
|
}
|
|
if(keysExtension.includes(extension))
|
|
{
|
|
const transpiled = await BakeCheck(url.pathname);
|
|
return JSResponse(transpiled[url.searchParams.has(keyReload) ? 0 : 1]);
|
|
}
|
|
|
|
if(!extension)
|
|
{
|
|
return IndexResponse();
|
|
}
|
|
|
|
return new Response();
|
|
});
|
|
|
|
|
|
|
|
const SocketsLive:Set<WebSocket> = new Set();
|
|
const SocketsSend =(inData:string)=>
|
|
{
|
|
// iterate over a snapshot so we can remove while iterating
|
|
for (const socket of Array.from(SocketsLive))
|
|
{
|
|
try
|
|
{
|
|
if (socket.readyState === WebSocket.OPEN)
|
|
{
|
|
socket.send(inData);
|
|
}
|
|
else
|
|
{
|
|
// not open any more — remove it
|
|
SocketsLive.delete(socket);
|
|
}
|
|
}
|
|
catch (err)
|
|
{
|
|
console.log("Failed to send to socket:", err);
|
|
SocketsLive.delete(socket);
|
|
}
|
|
}
|
|
}
|
|
|
|
const Watcher =async()=>
|
|
{
|
|
let blocking = false;
|
|
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(Deno.cwd().length).replaceAll("\\", "/");
|
|
console.log("File change", path, key);
|
|
if(action != "remove")
|
|
{
|
|
await BakeForce(key);
|
|
SocketsSend(key);
|
|
}
|
|
else
|
|
{
|
|
delete BakeCache[key];
|
|
}
|
|
}
|
|
}
|
|
filesChanged.clear();
|
|
blocking = false;
|
|
}
|
|
, 1000);
|
|
}
|
|
}
|
|
}
|
|
|
|
Watcher(); |