Merge pull request 'boot-function' (#1) from boot-function into master

Reviewed-on: #1
This commit is contained in:
SethTrowbridge 2023-06-21 07:53:13 -04:00
commit 6a5d97677a
10 changed files with 699 additions and 59 deletions

View File

@ -1,4 +1,5 @@
{
"deno.enable": true,
"deno.unstable": true
"deno.unstable": true,
"deno.config": "./deno.json"
}

15
_lib_/boot.tsx Normal file
View File

@ -0,0 +1,15 @@
import "../serve.tsx";
Deno.args.forEach(arg=>
{
if(arg.startsWith("--"))
{
const kvp = arg.substring(2).split("=");
Deno.env.set(kvp[0], kvp[1] || "true");
}
});
if(Deno.env.get("dev"))
{
await import("../local.tsx");
}

74
_lib_/hmr.tsx Normal file
View File

@ -0,0 +1,74 @@
import { type StateCapture } from "./react.tsx";
const FileListeners = new Map() as Map<string, Array<(module:unknown)=>void>>;
export const FileListen =(inPath:string, inHandler:()=>void)=>
{
const members = FileListeners.get(inPath)??[];
members.push(inHandler);
FileListeners.set(inPath, members);
};
const Socket:WebSocket = new WebSocket("ws://"+document.location.host);
Socket.addEventListener('message', async(event:{data:string})=>
{
// When a file changes, dynamically re-import it to get the updated members
// send the updated members to any listeners for that file
const reImport = await import(event.data+"?reload="+Math.random());
FileListeners.get(event.data)?.forEach(reExport=>reExport(reImport));
HMR.update();
});
Socket.addEventListener("error", ()=>{clearInterval(SocketTimer); console.log("HMR socket lost")})
const SocketTimer = setInterval(()=>{Socket.send("ping")}, 5000);
/*
Each custom component is secretly modified to have an extra state and id.
When there is an HMR update, this state is changed, forcing it to re-render.
Each *user-created* React.useState is secretly modified and accompanied by an ID.
Every time its state is set, the HMR.statesNew map for this ID is set to contain the new state and updater.
When a component is removed, any of it's states in HMR.statesNew are also removed.
(HMR.statesNew is the "running total" of all states currently at play).
---
When a state is interacted with:
- statesNew for this id is set
- the internal state is also set in the traditional way
When there is an HMR update:
- All custom components are re-rendered...
for each useState(value) call that then happens in the re-render:
- if there is a "statesOld" value for this state, use that and ignore the passed value, otherwise use the passed value
- if this state has not been interacted with since the last reload (statesNew is empty at this id), set statesNew<id> with whatever is in statesOld<id>
- statesNew is moved into *statesOld*
- statesNew is cleared.
*/
const HMR =
{
reloads:1,
RegisteredComponents: new Map() as Map<string, ()=>void>,
statesNew: new Map() as Map<string, StateCapture>,
statesOld: new Map() as Map<string, StateCapture>,
wireframe: false,
RegisterComponent(reactID:string, value:()=>void):void
{
this.RegisteredComponents.set(reactID, value);
},
update()
{
this.reloads++;
this.RegisteredComponents.forEach(handler=>handler());
this.RegisteredComponents.clear();
this.statesOld = this.statesNew;
this.statesNew = new Map();
}
};
export {HMR};

89
_lib_/mount.tsx Normal file
View File

@ -0,0 +1,89 @@
import React from "react";
import * as TW from "https://esm.sh/@twind/core@1.0.1";
import TWPreTail from "https://esm.sh/@twind/preset-tailwind@1.0.1";
import TWPreAuto from "https://esm.sh/@twind/preset-autoprefix@1.0.1";
const Configure =
{
theme: {},
presets: [TWPreTail(), TWPreAuto()],
hash: false
} as TW.TwindUserConfig;
export const Shadow =(inElement:HTMLElement, inConfig?:TW.TwindUserConfig)=>
{
const merge = inConfig ? {...Configure, ...inConfig} : Configure;
const ShadowDOM = inElement.attachShadow({ mode: "open" });
const ShadowDiv = document.createElement("div");
const ShadowCSS = document.createElement("style");
ShadowDOM.append(ShadowCSS);
ShadowDOM.append(ShadowDiv);
TW.observe(TW.twind(merge, TW.cssom(ShadowCSS)), ShadowDiv);
return ShadowDiv;
};
let booted = false;
export const Boot =async(inSettings:{App:()=>React.JSX.Element, CSS?:TW.TwindUserConfig, DOM?:string})=>
{
if(booted){return;}
booted = true;
const settings = {CSS:{...Configure, ...inSettings.CSS||{} }, DOM:inSettings.DOM||"#app", App:inSettings.App};
console.log("Clinet boot called")
let dom = document.querySelector(settings.DOM);
if(!dom)
{
console.log(`element "${settings.DOM}" not found.`);
return false;
}
dom = Shadow(dom as HTMLElement, settings.CSS)
const app = React.createElement(()=> React.createElement(settings.App, null), null);
if(React.render)
{
React.render(app, dom);
return ()=>dom && React.unmountComponentAtNode(dom);
}
else
{
const reactDom = await import(`https://esm.sh/react-dom@${React.version}/client`);
const root = reactDom.createRoot(dom);
root.render(app);
return root.unmount;
}
};
export default async(inSelector:string, inModulePath:string, inMemberApp="default", inMemberCSS="CSS"):Promise<(()=>void)|false>=>
{
let dom = document.querySelector(inSelector);
if(!dom)
{
console.log(`element "${inSelector}" not found.`);
return false;
}
const module = await import(inModulePath);
dom = Shadow(dom as HTMLElement, module[inMemberCSS])
const app = React.createElement(()=> React.createElement(module[inMemberApp], null), null);
if(React.render)
{
React.render(app, dom);
return ()=>dom && React.unmountComponentAtNode(dom);
}
else
{
const reactDom = await import(`https://esm.sh/react-dom@${React.version}/client`);
const root = reactDom.createRoot(dom);
root.render(app);
return root.unmount;
}
};

132
_lib_/react.tsx Normal file
View File

@ -0,0 +1,132 @@
import * as ReactParts from "react-original";
import { HMR } from "./hmr.tsx";
export type StateType = boolean|number|string|Record<string, string>
export type StateCapture = {state:StateType, set:ReactParts.StateUpdater<StateType>, reload:number};
type FuncArgs = [element:keyof ReactParts.JSX.IntrinsicElements, props:Record<string, string>, children:ReactParts.JSX.Element[]];
const H = ReactParts.createElement;
const MapIndex =(inMap:Map<string, StateCapture>, inIndex:number)=>
{
let index = 0;
for(const kvp of inMap)
{
if(index == inIndex)
{
return kvp;
}
index++;
}
return false;
};
const ProxyCreate =(...args:FuncArgs)=> (typeof args[0] == "string") ? H(...args) : H(ProxyElement, {__args:args, ...args[1]});
const ProxyElement = (props:{__args:FuncArgs})=>
{
const [stateGet, stateSet] = ReactParts.useState(0);
const id = ReactParts.useId();
HMR.RegisterComponent(id, ()=>stateSet(stateGet+1));
const child = H(...props.__args);
if(HMR.wireframe)
{
return H("div", {style:{padding:"10px", border:"2px solid red"}},
H("p", null, stateGet),
child
);
}
else
{
return child;
}
};
const ProxyState =(argNew:StateType)=>
{
// does statesOld have an entry for this state? use that instead of the passed arg
const check = MapIndex(HMR.statesOld, HMR.statesNew.size);
const argOld = check ? check[1].state : argNew;
const id = ReactParts.useId();
const [stateGet, stateSet] = ReactParts.useState(argOld);
// state updates due to clicks, interactivity, etc. since the last reload may already be in statesNew for this slot.
// DONT overwrite it.
if(!HMR.statesNew.get(id))
{
HMR.statesNew.set(id, {state:stateGet, set:stateSet, reload:HMR.reloads});
}
const lastKnowReloads = HMR.reloads;
ReactParts.useEffect(()=>{
return ()=>{
if(HMR.reloads == lastKnowReloads)/*i have no idea what this does. this may have to be re-introduced when routing is added*/
{
// this is a switch/ui change, not a HMR reload change
const oldState = MapIndex(HMR.statesOld, HMR.statesNew.size-1);
oldState && HMR.statesOld.set(oldState[0], {...oldState[1], state:argNew});
console.log("check: ui-invoked")
}
HMR.statesNew.delete(id);
}
}, []);
// do we need to account for the function set?
function proxySetter ( inArg:StateType|((old:StateType)=>StateType) )
{
const stateUser = {state:inArg as StateType, set:stateSet, reload:HMR.reloads};
if(typeof inArg == "function")
{
//const passedFunction = inArg;
stateSet((oldState:StateType)=>
{
const output = inArg(oldState);
stateUser.state = output;
HMR.statesNew.set(id, stateUser);
return output;
});
}
else
{
HMR.statesNew.set(id, stateUser);
stateSet(inArg);
}
}
return [stateGet, proxySetter];
};
type Storelike = Record<string, string>
const ProxyReducer =(inReducer:(inState:Storelike, inAction:string)=>Storelike, inState:Storelike, inInit?:(inState:Storelike)=>Storelike)=>
{
const check = MapIndex(HMR.statesOld, HMR.statesNew.size);
const argOld = check ? check[1].state : (inInit ? inInit(inState) : inState);
const intercept =(inInterceptState:Storelike, inInterceptAction:string)=>
{
const capture = inReducer(inInterceptState, inInterceptAction);
const stateUser = {state:capture, set:()=>{}, reload:HMR.reloads};
HMR.statesNew.set(id, stateUser);
console.log("interepted reducer", stateUser);
return capture;
};
const id = ReactParts.useId();
const [state, dispatch] = ReactParts.useReducer(intercept, argOld as Storelike);
if(!HMR.statesNew.get(id))
{
HMR.statesNew.set(id, {state:state, set:()=>{}, reload:HMR.reloads});
}
return [state, dispatch];
};
export * from "react-original";
export {ProxyCreate as createElement, ProxyState as useState, ProxyReducer as useReducer };
export default {...ReactParts, createElement:ProxyCreate, useState:ProxyState, useReducer:ProxyReducer};

17
deno.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": { "lib": ["deno.window", "dom"],
"jsx": "react-jsx",
"jsxImportSource": "https://esm.sh/preact@10.15.1/compat"
},
"imports":
{
"react":"https://esm.sh/preact@10.15.1/compat",
"react-original":"https://esm.sh/preact@10.15.1/compat",
"@able/": "./_lib_/"
},
"tasks":
{
"local": "deno run -A --no-lock ./local.tsx",
"serve": "deno run -A --no-lock ./serve.tsx"
}
}

