diff --git a/.vscode/settings.json b/.vscode/settings.json index 8675ad5..b003be2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { "deno.enable": true, - "deno.unstable": true + "deno.unstable": true, + "deno.config": "./deno.json" } \ No newline at end of file diff --git a/_lib_/boot.tsx b/_lib_/boot.tsx new file mode 100644 index 0000000..c510a00 --- /dev/null +++ b/_lib_/boot.tsx @@ -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"); +} \ No newline at end of file diff --git a/_lib_/hmr.tsx b/_lib_/hmr.tsx new file mode 100644 index 0000000..3abd95f --- /dev/null +++ b/_lib_/hmr.tsx @@ -0,0 +1,74 @@ +import { type StateCapture } from "./react.tsx"; + + +const FileListeners = new Map() as Mapvoid>>; +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 with whatever is in statesOld +- statesNew is moved into *statesOld* +- statesNew is cleared. + + +*/ + + +const HMR = +{ + reloads:1, + RegisteredComponents: new Map() as Mapvoid>, + statesNew: new Map() as Map, + statesOld: new Map() as Map, + 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}; \ No newline at end of file diff --git a/_lib_/mount.tsx b/_lib_/mount.tsx new file mode 100644 index 0000000..ab5e1ca --- /dev/null +++ b/_lib_/mount.tsx @@ -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; + } + +}; diff --git a/_lib_/react.tsx b/_lib_/react.tsx new file mode 100644 index 0000000..11bb7fe --- /dev/null +++ b/_lib_/react.tsx @@ -0,0 +1,132 @@ +import * as ReactParts from "react-original"; +import { HMR } from "./hmr.tsx"; + +export type StateType = boolean|number|string|Record +export type StateCapture = {state:StateType, set:ReactParts.StateUpdater, reload:number}; +type FuncArgs = [element:keyof ReactParts.JSX.IntrinsicElements, props:Record, children:ReactParts.JSX.Element[]]; + + +const H = ReactParts.createElement; +const MapIndex =(inMap:Map, 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 +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}; \ No newline at end of file diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..b3f0c20 --- /dev/null +++ b/deno.json @@ -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" + } +} \ No newline at end of file diff --git a/example/app.tsx b/example/app.tsx new file mode 100644 index 0000000..40e1139 --- /dev/null +++ b/example/app.tsx @@ -0,0 +1,53 @@ +import "@able/boot.tsx"; +import React from "react"; + +const CTXString = React.createContext("lol"); + +type StateBinding = [get:T, set:React.StateUpdater]; +const CTXState = React.createContext(null) as React.Context|null>; +const Outer =(props:{children:React.JSX.Element})=> +{ + const binding = React.useState(11); + return + {props.children} + +}; +const Inner =()=> +{ + const [stateGet, stateSet] = React.useContext(CTXState) || ["default", ()=>{}]; + return +}; + + +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 +
+

Title????

+

subtitle!

+

+ +

+
+ + + + + + +
; +} diff --git a/example/deno.json b/example/deno.json new file mode 100644 index 0000000..9ef9dc0 --- /dev/null +++ b/example/deno.json @@ -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" + } +} \ No newline at end of file diff --git a/local.tsx b/local.tsx new file mode 100644 index 0000000..60d17d2 --- /dev/null +++ b/local.tsx @@ -0,0 +1,100 @@ +import {Configure, Transpile, Extension} from "./serve.tsx"; + +const SocketsLive:Set = 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 = 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")); \ No newline at end of file diff --git a/serve.tsx b/serve.tsx index 66660dd..821c281 100644 --- a/serve.tsx +++ b/serve.tsx @@ -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}; +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 = 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}, inProxy:string)=>void|false|Response|Promise; +type CustomRemapper = (inImports:Record)=>Record; +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( + ` + + + + +
+ + + + `, {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, + 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}); + }); \ No newline at end of file