From e382b022030db96b8282337b304ec5e599a5f405 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 6 Dec 2022 09:21:17 -0300 Subject: web-util: utils for developing webapps --- packages/web-util/README | 0 packages/web-util/bin/taler-web-cli.mjs | 19 + packages/web-util/build.mjs | 103 ++++++ packages/web-util/create_certificate.sh | 48 +++ packages/web-util/package.json | 40 +++ packages/web-util/src/cli.ts | 59 ++++ packages/web-util/src/custom.d.ts | 12 + packages/web-util/src/index.browser.ts | 38 ++ packages/web-util/src/index.node.ts | 3 + packages/web-util/src/index.ts | 4 + packages/web-util/src/keys/ca.crt | 14 + packages/web-util/src/keys/ca.key | 16 + packages/web-util/src/keys/ca.srl | 1 + packages/web-util/src/keys/localhost.crt | 15 + packages/web-util/src/keys/localhost.csr | 10 + packages/web-util/src/keys/localhost.key | 16 + packages/web-util/src/live-reload.ts | 52 +++ packages/web-util/src/serve.ts | 108 ++++++ packages/web-util/src/stories.html | 17 + packages/web-util/src/stories.tsx | 580 +++++++++++++++++++++++++++++++ packages/web-util/tsconfig.json | 34 ++ 21 files changed, 1189 insertions(+) create mode 100644 packages/web-util/README create mode 100755 packages/web-util/bin/taler-web-cli.mjs create mode 100755 packages/web-util/build.mjs create mode 100644 packages/web-util/create_certificate.sh create mode 100644 packages/web-util/package.json create mode 100644 packages/web-util/src/cli.ts create mode 100644 packages/web-util/src/custom.d.ts create mode 100644 packages/web-util/src/index.browser.ts create mode 100644 packages/web-util/src/index.node.ts create mode 100644 packages/web-util/src/index.ts create mode 100644 packages/web-util/src/keys/ca.crt create mode 100644 packages/web-util/src/keys/ca.key create mode 100644 packages/web-util/src/keys/ca.srl create mode 100644 packages/web-util/src/keys/localhost.crt create mode 100644 packages/web-util/src/keys/localhost.csr create mode 100644 packages/web-util/src/keys/localhost.key create mode 100644 packages/web-util/src/live-reload.ts create mode 100644 packages/web-util/src/serve.ts create mode 100644 packages/web-util/src/stories.html create mode 100644 packages/web-util/src/stories.tsx create mode 100644 packages/web-util/tsconfig.json (limited to 'packages') diff --git a/packages/web-util/README b/packages/web-util/README new file mode 100644 index 000000000..e69de29bb diff --git a/packages/web-util/bin/taler-web-cli.mjs b/packages/web-util/bin/taler-web-cli.mjs new file mode 100755 index 000000000..4e89cf46d --- /dev/null +++ b/packages/web-util/bin/taler-web-cli.mjs @@ -0,0 +1,19 @@ +#!/usr/bin/env node +/* + This file is part of GNU Taler + (C) 2022 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see + */ + +import { main } from '../lib/cli.cjs'; +main(); diff --git a/packages/web-util/build.mjs b/packages/web-util/build.mjs new file mode 100755 index 000000000..ba277b666 --- /dev/null +++ b/packages/web-util/build.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import esbuild from 'esbuild' +import path from "path" +import fs from "fs" + +// eslint-disable-next-line no-undef +const BASE = process.cwd() + +let GIT_ROOT = BASE +while (!fs.existsSync(path.join(GIT_ROOT, '.git')) && GIT_ROOT !== '/') { + GIT_ROOT = path.join(GIT_ROOT, '../') +} +if (GIT_ROOT === '/') { + // eslint-disable-next-line no-undef + console.log("not found") + // eslint-disable-next-line no-undef + process.exit(1); +} +const GIT_HASH = GIT_ROOT === '/' ? undefined : git_hash() + + +let _package = JSON.parse(fs.readFileSync(path.join(BASE, 'package.json'))); + +function git_hash() { + const rev = fs.readFileSync(path.join(GIT_ROOT, '.git', 'HEAD')).toString().trim().split(/.*[: ]/).slice(-1)[0]; + if (rev.indexOf('/') === -1) { + return rev; + } else { + return fs.readFileSync(path.join(GIT_ROOT, '.git', rev)).toString().trim(); + } +} + +const buildConfigBase = { + outdir: "lib", + bundle: true, + minify: false, + target: [ + 'es6' + ], + loader: { + '.key': 'text', + '.crt': 'text', + '.html': 'text', + }, + sourcemap: true, + define: { + '__VERSION__': `"${_package.version}"`, + '__GIT_HASH__': `"${GIT_HASH}"`, + }, +} + +const buildConfigNode = { + ...buildConfigBase, + entryPoints: ["src/index.node.ts", "src/cli.ts"], + outExtension: { + '.js': '.cjs' + }, + format: 'cjs', + platform: 'node', + external: ["preact"], +}; + +const buildConfigBrowser = { + ...buildConfigBase, + entryPoints: ["src/index.browser.ts", "src/live-reload.ts", 'src/stories.tsx'], + outExtension: { + '.js': '.mjs' + }, + format: 'esm', + platform: 'browser', + external: ["preact", "@gnu-taler/taler-util", "jed"], + jsxFactory: 'h', + jsxFragment: 'Fragment', +}; + +[buildConfigNode, buildConfigBrowser].forEach((config) => { + esbuild + .build(config) + .catch((e) => { + // eslint-disable-next-line no-undef + console.log(e) + // eslint-disable-next-line no-undef + process.exit(1) + }); + +}) + diff --git a/packages/web-util/create_certificate.sh b/packages/web-util/create_certificate.sh new file mode 100644 index 000000000..980aaf642 --- /dev/null +++ b/packages/web-util/create_certificate.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -eu +org=localhost-ca +domain=localhost + +rm -rf keys +mkdir keys +cd keys + +openssl genpkey -algorithm RSA -out ca.key +openssl req -x509 -key ca.key -out ca.crt \ + -subj "/CN=$org/O=$org" + +openssl genpkey -algorithm RSA -out "$domain".key +openssl req -new -key "$domain".key -out "$domain".csr \ + -subj "/CN=$domain/O=$org" + +openssl x509 -req -in "$domain".csr -days 365 -out "$domain".crt \ + -CA ca.crt -CAkey ca.key -CAcreateserial \ + -extfile <(cat < { + setGlobalLogLevelFromString(x); + }, + }) + .flag("version", ["-v", "--version"], { + onPresentHandler: printVersion, + }) + .flag("verbose", ["-V", "--verbose"], { + help: "Enable verbose output.", + }) + +walletCli + .subcommand("serve", "serve", { help: "Create a server." }) + .maybeOption("folder", ["-F", "--folder"], clk.STRING, { + help: "should complete", + // default: "./dist" + }) + .maybeOption("port", ["-P", "--port"], clk.INT, { + help: "should complete", + // default: 8000 + }) + .flag("development", ["-D", "--dev"], { + help: "should complete", + }) + .action(async (args) => { + return serve({ + folder: args.serve.folder || "./dist", + port: args.serve.port || 8000, + development: args.serve.development + }) + } + ); + + + +declare const __VERSION__: string; +function printVersion(): void { + console.log(__VERSION__); + process.exit(0); +} + +export function main(): void { + walletCli.run(); +} + + diff --git a/packages/web-util/src/custom.d.ts b/packages/web-util/src/custom.d.ts new file mode 100644 index 000000000..6049ac6a9 --- /dev/null +++ b/packages/web-util/src/custom.d.ts @@ -0,0 +1,12 @@ +declare module "*.crt" { + const content: string; + export default content; +} +declare module "*.key" { + const content: string; + export default content; +} +declare module "*.html" { + const content: string; + export default content; +} diff --git a/packages/web-util/src/index.browser.ts b/packages/web-util/src/index.browser.ts new file mode 100644 index 000000000..514a2ec42 --- /dev/null +++ b/packages/web-util/src/index.browser.ts @@ -0,0 +1,38 @@ + +//`ws://localhost:8003/socket` +export function setupLiveReload(wsURL: string | undefined) { + if (!wsURL) return; + const ws = new WebSocket(wsURL); + ws.addEventListener('message', (message) => { + const event = JSON.parse(message.data); + if (event.type === "LOG") { + console.log(event.message); + } + if (event.type === "RELOAD") { + window.location.reload(); + } + if (event.type === "UPDATE") { + const c = document.getElementById("container") + if (c) { + document.body.removeChild(c); + } + const d = document.createElement("div"); + d.setAttribute("id", "container"); + d.setAttribute("class", "app-container"); + document.body.appendChild(d); + const s = document.createElement("script"); + s.setAttribute("id", "code"); + s.setAttribute("type", "application/javascript"); + s.textContent = atob(event.content); + document.body.appendChild(s); + } + }); + ws.onerror = (error) => { + console.error(error); + }; + ws.onclose = (e) => { + setTimeout(setupLiveReload, 500); + }; +} + +export { renderStories, parseGroupImport } from "./stories.js" diff --git a/packages/web-util/src/index.node.ts b/packages/web-util/src/index.node.ts new file mode 100644 index 000000000..0ef65921b --- /dev/null +++ b/packages/web-util/src/index.node.ts @@ -0,0 +1,3 @@ +export { serve } from "./serve.js" + + diff --git a/packages/web-util/src/index.ts b/packages/web-util/src/index.ts new file mode 100644 index 000000000..cf0c963ed --- /dev/null +++ b/packages/web-util/src/index.ts @@ -0,0 +1,4 @@ + + + +export default {} \ No newline at end of file diff --git a/packages/web-util/src/keys/ca.crt b/packages/web-util/src/keys/ca.crt new file mode 100644 index 000000000..d0fd544a6 --- /dev/null +++ b/packages/web-util/src/keys/ca.crt @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICODCCAaGgAwIBAgIUH8AY7kGN1yzGEwQOZKeL26ZOQHAwDQYJKoZIhvcNAQEL +BQAwLjEVMBMGA1UEAwwMbG9jYWxob3N0LWNhMRUwEwYDVQQKDAxsb2NhbGhvc3Qt +Y2EwHhcNMjIxMTMwMjIwNjAxWhcNMjIxMjMwMjIwNjAxWjAuMRUwEwYDVQQDDAxs +b2NhbGhvc3QtY2ExFTATBgNVBAoMDGxvY2FsaG9zdC1jYTCBnzANBgkqhkiG9w0B +AQEFAAOBjQAwgYkCgYEAo2gw/oYcKxrSeDbVTTFX8pZA8fojGMwcQlSmeYMUrhtn ++PkXEvCTyMWcreLg2Y4sgdOjvK0ZM7OXnf/jx4fDiMpGy5BHT2ZJRWPzSh6UmNUy +kyeRAkDB3gCyQSHmmL1rEFOuwmq1yoT0FlIyTQ+mWrs5yg7QTe1rRyFWXHIt1TMC +AwEAAaNTMFEwHQYDVR0OBBYEFO1Op1KRMkVkzadGy2TZFQlwG9FFMB8GA1UdIwQY +MBaAFO1Op1KRMkVkzadGy2TZFQlwG9FFMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADgYEAIdePTdDsD8IBFfHze9YVU+VZg3aNO5F/6QJPy/8InejQU0V8 +9Cod19SEh3Kdlpa4QLvZH1cX+ac7bvhL0JaZg0dsz8UaZ8xrkEPx6JJAwgCiv/Ir +YqhoRd4fv/c6/B0yqD4Dhoy/jGkxfvc8XDnAuAP0uRttGwvsvHS9cSkHYFo= +-----END CERTIFICATE----- diff --git a/packages/web-util/src/keys/ca.key b/packages/web-util/src/keys/ca.key new file mode 100644 index 000000000..8699ccb10 --- /dev/null +++ b/packages/web-util/src/keys/ca.key @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAKNoMP6GHCsa0ng2 +1U0xV/KWQPH6IxjMHEJUpnmDFK4bZ/j5FxLwk8jFnK3i4NmOLIHTo7ytGTOzl53/ +48eHw4jKRsuQR09mSUVj80oelJjVMpMnkQJAwd4AskEh5pi9axBTrsJqtcqE9BZS +Mk0Pplq7OcoO0E3ta0chVlxyLdUzAgMBAAECgYABkiDWcYeXynw3d595TH4h8NvS +96qatGuZH6MyC9aJDe5j8FEOd42UIoItEb9DmCBJZzVtvOQ/IPzWIf2Yj2+LvydI +qEA6ucroa9F9KG9T9ywNJfqM8fNzARQEAzK4/PglbT+n27hkNIm35BOA8PIUuBiD +pT6D0L0LHfNs6NkRAQJBAM9RS9ApnRmo4qV8kNJvysBJ/NO8PdLT47XIA2uPaAAT +O9NjrxGHaP0is+PIuwgTi9T5lyprpQss2yS9O7rN5PMCQQDJx0CMjkPDbelbWeH2 +nOvyxLLCev69ae6zVrMPcE7vRPohlJTSK/kgouLr0F6lomK9HVugD7VgrQHuj9am +UV7BAkBhCHnlejSvl95M+lqGRBCvo3GUYJzHGqmPoYgIRdy1fEsaC6QbHjfDkwSD +bqYrh4qBKjjYf/2Fl38SWQelzUyFAkBoht27cl9MN/3xIsjZ1kSsiJUKBmk8ekn7 +gWhVERry/EqPZscJcVonO/pNqq29JDf+O90hN8IACN+9U6ogknqBAkAr3SowHLyD +LfTrEDxeoAd2+K7gGKyrK3gyIISbuWtluONNPqenuFFHXxehwJ72VplNkpUZP4Bt +TQcIW9zIYT5r +-----END PRIVATE KEY----- diff --git a/packages/web-util/src/keys/ca.srl b/packages/web-util/src/keys/ca.srl new file mode 100644 index 000000000..a53ff9b36 --- /dev/null +++ b/packages/web-util/src/keys/ca.srl @@ -0,0 +1 @@ +7488FC4F9D5E2BB55DEA16CF051F4E99ACA25241 diff --git a/packages/web-util/src/keys/localhost.crt b/packages/web-util/src/keys/localhost.crt new file mode 100644 index 000000000..e32f2e24a --- /dev/null +++ b/packages/web-util/src/keys/localhost.crt @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICRTCCAa6gAwIBAgIUdIj8T51eK7Vd6hbPBR9OmayiUkEwDQYJKoZIhvcNAQEL +BQAwLjEVMBMGA1UEAwwMbG9jYWxob3N0LWNhMRUwEwYDVQQKDAxsb2NhbGhvc3Qt +Y2EwHhcNMjIxMTMwMjIwNjAyWhcNMjMxMTMwMjIwNjAyWjArMRIwEAYDVQQDDAls +b2NhbGhvc3QxFTATBgNVBAoMDGxvY2FsaG9zdC1jYTCBnzANBgkqhkiG9w0BAQEF +AAOBjQAwgYkCgYEAvir90pl9q6qUsBsBz7jjdw0r1DDPeViqkSyDDTt4Lw2zYJbn +Z7kxcTvNHNRWdtsWSzY/43ERCJu6nX60kMiML3NV00ty2VpaYeW9J5ozXgNbb+5P +esLHrIHmnOIUj46jyiHjDKs+hgrfcrFg7W7ndjW3dCAvkeAV+mncz59pFvkCAwEA +AaNjMGEwCQYDVR0TBAIwADAdBgNVHQ4EFgQUXADNSPivlIUBpKyd/XirIcqxqFgw +HwYDVR0jBBgwFoAU7U6nUpEyRWTNp0bLZNkVCXAb0UUwFAYDVR0RBA0wC4IJbG9j +YWxob3N0MA0GCSqGSIb3DQEBCwUAA4GBAClcLuKFnRJjAgP8652jJscYMLWYEkv3 +j9kChErpKZNKiv+VlWKPiOvhZVAl+/YEsBOKXpRFX3CuLCdGtuv7b6NaH7yEXaZn +9MVIrYMRub3k0gVAhu3z3VXuvHFXdTms3KRlGdPdQV2xgpQJczDNnd7idp/GyI4j +KqBo0UxuWZBJ +-----END CERTIFICATE----- diff --git a/packages/web-util/src/keys/localhost.csr b/packages/web-util/src/keys/localhost.csr new file mode 100644 index 000000000..5f821f8b5 --- /dev/null +++ b/packages/web-util/src/keys/localhost.csr @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIBajCB1AIBADArMRIwEAYDVQQDDAlsb2NhbGhvc3QxFTATBgNVBAoMDGxvY2Fs +aG9zdC1jYTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAvir90pl9q6qUsBsB +z7jjdw0r1DDPeViqkSyDDTt4Lw2zYJbnZ7kxcTvNHNRWdtsWSzY/43ERCJu6nX60 +kMiML3NV00ty2VpaYeW9J5ozXgNbb+5PesLHrIHmnOIUj46jyiHjDKs+hgrfcrFg +7W7ndjW3dCAvkeAV+mncz59pFvkCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4GBADJW +Ww+l4E///54fz82AE5x8U114Yk32EbB1qOfGLyXgoXySGyLuiNu40SXxioKa/Gpn +Z92o5JIrMVWUroPzMKAMXdAsixkaBGrT5RYzR9ztfy59djxp0f7dlL3ZxDO8JHOw +aTJXJxKEfYdv0oFhkx/u4ki6BsaqG9mQfsFXtlUp +-----END CERTIFICATE REQUEST----- diff --git a/packages/web-util/src/keys/localhost.key b/packages/web-util/src/keys/localhost.key new file mode 100644 index 000000000..c9b1cb6c8 --- /dev/null +++ b/packages/web-util/src/keys/localhost.key @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICeQIBADANBgkqhkiG9w0BAQEFAASCAmMwggJfAgEAAoGBAL4q/dKZfauqlLAb +Ac+443cNK9Qwz3lYqpEsgw07eC8Ns2CW52e5MXE7zRzUVnbbFks2P+NxEQibup1+ +tJDIjC9zVdNLctlaWmHlvSeaM14DW2/uT3rCx6yB5pziFI+Oo8oh4wyrPoYK33Kx +YO1u53Y1t3QgL5HgFfpp3M+faRb5AgMBAAECgYEAh1xgqdxZqKzWA3hl1K7dMmus +q/BGbjCf0JAnhG61QID3EqS3eIxI1jnj6UZ3eUi/WK/3z/Q2VLNMpTiAXKJzrUP0 +8m7yO87AeUxhy0rvtWEVmd8NBQjJKD2iElgy6tR9QUsgTXer9xuQf0sHRQb1psNU +11WsBnwdzeEEzquORVUCQQDtJx/HjHDVTDF02W5B23J4oqwuu1EDCVDqNJiYSDSt +2Dh0IdvSKJyh9lXIoY+kbbEui8uPPnhPKM1LIRfiv7FHAkEAzUf1mvTBNUGCwjZu +qy/oKDR7TlEbdyDJY1F0JPquyim/CenRtM8VAH22Tni8+bSSpnHknytvKfaC0YFb +VN8VvwJBAKTdJgKbZ3Vg2qDY5wVxgUrMC9cQ8Wii+VVX6x0yVSzlu5lAUIjxIrKV +hV1Ms4cjmqE5HfIfA5REUTOBdhF0IdECQQC/1lia19Ha7/6/eljP17RQJkN5O+i7 +2kL5crxkdnRz7rFeFUlpfAB3dgOxr7mCbZKCw3rQmKmJAJreKNHuLZBHAkEAwYZ4 +tc4mWjtw4AMDK59o8d8ANObyuVaIy6I54NZ0ogg+0nzrXii9LkZZhAWwVSN9BdXa +TYVu0J5fGxDZVAm0zQ== +-----END PRIVATE KEY----- diff --git a/packages/web-util/src/live-reload.ts b/packages/web-util/src/live-reload.ts new file mode 100644 index 000000000..bae0a5b84 --- /dev/null +++ b/packages/web-util/src/live-reload.ts @@ -0,0 +1,52 @@ +/* eslint-disable no-undef */ + +function setupLiveReload(): void { + const ws = new WebSocket("wss://localhost:8080/ws"); + + ws.addEventListener("message", (message) => { + try { + const event = JSON.parse(message.data); + if (event.type === "file-updated-start") { + showReloadOverlay(); + return; + } + if (event.type === "file-updated-done") { + window.location.reload(); + return; + } + } catch (e) { + return + } + console.log("unsupported", event); + }); + + ws.addEventListener("error", (error) => { + console.error(error); + }); + ws.addEventListener("close", (message) => { + setTimeout(setupLiveReload, 1500); + }); +} +setupLiveReload(); + + +function showReloadOverlay(): void { + const d = document.createElement("div"); + d.style.position = "absolute"; + d.style.width = "100%"; + d.style.height = "100%"; + d.style.color = "white"; + d.style.backgroundColor = "rgba(0,0,0,0.5)"; + d.style.display = "flex"; + d.style.justifyContent = "center"; + const h = document.createElement("h1"); + h.style.margin = "auto"; + h.innerHTML = "reloading..."; + d.appendChild(h); + if (document.body.firstChild) { + document.body.insertBefore(d, document.body.firstChild); + } else { + document.body.appendChild(d); + } +} + diff --git a/packages/web-util/src/serve.ts b/packages/web-util/src/serve.ts new file mode 100644 index 000000000..11cc6db39 --- /dev/null +++ b/packages/web-util/src/serve.ts @@ -0,0 +1,108 @@ +import { + Logger +} from "@gnu-taler/taler-util"; +import chokidar from 'chokidar'; +import express from "express"; +import https from "https"; +import { parse } from 'url'; +import WebSocket, { Server } from 'ws'; + + +import locahostCrt from './keys/localhost.crt'; +import locahostKey from './keys/localhost.key'; +import storiesHtml from './stories.html'; + +import path from "path"; + +const httpServerOptions = { + key: locahostKey, + cert: locahostCrt +}; + +const logger = new Logger("serve.ts"); + +const PATHS = { + WS: "/ws", + NOTIFY: "/notify", + EXAMPLE: "/examples", + APP: "/app", +} + +export async function serve(opts: { + folder: string, + port: number, + source?: string, + development?: boolean, + examplesLocationJs?: string, + examplesLocationCss?: string, + onUpdate?: () => Promise; +}): Promise { + + const app = express() + + app.use(PATHS.APP, express.static(opts.folder)) + const server = https.createServer(httpServerOptions, app) + server.listen(opts.port); + logger.info(`serving ${opts.folder} on ${opts.port}`) + logger.info(` ${PATHS.APP}: application`) + logger.info(` ${PATHS.EXAMPLE}: examples`) + logger.info(` ${PATHS.WS}: websocket`) + logger.info(` ${PATHS.NOTIFY}: broadcast`) + + if (opts.development) { + + const wss = new Server({ noServer: true }); + + wss.on('connection', function connection(ws) { + ws.send('welcome'); + }); + + server.on('upgrade', function upgrade(request, socket, head) { + const { pathname } = parse(request.url || ""); + if (pathname === PATHS.WS) { + wss.handleUpgrade(request, socket, head, function done(ws) { + wss.emit('connection', ws, request); + }); + } else { + socket.destroy(); + } + }); + + const sendToAllClients = function (data: object): void { + wss.clients.forEach(function each(client) { + if (client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify(data)); + } + }) + } + const watchingFolder = opts.source ?? opts.folder + logger.info(`watching ${watchingFolder} for change`) + + chokidar.watch(watchingFolder).on('change', (path, stats) => { + logger.trace(`changed ${path}`) + + sendToAllClients({ type: 'file-updated-start', data: { path } }) + if (opts.onUpdate) { + opts.onUpdate().then(result => { + sendToAllClients({ type: 'file-updated-done', data: { path, result } }) + }) + } else { + sendToAllClients({ type: 'file-change-done', data: { path } }) + } + }) + + app.get(PATHS.EXAMPLE, function (req: any, res: any) { + res.set('Content-Type', 'text/html') + res.send(storiesHtml + .replace('__EXAMPLES_JS_FILE_LOCATION__', opts.examplesLocationJs ?? `.${PATHS.APP}/stories.js`) + .replace('__EXAMPLES_CSS_FILE_LOCATION__', opts.examplesLocationCss ?? `.${PATHS.APP}/stories.css`)) + }) + + app.get(PATHS.NOTIFY, function (req: any, res: any) { + res.send('ok') + }) + + } +} + + diff --git a/packages/web-util/src/stories.html b/packages/web-util/src/stories.html new file mode 100644 index 000000000..4c16ad2ff --- /dev/null +++ b/packages/web-util/src/stories.html @@ -0,0 +1,17 @@ + + + + WebUtils: Stories + + + + + + + + + diff --git a/packages/web-util/src/stories.tsx b/packages/web-util/src/stories.tsx new file mode 100644 index 000000000..a8a9fdf77 --- /dev/null +++ b/packages/web-util/src/stories.tsx @@ -0,0 +1,580 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { setupI18n } from "@gnu-taler/taler-util"; +import e from "express"; +import { + ComponentChild, + ComponentChildren, + Fragment, + FunctionalComponent, + FunctionComponent, + h, + JSX, + render, + VNode, +} from "preact"; +import { useEffect, useErrorBoundary, useState } from "preact/hooks"; + +const Page: FunctionalComponent = ({ children }): VNode => { + return ( +
+ {children} +
+ ); +}; + +const SideBar: FunctionalComponent<{ width: number }> = ({ + width, + children, +}): VNode => { + return ( +
+ {children} +
+ ); +}; + +const ResizeHandleDiv: FunctionalComponent< + JSX.HTMLAttributes +> = ({ children, ...props }): VNode => { + return ( +
+ {children} +
+ ); +}; + +const Content: FunctionalComponent = ({ children }): VNode => { + return ( +
+ {children} +
+ ); +}; + +function findByGroupComponentName( + allExamples: Group[], + group: string, + component: string, + name: string, +): ExampleItem | undefined { + const gl = allExamples.filter((e) => e.title === group); + if (gl.length === 0) { + return undefined; + } + const cl = gl[0].list.filter((l) => l.name === component); + if (cl.length === 0) { + return undefined; + } + const el = cl[0].examples.filter((c) => c.name === name); + if (el.length === 0) { + return undefined; + } + return el[0]; +} + +function getContentForExample( + item: ExampleItem | undefined, + allExamples: Group[], +): FunctionalComponent { + if (!item) + return function SelectExampleMessage() { + return
select example from the list on the left
; + }; + const example = findByGroupComponentName( + allExamples, + item.group, + item.component, + item.name, + ); + if (!example) { + return function ExampleNotFoundMessage() { + return
example not found
; + }; + } + return () => example.render.component(example.render.props); +} + +function ExampleList({ + name, + list, + selected, + onSelectStory, +}: { + name: string; + list: { + name: string; + examples: ExampleItem[]; + }[]; + selected: ExampleItem | undefined; + onSelectStory: (i: ExampleItem, id: string) => void; +}): VNode { + const [isOpen, setOpen] = useState(selected && selected.group === name); + return ( +
    +
    setOpen(!isOpen)} + > + {name} +
    +
    + {list.map((k) => ( +
  1. +
    +
    {k.name}
    + {k.examples.map((r, i) => { + const e = encodeURIComponent; + const eId = `${e(r.group)}-${e(r.component)}-${e(r.name)}`; + const isSelected = + selected && + selected.component === r.component && + selected.group === r.group && + selected.name === r.name; + return ( +
    + { + e.preventDefault(); + location.hash = `#${eId}`; + onSelectStory(r, eId); + history.pushState({}, "", `#${eId}`); + }} + > + {r.name} + +
    + ); + })} +
    +
  2. + ))} +
    +
