gale/server.ts

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();