Register a service worker

To make installing as a PWA possible. Fixes #1181.
This commit is contained in:
Asher 2020-02-27 14:56:14 -06:00
parent eef2ed0e78
commit 963ebaca5b
No known key found for this signature in database
GPG Key ID: D63C1EF81242354A
17 changed files with 140 additions and 78 deletions

View File

@ -339,17 +339,24 @@ class Builder {
} }
private createBundler(out = "dist", commit?: string): Bundler { private createBundler(out = "dist", commit?: string): Bundler {
return new Bundler(path.join(this.rootPath, "src/browser/pages/app.ts"), { return new Bundler(
cache: true, [
cacheDir: path.join(this.rootPath, ".cache"), path.join(this.rootPath, "src/browser/pages/app.ts"),
detailedReport: true, path.join(this.rootPath, "src/browser/register.ts"),
minify: !!process.env.MINIFY, path.join(this.rootPath, "src/browser/serviceWorker.ts"),
hmr: false, ],
logLevel: 1, {
outDir: path.join(this.rootPath, out), cache: true,
publicUrl: `/static-${commit}/dist`, cacheDir: path.join(this.rootPath, ".cache"),
target: "browser", detailedReport: true,
}) minify: !!process.env.MINIFY,
hmr: false,
logLevel: 1,
outDir: path.join(this.rootPath, out),
publicUrl: `/static-${commit || "development"}/dist`,
target: "browser",
},
)
} }
} }

View File

@ -12,7 +12,7 @@
"sizes": "96x96" "sizes": "96x96"
}, },
{ {
"src": "/{{BASE}}/static-{{COMMIT}}/src/browser/media/pwa-icon-128.png", "src": "{{BASE}}/static-{{COMMIT}}/src/browser/media/pwa-icon-128.png",
"type": "image/png", "type": "image/png",
"sizes": "128x128" "sizes": "128x128"
}, },

View File

@ -19,6 +19,7 @@
<meta id="coder-options" data-settings="{{OPTIONS}}" /> <meta id="coder-options" data-settings="{{OPTIONS}}" />
</head> </head>
<body> <body>
<script src="{{BASE}}/static-{{COMMIT}}/dist/register.js"></script>
<script src="{{BASE}}/static-{{COMMIT}}/dist/app.js"></script> <script src="{{BASE}}/static-{{COMMIT}}/dist/app.js"></script>
</body> </body>
</html> </html>

View File

@ -8,8 +8,5 @@ import "./login.css"
import "./update.css" import "./update.css"
const options = getOptions() const options = getOptions()
const parts = window.location.pathname.replace(/^\//g, "").split("/")
parts[parts.length - 1] = options.base
const url = new URL(window.location.origin + "/" + parts.join("/"))
console.log(url) console.log(options)

View File

@ -16,6 +16,7 @@
/> />
<link rel="apple-touch-icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/code-server.png" /> <link rel="apple-touch-icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/code-server.png" />
<link href="{{BASE}}/static-{{COMMIT}}/dist/app.css" rel="stylesheet" /> <link href="{{BASE}}/static-{{COMMIT}}/dist/app.css" rel="stylesheet" />
<meta id="coder-options" data-settings="{{OPTIONS}}" />
</head> </head>
<body> <body>
<div class="center-container"> <div class="center-container">
@ -29,5 +30,6 @@
</div> </div>
</div> </div>
</div> </div>
<script src="{{BASE}}/static-{{COMMIT}}/dist/register.js"></script>
</body> </body>
</html> </html>

View File

@ -60,5 +60,6 @@
</div> </div>
</div> </div>
</div> </div>
<script src="{{BASE}}/static-{{COMMIT}}/dist/register.js"></script>
</body> </body>
</html> </html>

View File

@ -8,7 +8,7 @@
/> />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="style-src 'self'; script-src 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;" content="style-src 'self'; script-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;"
/> />
<title>code-server login</title> <title>code-server login</title>
<link rel="icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" /> <link rel="icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
@ -19,6 +19,7 @@
/> />
<link rel="apple-touch-icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/code-server.png" /> <link rel="apple-touch-icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/code-server.png" />
<link href="{{BASE}}/static-{{COMMIT}}/dist/app.css" rel="stylesheet" /> <link href="{{BASE}}/static-{{COMMIT}}/dist/app.css" rel="stylesheet" />
<meta id="coder-options" data-settings="{{OPTIONS}}" />
</head> </head>
<body> <body>
<div class="center-container"> <div class="center-container">
@ -52,6 +53,7 @@
</div> </div>
</div> </div>
</body> </body>
<script src="{{BASE}}/static-{{COMMIT}}/dist/register.js"></script>
<script> <script>
const parts = window.location.pathname.replace(/^\//g, "").split("/") const parts = window.location.pathname.replace(/^\//g, "").split("/")
parts[parts.length - 1] = "{{BASE}}" parts[parts.length - 1] = "{{BASE}}"

View File

@ -16,6 +16,7 @@
/> />
<link rel="apple-touch-icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/code-server.png" /> <link rel="apple-touch-icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/code-server.png" />
<link href="{{BASE}}/static-{{COMMIT}}/dist/app.css" rel="stylesheet" /> <link href="{{BASE}}/static-{{COMMIT}}/dist/app.css" rel="stylesheet" />
<meta id="coder-options" data-settings="{{OPTIONS}}" />
</head> </head>
<body> <body>
<div class="center-container"> <div class="center-container">
@ -32,5 +33,6 @@
</div> </div>
</div> </div>
</div> </div>
<script src="{{BASE}}/static-{{COMMIT}}/dist/register.js"></script>
</body> </body>
</html> </html>

View File

@ -41,6 +41,8 @@
<!-- PROD_ONLY <!-- PROD_ONLY
<link rel="prefetch" href="{{VS_BASE}}/static-{{COMMIT}}/node_modules/semver-umd/lib/semver-umd.js"> <link rel="prefetch" href="{{VS_BASE}}/static-{{COMMIT}}/node_modules/semver-umd/lib/semver-umd.js">
END_PROD_ONLY --> END_PROD_ONLY -->
<meta id="coder-options" data-settings="{{OPTIONS}}" />
</head> </head>
<body aria-label=""></body> <body aria-label=""></body>
@ -91,6 +93,7 @@
"vs/nls": nlsConfig, "vs/nls": nlsConfig,
} }
</script> </script>
<script src="{{BASE}}/static-{{COMMIT}}/dist/register.js"></script>
<script src="{{VS_BASE}}/static-{{COMMIT}}/out/vs/loader.js"></script> <script src="{{VS_BASE}}/static-{{COMMIT}}/out/vs/loader.js"></script>
<!-- PROD_ONLY <!-- PROD_ONLY
<script src="{{VS_BASE}}/static-{{COMMIT}}/out/vs/workbench/workbench.web.api.nls.js"></script> <script src="{{VS_BASE}}/static-{{COMMIT}}/out/vs/workbench/workbench.web.api.nls.js"></script>

