@alisio/server 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/dist/auth/guard.d.ts +37 -0
- package/dist/auth/guard.js +83 -0
- package/dist/auth/token.d.ts +6 -0
- package/dist/auth/token.js +22 -0
- package/dist/bridges/approval-bridge.d.ts +53 -0
- package/dist/bridges/approval-bridge.js +162 -0
- package/dist/bridges/interaction-bridge.d.ts +52 -0
- package/dist/bridges/interaction-bridge.js +163 -0
- package/dist/host/git.d.ts +22 -0
- package/dist/host/git.js +99 -0
- package/dist/host/presets.d.ts +21 -0
- package/dist/host/presets.js +60 -0
- package/dist/host/run-scheduler.d.ts +67 -0
- package/dist/host/run-scheduler.js +143 -0
- package/dist/host/sessions.d.ts +81 -0
- package/dist/host/sessions.js +211 -0
- package/dist/host/workspace-host.d.ts +87 -0
- package/dist/host/workspace-host.js +195 -0
- package/dist/http/body.d.ts +7 -0
- package/dist/http/body.js +33 -0
- package/dist/http/errors.d.ts +15 -0
- package/dist/http/errors.js +58 -0
- package/dist/http/router.d.ts +37 -0
- package/dist/http/router.js +62 -0
- package/dist/http/static.d.ts +27 -0
- package/dist/http/static.js +132 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +350 -0
- package/dist/log.d.ts +21 -0
- package/dist/log.js +19 -0
- package/dist/routes/approvals.d.ts +8 -0
- package/dist/routes/approvals.js +33 -0
- package/dist/routes/blobs.d.ts +13 -0
- package/dist/routes/blobs.js +46 -0
- package/dist/routes/commands.d.ts +21 -0
- package/dist/routes/commands.js +149 -0
- package/dist/routes/events.d.ts +14 -0
- package/dist/routes/events.js +32 -0
- package/dist/routes/files.d.ts +21 -0
- package/dist/routes/files.js +262 -0
- package/dist/routes/health.d.ts +18 -0
- package/dist/routes/health.js +38 -0
- package/dist/routes/images.d.ts +12 -0
- package/dist/routes/images.js +60 -0
- package/dist/routes/management.d.ts +32 -0
- package/dist/routes/management.js +326 -0
- package/dist/routes/prompts.d.ts +22 -0
- package/dist/routes/prompts.js +153 -0
- package/dist/routes/providers.d.ts +3 -0
- package/dist/routes/providers.js +230 -0
- package/dist/routes/session-views.d.ts +12 -0
- package/dist/routes/session-views.js +76 -0
- package/dist/routes/sessions.d.ts +16 -0
- package/dist/routes/sessions.js +160 -0
- package/dist/routes/workspaces.d.ts +7 -0
- package/dist/routes/workspaces.js +32 -0
- package/dist/schemas.d.ts +25 -0
- package/dist/schemas.js +54 -0
- package/dist/sse/hub.d.ts +80 -0
- package/dist/sse/hub.js +287 -0
- package/dist/sse/inflight.d.ts +16 -0
- package/dist/sse/inflight.js +84 -0
- package/dist/web/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/web/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/web/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/web/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/web/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/web/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/web/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/web/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/web/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/web/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/web/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/web/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/web/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/web/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/web/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/web/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/web/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/web/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/web/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/web/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/web/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/web/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/web/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/web/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/web/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/web/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/web/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/web/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/web/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/web/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/web/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/web/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/web/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/web/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/web/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/web/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/web/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/web/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/web/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/web/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/web/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/web/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/web/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/web/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/web/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/web/assets/KaTeX_Size3-Regular-gV2CO0n9.woff2 +0 -0
- package/dist/web/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/web/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/web/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/web/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/web/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/web/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/web/assets/SettingsModal-Dj5G3JIz.js +1 -0
- package/dist/web/assets/SettingsModal-YCahs6O5.css +1 -0
- package/dist/web/assets/abnfDiagram-VCTEODGH-Ct0-FsLe.js +1 -0
- package/dist/web/assets/arc-Pry-7G1_.js +1 -0
- package/dist/web/assets/architecture-7GRP2DOG-B5Qnm7io.js +1 -0
- package/dist/web/assets/architectureDiagram-5GKGNRK7-BvjnUlvj.js +36 -0
- package/dist/web/assets/array-BifhSqXX.js +1 -0
- package/dist/web/assets/blockDiagram-I7D4REHJ-CBHCj2Mn.js +129 -0
- package/dist/web/assets/c4Diagram-7LVT6UL2-9I13K7Yy.js +38 -0
- package/dist/web/assets/channel-B-TykkBN.js +1 -0
- package/dist/web/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/web/assets/chunk-4HAMMTFA-Du4xf36t.js +62 -0
- package/dist/web/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/web/assets/chunk-75Z2AOVW-93KpRXCT.js +2 -0
- package/dist/web/assets/chunk-DU6HZSFF-DP6dsL6K.js +125 -0
- package/dist/web/assets/chunk-F27PBJKO-CxLLRIN4.js +1 -0
- package/dist/web/assets/chunk-FOHPRMQF-DzxwRta7.js +161 -0
- package/dist/web/assets/chunk-GMAD6QVW-BGv500CR.js +72 -0
- package/dist/web/assets/chunk-GVQU2GXP-BmXv3St8.js +1 -0
- package/dist/web/assets/chunk-IMKFNOWR-tmbCLM1n.js +231 -0
- package/dist/web/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/web/assets/chunk-L3NEJ4N5-BULbKNtl.js +1 -0
- package/dist/web/assets/chunk-OSK3NFVY-DyvADXPn.js +10 -0
- package/dist/web/assets/chunk-P2QGCYS3-YhA2sAhR.js +1 -0
- package/dist/web/assets/chunk-POPQ4Y6H-BLr1KktA.js +1 -0
- package/dist/web/assets/chunk-PWAF6VOD-DJCZC9ie.js +1 -0
- package/dist/web/assets/chunk-SHT3W25Y-B8xH9z5t.js +168 -0
- package/dist/web/assets/chunk-SVP7TREG-DxynkM9y.js +88 -0
- package/dist/web/assets/chunk-TICWLB2K-CZPXqwaW.js +206 -0
- package/dist/web/assets/chunk-XXDRQBXY-DqAYeljN.js +1 -0
- package/dist/web/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/web/assets/classDiagram-ZZMXUADV-B6LKfKSk.js +1 -0
- package/dist/web/assets/classDiagram-v2-VYDZK3BY-B6LKfKSk.js +1 -0
- package/dist/web/assets/cose-bilkent-JH36ORCC-BEpcWcmK.js +1 -0
- package/dist/web/assets/css-BsVw1vtW.js +1 -0
- package/dist/web/assets/cynefin-OW5HDTMX-rct1-Bf_.js +1 -0
- package/dist/web/assets/cynefinDiagram-5FMLGOSQ-CuBJbnvx.js +62 -0
- package/dist/web/assets/cytoscape.esm-Yq6u8L66.js +321 -0
- package/dist/web/assets/dagre-C-rnJpZF.js +1 -0
- package/dist/web/assets/dagre-GXQ25YYZ-w8zs2moB.js +4 -0
- package/dist/web/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/web/assets/diagram-S7CK7UJ4-BMBhFewM.js +30 -0
- package/dist/web/assets/diagram-UQ7AKVKN-C7BN6534.js +41 -0
- package/dist/web/assets/diagram-VSXAHHWV-Dd8_wR6U.js +3 -0
- package/dist/web/assets/diagram-VX7I27RA-DR3aOC1b.js +24 -0
- package/dist/web/assets/diagram-Z3DM3KII-5aBBRZ2Z.js +24 -0
- package/dist/web/assets/diff-woXpYk--.js +1 -0
- package/dist/web/assets/dist-v5Q1xZ2K.js +1 -0
- package/dist/web/assets/ebnfDiagram-PWID7BFC-46Gc2APT.js +1 -0
- package/dist/web/assets/engine-CknQqOvU.js +44 -0
- package/dist/web/assets/erDiagram-RLTQ6QDP-CNfUFQmD.js +99 -0
- package/dist/web/assets/eventmodeling-NTZA5JFV-Dx_J4ait.js +1 -0
- package/dist/web/assets/flowDiagram-HODETNUW-Bs7QheBl.js +1 -0
- package/dist/web/assets/ganttDiagram-EL5Y4UJY-B1RLLE0F.js +292 -0
- package/dist/web/assets/gitGraph-4MIJSDKK-DmzdoCuA.js +1 -0
- package/dist/web/assets/gitGraphDiagram-WWUBYQGX-DN5-fMOS.js +106 -0
- package/dist/web/assets/github-dark-C-LZuMrd.js +1 -0
- package/dist/web/assets/github-light-EUqPIrTm.js +1 -0
- package/dist/web/assets/go-rLFTqkRN.js +1 -0
- package/dist/web/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/web/assets/highlight-C1LfZ1aI.js +152 -0
- package/dist/web/assets/index-CGolUSWa.js +75 -0
- package/dist/web/assets/index-vUqwyH-2.css +1 -0
- package/dist/web/assets/info-A6RAGUB7-CebZRx1l.js +1 -0
- package/dist/web/assets/infoDiagram-27XIBGKW-BeY-irSX.js +2 -0
- package/dist/web/assets/init-C-OQMol4.js +1 -0
- package/dist/web/assets/ishikawaDiagram-5VMMS53U-Djn1dIqp.js +70 -0
- package/dist/web/assets/javascript-BgS3c2Ky.js +1 -0
- package/dist/web/assets/journeyDiagram-3NMN7TZE-CV_b2MnE.js +139 -0
- package/dist/web/assets/json-qhed-kSA.js +1 -0
- package/dist/web/assets/jsx-Bz0zcwM4.js +1 -0
- package/dist/web/assets/jsxRuntime-D9k5B_Bf.js +1 -0
- package/dist/web/assets/kanban-definition-UXKFOSKX-CXTukynL.js +89 -0
- package/dist/web/assets/katex-ZlcWpGUi.js +257 -0
- package/dist/web/assets/line-DIt63x6x.js +1 -0
- package/dist/web/assets/linear-SzVmGSw4.js +1 -0
- package/dist/web/assets/mermaid-parser.core-UsYAxtjN.js +7 -0
- package/dist/web/assets/mindmap-definition-YA3MSWOX-G0K_7S5N.js +96 -0
- package/dist/web/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/web/assets/packet-AYTQ26CC-D3_L-O-_.js +1 -0
- package/dist/web/assets/path-fybaL0A-.js +1 -0
- package/dist/web/assets/pegDiagram-XKGWAZYB-BoSJtVsR.js +1 -0
- package/dist/web/assets/pie-WAS4IAKB-DUARjLYc.js +1 -0
- package/dist/web/assets/pieDiagram-E7YTZNPT-DaFXBymx.js +39 -0
- package/dist/web/assets/purify.es--06brOip.js +3 -0
- package/dist/web/assets/python-gzcpVVnB.js +1 -0
- package/dist/web/assets/quadrantDiagram-AXDQQJYC-DvJJGxS3.js +7 -0
- package/dist/web/assets/radar-RG4KPBEZ-CoTA2z0h.js +1 -0
- package/dist/web/assets/railroad-74A4TZTK-D22_YeTS.js +1 -0
- package/dist/web/assets/railroad-abnf-HS5TGJTU-Mvl9LvJX.js +1 -0
- package/dist/web/assets/railroad-ebnf-LZEXJU2U-hvVPkAs6.js +1 -0
- package/dist/web/assets/railroad-peg-WCYAUIDC-XX_5aYEK.js +1 -0
- package/dist/web/assets/railroadDiagram-O6MQD6OU-Cxnojv6Q.js +1 -0
- package/dist/web/assets/requirementDiagram-BXWQKSXE-gW2PlZJn.js +84 -0
- package/dist/web/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/dist/web/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/web/assets/rust-Cfkwpbl8.js +1 -0
- package/dist/web/assets/sankeyDiagram-P5KCCOFB-DcoJ0sYw.js +40 -0
- package/dist/web/assets/sequenceDiagram-WJ2MYXX4-kRLbkAfp.js +162 -0
- package/dist/web/assets/shellscript-CLZ0U2zV.js +1 -0
- package/dist/web/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/web/assets/sql-BsFa4tDR.js +1 -0
- package/dist/web/assets/src-B6xuSHsQ.js +1 -0
- package/dist/web/assets/stateDiagram-D77RDMKH-CEWdwxTm.js +1 -0
- package/dist/web/assets/stateDiagram-v2-MP3YSRHH-DZ8dTBXf.js +1 -0
- package/dist/web/assets/swimlanes-42K2YHIH-oyoSxsbH.js +1 -0
- package/dist/web/assets/swimlanesDiagram-VR7AAH4N-McbEeiYz.js +8 -0
- package/dist/web/assets/timeline-definition-24CTP7MA-D97QYUq5.js +120 -0
- package/dist/web/assets/toml-CcmNWLt0.js +1 -0
- package/dist/web/assets/treeView-Q6P3EWNA-FwhRnXTt.js +1 -0
- package/dist/web/assets/treemap-WGGIJYW6-ByFNzzzU.js +1 -0
- package/dist/web/assets/tsx-udAQXfEw.js +1 -0
- package/dist/web/assets/typescript-bsJCZSQ-.js +1 -0
- package/dist/web/assets/vennDiagram-4TSXK5OY-CcqjaQy9.js +34 -0
- package/dist/web/assets/view-0zE8CdnP.js +1 -0
- package/dist/web/assets/view-BQZA99Bp.js +1 -0
- package/dist/web/assets/view-BqpAWkcP.css +1 -0
- package/dist/web/assets/view-C9vvQ0AK.js +1 -0
- package/dist/web/assets/view-CS-jPsy6.js +2 -0
- package/dist/web/assets/view-CYRKKlxP.js +1 -0
- package/dist/web/assets/view-CyO_m5dR.css +1 -0
- package/dist/web/assets/view-D7KCHoy1.css +1 -0
- package/dist/web/assets/view-DE2OI6Vv.js +5 -0
- package/dist/web/assets/view-DPqI4qBt.css +1 -0
- package/dist/web/assets/view-DWcZ1gKW.css +1 -0
- package/dist/web/assets/view-Dl8ihdis.css +1 -0
- package/dist/web/assets/view-DrJSW55A.css +1 -0
- package/dist/web/assets/view-DzlpughI.js +257 -0
- package/dist/web/assets/view-NDb0VvVU.js +6 -0
- package/dist/web/assets/wardley-WFR3VGLG-Bt4CePrl.js +1 -0
- package/dist/web/assets/wardleyDiagram-VM6X3IG4-CcgXimi3.js +78 -0
- package/dist/web/assets/xychartDiagram-S5SC5T6Z-0Jx20wNz.js +7 -0
- package/dist/web/assets/yaml-rwi0_p6S.js +1 -0
- package/dist/web/index.html +17 -0
- package/package.json +55 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type AppOptions, createApplication, type SQLiteStore } from "@alisio/core";
|
|
2
|
+
import type { WorkspaceInfo } from "@alisio/sdk";
|
|
3
|
+
export type Application = Awaited<ReturnType<typeof createApplication>>;
|
|
4
|
+
/** `AppOptions` minus what the server owns per workspace (cwd, events, approvals). */
|
|
5
|
+
export type ServerAppOptions = Omit<AppOptions, "cwd" | "onEvent" | "approve" | "approveExternalDirectory">;
|
|
6
|
+
/** An open workspace: one `Application` bound to one canonical path. */
|
|
7
|
+
export interface OpenWorkspace {
|
|
8
|
+
id: string;
|
|
9
|
+
path: string;
|
|
10
|
+
app: Application;
|
|
11
|
+
trusted: boolean;
|
|
12
|
+
untrustedResources: boolean;
|
|
13
|
+
lastUsed: number;
|
|
14
|
+
}
|
|
15
|
+
export interface WorkspaceHostOptions {
|
|
16
|
+
base: ServerAppOptions;
|
|
17
|
+
/** Server-level store over the same database (workspace list, metadata). */
|
|
18
|
+
catalog: SQLiteStore;
|
|
19
|
+
maxOpen?: number;
|
|
20
|
+
/** Close an app after this long without runs, subscribers or approvals (default 10 min). */
|
|
21
|
+
idleEvictMs?: number;
|
|
22
|
+
/** Listed even before it has sessions (e.g. the directory `alisio serve` ran in). */
|
|
23
|
+
defaultWorkspace?: string;
|
|
24
|
+
/** Per-workspace wiring the server owns: events and approval handlers. */
|
|
25
|
+
wire?: (id: string, path: string) => Pick<AppOptions, "onEvent" | "approve" | "approveExternalDirectory">;
|
|
26
|
+
/** Called once an app is created (e.g. to bind interactive UI) and before it is closed. */
|
|
27
|
+
onOpen?: (workspace: OpenWorkspace) => void;
|
|
28
|
+
onClose?: (workspace: OpenWorkspace) => void;
|
|
29
|
+
/** A workspace with runs, subscribers or pending approvals is never evicted. */
|
|
30
|
+
busy?: (id: string) => boolean;
|
|
31
|
+
/** Injectable for tests. */
|
|
32
|
+
create?: typeof createApplication;
|
|
33
|
+
now?: () => number;
|
|
34
|
+
}
|
|
35
|
+
/** Opaque, stable workspace id: a short sha256 of the canonical path (never a path in URLs). */
|
|
36
|
+
export declare const workspaceId: (path: string) => string;
|
|
37
|
+
/**
|
|
38
|
+
* One lazily created `Application` per workspace (ADR-06), with LRU eviction of idle apps,
|
|
39
|
+
* a cap on open apps and trust resolved from the terminal's trust store (the web never grants
|
|
40
|
+
* trust). All apps share the same session database (WAL).
|
|
41
|
+
*/
|
|
42
|
+
export declare class WorkspaceHost {
|
|
43
|
+
private options;
|
|
44
|
+
private open;
|
|
45
|
+
private opening;
|
|
46
|
+
private readonly maxOpen;
|
|
47
|
+
private readonly idleEvictMs;
|
|
48
|
+
private readonly now;
|
|
49
|
+
private closed;
|
|
50
|
+
constructor(options: WorkspaceHostOptions);
|
|
51
|
+
/** Canonical workspace root of a directory (realpath, then the enclosing git root). */
|
|
52
|
+
canonical(path: string): Promise<string>;
|
|
53
|
+
get openCount(): number;
|
|
54
|
+
/** Open workspaces (no side effects). */
|
|
55
|
+
entries(): OpenWorkspace[];
|
|
56
|
+
get(id: string): OpenWorkspace | undefined;
|
|
57
|
+
/** Opens (or reuses) the app of a canonical workspace path, evicting an idle one if needed. */
|
|
58
|
+
openPath(path: string): Promise<OpenWorkspace>;
|
|
59
|
+
private create;
|
|
60
|
+
/** Closes the least recently used idle app, or fails with 503 `workspace_limit`. */
|
|
61
|
+
private evictOne;
|
|
62
|
+
/** Marks a workspace as used now (keeps it out of idle eviction). */
|
|
63
|
+
touch(id: string): void;
|
|
64
|
+
/** Closes apps idle for longer than `idleEvictMs`; returns how many were closed. */
|
|
65
|
+
sweep(): Promise<number>;
|
|
66
|
+
close(id: string): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* Closes and reopens a workspace's app so configuration read at startup (plugins, skills) takes
|
|
69
|
+
* effect. Callers make sure it has no runs; SSE subscribers are unaffected (they read the
|
|
70
|
+
* shared store) and the next request uses the new app.
|
|
71
|
+
*/
|
|
72
|
+
recycle(id: string): Promise<OpenWorkspace | undefined>;
|
|
73
|
+
/** Closes every app in parallel (each close is capped by the core's teardown timeouts). */
|
|
74
|
+
closeAll(): Promise<void>;
|
|
75
|
+
/** Every known workspace: recorded or with root sessions, the default one and the open ones. */
|
|
76
|
+
list(): Promise<WorkspaceInfo[]>;
|
|
77
|
+
/** The `WorkspaceInfo` of one canonical path. */
|
|
78
|
+
info(path: string, meta?: {
|
|
79
|
+
path: string;
|
|
80
|
+
label?: string;
|
|
81
|
+
pinned: boolean;
|
|
82
|
+
lastOpenedAt?: number;
|
|
83
|
+
sessions: number;
|
|
84
|
+
} | undefined): Promise<WorkspaceInfo>;
|
|
85
|
+
/** Canonical path of a workspace id among the known ones. */
|
|
86
|
+
pathOf(id: string): Promise<string | undefined>;
|
|
87
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { createApplication, findWorkspace, resolveTrust, } from "@alisio/core";
|
|
4
|
+
import { HttpError } from "../http/errors.js";
|
|
5
|
+
/** Opaque, stable workspace id: a short sha256 of the canonical path (never a path in URLs). */
|
|
6
|
+
export const workspaceId = (path) => createHash("sha256").update(path).digest("hex").slice(0, 16);
|
|
7
|
+
/**
|
|
8
|
+
* One lazily created `Application` per workspace (ADR-06), with LRU eviction of idle apps,
|
|
9
|
+
* a cap on open apps and trust resolved from the terminal's trust store (the web never grants
|
|
10
|
+
* trust). All apps share the same session database (WAL).
|
|
11
|
+
*/
|
|
12
|
+
export class WorkspaceHost {
|
|
13
|
+
options;
|
|
14
|
+
open = new Map();
|
|
15
|
+
opening = new Map();
|
|
16
|
+
maxOpen;
|
|
17
|
+
idleEvictMs;
|
|
18
|
+
now;
|
|
19
|
+
closed = false;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.options = options;
|
|
22
|
+
this.maxOpen = options.maxOpen ?? 4;
|
|
23
|
+
this.idleEvictMs = options.idleEvictMs ?? 10 * 60_000;
|
|
24
|
+
this.now = options.now ?? Date.now;
|
|
25
|
+
}
|
|
26
|
+
/** Canonical workspace root of a directory (realpath, then the enclosing git root). */
|
|
27
|
+
async canonical(path) {
|
|
28
|
+
try {
|
|
29
|
+
if (!(await stat(path)).isDirectory())
|
|
30
|
+
throw new Error("not a directory");
|
|
31
|
+
return await findWorkspace(path);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw new HttpError("not_found", "Directory not found");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
get openCount() {
|
|
38
|
+
return this.open.size;
|
|
39
|
+
}
|
|
40
|
+
/** Open workspaces (no side effects). */
|
|
41
|
+
entries() {
|
|
42
|
+
return [...this.open.values()];
|
|
43
|
+
}
|
|
44
|
+
get(id) {
|
|
45
|
+
return this.open.get(id);
|
|
46
|
+
}
|
|
47
|
+
/** Opens (or reuses) the app of a canonical workspace path, evicting an idle one if needed. */
|
|
48
|
+
async openPath(path) {
|
|
49
|
+
if (this.closed)
|
|
50
|
+
throw new HttpError("shutting_down", "Server is shutting down");
|
|
51
|
+
const id = workspaceId(path);
|
|
52
|
+
const existing = this.open.get(id);
|
|
53
|
+
if (existing) {
|
|
54
|
+
existing.lastUsed = this.now();
|
|
55
|
+
return existing;
|
|
56
|
+
}
|
|
57
|
+
const pending = this.opening.get(id);
|
|
58
|
+
if (pending)
|
|
59
|
+
return pending;
|
|
60
|
+
if (this.open.size + this.opening.size >= this.maxOpen)
|
|
61
|
+
await this.evictOne();
|
|
62
|
+
const promise = this.create(id, path).finally(() => this.opening.delete(id));
|
|
63
|
+
this.opening.set(id, promise);
|
|
64
|
+
return promise;
|
|
65
|
+
}
|
|
66
|
+
async create(id, path) {
|
|
67
|
+
const base = this.options.base;
|
|
68
|
+
// Launch flags are explicit, one-run trust like in the terminal; otherwise the trust store
|
|
69
|
+
// decides and a workspace that would need a prompt opens untrusted.
|
|
70
|
+
const explicit = !!base.trustProject || !!base.config;
|
|
71
|
+
const trust = explicit ? undefined : await resolveTrust(path);
|
|
72
|
+
const trusted = explicit || !!trust?.trusted;
|
|
73
|
+
const app = await (this.options.create ?? createApplication)({
|
|
74
|
+
...base,
|
|
75
|
+
trustProject: explicit ? base.trustProject : trusted,
|
|
76
|
+
cwd: path,
|
|
77
|
+
...(this.options.wire?.(id, path) ?? {}),
|
|
78
|
+
});
|
|
79
|
+
const entry = {
|
|
80
|
+
id,
|
|
81
|
+
path,
|
|
82
|
+
app,
|
|
83
|
+
trusted,
|
|
84
|
+
untrustedResources: !trusted && !!trust?.hasProjectResources,
|
|
85
|
+
lastUsed: this.now(),
|
|
86
|
+
};
|
|
87
|
+
if (this.closed) {
|
|
88
|
+
await app.close();
|
|
89
|
+
throw new HttpError("shutting_down", "Server is shutting down");
|
|
90
|
+
}
|
|
91
|
+
this.open.set(id, entry);
|
|
92
|
+
this.options.catalog.recordWorkspace(path, { lastOpenedAt: entry.lastUsed });
|
|
93
|
+
this.options.onOpen?.(entry);
|
|
94
|
+
return entry;
|
|
95
|
+
}
|
|
96
|
+
/** Closes the least recently used idle app, or fails with 503 `workspace_limit`. */
|
|
97
|
+
async evictOne() {
|
|
98
|
+
const idle = this.entries()
|
|
99
|
+
.filter((w) => !this.options.busy?.(w.id))
|
|
100
|
+
.sort((a, b) => a.lastUsed - b.lastUsed)[0];
|
|
101
|
+
if (!idle)
|
|
102
|
+
throw new HttpError("workspace_limit", `All ${this.maxOpen} open workspaces are busy; retry when a run finishes`);
|
|
103
|
+
await this.close(idle.id);
|
|
104
|
+
}
|
|
105
|
+
/** Marks a workspace as used now (keeps it out of idle eviction). */
|
|
106
|
+
touch(id) {
|
|
107
|
+
const entry = this.open.get(id);
|
|
108
|
+
if (entry)
|
|
109
|
+
entry.lastUsed = this.now();
|
|
110
|
+
}
|
|
111
|
+
/** Closes apps idle for longer than `idleEvictMs`; returns how many were closed. */
|
|
112
|
+
async sweep() {
|
|
113
|
+
const cutoff = this.now() - this.idleEvictMs;
|
|
114
|
+
const idle = this.entries().filter((w) => w.lastUsed <= cutoff && !this.options.busy?.(w.id));
|
|
115
|
+
await Promise.all(idle.map((w) => this.close(w.id)));
|
|
116
|
+
return idle.length;
|
|
117
|
+
}
|
|
118
|
+
async close(id) {
|
|
119
|
+
const entry = this.open.get(id);
|
|
120
|
+
if (!entry)
|
|
121
|
+
return;
|
|
122
|
+
this.open.delete(id);
|
|
123
|
+
try {
|
|
124
|
+
this.options.onClose?.(entry);
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await entry.app.close();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Closes and reopens a workspace's app so configuration read at startup (plugins, skills) takes
|
|
132
|
+
* effect. Callers make sure it has no runs; SSE subscribers are unaffected (they read the
|
|
133
|
+
* shared store) and the next request uses the new app.
|
|
134
|
+
*/
|
|
135
|
+
async recycle(id) {
|
|
136
|
+
const entry = this.open.get(id);
|
|
137
|
+
if (!entry)
|
|
138
|
+
return undefined;
|
|
139
|
+
await this.close(id);
|
|
140
|
+
return this.openPath(entry.path);
|
|
141
|
+
}
|
|
142
|
+
/** Closes every app in parallel (each close is capped by the core's teardown timeouts). */
|
|
143
|
+
async closeAll() {
|
|
144
|
+
this.closed = true;
|
|
145
|
+
await Promise.allSettled([...this.opening.values()]);
|
|
146
|
+
await Promise.allSettled(this.entries().map((w) => this.close(w.id)));
|
|
147
|
+
}
|
|
148
|
+
/** Every known workspace: recorded or with root sessions, the default one and the open ones. */
|
|
149
|
+
async list() {
|
|
150
|
+
const known = new Map(this.options.catalog.workspaces().map((w) => [w.path, w]));
|
|
151
|
+
const paths = new Set(known.keys());
|
|
152
|
+
for (const w of this.open.values())
|
|
153
|
+
paths.add(w.path);
|
|
154
|
+
const fallback = this.options.defaultWorkspace
|
|
155
|
+
? await this.canonical(this.options.defaultWorkspace).catch(() => undefined)
|
|
156
|
+
: undefined;
|
|
157
|
+
if (fallback)
|
|
158
|
+
paths.add(fallback);
|
|
159
|
+
return Promise.all([...paths].sort().map((path) => this.info(path, known.get(path))));
|
|
160
|
+
}
|
|
161
|
+
/** The `WorkspaceInfo` of one canonical path. */
|
|
162
|
+
async info(path, meta = this.options.catalog.workspaces().find((w) => w.path === path)) {
|
|
163
|
+
const id = workspaceId(path);
|
|
164
|
+
const entry = this.open.get(id);
|
|
165
|
+
let trusted = entry?.trusted ?? false;
|
|
166
|
+
let untrusted = entry?.untrustedResources ?? false;
|
|
167
|
+
if (!entry) {
|
|
168
|
+
const base = this.options.base;
|
|
169
|
+
if (base.trustProject || base.config)
|
|
170
|
+
trusted = true;
|
|
171
|
+
else {
|
|
172
|
+
const trust = await resolveTrust(path).catch(() => undefined);
|
|
173
|
+
trusted = !!trust?.trusted;
|
|
174
|
+
untrusted = !trusted && !!trust?.hasProjectResources;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
id,
|
|
179
|
+
path,
|
|
180
|
+
...(meta?.label ? { label: meta.label } : {}),
|
|
181
|
+
pinned: meta?.pinned ?? false,
|
|
182
|
+
open: !!entry,
|
|
183
|
+
trusted,
|
|
184
|
+
untrustedResources: untrusted,
|
|
185
|
+
...(meta?.lastOpenedAt !== undefined ? { lastOpenedAt: meta.lastOpenedAt } : {}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** Canonical path of a workspace id among the known ones. */
|
|
189
|
+
async pathOf(id) {
|
|
190
|
+
const open = this.open.get(id);
|
|
191
|
+
if (open)
|
|
192
|
+
return open.path;
|
|
193
|
+
return (await this.list()).find((w) => w.id === id)?.path;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
/** Default JSON body limit (§11.1: JSON bodies ≤ 1 MB). */
|
|
3
|
+
export declare const MAX_JSON_BYTES: number;
|
|
4
|
+
/** Reads the whole request body, failing with 413 as soon as it exceeds `limit` bytes. */
|
|
5
|
+
export declare function readBody(req: IncomingMessage, limit?: number): Promise<Buffer>;
|
|
6
|
+
/** Reads and parses a JSON body; an empty body is `{}`. Malformed JSON is a 400. */
|
|
7
|
+
export declare function readJson(req: IncomingMessage, limit?: number): Promise<unknown>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { HttpError } from "./errors.js";
|
|
2
|
+
/** Default JSON body limit (§11.1: JSON bodies ≤ 1 MB). */
|
|
3
|
+
export const MAX_JSON_BYTES = 1024 * 1024;
|
|
4
|
+
/** Reads the whole request body, failing with 413 as soon as it exceeds `limit` bytes. */
|
|
5
|
+
export async function readBody(req, limit = MAX_JSON_BYTES) {
|
|
6
|
+
const declared = Number(req.headers["content-length"] ?? Number.NaN);
|
|
7
|
+
if (Number.isFinite(declared) && declared > limit)
|
|
8
|
+
throw new HttpError("payload_too_large", `Request body exceeds ${limit} bytes`);
|
|
9
|
+
const chunks = [];
|
|
10
|
+
let size = 0;
|
|
11
|
+
for await (const chunk of req) {
|
|
12
|
+
const buffer = chunk;
|
|
13
|
+
size += buffer.length;
|
|
14
|
+
if (size > limit) {
|
|
15
|
+
req.resume();
|
|
16
|
+
throw new HttpError("payload_too_large", `Request body exceeds ${limit} bytes`);
|
|
17
|
+
}
|
|
18
|
+
chunks.push(buffer);
|
|
19
|
+
}
|
|
20
|
+
return Buffer.concat(chunks);
|
|
21
|
+
}
|
|
22
|
+
/** Reads and parses a JSON body; an empty body is `{}`. Malformed JSON is a 400. */
|
|
23
|
+
export async function readJson(req, limit = MAX_JSON_BYTES) {
|
|
24
|
+
const raw = await readBody(req, limit);
|
|
25
|
+
if (raw.length === 0)
|
|
26
|
+
return {};
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(raw.toString("utf8"));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new HttpError("validation_failed", "Request body is not valid JSON");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ApiError, ApiErrorCode } from "@alisio/sdk";
|
|
2
|
+
/** HTTP status for every web API error code (§8.3). */
|
|
3
|
+
export declare const STATUS: Record<ApiErrorCode, number>;
|
|
4
|
+
/** A typed API failure; routes throw it and the server renders it as an `ApiError` body. */
|
|
5
|
+
export declare class HttpError extends Error {
|
|
6
|
+
readonly code: ApiErrorCode;
|
|
7
|
+
readonly details?: unknown;
|
|
8
|
+
constructor(code: ApiErrorCode, message: string, details?: unknown);
|
|
9
|
+
get status(): number;
|
|
10
|
+
}
|
|
11
|
+
/** Renders any thrown value as an `ApiError`; unknown errors become a generic 500. */
|
|
12
|
+
export declare function toApiError(error: unknown, correlationId: string): {
|
|
13
|
+
status: number;
|
|
14
|
+
body: ApiError;
|
|
15
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** HTTP status for every web API error code (§8.3). */
|
|
2
|
+
export const STATUS = {
|
|
3
|
+
unauthorized: 401,
|
|
4
|
+
forbidden_origin: 403,
|
|
5
|
+
forbidden_host: 403,
|
|
6
|
+
validation_failed: 400,
|
|
7
|
+
not_found: 404,
|
|
8
|
+
unknown_command: 404,
|
|
9
|
+
session_busy: 409,
|
|
10
|
+
session_locked: 409,
|
|
11
|
+
workspace_limit: 503,
|
|
12
|
+
payload_too_large: 413,
|
|
13
|
+
unsupported_media_type: 415,
|
|
14
|
+
path_outside_workspace: 403,
|
|
15
|
+
not_a_git_repo: 409,
|
|
16
|
+
approval_resolved: 409,
|
|
17
|
+
capability_ceiling: 403,
|
|
18
|
+
not_manageable: 403,
|
|
19
|
+
mcp_not_permitted: 403,
|
|
20
|
+
runs_active: 409,
|
|
21
|
+
provider_unavailable: 502,
|
|
22
|
+
protocol_mismatch: 426,
|
|
23
|
+
shutting_down: 503,
|
|
24
|
+
stream_limit: 503,
|
|
25
|
+
internal: 500,
|
|
26
|
+
};
|
|
27
|
+
/** A typed API failure; routes throw it and the server renders it as an `ApiError` body. */
|
|
28
|
+
export class HttpError extends Error {
|
|
29
|
+
code;
|
|
30
|
+
details;
|
|
31
|
+
constructor(code, message, details) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.code = code;
|
|
34
|
+
this.details = details;
|
|
35
|
+
}
|
|
36
|
+
get status() {
|
|
37
|
+
return STATUS[this.code];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Renders any thrown value as an `ApiError`; unknown errors become a generic 500. */
|
|
41
|
+
export function toApiError(error, correlationId) {
|
|
42
|
+
if (error instanceof HttpError)
|
|
43
|
+
return {
|
|
44
|
+
status: error.status,
|
|
45
|
+
body: {
|
|
46
|
+
error: {
|
|
47
|
+
code: error.code,
|
|
48
|
+
message: error.message,
|
|
49
|
+
...(error.details !== undefined ? { details: error.details } : {}),
|
|
50
|
+
},
|
|
51
|
+
correlationId,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
status: 500,
|
|
56
|
+
body: { error: { code: "internal", message: "Internal server error" }, correlationId },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
/** Everything a route handler receives for one request. */
|
|
3
|
+
export interface RouteContext {
|
|
4
|
+
req: IncomingMessage;
|
|
5
|
+
res: ServerResponse;
|
|
6
|
+
url: URL;
|
|
7
|
+
params: Record<string, string>;
|
|
8
|
+
/** `X-Request-Id` of the request (validated or generated); also the run correlation id. */
|
|
9
|
+
correlationId: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A route returns a JSON-serializable value (sent with `status`, 200 by default), or
|
|
13
|
+
* `undefined` after writing the response itself (e.g. an SSE stream).
|
|
14
|
+
*/
|
|
15
|
+
export type RouteResult = {
|
|
16
|
+
status?: number;
|
|
17
|
+
body: unknown;
|
|
18
|
+
} | undefined;
|
|
19
|
+
export type RouteHandler = (ctx: RouteContext) => Promise<RouteResult> | RouteResult;
|
|
20
|
+
/** Minimal method + path router with `:param` segments (no regex, no wildcards). */
|
|
21
|
+
export declare class Router {
|
|
22
|
+
private routes;
|
|
23
|
+
add(method: string, path: string, handler: RouteHandler): this;
|
|
24
|
+
get(path: string, handler: RouteHandler): this;
|
|
25
|
+
post(path: string, handler: RouteHandler): this;
|
|
26
|
+
patch(path: string, handler: RouteHandler): this;
|
|
27
|
+
put(path: string, handler: RouteHandler): this;
|
|
28
|
+
delete(path: string, handler: RouteHandler): this;
|
|
29
|
+
/**
|
|
30
|
+
* The handler and params for a request, `"method"` when the path exists with another method,
|
|
31
|
+
* or `undefined` when no route matches the path.
|
|
32
|
+
*/
|
|
33
|
+
match(method: string, pathname: string): {
|
|
34
|
+
handler: RouteHandler;
|
|
35
|
+
params: Record<string, string>;
|
|
36
|
+
} | "method" | undefined;
|
|
37
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Minimal method + path router with `:param` segments (no regex, no wildcards). */
|
|
2
|
+
export class Router {
|
|
3
|
+
routes = [];
|
|
4
|
+
add(method, path, handler) {
|
|
5
|
+
this.routes.push({ method, segments: path.split("/").filter(Boolean), handler });
|
|
6
|
+
return this;
|
|
7
|
+
}
|
|
8
|
+
get(path, handler) {
|
|
9
|
+
return this.add("GET", path, handler);
|
|
10
|
+
}
|
|
11
|
+
post(path, handler) {
|
|
12
|
+
return this.add("POST", path, handler);
|
|
13
|
+
}
|
|
14
|
+
patch(path, handler) {
|
|
15
|
+
return this.add("PATCH", path, handler);
|
|
16
|
+
}
|
|
17
|
+
put(path, handler) {
|
|
18
|
+
return this.add("PUT", path, handler);
|
|
19
|
+
}
|
|
20
|
+
delete(path, handler) {
|
|
21
|
+
return this.add("DELETE", path, handler);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The handler and params for a request, `"method"` when the path exists with another method,
|
|
25
|
+
* or `undefined` when no route matches the path.
|
|
26
|
+
*/
|
|
27
|
+
match(method, pathname) {
|
|
28
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
29
|
+
let pathMatched = false;
|
|
30
|
+
for (const route of this.routes) {
|
|
31
|
+
const params = matchSegments(route.segments, parts);
|
|
32
|
+
if (!params)
|
|
33
|
+
continue;
|
|
34
|
+
if (route.method === method)
|
|
35
|
+
return { handler: route.handler, params };
|
|
36
|
+
pathMatched = true;
|
|
37
|
+
}
|
|
38
|
+
return pathMatched ? "method" : undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function matchSegments(pattern, parts) {
|
|
42
|
+
if (pattern.length !== parts.length)
|
|
43
|
+
return undefined;
|
|
44
|
+
const params = {};
|
|
45
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
46
|
+
const expected = pattern[i];
|
|
47
|
+
const actual = parts[i];
|
|
48
|
+
if (expected.startsWith(":")) {
|
|
49
|
+
let decoded;
|
|
50
|
+
try {
|
|
51
|
+
decoded = decodeURIComponent(actual);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
params[expected.slice(1)] = decoded;
|
|
57
|
+
}
|
|
58
|
+
else if (expected !== actual)
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
return params;
|
|
62
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ServerResponse } from "node:http";
|
|
2
|
+
/**
|
|
3
|
+
* Default location of the web UI build: `dist/web` next to the compiled server
|
|
4
|
+
* (`packages/server/dist/web`, filled by the `@alisio/web` build in phase 3).
|
|
5
|
+
*/
|
|
6
|
+
export declare function defaultWebRoot(): string;
|
|
7
|
+
/** Served when the web UI assets are not installed (source checkouts, standalone binary). */
|
|
8
|
+
export declare const PLACEHOLDER_HTML = "<!doctype html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Alisio</title></head>\n<body style=\"font-family: system-ui, sans-serif; max-width: 40rem; margin: 3rem auto; padding: 0 1rem\">\n<h1>Alisio server is running</h1>\n<p>The web UI assets are not installed in this build, so only the HTTP API is available\n(<a href=\"/api/health\">/api/health</a>). Install the npm package <code>@alisio/alisio-code</code>\nto get the web UI.</p>\n</body>\n</html>\n";
|
|
9
|
+
/** SHA-256 CSP sources for every inline `<script>` of an HTML document. */
|
|
10
|
+
export declare function inlineScriptHashes(html: string): string[];
|
|
11
|
+
/** The Content-Security-Policy of §11.1, allowing the given inline script hashes. */
|
|
12
|
+
export declare function contentSecurityPolicy(scriptHashes?: string[]): string;
|
|
13
|
+
/**
|
|
14
|
+
* Serves the web UI build with an SPA fallback to `index.html`. Paths are confined to the root
|
|
15
|
+
* (resolved and realpath-checked); a missing build serves {@link PLACEHOLDER_HTML}.
|
|
16
|
+
*/
|
|
17
|
+
export declare class StaticAssets {
|
|
18
|
+
readonly root: string;
|
|
19
|
+
readonly installed: boolean;
|
|
20
|
+
readonly index: string;
|
|
21
|
+
readonly csp: string;
|
|
22
|
+
constructor(root?: string);
|
|
23
|
+
/** Serves a GET/HEAD for a non-API path. */
|
|
24
|
+
serve(res: ServerResponse, pathname: string, head?: boolean): Promise<void>;
|
|
25
|
+
private resolveFile;
|
|
26
|
+
private send;
|
|
27
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import { dirname, extname, join, resolve, sep } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
const MIME = {
|
|
7
|
+
".html": "text/html; charset=utf-8",
|
|
8
|
+
".js": "text/javascript; charset=utf-8",
|
|
9
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
10
|
+
".css": "text/css; charset=utf-8",
|
|
11
|
+
".json": "application/json; charset=utf-8",
|
|
12
|
+
".svg": "image/svg+xml",
|
|
13
|
+
".png": "image/png",
|
|
14
|
+
".jpg": "image/jpeg",
|
|
15
|
+
".jpeg": "image/jpeg",
|
|
16
|
+
".gif": "image/gif",
|
|
17
|
+
".webp": "image/webp",
|
|
18
|
+
".ico": "image/x-icon",
|
|
19
|
+
".woff2": "font/woff2",
|
|
20
|
+
".woff": "font/woff",
|
|
21
|
+
".txt": "text/plain; charset=utf-8",
|
|
22
|
+
".map": "application/json; charset=utf-8",
|
|
23
|
+
".wasm": "application/wasm",
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Default location of the web UI build: `dist/web` next to the compiled server
|
|
27
|
+
* (`packages/server/dist/web`, filled by the `@alisio/web` build in phase 3).
|
|
28
|
+
*/
|
|
29
|
+
export function defaultWebRoot() {
|
|
30
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "web");
|
|
31
|
+
}
|
|
32
|
+
/** Served when the web UI assets are not installed (source checkouts, standalone binary). */
|
|
33
|
+
export const PLACEHOLDER_HTML = `<!doctype html>
|
|
34
|
+
<html lang="en">
|
|
35
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
36
|
+
<title>Alisio</title></head>
|
|
37
|
+
<body style="font-family: system-ui, sans-serif; max-width: 40rem; margin: 3rem auto; padding: 0 1rem">
|
|
38
|
+
<h1>Alisio server is running</h1>
|
|
39
|
+
<p>The web UI assets are not installed in this build, so only the HTTP API is available
|
|
40
|
+
(<a href="/api/health">/api/health</a>). Install the npm package <code>@alisio/alisio-code</code>
|
|
41
|
+
to get the web UI.</p>
|
|
42
|
+
</body>
|
|
43
|
+
</html>
|
|
44
|
+
`;
|
|
45
|
+
/** SHA-256 CSP sources for every inline `<script>` of an HTML document. */
|
|
46
|
+
export function inlineScriptHashes(html) {
|
|
47
|
+
const hashes = [];
|
|
48
|
+
for (const match of html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi)) {
|
|
49
|
+
const body = match[1] ?? "";
|
|
50
|
+
if (body.trim())
|
|
51
|
+
hashes.push(`'sha256-${createHash("sha256").update(body, "utf8").digest("base64")}'`);
|
|
52
|
+
}
|
|
53
|
+
return hashes;
|
|
54
|
+
}
|
|
55
|
+
/** The Content-Security-Policy of §11.1, allowing the given inline script hashes. */
|
|
56
|
+
export function contentSecurityPolicy(scriptHashes = []) {
|
|
57
|
+
return [
|
|
58
|
+
"default-src 'self'",
|
|
59
|
+
`script-src ${["'self'", ...scriptHashes].join(" ")}`,
|
|
60
|
+
"style-src 'self' 'unsafe-inline'",
|
|
61
|
+
"img-src 'self' blob: data:",
|
|
62
|
+
"connect-src 'self'",
|
|
63
|
+
"frame-src 'self'",
|
|
64
|
+
"object-src 'none'",
|
|
65
|
+
"base-uri 'none'",
|
|
66
|
+
"frame-ancestors 'none'",
|
|
67
|
+
].join("; ");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Serves the web UI build with an SPA fallback to `index.html`. Paths are confined to the root
|
|
71
|
+
* (resolved and realpath-checked); a missing build serves {@link PLACEHOLDER_HTML}.
|
|
72
|
+
*/
|
|
73
|
+
export class StaticAssets {
|
|
74
|
+
root;
|
|
75
|
+
installed;
|
|
76
|
+
index;
|
|
77
|
+
csp;
|
|
78
|
+
constructor(root = defaultWebRoot()) {
|
|
79
|
+
this.root = resolve(root);
|
|
80
|
+
const indexPath = join(this.root, "index.html");
|
|
81
|
+
this.installed = existsSync(indexPath);
|
|
82
|
+
this.index = this.installed ? readFileSync(indexPath, "utf8") : PLACEHOLDER_HTML;
|
|
83
|
+
this.csp = contentSecurityPolicy(inlineScriptHashes(this.index));
|
|
84
|
+
}
|
|
85
|
+
/** Serves a GET/HEAD for a non-API path. */
|
|
86
|
+
async serve(res, pathname, head = false) {
|
|
87
|
+
const file = this.installed ? await this.resolveFile(pathname) : undefined;
|
|
88
|
+
if (!file) {
|
|
89
|
+
if (extname(pathname) && pathname !== "/index.html") {
|
|
90
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
91
|
+
res.end(head ? undefined : "Not found");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.send(res, 200, MIME[".html"], Buffer.from(this.index), "no-store", head);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const bytes = await readFile(file);
|
|
98
|
+
const immutable = pathname.startsWith("/assets/");
|
|
99
|
+
this.send(res, 200, MIME[extname(file).toLowerCase()] ?? "application/octet-stream", bytes, immutable ? "public, max-age=31536000, immutable" : "no-cache", head);
|
|
100
|
+
}
|
|
101
|
+
async resolveFile(pathname) {
|
|
102
|
+
let decoded;
|
|
103
|
+
try {
|
|
104
|
+
decoded = decodeURIComponent(pathname);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
if (decoded.includes("\0"))
|
|
110
|
+
return undefined;
|
|
111
|
+
const candidate = resolve(this.root, `.${decoded}`);
|
|
112
|
+
if (candidate !== this.root && !candidate.startsWith(this.root + sep))
|
|
113
|
+
return undefined;
|
|
114
|
+
try {
|
|
115
|
+
const [real, rootReal] = await Promise.all([realpath(candidate), realpath(this.root)]);
|
|
116
|
+
if (!real.startsWith(rootReal + sep))
|
|
117
|
+
return undefined;
|
|
118
|
+
return (await stat(real)).isFile() ? real : undefined;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
send(res, status, type, bytes, cache, head) {
|
|
125
|
+
res.writeHead(status, {
|
|
126
|
+
"Content-Type": type,
|
|
127
|
+
"Content-Length": bytes.length,
|
|
128
|
+
"Cache-Control": cache,
|
|
129
|
+
});
|
|
130
|
+
res.end(head ? undefined : bytes);
|
|
131
|
+
}
|
|
132
|
+
}
|