53
example/app.tsx Normal file
View File

@ -0,0 +1,53 @@
import "@able/boot.tsx";
import React from "react";
const CTXString = React.createContext("lol");
type StateBinding<T> = [get:T, set:React.StateUpdater<T>];
const CTXState = React.createContext(null) as React.Context<StateBinding<number>|null>;
const Outer =(props:{children:React.JSX.Element})=>
{
const binding = React.useState(11);
return <CTXState.Provider value={binding}>
{props.children}
</CTXState.Provider>
};
const Inner =()=>
{
const [stateGet, stateSet] = React.useContext(CTXState) || ["default", ()=>{}];
return <button onClick={e=>stateSet((old)=>old+1)}>count: {stateGet} :)</button>
};
type Store = {name:string, age:number}
const reducer =(inState:Store, inAction:number)=>
{
return {...inState, age:inState.age+inAction};
}
const builder =(inState:Store):Store=>
{
inState.age = 100;
return inState;
}
export default ()=>
{
const [Store, Dispatch] = React.useReducer(reducer, {name:"seth", age:24} as Store, builder)
return <CTXString.Provider value="intradestink">
<div class="my-4 font-sans">
<h1 class="font-black text-xl text-red-500">Title????</h1>
<h2>subtitle!</h2>
<p>
<button onClick={e=>Dispatch(1)}>{Store.name}|{Store.age}?</button>
</p>
</div>
<Outer>
<Inner/>
</Outer>
<Outer>
<Inner/>
</Outer>
</CTXString.Provider>;
}