+ ); +} + +/** + * Prevents the UI from redirecting and inform the dev + * where the should have redirected + * @returns + */ +function PreventLinkNavigation({ + children, +}: { + children: ComponentChildren; +}): VNode { + return ( +
{ + let t: any = e.target; + do { + if (t.localName === "a" && t.getAttribute("href")) { + alert(`should navigate to: ${t.attributes.href.value}`); + e.stopImmediatePropagation(); + e.stopPropagation(); + e.preventDefault(); + return false; + } + } while ((t = t.parentNode)); + return true; + }} + > + {children} +
+ ); +} + +function ErrorReport({ + children, + selected, +}: { + children: ComponentChild; + selected: ExampleItem | undefined; +}): VNode { + const [error, resetError] = useErrorBoundary(); + //if there is an error, reset when unloading this component + useEffect(() => (error ? resetError : undefined)); + if (error) { + return ( +
+

Error was thrown trying to render

+ {selected && ( +
    +
  • + group: {selected.group} +
  • +
  • + component: {selected.component} +
  • +
  • + example: {selected.name} +
  • +
  • + args:{" "} +
    {JSON.stringify(selected.render.props, undefined, 2)}
    +
  • +
+ )} +

{error.message}

+
{error.stack}
+
+ ); + } + return {children}; +} + +function getSelectionFromLocationHash( + hash: string, + allExamples: Group[], +): ExampleItem | undefined { + if (!hash) return undefined; + const parts = hash.substring(1).split("-"); + if (parts.length < 3) return undefined; + return findByGroupComponentName( + allExamples, + decodeURIComponent(parts[0]), + decodeURIComponent(parts[1]), + decodeURIComponent(parts[2]), + ); +} + +function parseExampleImport( + group: string, + componentName: string, + im: MaybeComponent, +): ComponentItem { + const examples: ExampleItem[] = Object.entries(im) + .filter(([k]) => k !== "default") + .map(([exampleName, exampleValue]): ExampleItem => { + if (!exampleValue) { + throw Error( + `example "${exampleName}" from component "${componentName}" in group "${group}" is undefined`, + ); + } + + if (typeof exampleValue === "function") { + return { + group, + component: componentName, + name: exampleName, + render: { + component: exampleValue as FunctionComponent, + props: {}, + }, + }; + } + const v: any = exampleValue; + if ( + "component" in v && + typeof v.component === "function" && + "props" in v + ) { + return { + group, + component: componentName, + name: exampleName, + render: v, + }; + } + throw Error( + `example "${exampleName}" from component "${componentName}" in group "${group}" doesn't follow one of the two ways of example`, + ); + }); + return { + name: componentName, + examples, + }; +} + +export function parseGroupImport( + groups: Record, +): Group[] { + return Object.entries(groups).map(([groupName, value]) => { + return { + title: groupName, + list: Object.entries(value).flatMap(([key, value]) => + folder(groupName, value), + ), + }; + }); +} + +export interface Group { + title: string; + list: ComponentItem[]; +} + +export interface ComponentItem { + name: string; + examples: ExampleItem[]; +} + +export interface ExampleItem { + group: string; + component: string; + name: string; + render: { + component: FunctionalComponent; + props: object; + }; +} + +type ComponentOrFolder = MaybeComponent | MaybeFolder; +interface MaybeFolder { + default?: { title: string }; + // [exampleName: string]: FunctionalComponent; +} +interface MaybeComponent { + // default?: undefined; + [exampleName: string]: undefined | object; +} + +function folder(groupName: string, value: ComponentOrFolder): ComponentItem[] { + let title: string | undefined = undefined; + try { + title = + typeof value === "object" && + typeof value.default === "object" && + value.default !== undefined && + "title" in value.default && + typeof value.default.title === "string" + ? value.default.title + : undefined; + } catch (e) { + throw Error( + `Could not defined if it is component or folder ${groupName}: ${JSON.stringify( + value, + undefined, + 2, + )}`, + ); + } + if (title) { + const c = parseExampleImport(groupName, title, value as MaybeComponent); + return [c]; + } + return Object.entries(value).flatMap(([subkey, value]) => + folder(groupName, value), + ); +} + +interface Props { + getWrapperForGroup: (name: string) => FunctionComponent; + examplesInGroups: Group[]; + langs: Record; +} + +function Application({ + langs, + examplesInGroups, + getWrapperForGroup, +}: Props): VNode { + const initialSelection = getSelectionFromLocationHash( + location.hash, + examplesInGroups, + ); + + const url = new URL(window.location.href); + const currentLang = url.searchParams.get("lang") || "en"; + + if (!langs["en"]) { + langs["en"] = {}; + } + setupI18n(currentLang, langs); + + const [selected, updateSelected] = useState( + initialSelection, + ); + const [sidebarWidth, setSidebarWidth] = useState(200); + useEffect(() => { + if (location.hash) { + const hash = location.hash.substring(1); + const found = document.getElementById(hash); + if (found) { + setTimeout(() => { + found.scrollIntoView({ + block: "center", + }); + }, 10); + } + } + }, []); + + const GroupWrapper = getWrapperForGroup(selected?.group || "default"); + const ExampleContent = getContentForExample(selected, examplesInGroups); + + //style={{ "--with-size": `${sidebarWidth}px` }} + return ( + + {/* */} + +
+ Language: + +
+ {examplesInGroups.map((group) => ( + { + document.getElementById(htmlId)?.scrollIntoView({ + block: "center", + }); + updateSelected(item); + }} + /> + ))} +
+
+ { + setSidebarWidth((s) => s + x); + }} + /> + + + + + + + + + +
+ ); +} + +export interface Options { + id?: string; + strings?: any; + getWrapperForGroup?: (name: string) => FunctionComponent; +} + +export function renderStories( + groups: Record, + options: Options = {}, +): void { + const examples = parseGroupImport(groups); + + try { + const cid = options.id ?? "container"; + const container = document.getElementById(cid); + if (!container) { + throw Error( + `container with id ${cid} not found, can't mount page contents`, + ); + } + render( + Fragment)} + langs={options.strings ?? { en: {} }} + />, + container, + ); + } catch (e) { + console.error("got error", e); + if (e instanceof Error) { + document.body.innerText = `Fatal error: "${e.message}". Please report this bug at https://bugs.gnunet.org/.`; + } + } +} + +function ResizeHandle({ onUpdate }: { onUpdate: (x: number) => void }): VNode { + const [start, setStart] = useState(undefined); + return ( + { + setStart(e.pageX); + console.log("active", e.pageX); + return false; + }} + onMouseMove={(e: any) => { + if (start !== undefined) { + onUpdate(e.pageX - start); + } + return false; + }} + onMouseUp={() => { + setStart(undefined); + return false; + }} + /> + ); +} diff --git a/packages/web-util/tsconfig.json b/packages/web-util/tsconfig.json new file mode 100644 index 000000000..aede0a0ac --- /dev/null +++ b/packages/web-util/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "composite": true, + "target": "ES6", + "module": "ESNext", + "jsx": "react", + "jsxFactory": "h", + "jsxFragmentFactory": "Fragment", + "moduleResolution": "Node", + "sourceMap": true, + "lib": [ + "es6" + ], + "outDir": "lib", + "preserveSymlinks": true, + "skipLibCheck": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "strict": true, + "strictPropertyInitialization": false, + "noImplicitAny": true, + "noImplicitThis": true, + "incremental": true, + "esModuleInterop": true, + "importHelpers": true, + "rootDir": "./src", + "typeRoots": [ + "./node_modules/@types" + ] + }, + "include": [ + "src/**/*" + ] +} \ No newline at end of file -- cgit v1.2.3