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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ DEFERRED_RESPONSE_ENABLED='false'
# DEFERRED_RESPONSE_THRESHOLD_MS='75000'
# DEFERRED_RESPONSE_KEEPALIVE_MS='25000'

# Emergency kill switch for the /app precache service worker: when true, /app/sw.js serves a
# self-destructing worker that unregisters itself and deletes its caches on every client
SW_KILL_SWITCH='false'

# Set to true to enable test endpoints (e.g. /api/test/deferred-response)
# ENABLE_TEST_ENDPOINTS='false'

Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/app/routes/api.routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ENV } from '@jetstream/api-config';
import { UNKNOWN_APP_VERSION } from '@jetstream/shared/constants';
import { getDefaultAppState } from '@jetstream/shared/utils';
import { AppInfo } from '@jetstream/types';
import express, { Router } from 'express';
Expand Down Expand Up @@ -48,7 +49,7 @@ routes.use(addOrgsToLocal);
// used to make sure the user is authenticated and can communicate with the server
routes.get('/heartbeat', async (req: express.Request, res: express.Response) => {
const result: AppInfo = {
version: ENV.VERSION || 'unknown',
version: ENV.VERSION || UNKNOWN_APP_VERSION,
announcements: await getAnnouncements(req.session.user),
appInfo: getDefaultAppState({
serverUrl: ENV.JETSTREAM_SERVER_URL,
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/app/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import oauthRoutes from './oauth.routes';
import { openApiRoutes } from './openapi.routes';
import platformEventRoutes from './platform-event.routes';
import redirectRoutes from './redirect.routes';
import scannerRoutes from './scanner.routes';
import { createSpaAssetRoutes } from './spa-assets.routes';
import staticAuthenticatedRoutes from './static-authenticated.routes';
import teamRoutes from './team.routes';
import scannerRoutes from './scanner.routes';
import testRoutes from './test.routes';
import webExtensionRoutes from './web-extension-server.routes';
import webhookRoutes from './webhook.routes';
Expand All @@ -21,6 +22,7 @@ export {
authRoutes,
billingRoutes,
canvasRoutes,
createSpaAssetRoutes,
cspReportRoutes,
desktopAppRoutes,
desktopAssetsRoutes,
Expand Down
137 changes: 137 additions & 0 deletions apps/api/src/app/routes/spa-assets.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { ENV, logger } from '@jetstream/api-config';
import { SW_PRECACHE_PREFIX } from '@jetstream/shared/constants';
import express, { Router } from 'express';
import { readFileSync } from 'fs';
import { basename, join, posix as pathPosix } from 'path';

/**
* Everything the SPA needs served from disk that is not the `/app` shell itself: the hashed build
* assets at the origin root and the precache service worker at `/app/sw.js`.
*
* Mounted at the root, and must be mounted BEFORE the `/app` handler so service worker update
* re-fetches are never intercepted by the auth redirect middlewares.
*/

const IMMUTABLE_ASSETS_MANIFEST = 'immutable-assets.json';
const IMMUTABLE_CACHE_CONTROL = 'public, max-age=31536000, immutable';

/**
* `/app/sw.js` keeps one URL forever, so an intermediary that holds onto a copy is the one thing
* that could stop SW_KILL_SWITCH from reaching clients. `no-store` rather than `no-cache` because
* Render's edge treats a 200 with a non-qualifying Cache-Control as cacheable by status code and
* documents `no-store` as the way to opt out; `CDN-Cache-Control` takes precedence there and is
* ignored by browsers. Costs nothing - browsers already bypass their HTTP cache when checking a
* service worker script for updates.
*/
function setServiceWorkerCacheHeaders(res: express.Response): void {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('CDN-Cache-Control', 'no-store');
}

/**
* Filenames the build declared content-hashed (immutableAssetsPlugin in apps/jetstream/vite.plugins.ts).
* A one-year cache is only safe because a new build means a new URL, and only the build knows which
* filenames carry a hash - inferring it from the name marks hand-named files like
* `jetstream-logo-pro.svg` immutable too, which no deploy could ever undo. A missing or unreadable
* manifest yields an empty set, so every asset falls back to revalidate-on-every-request.
*/
function readImmutableAssets(jetstreamDistPath: string): Set<string> {
try {
return new Set<string>(JSON.parse(readFileSync(join(jetstreamDistPath, IMMUTABLE_ASSETS_MANIFEST), 'utf8')));
} catch (error) {
logger.error(
{ err: error },
'[SPA] Failed to read immutable-assets.json at startup — build assets will be revalidated on every request',
);
return new Set<string>();
}
}

/**
* Served in place of the real worker when SW_KILL_SWITCH is set: browsers bypass the HTTP cache when
* checking /app/sw.js for updates (on every register() call and at least every 24h), so every client
* unregisters and clears its caches shortly after the flag is enabled. Pages keep working - the
* worker is network-passthrough for everything it doesn't have cached, and navigations are never
* intercepted.
*/
const KILL_SWITCH_WORKER_SCRIPT = `
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const cacheNames = (await caches.keys()).filter((cacheName) => cacheName.startsWith('${SW_PRECACHE_PREFIX}'));
await Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)));
await self.registration.unregister();
})());
});
`.trim();

export function createSpaAssetRoutes(jetstreamDistPath: string): express.Router {
const routes: express.Router = Router();

// Serve the SPA's built assets, but never `index.html` — it contains unreplaced
// `__CSP_NONCE__` placeholders and must only be sent through the /app handler.
// `{ index: false }` disables directory→index.html mapping; the guard below catches
// direct index.html requests regardless of encoding.
const immutableAssets = readImmutableAssets(jetstreamDistPath);
const jetstreamStatic = express.static(jetstreamDistPath, {
index: false,
setHeaders: (res, filePath) => {
if (immutableAssets.has(basename(filePath))) {
res.setHeader('Cache-Control', IMMUTABLE_CACHE_CONTROL);
}
},
});

routes.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
// Must mirror send's own path transform (decode → collapse slash runs → normalize
// dot segments) or the guard is bypassable. `req.path` from parseurl is raw —
// it doesn't decode `%2f`, doesn't collapse `//`, and doesn't resolve `.`/`..` —
// while `send` inside express.static does all three and would happily serve
// <root>/index.html for e.g. `//index.html`, `/%2findex.html`, or `/foo/%2e%2e/index.html`.
let decodedPath: string;
try {
decodedPath = decodeURIComponent(req.path);
} catch {
// Malformed percent-encoding — let downstream 404 handle it.
return next();
}
const normalizedPath = pathPosix.normalize(decodedPath.replace(/\/+/g, '/'));
// index.html contains unreplaced __CSP_NONCE__ placeholders and must only be sent through the
// /app handler. sw.js must only be reachable at /app/sw.js - served from the root it could be
// registered with a max scope covering the entire origin (landing, /canvas, /web-extension).
// immutable-assets.json is consumed here at startup and is not a client asset.
if (normalizedPath === '/index.html' || normalizedPath === '/sw.js' || normalizedPath === `/${IMMUTABLE_ASSETS_MANIFEST}`) {
return next();
}
jetstreamStatic(req, res, next);
});

let serviceWorkerScript: string | null = null;
try {
serviceWorkerScript = readFileSync(join(jetstreamDistPath, 'sw.js'), 'utf8');
} catch (error) {
logger.error({ err: error }, '[SPA] Failed to read jetstream/sw.js at startup — /app/sw.js will return 404');
}

// A 404 (missing build) is safe: browsers unregister a service worker whose script returns 404 on
// an update check, which fails open to no-worker behavior.
routes.get('/app/sw.js', (_: express.Request, res: express.Response) => {
const script = ENV.SW_KILL_SWITCH ? KILL_SWITCH_WORKER_SCRIPT : serviceWorkerScript;
if (!script) {
// 404 is cacheable by default at the edge, and a sticky 404 here would keep every client
// unregistered until the next deploy purge
res.status(404);
setServiceWorkerCacheHeaders(res);
res.end();
return;
}
res.setHeader('Content-Type', 'text/javascript; charset=utf-8');
setServiceWorkerCacheHeaders(res);
// Required: the script's directory-derived max scope is /app/, which as a string prefix would
// not cover the bare /app document URL; this header allows registration with scope '/app'.
res.setHeader('Service-Worker-Allowed', '/app');
res.send(script);
});

return routes;
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 31 additions & 0 deletions apps/api/src/assets/jetstream-app.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "Jetstream",
"short_name": "Jetstream",
"description": "Jetstream is a set of tools that supercharge your administration of Salesforce.com.",
"id": "/app",
"start_url": "/app",
"scope": "/app",
"display": "standalone",
"theme_color": "#f3f3f3",
"background_color": "#f3f3f3",
"icons": [
{
"src": "/assets/images/android-icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/assets/images/jetstream-icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/assets/images/jetstream-icon-maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
32 changes: 7 additions & 25 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,14 @@ import cluster from 'node:cluster';
import { readFileSync } from 'node:fs';
import http from 'node:http';
import { cpus } from 'node:os';
import { join, posix as pathPosix } from 'node:path';
import { join } from 'node:path';
import { initSocketServer } from './app/controllers/socket.controller';
import {
apiRoutes,
authRoutes,
billingRoutes,
canvasRoutes,
createSpaAssetRoutes,
cspReportRoutes,
desktopAppRoutes,
desktopAssetsRoutes,
Expand Down Expand Up @@ -373,30 +374,11 @@ if (ENV.NODE_ENV === 'production' && !ENV.CI && cluster.isPrimary) {
);
}

// Serve the SPA's built assets, but never `index.html` — it contains unreplaced
// `__CSP_NONCE__` placeholders and must only be sent through the /app handler below.
// `{ index: false }` disables directory→index.html mapping; the guard after catches
// direct index.html requests regardless of encoding.
const jetstreamStatic = express.static(join(__dirname, '../jetstream'), { index: false });
app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
// Must mirror send's own path transform (decode → collapse slash runs → normalize
// dot segments) or the guard is bypassable. `req.path` from parseurl is raw —
// it doesn't decode `%2f`, doesn't collapse `//`, and doesn't resolve `.`/`..` —
// while `send` inside express.static does all three and would happily serve
// <root>/index.html for e.g. `//index.html`, `/%2findex.html`, or `/foo/%2e%2e/index.html`.
let decodedPath: string;
try {
decodedPath = decodeURIComponent(req.path);
} catch {
// Malformed percent-encoding — let downstream 404 handle it.
return next();
}
const normalizedPath = pathPosix.normalize(decodedPath.replace(/\/+/g, '/'));
if (normalizedPath === '/index.html') {
return next();
}
jetstreamStatic(req, res, next);
});
// Hashed build assets at the origin root + the precache service worker at /app/sw.js.
// Must stay ahead of the /app mount so service worker update re-fetches are never intercepted
// by the auth redirect middlewares.
app.use(createSpaAssetRoutes(join(__dirname, '../jetstream')));

app.use(
'/app',
spaRateLimit,
Expand Down
16 changes: 15 additions & 1 deletion apps/jetstream/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,21 @@
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />

<meta name="theme-color" content="#ffffff" />
<!--
Seeds the browser/PWA title bar before JS runs; ThemeApplier then keeps it in step with the
resolved scheme. Matches the SLDS light surface (--slds-g-color-surface-2) so there is no
white flash ahead of the app painting.
-->
<meta name="theme-color" content="#f3f3f3" />

<!--
href must be absolute: <base href="/app"> is injected at build and would break a relative path.
crossorigin="use-credentials" is required, not cosmetic: manifests are the one subresource fetched
with credentials omitted by default, so behind an auth proxy (Cloudflare Access on staging) the
request arrives cookie-less, gets 302'd to a cross-origin login page, and fails the CORS check.
-->
<link rel="manifest" href="/assets/jetstream-app.webmanifest" crossorigin="use-credentials" />
<meta name="apple-mobile-web-app-title" content="Jetstream" />

<link rel="apple-touch-icon" sizes="57x57" href="/assets/images/apple-icon-57x57.png" />
<link rel="apple-touch-icon" sizes="60x60" href="/assets/images/apple-icon-60x60.png" />
Expand Down
Loading
Loading