Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
284 changes: 283 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1",
"vite-plugin-solid": "catalog:"
"vite-plugin-pwa": "1.3.0",
"vite-plugin-solid": "catalog:",
"workbox-window": "7.4.1"
},
"dependencies": {
"@kobalte/core": "catalog:",
Expand Down
7 changes: 7 additions & 0 deletions packages/app/public/_headers
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,10 @@

/*.css
Content-Type: text/css

/site.webmanifest
Content-Type: application/manifest+json

/sw.js
Content-Type: application/javascript
Cache-Control: no-cache
10 changes: 9 additions & 1 deletion packages/app/src/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import * as Sentry from "@sentry/solid"
import { render } from "solid-js/web"
import { registerSW } from "virtual:pwa-register"
import { AppBaseProviders, AppInterface } from "@/app"
import { type Platform, PlatformProvider } from "@/context/platform"
import { dict as en } from "@/i18n/en"
Expand All @@ -11,6 +12,13 @@ import { authFromToken } from "@/utils/server"
import pkg from "../package.json"
import { ServerConnection } from "./context/server"

const updateServiceWorker = registerSW({
immediate: true,
onNeedRefresh() {
void updateServiceWorker(true)
},
})

const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"

const getLocale = () => {
Expand Down Expand Up @@ -69,7 +77,7 @@ const notify: Platform["notify"] = async (title, description, href) => {

const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96-v3.png",
icon: "/favicon-96x96-v3.png",
})

notification.onclick = () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/// <reference types="vite-plugin-pwa/client" />

interface ImportMetaEnv {
readonly VITE_OPENCODE_SERVER_HOST: string
readonly VITE_OPENCODE_SERVER_PORT: string
Expand Down
47 changes: 46 additions & 1 deletion packages/app/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { sentryVitePlugin } from "@sentry/vite-plugin"
import { defineConfig } from "vite"
import { VitePWA } from "vite-plugin-pwa"
import desktopPlugin from "./vite"

const sentry =
Expand All @@ -20,7 +21,51 @@ const sentry =
: false

export default defineConfig({
plugins: [desktopPlugin, sentry] as any,
plugins: [
desktopPlugin,
VitePWA({
strategies: "generateSW",
registerType: "prompt",
injectRegister: null,
manifest: false,
workbox: {
cleanupOutdatedCaches: true,
clientsClaim: true,
inlineWorkboxRuntime: true,
navigateFallback: "/index.html",
globPatterns: [
"index.html",
"site.webmanifest",
"favicon*",
"apple-touch-icon*",
"web-app-manifest*",
"assets/index-*.{js,css}",
"assets/home-*.js",
"assets/new-session-*.js",
"assets/session-*.js",
"assets/workbox-window*.js",
"assets/Inter.ttf",
"assets/JetBrainsMonoNerdFontMono-Regular.woff2",
],
runtimeCaching: [
{
urlPattern: ({ url }) => url.origin === self.location.origin && url.pathname.startsWith("/assets/"),
handler: "CacheFirst",
options: {
cacheName: "opencode-assets",
cacheableResponse: {
statuses: [200],
},
expiration: {
maxEntries: 1000,
},
},
},
],
},
}),
sentry,
] as any,
server: {
host: "0.0.0.0",
allowedHosts: true,
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/server/shared/public-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// the manifest icons even when a server password is configured.
export const PUBLIC_UI_PATHS = new Set<string>([
"/site.webmanifest",
"/sw.js",
"/web-app-manifest-192x192.png",
"/web-app-manifest-512x512.png",
])
Expand Down
7 changes: 5 additions & 2 deletions packages/opencode/src/server/shared/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@ function notFound() {
return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
}

function embeddedUIResponse(file: string, body: Uint8Array) {
function embeddedUIResponse(requestPath: string, file: string, body: Uint8Array) {
const mime = FSUtil.mimeType(file)
const headers = new Headers({ "content-type": mime })
if (requestPath === "/sw.js") headers.set("cache-control", "no-cache")
if (mime.startsWith("text/html")) {
headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body)))
}
Expand All @@ -70,7 +71,7 @@ export function serveEmbeddedUIEffect(
if (!file) return Effect.succeed(notFound())

return fs.readFile(file).pipe(
Effect.map((body) => embeddedUIResponse(file, body)),
Effect.map((body) => embeddedUIResponse(requestPath, file, body)),
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(notFound())),
)
}
Expand All @@ -93,6 +94,8 @@ export function serveUIEffect(
)
const headers = proxyResponseHeaders(response.headers)

if (path === "/sw.js") headers.set("Cache-Control", "no-cache")

if (response.headers["content-type"]?.includes("text/html")) {
const body = yield* response.text
headers.set("Content-Security-Policy", cspForHtml(body))
Expand Down
25 changes: 24 additions & 1 deletion packages/opencode/test/server/httpapi-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,23 @@ describe("HttpApi UI fallback", () => {
}),
)

it.live("revalidates the embedded service worker", () =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const response = yield* serveEmbeddedUIEffect(
"/sw.js",
{
...fs,
readFile: () => Effect.succeed(new TextEncoder().encode("service worker")),
},
{ "sw.js": "/$bunfs/root/sw.js" },
).pipe(Effect.map(HttpServerResponse.toWeb))

expect(response.status).toBe(200)
expect(response.headers.get("cache-control")).toBe("no-cache")
}),
)

it.live("allows embedded UI terminal wasm and theme preload CSP", () =>
Effect.gen(function* () {
const script = 'document.documentElement.dataset.theme = "dark"'
Expand Down Expand Up @@ -424,14 +441,20 @@ describe("HttpApi UI fallback", () => {
// should bypass auth.
it.live("serves the PWA manifest without auth even when a server password is set", () =>
Effect.gen(function* () {
for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) {
for (const path of [
"/site.webmanifest",
"/sw.js",
"/web-app-manifest-192x192.png",
"/web-app-manifest-512x512.png",
]) {
const response = yield* uiApp({
password: "secret",
username: "opencode",
disableEmbeddedWebUi: true,
client: httpClient(new Response("ok")),
}).request(path)
expect(response.status).not.toBe(401)
if (path === "/sw.js") expect(response.headers.get("cache-control")).toBe("no-cache")
}
}),
)
Expand Down
Loading