13
example/deno.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": { "lib": ["deno.window", "dom"] },
"imports":
{
"react":"https://esm.sh/preact@10.15.1/compat",
"@able/":"http://localhost:4507/_lib_/"
},
"tasks":
{
"local": "deno run -A --no-lock --reload=http://localhost:4507 app.tsx --dev",
"serve": "deno run -A --no-lock --reload=http://localhost:4507 app.tsx"
}
}

100
local.tsx Normal file
View File

@ -0,0 +1,100 @@
import {Configure, Transpile, Extension} from "./serve.tsx";
const SocketsLive:Set<WebSocket> = new Set();
const SocketsSend =(inData:string)=>{ console.log(inData); for (const socket of SocketsLive){ socket.send(inData); } }
Configure({
SWCOp:
{
sourceMaps: "inline",
minify: false,
jsc:
{
target:"es2022",
parser:
{
syntax: "typescript",
tsx: true,
}
}
},
Remap: (inImports)=>
{
inImports["react-original"] = inImports["react"];
inImports["react"] = "/_lib_/react.tsx";
return inImports;
},
async Serve(inReq, inURL, inExt, inMap, inProxy)
{
if(Transpile.Check(inExt) && !inURL.searchParams.get("reload") && !inURL.pathname.startsWith("/_lib_/"))
{
const imp = await import(inProxy+inURL.pathname);
const members = [];
for( const key in imp ) { members.push(key); }
const code =`
import {FileListen} from "/_lib_/hmr.tsx";
import * as Import from "${inURL.pathname}?reload=0";
${ members.map(m=>`let proxy_${m} = Import.${m}; export { proxy_${m} as ${m} };`).join("\n") }
FileListen("${inURL.pathname}", (updatedModule)=>
{
${ members.map(m=>`proxy_${m} = updatedModule.${m};`).join("\n") }
});`
return new Response(code, {headers:{"content-type":"application/javascript"}});
}
if(inReq.headers.get("upgrade") == "websocket")
{
try
{
const { response, socket } = Deno.upgradeWebSocket(inReq);
socket.onopen = () => SocketsLive.add(socket);
socket.onclose = () => SocketsLive.delete(socket);
socket.onmessage = (e) => {};
socket.onerror = (e) => console.log("Socket errored:", e);
return response;
}
catch(e){ /**/ }
}
}
});
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()=>
{
for await (const [path, action] of filesChanged)
{
if(Transpile.Check(Extension(path)))
{
const key = path.substring(Deno.cwd().length).replaceAll("\\", "/");
if(action != "remove")
{
const tsx = await Transpile.Fetch(`file://${Deno.cwd().replaceAll("\\", "/")}`+key, key, true);
tsx && SocketsSend(key);
}
else
{
Transpile.Cache.delete(key);
}
}
}
filesChanged.clear();
blocking = false;
}
, 1000);
}
}
}
Watcher().then(()=>console.log("done watching"));