14
src/browser/register.ts Normal file
View File

@ -0,0 +1,14 @@
import { getOptions, normalize } from "../common/util"
const options = getOptions()
if ("serviceWorker" in navigator) {
const path = normalize(`${options.base}/static-${options.commit}/dist/serviceWorker.js`)
navigator.serviceWorker
.register(path, {
scope: options.base || "/",
})
.then(function() {
console.log("[Service Worker] registered")
})
}

View File

@ -0,0 +1,24 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
self.addEventListener("install", () => {
console.log("[Service Worker] install")
})
self.addEventListener("activate", (event: any) => {
event.waitUntil((self as any).clients.claim())
})
self.addEventListener("fetch", (event: any) => {
if (!navigator.onLine) {
event.respondWith(
new Promise((resolve) => {
resolve(
new Response("OFFLINE", {
status: 200,
statusText: "OK",
}),
)
}),
)
}
})

View File

@ -2,8 +2,9 @@ import { logger } from "@coder/logger"
export interface Options { export interface Options {
base: string base: string
commit: string
logLevel: number logLevel: number
sessionId: string sessionId?: string
} }
/** /**
@ -25,6 +26,13 @@ export const generateUuid = (length = 24): string => {
.join("") .join("")
} }
/**
* Remove extra slashes in a URL.
*/
export const normalize = (url: string, keepTrailing = false): string => {
return url.replace(/\/\/+/g, "/").replace(/\/+$/, keepTrailing ? "/" : "")
}
/** /**
* Get options embedded in the HTML from the server. * Get options embedded in the HTML from the server.
*/ */
@ -45,16 +53,15 @@ export const getOptions = <T extends Options>(): T => {
if (typeof options.logLevel !== "undefined") { if (typeof options.logLevel !== "undefined") {
logger.level = options.logLevel logger.level = options.logLevel
} }
return options const parts = window.location.pathname.replace(/^\//g, "").split("/")
parts[parts.length - 1] = options.base
const url = new URL(window.location.origin + "/" + parts.join("/"))
return {
...options,
base: normalize(url.pathname, true),
}
} catch (error) { } catch (error) {
logger.warn(error.message) logger.warn(error.message)
return {} as T return {} as T
} }
} }
/**
* Remove extra slashes in a URL.
*/
export const normalize = (url: string, keepTrailing = false): string => {
return url.replace(/\/\/+/g, "/").replace(/\/+$/, keepTrailing ? "/" : "")
}

View File

