tinyrelaycommit

Commit aad6cc915b41

browse files at this commit | history

1diff --git a/internal/webui/browse_test.go b/internal/webui/browse_test.go
2index 56a1a50..379e64a 100644
3--- a/internal/webui/browse_test.go
4+++ b/internal/webui/browse_test.go
5@@ -49,7 +49,7 @@ func TestBrowseRoutesUseObjectContractsAndRenderData(t *testing.T) {
6 {"/repo?owner=alice&repo=notes&view=file", "browserepo", "one"},
7 {"/files", "browsefiles", "deadbeef"},
8 {"/file?hash=deadbeef", "browsefile", "blob"},
9- {"/manage/status", "browsestatus", "storage"},
10+ {"/manage/status", "browsestatus", "manage this relay"},
11 }
12 for _, test := range tests {
13 recorder := httptest.NewRecorder()
14@@ -79,11 +79,17 @@ func TestConnectPublishesGRASPAndDeliveryKinds(t *testing.T) {
15 recorder := httptest.NewRecorder()
16 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/connect", nil))
17 body := recorder.Body.String()
18- for _, marker := range []string{`<publish-list kind="10317" tag="g" scheme="ws">`, `<publish-list kind="10063" tag="server">`, `name="lines"`, `customElements.define("publish-list"`, `tiny.signedFetch("/events", "POST"`} {
19+ for _, marker := range []string{`<publish-list kind="10317" tag="g" scheme="ws">`, `<publish-list kind="10063" tag="server">`, `name="lines"`, `src="/scripts/components.js?v=`} {
20 if !strings.Contains(body, marker) {
21 t.Fatalf("connect page missing %q", marker)
22 }
23 }
24+ components := servedScript(t, app, "/scripts/components.js")
25+ for _, marker := range []string{`customElements.define("publish-list"`, `tiny.signedFetch("/events", "POST"`} {
26+ if !strings.Contains(components, marker) {
27+ t.Fatalf("components script missing %q", marker)
28+ }
29+ }
30 }
31
32 func TestBrowseRendersTypedGitRelayPage(t *testing.T) {
33@@ -141,3 +147,14 @@ func (b *typedBrowseBackend) Query(_ context.Context, method string, _ []json.Ra
34 }
35 return b.browseBackend.Query(context.Background(), method, nil, "")
36 }
37+
38+// servedScript fetches one of the cached browser scripts through the app.
39+func servedScript(t *testing.T, app *App, path string) string {
40+ t.Helper()
41+ recorder := httptest.NewRecorder()
42+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
43+ if recorder.Code != http.StatusOK {
44+ t.Fatalf("%s: status=%d", path, recorder.Code)
45+ }
46+ return recorder.Body.String()
47+}
48diff --git a/internal/webui/components.js b/internal/webui/components.js
49index f1386c3..609dab7 100644
50--- a/internal/webui/components.js
51+++ b/internal/webui/components.js
52@@ -220,6 +220,9 @@
53 connectedCallback() {
54 if (this.bound) return;
55 this.bound = true;
56+ // The encryption and messaging modules load on demand; the controls
57+ // render right away and decryption waits for them.
58+ this.ready = Promise.resolve(tiny.require?.("files")).catch(() => {});
59 this.innerHTML = '<h3>Share</h3><p><button type="button" data-copy>Copy Blossom URI</button> <button type="button" data-link>Copy share link</button> <share-link></share-link></p><form data-share><label>Send to <input name="recipient" inputmode="text" placeholder="npub or hex pubkey"></label><button>Send privately</button></form><output role="status"></output>';
60 this.output = this.querySelector("output");
61 this.querySelector("[data-copy]").addEventListener("click", () => this.copy(this.blossomURI()));
62@@ -229,7 +232,7 @@
63 const params = new URLSearchParams(location.hash.slice(1));
64 this.querySelector("[data-share]").hidden = !(params.get("key") && params.get("iv"));
65 this.updateFileControls();
66- this.decryptFromFragment();
67+ this.ready.then(() => this.decryptFromFragment());
68 }
69
70 hash() { return this.getAttribute("hash") || ""; }
71@@ -279,6 +282,7 @@
72
73 async share(form) {
74 if (this.sharing) return;
75+ await this.ready;
76 const recipient = form.elements.namedItem("recipient").value.trim();
77 if (!recipient) { this.say("Enter a public key or npub.", true); return; }
78 const params = new URLSearchParams(location.hash.slice(1));
79@@ -820,6 +824,20 @@
80 }
81 }
82
83+ // ensureModules loads the feature bundle for any custom element on the
84+ // page whose definition has not arrived, such as after an in-place
85+ // navigation to the Files or Connect pages.
86+ const bundlesFor = {"file-upload": "files", "file-workspace-root": "files", "private-services": "private"};
87+ const ensureModules = () => {
88+ if (!tiny.require || typeof customElements.get !== "function") return;
89+ const wanted = new Set();
90+ for (const [tag, bundle] of Object.entries(bundlesFor)) {
91+ if (!customElements.get(tag) && document.querySelector(tag)) wanted.add(bundle);
92+ }
93+ wanted.forEach(bundle => tiny.require(bundle).catch(() => {}));
94+ };
95+ ensureModules();
96+
97 customElements.define("rpc-form", RpcForm);
98 customElements.define("connect-card", ConnectCard);
99 customElements.define("connect-list", ConnectList);
100@@ -902,6 +920,7 @@
101 if (focus && typeof focus.focus === "function") focus.focus({preventScroll: true});
102 else document.querySelector("#content")?.focus({preventScroll: true});
103 document.dispatchEvent(new CustomEvent("tiny:navigation", {detail: {url: url.href}}));
104+ ensureModules();
105 decorate();
106 decorateForms();
107 document.dispatchEvent(new CustomEvent("fx:process", {detail: {url: url.href}, bubbles: false}));
108diff --git a/internal/webui/page.html b/internal/webui/page.html
109index a02731a..aa4e6c6 100644
110--- a/internal/webui/page.html
111+++ b/internal/webui/page.html
112@@ -1,5 +1,5 @@
113 {{define "page"}}<!doctype html>
114-<html lang="en" data-js="no">
115+<html lang="en" data-js="no" data-scripts="{{scriptsVersion}}">
116 <head>
117 <meta charset="utf-8">
118 <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
119@@ -62,16 +62,16 @@
120 <td id="aside"><div id="panel">{{template "panel" .}}</div></td>
121 </tr></tbody></table>
122 <footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> &raquo; {{prompt .Path .Query}}</span></td><td><span id="who">{{if .Private}}private relay{{else if .Actor}}signed in as {{template "nostrKey" .Actor}} (<button id="session-logout" type="button">sign out</button>){{else}}browsing as guest (<a href="/signin">Sign in</a>){{end}}</span> <span id="session-status" role="status"></span><span id="navigation-status" role="status" aria-live="polite"></span></td></tr></table></footer>
123-<script>{{script "tiny.js"}}</script>
124-<script>{{script "bridge.js"}}</script>
125-<script>{{script "blossom-encryption.js"}}</script>
126-<script>{{script "blossom-manifests.js"}}</script>
127-<script>{{script "blossom-upload.js"}}</script>
128-<script>{{script "file-messages.js"}}</script>
129-<script>{{script "file-workspace.js"}}</script>
130-<script>{{script "components.js"}}</script>
131-<script>{{script "private-services.js"}}</script>
132-</body>
133+<script src="{{scriptURL "tiny.js"}}"></script>
134+<script src="{{scriptURL "bridge.js"}}"></script>
135+{{if or (eq .Tab "files") (eq .Tab "file")}}<script src="{{scriptURL "blossom-encryption.js"}}"></script>
136+<script src="{{scriptURL "blossom-manifests.js"}}"></script>
137+<script src="{{scriptURL "blossom-upload.js"}}"></script>
138+<script src="{{scriptURL "file-messages.js"}}"></script>
139+<script src="{{scriptURL "file-workspace.js"}}"></script>
140+{{end}}<script src="{{scriptURL "components.js"}}"></script>
141+{{if eq .Tab "connect"}}<script src="{{scriptURL "private-services.js"}}"></script>
142+{{end}}</body>
143 </html>
144 {{end}}
145
146diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go
147index 3ac1755..eaf2116 100644
148--- a/internal/webui/shell_test.go
149+++ b/internal/webui/shell_test.go
150@@ -327,11 +327,14 @@ func TestAccountPageOffersRelayListsAndHomeShowsConnectCards(t *testing.T) {
151 recorder := httptest.NewRecorder()
152 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/signin", nil))
153 body := recorder.Body.String()
154- for _, want := range []string{`<relay-lists relay="ws://relay.example" server="http://relay.example" pubkey="` + backend.policy.Owner + `">`, `data-kind="10002" data-tag="r"`, `data-kind="10050"`, `data-kind="10007"`, `data-kind="10063" data-tag="server"`, `customElements.define("relay-lists"`} {
155+ for _, want := range []string{`<relay-lists relay="ws://relay.example" server="http://relay.example" pubkey="` + backend.policy.Owner + `">`, `data-kind="10002" data-tag="r"`, `data-kind="10050"`, `data-kind="10007"`, `data-kind="10063" data-tag="server"`} {
156 if !strings.Contains(body, want) {
157 t.Errorf("account page missing %q", want)
158 }
159 }
160+ if !strings.Contains(servedScript(t, app, "/scripts/components.js"), `customElements.define("relay-lists"`) {
161+ t.Error("components script missing relay-lists")
162+ }
163 recorder = httptest.NewRecorder()
164 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
165 body = recorder.Body.String()
166diff --git a/internal/webui/style.css b/internal/webui/style.css
167index ff19462..2043fe7 100644
168--- a/internal/webui/style.css
169+++ b/internal/webui/style.css
170@@ -36,7 +36,8 @@ img{max-width:100%}
171 #prompt{color:var(--muted)}#prompt b{color:var(--ink);font-weight:500}
172 #who{color:var(--muted)}#who b,#who nostr-key{color:var(--ink)}
173 #who button{border:0;background:none;padding:0;color:var(--accent);font:inherit}#who button:hover{text-decoration:underline}
174-#rail-tools{margin-top:12px}
175+#railbox{box-sizing:border-box;padding-bottom:44px}
176+#rail-tools{position:absolute;right:0;bottom:0;margin:0;padding:10px 2ch 10px 2.5ch}
177 share-link{display:inline-block}share-link:empty{display:none}
178 #theme{border:0;background:none;padding:0;color:var(--muted);font:12px var(--mono)}#theme:hover{color:var(--ink);text-decoration:underline}
179
180@@ -201,7 +202,8 @@ html,body{overflow:auto}
181 #layout,#layout>tbody,#layout>tbody>tr,#layout>tbody>tr>td{display:block;width:auto;height:auto}
182 #layout{padding:40px 0 28px}
183 #railbox,#content,#panel{position:static;overflow:visible}
184-#layout>tbody>tr>td#rail{position:fixed;top:40px;bottom:28px;left:0;width:240px;height:auto;border-right:1px solid var(--line);transform:translateX(-102%);visibility:hidden;transition:transform .15s;z-index:2;overflow:auto}
185+#layout>tbody>tr>td#rail{position:fixed;top:40px;bottom:28px;left:0;width:240px;height:auto;box-sizing:border-box;border-right:1px solid var(--line);transform:translateX(-102%);visibility:hidden;transition:transform .15s;z-index:2;overflow:hidden auto}
186+#railbox{position:relative;min-height:100%}
187 #topbar:has(#nav-menu[open]) + #layout>tbody>tr>td#rail{transform:none;visibility:visible;box-shadow:8px 0 24px rgba(0,0,0,.15)}
188 #layout>tbody>tr>td#aside{width:auto;border-left:0;border-top:1px solid var(--line)}
189 #content>section{padding:14px 2.5ch 20px}
190diff --git a/internal/webui/sw.js b/internal/webui/sw.js
191index d412cd6..8621ba6 100644
192--- a/internal/webui/sw.js
193+++ b/internal/webui/sw.js
194@@ -3,7 +3,7 @@
195 // page loads and the shell scripts are kept as the offline fallback; relay
196 // data requested by scripts is never cached.
197 const CACHE = "tiny-shell-v1";
198-const shellAsset = url => /\/(fixi\.js|signer\.js|webmcp\.js|sw\.js|icon[^/]*\.(svg|png)|apple-touch-icon\.png|manifest\.webmanifest)$/.test(url.pathname);
199+const shellAsset = url => /\/(fixi\.js|signer\.js|webmcp\.js|sw\.js|scripts\/[\w.-]+\.js|icon[^/]*\.(svg|png)|badge-96\.png|apple-touch-icon\.png|manifest\.webmanifest)$/.test(url.pathname);
200 self.addEventListener("install", () => self.skipWaiting());
201 self.addEventListener("activate", event => {
202 event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim()));
203diff --git a/internal/webui/templates.go b/internal/webui/templates.go
204index e130967..1401d56 100644
205--- a/internal/webui/templates.go
206+++ b/internal/webui/templates.go
207@@ -1,11 +1,14 @@
208 package webui
209
210 import (
211+ "crypto/sha256"
212 "embed"
213+ "encoding/hex"
214 "encoding/json"
215 "fmt"
216 "html/template"
217 "net/url"
218+ "sort"
219 "strings"
220 "time"
221 )
222@@ -145,17 +148,36 @@ func repoView(query url.Values) string {
223 return "tree"
224 }
225
226+// scripts are served at /scripts/<name> and cached by the service worker.
227+// One version stamp covers them all, so a change to any file refreshes every
228+// cached copy together.
229+var scripts = map[string]string{"bridge.js": bridgeJS, "tiny.js": tinyJS, "components.js": componentsJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS}
230+
231+var scriptsVersion = func() string {
232+ names := make([]string, 0, len(scripts))
233+ for name := range scripts {
234+ names = append(names, name)
235+ }
236+ sort.Strings(names)
237+ digest := sha256.New()
238+ for _, name := range names {
239+ digest.Write([]byte(name))
240+ digest.Write([]byte{0})
241+ digest.Write([]byte(scripts[name]))
242+ }
243+ return hex.EncodeToString(digest.Sum(nil))[:12]
244+}()
245+
246 func parseTemplates() (*template.Template, error) {
247- scripts := map[string]string{"bridge.js": bridgeJS, "tiny.js": tinyJS, "components.js": componentsJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS}
248 funcs := template.FuncMap{
249 "stylesheet": func() template.CSS { return template.CSS(styleCSS) },
250- "script": func(name string) (template.JS, error) {
251- source, ok := scripts[name]
252- if !ok {
253+ "scriptURL": func(name string) (string, error) {
254+ if _, ok := scripts[name]; !ok {
255 return "", fmt.Errorf("unknown script %q", name)
256 }
257- return template.JS(source), nil
258+ return "/scripts/" + name + "?v=" + scriptsVersion, nil
259 },
260+ "scriptsVersion": func() string { return scriptsVersion },
261 "json": func(value any) string {
262 encoded, err := json.Marshal(value)
263 if err != nil {
264diff --git a/internal/webui/tiny.js b/internal/webui/tiny.js
265index a7f7100..6716f3e 100644
266--- a/internal/webui/tiny.js
267+++ b/internal/webui/tiny.js
268@@ -68,6 +68,30 @@
269 };
270 tiny.nip44Encrypt = (pubkey, text, signer) => nip44("Encrypt", pubkey, text, signer);
271 tiny.nip44Decrypt = (pubkey, text, signer) => nip44("Decrypt", pubkey, text, signer);
272+ // Feature bundles load on the pages that use them and again after an
273+ // in-place navigation lands on such a page. Each script loads once.
274+ tiny.bundles = {
275+ files: ["blossom-encryption.js", "blossom-manifests.js", "blossom-upload.js", "file-messages.js", "file-workspace.js"],
276+ private: ["private-services.js"]
277+ };
278+ const loaded = new Map();
279+ const loadScript = name => {
280+ if (!loaded.has(name)) {
281+ const version = document.documentElement.dataset.scripts || "";
282+ const present = [...(document.scripts || [])].some(script => (script.src || "").includes("/scripts/" + name));
283+ loaded.set(name, present ? Promise.resolve() : new Promise((resolve, reject) => {
284+ const script = document.createElement("script");
285+ script.src = (tiny.localPath ? tiny.localPath("/scripts/" + name) : "/scripts/" + name) + (version ? "?v=" + version : "");
286+ script.onload = () => resolve();
287+ script.onerror = () => { loaded.delete(name); reject(Error("Could not load " + name)); };
288+ document.head.append(script);
289+ }));
290+ }
291+ return loaded.get(name);
292+ };
293+ tiny.require = async bundle => {
294+ for (const name of tiny.bundles[bundle] || [bundle]) await loadScript(name);
295+ };
296 // Feature modules add to these groups; guards can read them before load.
297 tiny.blossom = tiny.blossom || {};
298 tiny.files = tiny.files || {};
299diff --git a/internal/webui/webui.go b/internal/webui/webui.go
300index 57bf9a8..f751837 100644
301--- a/internal/webui/webui.go
302+++ b/internal/webui/webui.go
303@@ -189,6 +189,21 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
304 a.invitePage(writer, request)
305 return
306 }
307+ if strings.HasPrefix(request.URL.Path, "/scripts/") {
308+ source, ok := scripts[strings.TrimPrefix(request.URL.Path, "/scripts/")]
309+ if !ok {
310+ http.NotFound(writer, request)
311+ return
312+ }
313+ writer.Header().Set("content-type", "application/javascript; charset=utf-8")
314+ if request.URL.Query().Get("v") == scriptsVersion {
315+ writer.Header().Set("cache-control", "public, max-age=31536000, immutable")
316+ } else {
317+ writer.Header().Set("cache-control", "no-cache")
318+ }
319+ _, _ = writer.Write([]byte(source))
320+ return
321+ }
322 if request.URL.Path == "/signer.js" {
323 writer.Header().Set("content-type", "application/javascript; charset=utf-8")
324 _, _ = writer.Write([]byte(signerJS))
325diff --git a/internal/webui/webui_test.go b/internal/webui/webui_test.go
326index e3ce614..e73f8fd 100644
327--- a/internal/webui/webui_test.go
328+++ b/internal/webui/webui_test.go
329@@ -229,11 +229,14 @@ func TestSignerBundleAndDedicatedJourneysArePresent(t *testing.T) {
330 recorder := httptest.NewRecorder()
331 app.ServeHTTP(recorder, request)
332 body := recorder.Body.String()
333- for _, want := range []string{"bunker://", "nostrconnect", "signer-qr", "27235", "/signer.js"} {
334+ for _, want := range []string{"bunker://", "nostrconnect", "signer-qr", "/signer.js", `src="/scripts/bridge.js?v=`} {
335 if !strings.Contains(body, want) {
336 t.Errorf("connect page missing %q", want)
337 }
338 }
339+ if !strings.Contains(servedScript(t, app, "/scripts/bridge.js"), "27235") {
340+ t.Error("bridge script missing the NIP-98 kind")
341+ }
342 signerRequest := httptest.NewRequest(http.MethodGet, "/signer.js", nil)
343 signerResponse := httptest.NewRecorder()
344 app.ServeHTTP(signerResponse, signerRequest)
345@@ -343,11 +346,20 @@ func TestTenantPrefixRewritesUIEndpoints(t *testing.T) {
346 recorder := httptest.NewRecorder()
347 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/r/alice/manage/sync", nil))
348 body := recorder.Body.String()
349- for _, want := range []string{`src="/r/alice/fixi.js"`, `src="/r/alice/signer.js"`, `fx-action="/r/alice/manage/jobs/status"`, `localPath(action || "/manage/rpc")`, `signedSession("/session")`, `replace(/^http/,"ws")+root`} {
350+ for _, want := range []string{`src="/r/alice/fixi.js"`, `src="/r/alice/signer.js"`, `fx-action="/r/alice/manage/jobs/status"`, `src="/r/alice/scripts/bridge.js?v=`, `src="/r/alice/scripts/components.js?v=`} {
351 if !strings.Contains(body, want) {
352 t.Errorf("tenant prefix missing %q", want)
353 }
354 }
355+ bridge := servedScript(t, app, "/scripts/bridge.js")
356+ for _, want := range []string{`signedSession("/session")`, `replace(/^http/,"ws")+root`} {
357+ if !strings.Contains(bridge, want) {
358+ t.Errorf("bridge script missing %q", want)
359+ }
360+ }
361+ if !strings.Contains(servedScript(t, app, "/scripts/components.js"), `localPath(action || "/manage/rpc")`) {
362+ t.Error("components script missing the prefixed management endpoint")
363+ }
364 }
365
366 func TestTenantPrefixHeaderSurvivesDaemonPathStripping(t *testing.T) {

About

ownerc01503e8035f
visibilitypublic
headaad6cc915b41

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs