diff --git a/.github/workflows/upload.yaml b/.github/workflows/upload.yaml index 6aafe8d..54c1d26 100644 --- a/.github/workflows/upload.yaml +++ b/.github/workflows/upload.yaml @@ -25,12 +25,26 @@ jobs: - run: | caps --ci + caps reset + caps verify calcit calcit.cirru edit format git diff --exit-code -- calcit.cirru + calcit calcit.cirru --check-only calcit calcit.cirru --entry server --check-only + - name: Smoke-test native server + run: node scripts/smoke-native-server.mjs + - run: yarn install --immutable && yarn compile-page && yarn release-page + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: copyboard-dist + path: dist + if-no-files-found: error + retention-days: 3 + - run: mkdir -p dist-server && cp calcit.cirru package.json deps.cirru dist-server name: Copy server scripts diff --git a/calcit.cirru b/calcit.cirru index 38ac131..ccf9915 100644 --- a/calcit.cirru +++ b/calcit.cirru @@ -66,14 +66,18 @@ :code $ quote defn load-preview-data! () hint-fn $ {} (:async true) - let - response $ js-await - js/fetch $ if config/dev? |http://localhost:11030/ |/apis/query - text $ js-await (.!text response) - ; js/console.log |preview $ parse-cirru-edn text - reset! *preview-data $ option:unwrap-or - get (parse-cirru-edn text) :snippets-list - , {} + try + let + response $ js-await + js/fetch $ if config/dev? |http://localhost:11030/ |/apis/query + text $ js-await (.!text response) + parsed $ parse-cirru-edn text + snippets $ if (map? parsed) + get-or parsed :snippets-list $ [] + , [] + reset! *preview-data $ if (list? snippets) snippets ([]) + fn (error) (js/console.warn |Failed-to-load-preview-data error) + reset! *preview-data $ [] :examples $ [] :schema $ :: 'Fn {} (:return 'Dynamic) @@ -142,7 +146,8 @@ .!then promise $ fn (text) respo.controller.client/send-to-component! $ :: :clipboard/read text , JsObject - .!catch result $ fn (err) (js/console.error err) + .!catch result $ fn (err) + do (js/console.warn |Clipboard-read-skipped err) nil js/console.log "|navigator.clipboard not available." :examples $ [] :schema $ :: 'Fn @@ -163,17 +168,19 @@ :schema $ :: 'Dynamic |render-app! $ %{} 'CodeEntry (:doc |) :code $ quote - defn render-app! () $ render! mount-target - comp-container - option:unwrap-or (get @*states :states) {} - , @*store @*preview-data - , dispatch! + defn render-app! () $ let + all-states @*states + nested-states $ if (map? all-states) + get-or all-states :states $ {} + {} + component-states $ if (map? nested-states) nested-states ({}) + render! mount-target (comp-container component-states @*store @*preview-data) dispatch! :examples $ [] :schema $ :: 'Dynamic |simulate-login! $ %{} 'CodeEntry (:doc |) :code $ quote defn simulate-login! () $ let - raw $ js/localStorage.getItem (:storage-key config/site) + raw $ js/localStorage.getItem (get-or config/site :storage-key |copyboard) if (js-present? raw) do (println "|Found storage.") dispatch! $ :: :user/log-in @@ -213,13 +220,13 @@ _ $ <> |unknown let cursor $ option:unwrap-or (get states :cursor) [] - state $ option:unwrap-or (get states :data) {} - session $ option:unwrap-or (get store :session) {} - router $ option:unwrap-or (get store :router) {} - user $ option:unwrap-or (get store :user) {} + state $ option:unwrap-or (get states :data) ({}) + session $ option:unwrap-or (get store :session) ({}) + router $ option:unwrap-or (get store :router) ({}) + user $ option:unwrap-or (get store :user) ({}) logged-in? $ option:unwrap-or (get store :logged-in?) false count-members $ option:unwrap-or (get store :count) 0 - snippets $ option:unwrap-or (get store :snippets) {} + snippets $ option:unwrap-or (get store :snippets) ({}) show-all? $ option:unwrap-or (get store :show-all?) false color $ option:unwrap-or (get store :color) |white reel-length $ option:unwrap-or (get store :reel-length) 0 @@ -234,7 +241,7 @@ <> router :home $ comp-home (>> states :snippets) snippets show-all? user :profile $ comp-profile user - option:unwrap-or (get router :data) {} + option:unwrap-or (get router :data) ({}) div ({}) if (some? preview-data) comp-preview (>> states :preview) preview-data :login @@ -243,7 +250,7 @@ when dev? $ comp-inspect |Store store {} (:bottom 40) (:left 0) (:max-width |100%) comp-messages - option:unwrap-or (get session :messages) {} + option:unwrap-or (get session :messages) ({}) {} fn (info d!) (d! :session/remove-message info) when dev? $ comp-reel reel-length @@ -257,12 +264,12 @@ {} :class-name $ str-spaced css/expand css/fullscreen css/column-dispersive :style $ {} - :background-color $ :theme config/site + :background-color $ get-or config/site :theme |#ECCE32 div $ {} :style $ {} (:height 0) div $ {} :style $ {} - :background-image $ str "|url(" (:icon config/site) "|)" + :background-image $ str "|url(" (get-or config/site :icon |) "|)" :width 128 :height 128 :background-size :contain @@ -340,9 +347,11 @@ {} (:position :relative) (:cursor :pointer) (:max-width |100%) :on-click $ fn (e d!) (copy! value) d! cursor $ {} (:visible? true) - js/setTimeout - \ d! cursor $ {} (:visible? false) - , 1200 + do + js/setTimeout + \ d! cursor $ {} (:visible? false) + , 1200 + , nil , child $ when option:unwrap-or (get state :visible?) false div @@ -393,19 +402,21 @@ .!preventDefault $ option:unwrap-or (get e :event) (js-object) send! e d! :on-paste $ fn (e d!) - let - event $ unsafe-coerce - option:unwrap-or (get e :event) (js-object) - , JsObject - clipboard-data $ unsafe-coerce (.-clipboardData event) JsObject - files $ unsafe-coerce (.-files clipboard-data) JsObject - if - > - unsafe-coerce (.-length files) 'Number - , 0 - let - file $ unsafe-coerce (.-0 files) JsObject - upload-file! file user d! $ fn (_e) + do + let + event $ unsafe-coerce + option:unwrap-or (get e :event) (js-object) + , JsObject + clipboard-data $ unsafe-coerce (.-clipboardData event) JsObject + files $ unsafe-coerce (.-files clipboard-data) JsObject + if + > + unsafe-coerce (.-length files) 'Number + , 0 + let + file $ unsafe-coerce (.-0 files) JsObject + upload-file! file user d! $ fn (_e) + , nil [] %{} respo.schema/RespoListener (:name :clipboard-listener) :handler $ fn (event d!) @@ -432,16 +443,19 @@ a {} (:style style/link) :on-click $ fn (e d!) - if (js-present? js/navigator.clipboard) - let - clipboard $ unsafe-coerce js/navigator.clipboard JsObject - promise $ unsafe-coerce (.!readText clipboard) JsObject - result $ unsafe-coerce - .!then promise $ fn (text) - d! cursor $ assoc state :content text - , JsObject - .!catch result $ fn (err) (js/console.error err) - js/console.log "|navigator.clipboard not available." + do + if (js-present? js/navigator.clipboard) + let + clipboard $ unsafe-coerce js/navigator.clipboard JsObject + promise $ unsafe-coerce (.!readText clipboard) JsObject + result $ unsafe-coerce + .!then promise $ fn (text) + d! cursor $ assoc state :content text + , JsObject + .!catch result $ fn (err) + do (js/console.error err) nil + js/console.log "|navigator.clipboard not available." + , nil <> |Read =< 8 nil button @@ -459,7 +473,11 @@ defcomp comp-home (states snippets show-all? user) div unsafe-coerce (home-props user) respo.schema/DomProps - (if (some? user) (div ({} (:style ({} (:position :relative)))) (comp-box (>> states :box) user))) + if (some? user) + div + {} $ :style + {} $ :position :relative + comp-box (>> states :box) user =< nil 8 list-> {} @@ -511,14 +529,16 @@ {} :class-name $ str-spaced css/center style-link-mark :style $ {} (:right 104) - :on-click $ fn (e d!) (download-image! some-img) + :on-click $ fn (e d!) + do (download-image! some-img) nil comp-i :download 14 $ hsl 200 80 60 if (some? some-img) a {} :class-name $ str-spaced css/center style-link-mark :style $ {} (:right 72) - :on-click $ fn (e d!) (copy-to-clipboard some-img) + :on-click $ fn (e d!) + do (copy-to-clipboard some-img) nil comp-i :copy 14 $ hsl 200 80 60 if .starts-with? @@ -529,7 +549,9 @@ :class-name $ str-spaced css/center style-link-mark :style $ {} (:right 40) :on-click $ fn (e d!) - js/window.open $ option:unwrap-or (get snippet :content) | + do + js/window.open $ option:unwrap-or (get snippet :content) | + , nil comp-i :external-link 14 $ hsl 200 80 60 div {} @@ -591,18 +613,24 @@ :style $ {} (:padding "|12px 16px 240px 16px") (:overflow :auto) :background-color $ hsl 0 0 97 :on-dragover $ fn (e d!) - .!preventDefault $ option:unwrap-or (get e :event) (js-object) + do + .!preventDefault $ option:unwrap-or (get e :event) (js-object) + , nil :on-drop $ fn (e d!) .!preventDefault $ option:unwrap-or (get e :event) (js-object) - let - event $ unsafe-coerce + do + let + event $ unsafe-coerce option:unwrap-or (get e :event) (js-object) - , JsObject - data-transfer $ unsafe-coerce (.-dataTransfer event) JsObject - items $ unsafe-coerce (.-items data-transfer) JsObject - items-array $ unsafe-coerce (js/Array.from items) JsObject - .!forEach items-array $ fn (item & _a) - upload-file! (.!getAsFile item) user d! $ fn (_e) + , JsObject + data-transfer $ unsafe-coerce (.-dataTransfer event) JsObject + items $ unsafe-coerce (.-items data-transfer) JsObject + items-array $ unsafe-coerce (js/Array.from items) JsObject + .!forEach items-array $ fn (item & _a) + do + upload-file! (.!getAsFile item) user d! $ fn (_e) + , nil + , nil :examples $ [] :schema $ :: 'Fn {} (:return 'Dynamic) @@ -730,14 +758,14 @@ {} $ :text-align :right span $ {} (:inner-text "|Sign up") (:class-name css/link) :on-click $ on-submit - option:unwrap-or (get state :username) | - (option:unwrap-or (get state :password) |) + option:unwrap-or (get state :username) | + option:unwrap-or (get state :password) | , true =< 8 nil span $ {} (:inner-text "|Log in") (:class-name css/link) :on-click $ on-submit - option:unwrap-or (get state :username) | - (option:unwrap-or (get state :password) |) + option:unwrap-or (get state :username) | + option:unwrap-or (get state :password) | , false :examples $ [] :schema $ :: 'Dynamic @@ -751,7 +779,7 @@ defn on-submit (username password signup?) fn (e dispatch!) dispatch! (if signup? :user/sign-up :user/log-in) ([] username password) - js/localStorage.setItem (:storage-key config/site) + js/localStorage.setItem (get-or config/site :storage-key |copyboard) format-cirru-edn $ [] username password :examples $ [] :schema $ :: 'Dynamic @@ -799,7 +827,7 @@ :code $ quote defstyle style-nav $ {} |& $ {} (:justify-content :space-between) (:padding "|0px 16px") (:font-size 16) (:font-family ui/font-fancy) - :background-color $ :theme config/site + :background-color $ get-or config/site :theme |#ECCE32 :color :white :z-index 100 :examples $ [] @@ -853,7 +881,7 @@ :color :white :padding "|0 8px" :on-click $ fn (e dispatch!) (dispatch! :user/log-out nil) - .removeItem js/localStorage $ :storage-key schema/configs + .removeItem js/localStorage $ get-or config/site :storage-key |copyboard <> "|Log out" nil :examples $ [] :schema $ :: 'Dynamic @@ -861,7 +889,7 @@ :code $ quote ns app.comp.profile $ :require respo-ui.core :refer $ hsl - app.schema :as schema + app.config :as config respo-ui.core :as ui respo.core :refer $ defcomp list-> <> span div a respo.comp.space :refer $ =< @@ -881,25 +909,31 @@ {} $ :class-name css/row-middle input $ {} (:type |file) (:id |upload-input) (:class-name style-hidden-input) (:multiple true) :on-input $ fn (e d!) - let - event $ option:unwrap-or (get e :event) (js-object) - target $ unsafe-coerce (.-target event) JsObject - files $ unsafe-coerce (.-files target) JsObject - files-array $ unsafe-coerce (js/Array.from files) JsObject - .!forEach files-array $ fn (file & _a) - if - < - unsafe-coerce (.-size file) 'Number - , 100000000 - upload-file! file user d! $ fn (next) (d! cursor next) - js/console.warn "|File too large" - set! (.-value target) nil + do + let + event $ option:unwrap-or (get e :event) (js-object) + target $ unsafe-coerce (.-target event) JsObject + files $ unsafe-coerce (.-files target) JsObject + files-array $ unsafe-coerce (js/Array.from files) JsObject + .!forEach files-array $ fn (file & _a) + do + if + < + unsafe-coerce (.-size file) 'Number + , 100000000 + upload-file! file user d! $ fn (next) (d! cursor next) + js/console.warn "|File too large" + , nil + set! (.-value target) nil + , nil a {} (:class-name css/link) :style $ {} :color $ hsl 200 90 70 :on-click $ fn (e d!) - .!click $ unsafe-coerce (js/document.querySelector |#upload-input) JsObject + do + .!click $ unsafe-coerce (js/document.querySelector |#upload-input) JsObject + , nil <> |Upload if uploading? $ span {} (:class-name css/font-fancy) @@ -1022,7 +1056,7 @@ :sessions $ {} :users $ {} :count 0 - :snippets $ {} + :snippets $ [] :examples $ [] :schema $ :: 'Dynamic |notification $ %{} 'CodeEntry (:doc |) @@ -1145,9 +1179,10 @@ :code $ quote defn on-request! (req) let - db $ option:unwrap-or (get @*reel :db) {} + reel $ unsafe-coerce @*reel 'cumulo-reel.core/ReelState + db $ :db reel snippets $ -> - option:unwrap-or (get db :snippets) {} + get-or db :snippets $ [] take-last 12 with-cpu-time {} (:code 200) @@ -1159,10 +1194,9 @@ |persist-db! $ %{} 'CodeEntry (:doc |) :code $ quote defn persist-db! () $ let + reel $ unsafe-coerce @*reel 'cumulo-reel.core/ReelState file-content $ format-cirru-edn - assoc - option:unwrap-or (get @*reel :db) {} - , :sessions $ {} + assoc (:db reel) :sessions $ {} storage-path storage-file backup-path $ get-backup-path! check-write-file! storage-path file-content @@ -1223,8 +1257,9 @@ defn sync-clients! (reel) wss-each! $ fn (sid) let - db $ option:unwrap-or (get reel :db) {} - records $ option:unwrap-or (get reel :records) [] + reel-state $ unsafe-coerce reel 'cumulo-reel.core/ReelState + db $ :db reel-state + records $ :records reel-state session $ get-in db ([] :sessions sid) old-store $ or (get @*client-caches sid) nil new-store $ twig-container db session records @@ -1447,10 +1482,10 @@ maybe-user $ -> option:unwrap-or (get db :users) {} vals - (.to-list) - find $ fn (user) - and $ = username - option:unwrap-or (get user :name) |unknown + , &set:to-list + find $ fn (user) + and $ = username + option:unwrap-or (get user :name) |unknown update-in db ([] :sessions sid) fn (session?) if (option:some? maybe-user) @@ -1501,7 +1536,7 @@ -> option:unwrap-or (get db :users) {} vals - (.to-list) + , &set:to-list fn (user) = username $ option:unwrap-or (get user :name) |unknown if (option:some? maybe-user) diff --git a/deps.cirru b/deps.cirru index 444c329..76ff0dd 100644 --- a/deps.cirru +++ b/deps.cirru @@ -1,15 +1,15 @@ -{} (:calcit-version |0.13.29) +{} (:calcit-version |0.13.37) :version |0.1.0 :dependencies $ {} (|Cumulo/cumulo-reel.calcit |0.0.24) |Cumulo/cumulo-util.calcit |0.0.13 - |Respo/alerts.calcit |codex/upgrade-calcit-0.13.27-20260821 + |Respo/alerts.calcit |6b30f0b4aa94e4471fb8d91865f1dd6185152f87 |Respo/respo-feather.calcit |0.4.4 |Respo/respo-message.calcit |0.0.13 |Respo/respo-ui.calcit |0.7.9 - |Respo/respo.calcit |0.16.81 - |Respo/respo-value.calcit |0.5.0-a2 - |calcit-lang/calcit-http |0.3.3 + |Respo/respo.calcit |0.16.84 + |Respo/respo-value.calcit |0.5.1 + |calcit-lang/calcit-http |0.3.5 |calcit-lang/calcit-wss |0.2.12 |calcit-lang/calcit.std |0.2.18 |calcit-lang/js-ffi |0.1.9 diff --git a/package.json b/package.json index 4ac6102..261e4b9 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "compile-page": "calcit calcit.cirru js", "release-page": "vite build --base=./", + "smoke-server": "node scripts/smoke-native-server.mjs", "watch-page": "calcit -w calcit.cirru js" }, "repository": { @@ -16,7 +17,7 @@ "author": "jiyinyiyong", "license": "MIT", "dependencies": { - "@calcit/procs": "^0.13.29", + "@calcit/procs": "0.13.37", "axios": "^1.18.0", "mime": "^4.1.0" }, diff --git a/scripts/smoke-native-server.mjs b/scripts/smoke-native-server.mjs new file mode 100644 index 0000000..3d02346 --- /dev/null +++ b/scripts/smoke-native-server.mjs @@ -0,0 +1,86 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; + +const httpUrl = "http://127.0.0.1:11030/"; +const websocketUrl = "ws://127.0.0.1:11006"; +const serverOutput = []; +const server = spawn("calcit", ["calcit.cirru", "--entry", "server"]); + +for (const stream of [server.stdout, server.stderr]) { + stream.on("data", (chunk) => serverOutput.push(chunk.toString())); +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function serverError(message) { + const output = serverOutput.join(""); + return new Error(`${message}\n\nServer output:\n${output || "(no output)"}`); +} + +async function waitForHttp() { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (server.exitCode !== null) { + throw serverError("Native server exited before accepting HTTP requests"); + } + + try { + const response = await fetch(httpUrl); + if (response.ok) return; + } catch { + // The server has not bound its socket yet. + } + + await sleep(1000); + } + + throw serverError("Native server did not return HTTP 200 within 20 seconds"); +} + +async function verifyWebSocketLogin() { + if (typeof WebSocket !== "function") { + throw new Error("WebSocket is unavailable; run this smoke test with Node.js 22 or newer"); + } + + await new Promise((resolve, reject) => { + const websocket = new WebSocket(websocketUrl); + let loginSent = false; + const timeout = setTimeout(() => reject(serverError("WebSocket login response timeout")), 10000); + + websocket.addEventListener("open", () => { + setTimeout(() => { + loginSent = true; + websocket.send("\n:: 'user/log-in $ [] |ci |invalid\n"); + }, 500); + }); + websocket.addEventListener("message", () => { + if (loginSent) { + clearTimeout(timeout); + websocket.close(); + resolve(); + } + }); + websocket.addEventListener("error", (event) => { + clearTimeout(timeout); + reject(event.error || serverError("WebSocket connection failed")); + }); + }); +} + +async function stopServer() { + if (server.exitCode !== null) return; + + server.kill("SIGINT"); + await Promise.race([once(server, "exit"), sleep(3000)]); + if (server.exitCode === null) server.kill("SIGKILL"); +} + +try { + await waitForHttp(); + await verifyWebSocketLogin(); + if (server.exitCode !== null) throw serverError("Native server exited during smoke test"); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +} finally { + await stopServer(); +} diff --git a/yarn.lock b/yarn.lock index 09a949f..568a87e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,14 +5,14 @@ __metadata: version: 8 cacheKey: 10c0 -"@calcit/procs@npm:^0.13.29": - version: 0.13.29 - resolution: "@calcit/procs@npm:0.13.29" +"@calcit/procs@npm:0.13.37": + version: 0.13.37 + resolution: "@calcit/procs@npm:0.13.37" dependencies: "@calcit/ternary-tree": "npm:0.0.26" "@cirru/parser.ts": "npm:^0.0.9" "@cirru/writer.ts": "npm:^0.1.9" - checksum: 10c0/d8db7c62054dda827557f1bd8c8d7a777c17b40bd43f51fb3b39c57386d1c1a41dad449b641201daa05ad14c38a423601c803b5d9eb3557ae0173ae1a03f570a + checksum: 10c0/4357ec83fc18f26e8616c844662ea40e92ea62f4f827adb31de31a38dfeb3fc31f41eae535d839549847fa56b22c5543493aba9f858ac6d34ea880415eb98a3a languageName: node linkType: hard @@ -398,7 +398,7 @@ __metadata: version: 0.0.0-use.local resolution: "copyboard@workspace:." dependencies: - "@calcit/procs": "npm:^0.13.29" + "@calcit/procs": "npm:0.13.37" axios: "npm:^1.18.0" bottom-tip: "npm:^0.1.5" copy-text-to-clipboard: "npm:^3.2.2"