@ -1,9 +1,7 @@
import { logger } from "@coder/logger"
import * as http from "http" import * as http from "http"
import * as querystring from "querystring" import * as querystring from "querystring"
import { Application } from "../../common/api" import { Application } from "../../common/api"
import { HttpCode, HttpError } from "../../common/http" import { HttpCode, HttpError } from "../../common/http"
import { Options } from "../../common/util"
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http" import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
import { ApiHttpProvider } from "./api" import { ApiHttpProvider } from "./api"
import { UpdateHttpProvider } from "./update" import { UpdateHttpProvider } from "./update"
@ -61,15 +59,7 @@ export class MainHttpProvider extends HttpProvider {
} }
if (sessionId) { if (sessionId) {
return this.getAppRoot( return this.getAppRoot(route, (app && app.name) || "", sessionId)
route,
{
sessionId,
base: this.base(route),
logLevel: logger.level,
},
(app && app.name) || "",
)
} }
return this.getErrorRoot(route, "404", "404", "Application not found") return this.getErrorRoot(route, "404", "404", "Application not found")
@ -79,12 +69,12 @@ export class MainHttpProvider extends HttpProvider {
* Return a resource with variables replaced where necessary. * Return a resource with variables replaced where necessary.
*/ */
protected async getReplacedResource(route: Route): Promise<HttpResponse> { protected async getReplacedResource(route: Route): Promise<HttpResponse> {
if (route.requestPath.endsWith("/manifest.json")) { const split = route.requestPath.split("/")
const response = await this.getUtf8Resource(this.rootPath, route.requestPath) switch (split[split.length - 1]) {
response.content = response.content case "manifest.json": {
.replace(/{{BASE}}/g, this.base(route)) const response = await this.getUtf8Resource(this.rootPath, route.requestPath)
.replace(/{{COMMIT}}/g, this.options.commit) return this.replaceTemplates(route, response)
return response }
} }
return this.getResource(this.rootPath, route.requestPath) return this.getResource(this.rootPath, route.requestPath)
} }
@ -94,8 +84,6 @@ export class MainHttpProvider extends HttpProvider {
const apps = await this.api.installedApplications() const apps = await this.api.installedApplications()
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/home.html") const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/home.html")
response.content = response.content response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/{{UPDATE:NAME}}/, await this.getUpdate()) .replace(/{{UPDATE:NAME}}/, await this.getUpdate())
.replace(/{{APP_LIST:RUNNING}}/, this.getAppRows(running.applications)) .replace(/{{APP_LIST:RUNNING}}/, this.getAppRows(running.applications))
.replace( .replace(
@ -106,17 +94,13 @@ export class MainHttpProvider extends HttpProvider {
/{{APP_LIST:OTHER}}/, /{{APP_LIST:OTHER}}/,
this.getAppRows(apps.filter((app) => !app.categories || !app.categories.includes("Editor"))), this.getAppRows(apps.filter((app) => !app.categories || !app.categories.includes("Editor"))),
) )
return response return this.replaceTemplates(route, response)
} }
public async getAppRoot(route: Route, options: Options, name: string): Promise<HttpResponse> { public async getAppRoot(route: Route, name: string, sessionId: string): Promise<HttpResponse> {
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/app.html") const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/app.html")
response.content = response.content response.content = response.content.replace(/{{APP_NAME}}/, name)
.replace(/{{COMMIT}}/g, this.options.commit) return this.replaceTemplates(route, response, sessionId)
.replace(/{{BASE}}/g, this.base(route))
.replace(/{{APP_NAME}}/, name)
.replace(/"{{OPTIONS}}"/, `'${JSON.stringify(options)}'`)
return response
} }
public async handleWebSocket(): Promise<undefined> { public async handleWebSocket(): Promise<undefined> {

View File

@ -48,11 +48,9 @@ export class LoginHttpProvider extends HttpProvider {
public async getRoot(route: Route, value?: string, error?: Error): Promise<HttpResponse> { public async getRoot(route: Route, value?: string, error?: Error): Promise<HttpResponse> {
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/login.html") const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/login.html")
response.content = response.content response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/{{VALUE}}/, value || "") .replace(/{{VALUE}}/, value || "")
.replace(/{{ERROR}}/, error ? `<div class="error">${error.message}</div>` : "") .replace(/{{ERROR}}/, error ? `<div class="error">${error.message}</div>` : "")
return response return this.replaceTemplates(route, response)
} }
/** /**

View File

@ -86,11 +86,9 @@ export class UpdateHttpProvider extends HttpProvider {
public async getRoot(route: Route, error?: Error): Promise<HttpResponse> { public async getRoot(route: Route, error?: Error): Promise<HttpResponse> {
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/update.html") const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/update.html")
response.content = response.content response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/{{UPDATE_STATUS}}/, await this.getUpdateHtml()) .replace(/{{UPDATE_STATUS}}/, await this.getUpdateHtml())
.replace(/{{ERROR}}/, error ? `<div class="error">${error.message}</div>` : "") .replace(/{{ERROR}}/, error ? `<div class="error">${error.message}</div>` : "")
return response return this.replaceTemplates(route, response)
} }
public async handleWebSocket(): Promise<undefined> { public async handleWebSocket(): Promise<undefined> {

View File

@ -219,17 +219,13 @@ export class VscodeHttpProvider extends HttpProvider {
response.content = response.content.replace(/<!-- PROD_ONLY/g, "").replace(/END_PROD_ONLY -->/g, "") response.content = response.content.replace(/<!-- PROD_ONLY/g, "").replace(/END_PROD_ONLY -->/g, "")
} }
return { response.content = response.content
...response, .replace(/{{VS_BASE}}/g, this.base(route) + this.options.base)
content: response.content .replace(`"{{REMOTE_USER_DATA_URI}}"`, `'${JSON.stringify(options.remoteUserDataUri)}'`)
.replace(/{{COMMIT}}/g, options.commit) .replace(`"{{PRODUCT_CONFIGURATION}}"`, `'${JSON.stringify(options.productConfiguration)}'`)
.replace(/{{BASE}}/g, this.base(route)) .replace(`"{{WORKBENCH_WEB_CONFIGURATION}}"`, `'${JSON.stringify(options.workbenchWebConfiguration)}'`)
.replace(/{{VS_BASE}}/g, this.base(route) + this.options.base) .replace(`"{{NLS_CONFIGURATION}}"`, `'${JSON.stringify(options.nlsConfiguration)}'`)
.replace(`"{{REMOTE_USER_DATA_URI}}"`, `'${JSON.stringify(options.remoteUserDataUri)}'`) return this.replaceTemplates(route, response)
.replace(`"{{PRODUCT_CONFIGURATION}}"`, `'${JSON.stringify(options.productConfiguration)}'`)
.replace(`"{{WORKBENCH_WEB_CONFIGURATION}}"`, `'${JSON.stringify(options.workbenchWebConfiguration)}'`)
.replace(`"{{NLS_CONFIGURATION}}"`, `'${JSON.stringify(options.nlsConfiguration)}'`),
}
} }
/** /**

View File

@ -12,7 +12,7 @@ import * as tarFs from "tar-fs"
import * as tls from "tls" import * as tls from "tls"
import * as url from "url" import * as url from "url"
import { HttpCode, HttpError } from "../common/http" import { HttpCode, HttpError } from "../common/http"
import { normalize, plural, split } from "../common/util" import { normalize, Options, plural, split } from "../common/util"
import { SocketProxyProvider } from "./socket" import { SocketProxyProvider } from "./socket"
import { getMediaMime, xdgLocalDir } from "./util" import { getMediaMime, xdgLocalDir } from "./util"
@ -165,14 +165,36 @@ export abstract class HttpProvider {
return normalize("./" + (depth > 1 ? "../".repeat(depth - 1) : "")) return normalize("./" + (depth > 1 ? "../".repeat(depth - 1) : ""))
} }
/**
* Get error response.
*/
public async getErrorRoot(route: Route, title: string, header: string, body: string): Promise<HttpResponse> { public async getErrorRoot(route: Route, title: string, header: string, body: string): Promise<HttpResponse> {
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/error.html") const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/error.html")
response.content = response.content response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/{{ERROR_TITLE}}/g, title) .replace(/{{ERROR_TITLE}}/g, title)
.replace(/{{ERROR_HEADER}}/g, header) .replace(/{{ERROR_HEADER}}/g, header)
.replace(/{{ERROR_BODY}}/g, body) .replace(/{{ERROR_BODY}}/g, body)
return this.replaceTemplates(route, response)
}
/**
* Replace common templates strings.
*/
protected replaceTemplates(
route: Route,
response: HttpStringFileResponse,
sessionId?: string,
): HttpStringFileResponse {
const options: Options = {
base: this.base(route),
commit: this.options.commit,
logLevel: logger.level,
sessionId,
}
response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/"{{OPTIONS}}"/, `'${JSON.stringify(options)}'`)
return response return response
} }
@ -338,11 +360,15 @@ export class Heart {
clearTimeout(this.heartbeatTimer) clearTimeout(this.heartbeatTimer)
} }
this.heartbeatTimer = setTimeout(() => { this.heartbeatTimer = setTimeout(() => {
this.isActive().then((active) => { this.isActive()
if (active) { .then((active) => {
this.beat() if (active) {
} this.beat()
}) }
})
.catch((error) => {
logger.warn(error.message)
})
}, this.heartbeatInterval) }, this.heartbeatInterval)
} }
} }