262
serve.tsx
View File

@ -2,45 +2,133 @@ import * as MIME from "https://deno.land/std@0.180.0/media_types/mod.ts";
import * as HTTP from "https://deno.land/std@0.177.0/http/server.ts";
import * as SWCW from "https://esm.sh/@swc/wasm-web@1.3.62";
type Configuration = {Proxy:string, Allow:string, Reset:string};
type ConfigurationArgs = {Proxy?:string, Allow?:string, Reset?:string};
let Configure:Configuration =
type DenoConfig = {imports:Record<string, string>};
const ImportMap:DenoConfig = {imports:{}};
const ImportMapReload =async()=>
{
Proxy: "",
Allow: "*",
Reset: "/clear-cache"
};
export default (config:ConfigurationArgs)=> Configure = {...Configure, ...config};
const TranspileConfig:SWCW.Options = {
sourceMaps: true,
minify: true,
jsc:
{
minify:
let json:DenoConfig;
const path = Configuration.Proxy+"/deno.json";
try
{
compress: { unused: true },
mangle: true
},
parser:
{
syntax: "typescript",
tsx: true,
},
transform:
{
react: { runtime: "automatic" }
const resp = await fetch(path);
json = await resp.json();
if(!json?.imports)
{ throw new Error("imports not specified in deno.json") }
}
},
}
const TranspileCache:Map<string, string> = new Map();
const TranspileFetch =async(inPath:string)=>
{
if(inPath.endsWith(".tsx") || inPath.endsWith(".jsx") || inPath.endsWith(".js") || inPath.endsWith(".mjs"))
catch(e)
{
const check = TranspileCache.get(inPath);
if(check)
console.log(`error reading deno config "${path}" message:"${e}"`);
return;
}
Object.entries(json.imports).forEach(([key, value])=>
{
if(value.startsWith("./"))
{
json.imports[key] = value.substring(1);
}
});
if(!json.imports["@able/"])
{
console.log(`"@able/" specifier not defined in import map`);
}
json.imports["@able/"] = "/_lib_/";
if(!json.imports["react"])
{
console.log(`"react" specifier not defined in import map`);
}
ImportMap.imports = Configuration.Remap(json.imports);
console.log(ImportMap.imports);
};
type CustomHTTPHandler = (inReq:Request, inURL:URL, inExt:string|false, inMap:{imports:Record<string, string>}, inProxy:string)=>void|false|Response|Promise<Response|void|false>;
type CustomRemapper = (inImports:Record<string, string>)=>Record<string, string>;
type Configuration = {Proxy:string, Allow:string, Reset:string, SWCOp:SWCW.Options, Serve:CustomHTTPHandler, Shell:CustomHTTPHandler, Remap:CustomRemapper};
type ConfigurationArgs = {Proxy?:string, Allow?:string, Reset?:string, SWCOp?:SWCW.Options, Serve?:CustomHTTPHandler, Shell?:CustomHTTPHandler, Remap?:CustomRemapper};
let Configuration:Configuration =
{
Proxy: new URL(`file://${Deno.cwd().replaceAll("\\", "/")}`).toString(),
Allow: "*",
Reset: "/clear-cache",
Serve(inReq, inURL, inExt, inMap, inProxy){},
Remap: (inImports)=>
{
const reactURL = inImports["react"];
const setting = Configuration.SWCOp?.jsc?.transform?.react;
if(setting && reactURL)
{
setting.importSource = reactURL;
}
return inImports;
},
Shell(inReq, inURL, inExt, inMap, inProxy)
{
console.log("Start app:", Deno.mainModule, "start dir", inProxy);
console.log("Split:", Deno.mainModule.split(inProxy) );
const parts = Deno.mainModule.split(inProxy);
return new Response(
`<!doctype html>
<html>
<head>
</head>
<body>
<div id="app"></div>
<script type="importmap">${JSON.stringify(inMap)}</script>
<script type="module">
import Mount from "/_lib_/mount.tsx";
Mount("#app", "${parts[1]??"/app.tsx"}");
</script>
</body>
</html>`, {status:200, headers:{"content-type":"text/html"}});
},
SWCOp:
{
sourceMaps: false,
minify: true,
jsc:
{
target:"es2022",
minify:
{
compress: { unused: true },
mangle: true
},
parser:
{
syntax: "typescript",
tsx: true,
},
transform:
{
react: { runtime: "automatic" }
}
}
}
};
export const Transpile =
{
Cache: new Map() as Map<string, string>,
Files: ["tsx", "jsx", "ts", "js", "mjs"],
Check(inExtension:string|false)
{
return inExtension ? this.Files.includes(inExtension) : false;
},
Clear()
{
const size = this.Cache.size;
this.Cache.clear();
ImportMapReload();
return size;
},
Fetch: async function(inPath:string, inKey:string, inCheckCache=true)
{
const check = this.Cache.get(inPath);
if(check && inCheckCache)
{
return check;
}
@ -48,52 +136,110 @@ const TranspileFetch =async(inPath:string)=>
{
try
{
const resp = await fetch(Configure.Proxy + inPath);
const resp = await fetch(inPath);
const text = await resp.text();
const {code, map} = await SWCW.transform(text, TranspileConfig);
TranspileCache.set(inPath, code);
const {code} = await SWCW.transform(text, { ...Configuration.SWCOp, filename:inKey});
this.Cache.set(inKey, code);
return code;
}
catch(e)
{
console.log(`Transpile.Fetch error. Key:"${inKey}" Path:"${inPath}" Error:"${e}"`);
return null;
}
}
}
else
{
return false;
}
};
export const Extension =(inPath:string)=>
{
const posSlash = inPath.lastIndexOf("/");
const posDot = inPath.lastIndexOf(".");
return posDot > posSlash ? inPath.substring(posDot+1).toLowerCase() : false;
};
export const Configure =(config:ConfigurationArgs)=>
{
Configuration = {...Configuration, ...config};
ImportMapReload();
}
await ImportMapReload();
await SWCW.default();
HTTP.serve(async(req: Request)=>
{
const url:URL = new URL(req.url);
const ext = Extension(url.pathname);
const headers = {"content-type":"application/json", "Access-Control-Allow-Origin": Configuration.Allow, "charset":"UTF-8"};
if(url.pathname === Configure.Reset)
// cache-reset route
if(url.pathname === Configuration.Reset)
{
const size = TranspileCache.size;
TranspileCache.clear();
return new Response(`cache cleared (${size} items)`);
return new Response(`{"cleared":${Transpile.Clear()}}`, {headers});
}
const lookup = await TranspileFetch(url.pathname);
if(lookup === null)
// allow for custom handler
const custom = await Configuration.Serve(req, url, ext, ImportMap, Configuration.Proxy);
if(custom)
{
// error
return new Response(`error (see console)`, {status:404, headers:{"content-type":"application/javascript", "Access-Control-Allow-Origin": Configure.Allow, charset:"utf-8"}});
return custom;
}
else if(lookup === false)
// transpileable files
if(Transpile.Check(ext))
{
// not a javascript file
const type = MIME.typeByExtension(url.pathname.substring(url.pathname.lastIndexOf("."))) || "text/html";
const file = await fetch(Configure.Proxy + url.pathname);
const text = await file.text();
return new Response(text, {headers:{"content-type":type, "Access-Control-Allow-Origin":Configure.Allow, charset:"utf-8"}});
let code;
let path;
if(url.pathname.startsWith("/_lib_/"))
{
if(url.pathname.endsWith("boot.tsx"))
{
path = import.meta.url+"/../_lib_/mount.tsx";
}
else
{
path = import.meta.url+"/.."+url.pathname;
}
code = await Transpile.Fetch(path, url.pathname, true);
}
else
{
path = Configuration.Proxy + url.pathname;
code = await Transpile.Fetch(path, url.pathname);
}
if(code)
{
return new Response(code, {headers:{...headers, "content-type":"application/javascript"}} );
}
}
else
// custom page html
if(!ext)
{
return new Response(lookup, {headers:{"content-type":"application/javascript", "Access-Control-Allow-Origin": Configure.Allow, charset:"utf-8"}});
const shell = await Configuration.Shell(req, url, ext, ImportMap, Configuration.Proxy);
if(shell)
{
return shell;
}
}
// all other static files
if(ext)
{
try
{
const type = MIME.typeByExtension(ext);
const file = await fetch(Configuration.Proxy + url.pathname);
const text = await file.text();
return new Response(text, {headers:{...headers, "content-type":type||""}});
}
catch(e)
{
return new Response(`{"error":"${e}", "path":"${url.pathname}"}`, {status:404, headers});
}
}
return new Response(`{"error":"unmatched route", "path":"${url.pathname}"}`, {status:404, headers});
});