@openparachute/app 0.2.0-rc.4
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/.parachute/config/schema +62 -0
- package/.parachute/info +14 -0
- package/.parachute/module.json +14 -0
- package/CHANGELOG.md +405 -0
- package/LICENSE +661 -0
- package/bin/parachute-app.ts +525 -0
- package/dist/admin/assets/index-BXlRNPxk.js +60 -0
- package/dist/admin/assets/index-DaGP1hmw.css +1 -0
- package/dist/admin/index.html +14 -0
- package/package.json +51 -0
- package/src/admin-routes.ts +884 -0
- package/src/auth.ts +212 -0
- package/src/bootstrap.ts +153 -0
- package/src/cache-headers.ts +106 -0
- package/src/config.ts +289 -0
- package/src/dcr.ts +334 -0
- package/src/dev-injection.ts +166 -0
- package/src/dev-mode.ts +205 -0
- package/src/dev-routes.ts +380 -0
- package/src/dev-watcher.ts +479 -0
- package/src/http-server.ts +533 -0
- package/src/index.ts +394 -0
- package/src/meta-schema.ts +662 -0
- package/src/npm-fetch.ts +320 -0
- package/src/operator-token.ts +95 -0
- package/src/provision-schema.ts +180 -0
- package/src/self-register.ts +155 -0
- package/src/services-manifest.ts +104 -0
- package/src/ui-registry.ts +202 -0
package/src/auth.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bearer-token auth for parachute-app's HTTP admin endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `parachute-runner/src/auth.ts` deliberately — same trust kernel
|
|
5
|
+
* (`@openparachute/scope-guard`), same hub-origin resolution, same shape for
|
|
6
|
+
* the 401/403 responses. The single difference is the audience: `aud === "app"`.
|
|
7
|
+
*
|
|
8
|
+
* Two scopes apps defines:
|
|
9
|
+
* - `app:read` — list UIs, read per-UI info. Read-only.
|
|
10
|
+
* - `app:admin` — add / remove / reload UIs + DCR registration on add.
|
|
11
|
+
*
|
|
12
|
+
* Endpoints that stay unauthenticated:
|
|
13
|
+
* - `/healthz`, `/app/healthz` (liveness, hub probes)
|
|
14
|
+
* - `/.parachute/info`, `/.parachute/config/schema` (open module-protocol
|
|
15
|
+
* surface — module identity + schema shape leak nothing)
|
|
16
|
+
* - Per-UI bundle serving under `/app/<name>/*` (static assets; hub gates
|
|
17
|
+
* these at the reverse-proxy layer per design doc section 9)
|
|
18
|
+
* - `/app/<name>/oauth-client` (UIs need this at page load before they
|
|
19
|
+
* have any token — public OAuth client_id is by definition public)
|
|
20
|
+
*
|
|
21
|
+
* Admin endpoints take `app:admin`. Read-only admin endpoints accept
|
|
22
|
+
* `app:admin` OR `app:read` per design doc section 13.
|
|
23
|
+
*
|
|
24
|
+
* Hub-origin resolution follows the same shape every other module uses:
|
|
25
|
+
* - `PARACHUTE_HUB_ORIGIN` env var when set
|
|
26
|
+
* - `config.hub_url` from `loadConfig()` as the daemon-config-aware override
|
|
27
|
+
* - `http://127.0.0.1:1939` loopback fallback (v0.6 single-container)
|
|
28
|
+
*
|
|
29
|
+
* `getHubOrigin()` re-resolves on every call so tests can swap the env
|
|
30
|
+
* mid-run; production callers set the env once at boot.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { HubJwtError, type ScopeGuard, createScopeGuard } from "@openparachute/scope-guard";
|
|
34
|
+
|
|
35
|
+
export const SCOPE_ADMIN = "app:admin" as const;
|
|
36
|
+
export const SCOPE_READ = "app:read" as const;
|
|
37
|
+
|
|
38
|
+
/** Hub loopback for v0.6 single-container; deploys override via env. */
|
|
39
|
+
const DEFAULT_HUB_LOOPBACK = "http://127.0.0.1:1939";
|
|
40
|
+
|
|
41
|
+
/** Audience the daemon declares — hub#300 mints with `aud: "app"` for our endpoints. */
|
|
42
|
+
export const AUDIENCE = "app" as const;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the hub origin used for JWT validation.
|
|
46
|
+
*
|
|
47
|
+
* Honors `PARACHUTE_HUB_ORIGIN` first (the canonical override every committed-
|
|
48
|
+
* core module respects), then `hubUrl` (the runtime config field), then falls
|
|
49
|
+
* back to the loopback. `hubUrl` is the same string `config.hub_url` carries —
|
|
50
|
+
* callers in `http-server.ts` pass `state.config.hub_url` so a runtime config
|
|
51
|
+
* change (Phase 1.3 admin SPA toggles) takes effect without a daemon restart.
|
|
52
|
+
*/
|
|
53
|
+
export function getHubOrigin(hubUrl?: string): string {
|
|
54
|
+
const env = process.env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
|
|
55
|
+
if (env && env.length > 0) return env;
|
|
56
|
+
if (hubUrl && hubUrl.length > 0) return hubUrl.replace(/\/$/, "");
|
|
57
|
+
return DEFAULT_HUB_LOOPBACK;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let guard: ScopeGuard | null = null;
|
|
61
|
+
let guardHubOrigin: string | null = null;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Lazy process-wide guard. The resolver form lets tests flip
|
|
65
|
+
* `PARACHUTE_HUB_ORIGIN` between cases without restarting the harness; the
|
|
66
|
+
* lib re-resolves on every `validateHubJwt` call. JWKS + revocation caches
|
|
67
|
+
* live inside the guard and survive across requests in production.
|
|
68
|
+
*
|
|
69
|
+
* If `hubUrl` is supplied and differs from the cached guard's origin, the
|
|
70
|
+
* cached guard is replaced — daemon-config writes can change `hub_url` at
|
|
71
|
+
* runtime and we want subsequent validations to track the new origin.
|
|
72
|
+
*/
|
|
73
|
+
function getGuard(hubUrl?: string): ScopeGuard {
|
|
74
|
+
const wanted = getHubOrigin(hubUrl);
|
|
75
|
+
if (!guard || guardHubOrigin !== wanted) {
|
|
76
|
+
guard = createScopeGuard({ hubOrigin: () => wanted });
|
|
77
|
+
guardHubOrigin = wanted;
|
|
78
|
+
}
|
|
79
|
+
return guard;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Test seam: forget the cached guard so a beforeEach that swaps the
|
|
84
|
+
* `PARACHUTE_HUB_ORIGIN` env var picks up the new origin on the next call.
|
|
85
|
+
*/
|
|
86
|
+
export function resetGuard(): void {
|
|
87
|
+
if (guard) {
|
|
88
|
+
guard.resetJwksCache();
|
|
89
|
+
guard.resetRevocationCache();
|
|
90
|
+
}
|
|
91
|
+
guard = null;
|
|
92
|
+
guardHubOrigin = null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function extractBearer(authHeader: string | null | undefined): string | undefined {
|
|
96
|
+
if (!authHeader) return undefined;
|
|
97
|
+
const m = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
98
|
+
return m?.[1]?.trim() || undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type AuthResult =
|
|
102
|
+
| { ok: true; scopes: readonly string[] }
|
|
103
|
+
| { ok: false; status: 401 | 403; body: { error: string; message: string } };
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Validate the presented bearer against the hub. Returns the granted scope
|
|
107
|
+
* list on success; on failure returns a typed 401 or 403 the caller forwards
|
|
108
|
+
* verbatim.
|
|
109
|
+
*
|
|
110
|
+
* `aud === "app"` enforced via `expectedAudience` — a token minted for a
|
|
111
|
+
* different module can't reach our admin surface even if its bearer carries
|
|
112
|
+
* `app:admin` (which it can't, but defense-in-depth).
|
|
113
|
+
*/
|
|
114
|
+
export async function validateBearer(
|
|
115
|
+
token: string | undefined,
|
|
116
|
+
opts: { hubUrl?: string } = {},
|
|
117
|
+
): Promise<AuthResult> {
|
|
118
|
+
if (!token) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
status: 401,
|
|
122
|
+
body: { error: "unauthorized", message: "Authorization: Bearer <token> required" },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const claims = await getGuard(opts.hubUrl).validateHubJwt(token, {
|
|
127
|
+
expectedAudience: AUDIENCE,
|
|
128
|
+
});
|
|
129
|
+
return { ok: true, scopes: claims.scopes };
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (err instanceof HubJwtError && err.code === "revoked") {
|
|
132
|
+
console.warn(`[app-auth] hub JWT rejected: ${err.message}`);
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
status: 401,
|
|
136
|
+
body: { error: "unauthorized", message: "token has been revoked" },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (err instanceof HubJwtError && err.code === "revocation_unavailable") {
|
|
140
|
+
console.warn(`[app-auth] hub JWT rejected: ${err.message}`);
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
status: 401,
|
|
144
|
+
body: {
|
|
145
|
+
error: "unauthorized",
|
|
146
|
+
message: "token cannot be validated: revocation list unavailable",
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const message =
|
|
151
|
+
err instanceof HubJwtError
|
|
152
|
+
? err.message
|
|
153
|
+
: err instanceof Error
|
|
154
|
+
? err.message
|
|
155
|
+
: "JWT validation failed";
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
status: 401,
|
|
159
|
+
body: { error: "unauthorized", message },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Exact-match scope check. Non-vault scopes don't inherit per oauth-scopes.md. */
|
|
165
|
+
export function hasScope(granted: readonly string[], required: string): boolean {
|
|
166
|
+
return granted.includes(required);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Pass-through scope check that treats `app:admin` as implying `app:read`.
|
|
171
|
+
* Read-only admin endpoints (GET /app/list, GET /app/<name>/info) accept
|
|
172
|
+
* either scope; admin endpoints (add/remove/reload) require `app:admin`
|
|
173
|
+
* exactly.
|
|
174
|
+
*/
|
|
175
|
+
export function hasReadAccess(granted: readonly string[]): boolean {
|
|
176
|
+
return granted.includes(SCOPE_READ) || granted.includes(SCOPE_ADMIN);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Resolve auth + scope. Returns either a Response to forward (401/403) or
|
|
181
|
+
* the granted scopes for the caller to use in finer-grained checks.
|
|
182
|
+
*
|
|
183
|
+
* `requiredScope` is one of:
|
|
184
|
+
* - `app:admin` — exact match required
|
|
185
|
+
* - `app:read` — accepts `app:read` OR `app:admin` (admin implies read)
|
|
186
|
+
*/
|
|
187
|
+
export async function enforceScope(
|
|
188
|
+
req: Request,
|
|
189
|
+
requiredScope: typeof SCOPE_ADMIN | typeof SCOPE_READ,
|
|
190
|
+
opts: { hubUrl?: string } = {},
|
|
191
|
+
): Promise<Response | { scopes: readonly string[] }> {
|
|
192
|
+
const token = extractBearer(req.headers.get("authorization"));
|
|
193
|
+
const result = await validateBearer(token, opts);
|
|
194
|
+
if (!result.ok) {
|
|
195
|
+
return Response.json(result.body, { status: result.status });
|
|
196
|
+
}
|
|
197
|
+
const granted = result.scopes;
|
|
198
|
+
const ok = requiredScope === SCOPE_READ ? hasReadAccess(granted) : hasScope(granted, SCOPE_ADMIN);
|
|
199
|
+
if (!ok) {
|
|
200
|
+
return Response.json(
|
|
201
|
+
{
|
|
202
|
+
error: "Forbidden",
|
|
203
|
+
error_type: "insufficient_scope",
|
|
204
|
+
message: `This endpoint requires the '${requiredScope}' scope.`,
|
|
205
|
+
required_scope: requiredScope,
|
|
206
|
+
granted_scopes: granted,
|
|
207
|
+
},
|
|
208
|
+
{ status: 403 },
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return { scopes: granted };
|
|
212
|
+
}
|
package/src/bootstrap.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-boot default-app bootstrap — Phase 2.1.
|
|
3
|
+
*
|
|
4
|
+
* On `parachute-app serve` startup, when `$PARACHUTE_HOME/app/uis/` is
|
|
5
|
+
* empty and `config.bootstrap_default_apps.enabled` is true, apps
|
|
6
|
+
* auto-installs each entry in `config.bootstrap_default_apps.apps` via
|
|
7
|
+
* the same npm-fetch pipeline `parachute-app add` uses.
|
|
8
|
+
*
|
|
9
|
+
* Friend-deploy story: spin up a hub + run `parachute-app serve`, and
|
|
10
|
+
* Notes is there waiting — no manual `add` step. The operator can
|
|
11
|
+
* always disable this by flipping `bootstrap_default_apps.enabled =
|
|
12
|
+
* false` or setting `apps: []`.
|
|
13
|
+
*
|
|
14
|
+
* Design rationale (design doc Section 16): Notes is the canonical
|
|
15
|
+
* first app installed under parachute-app. The bootstrap registry is
|
|
16
|
+
* the implementation of "Notes ships with app." Future committed-core
|
|
17
|
+
* apps may join the default list; today it's just notes-ui.
|
|
18
|
+
*
|
|
19
|
+
* Failure mode: if npm-fetch fails (network down, package not on
|
|
20
|
+
* registry, registry timeout), log a warning and continue. The
|
|
21
|
+
* operator can run `parachute-app add @openparachute/notes-ui` later
|
|
22
|
+
* to retry. We never block daemon startup on the bootstrap — the
|
|
23
|
+
* daemon's primary job is hosting whatever's in `uis/` already (which,
|
|
24
|
+
* in the empty case, is "nothing"), and a failed bootstrap is just
|
|
25
|
+
* "nothing got added."
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { readdirSync, statSync } from "node:fs";
|
|
29
|
+
|
|
30
|
+
import type { AppConfig } from "./config.ts";
|
|
31
|
+
import type { NpmSpawnFn, fetchNpmPackage } from "./npm-fetch.ts";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Minimal `add` surface bootstrap needs. The full admin handler does
|
|
35
|
+
* staging + meta-merge + DCR + state-swap + services.json refresh;
|
|
36
|
+
* bootstrap reuses that same flow by passing a callback that performs
|
|
37
|
+
* the equivalent (in practice, the caller in `index.ts` adapts the
|
|
38
|
+
* admin handler so bootstrap stays decoupled from admin-routes.ts's
|
|
39
|
+
* `AppState` mutation pattern).
|
|
40
|
+
*/
|
|
41
|
+
export type BootstrapAddFn = (source: string) => Promise<{ name: string; path: string }>;
|
|
42
|
+
|
|
43
|
+
export type BootstrapOpts = {
|
|
44
|
+
config: AppConfig;
|
|
45
|
+
/** Resolved uis dir; allows tests to inject a tempdir. */
|
|
46
|
+
uisDir: string;
|
|
47
|
+
/** The npm-fetch entry-point — overridable for tests. */
|
|
48
|
+
npmFetch?: typeof fetchNpmPackage;
|
|
49
|
+
/** The `add` callback — orchestrator wires this to admin-routes' add path. */
|
|
50
|
+
add: BootstrapAddFn;
|
|
51
|
+
/** Logger override; default console. */
|
|
52
|
+
logger?: Pick<Console, "log" | "warn" | "error">;
|
|
53
|
+
/** npm-spawn override (tests). Passed to `npmFetch`. */
|
|
54
|
+
npmSpawnFn?: NpmSpawnFn;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type BootstrapResult = {
|
|
58
|
+
/** npm specs of packages successfully added. */
|
|
59
|
+
bootstrapped: string[];
|
|
60
|
+
/** npm specs skipped (per-spec reason). */
|
|
61
|
+
skipped: Array<{ pkg: string; reason: string }>;
|
|
62
|
+
/** npm specs that failed (per-spec error). */
|
|
63
|
+
failed: Array<{ pkg: string; error: string }>;
|
|
64
|
+
/** Why the whole pass was skipped, if it was (else undefined). */
|
|
65
|
+
skipReason?: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Inspect `uisDir` + `config`, then maybe run bootstrap. Returns a
|
|
70
|
+
* summary the caller (`serve()` in index.ts) logs.
|
|
71
|
+
*
|
|
72
|
+
* Skip conditions (any one triggers an early return):
|
|
73
|
+
* - `config.bootstrap_default_apps.enabled === false`
|
|
74
|
+
* - `config.bootstrap_default_apps.apps` is empty
|
|
75
|
+
* - `uisDir` exists AND contains at least one entry (we don't touch
|
|
76
|
+
* installs the operator already manages)
|
|
77
|
+
*
|
|
78
|
+
* When none of the skip conditions fire: iterate `apps` and call
|
|
79
|
+
* `add(spec)` for each. Each call is independent — a failure on one
|
|
80
|
+
* doesn't stop the rest.
|
|
81
|
+
*/
|
|
82
|
+
export async function maybeBootstrapDefaultApps(opts: BootstrapOpts): Promise<BootstrapResult> {
|
|
83
|
+
const logger = opts.logger ?? console;
|
|
84
|
+
const result: BootstrapResult = {
|
|
85
|
+
bootstrapped: [],
|
|
86
|
+
skipped: [],
|
|
87
|
+
failed: [],
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (!opts.config.bootstrap_default_apps.enabled) {
|
|
91
|
+
result.skipReason = "config.bootstrap_default_apps.enabled is false";
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
if (opts.config.bootstrap_default_apps.apps.length === 0) {
|
|
95
|
+
result.skipReason = "config.bootstrap_default_apps.apps is empty";
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (uisDirHasEntries(opts.uisDir)) {
|
|
100
|
+
result.skipReason = "uisDir is non-empty (existing operator install)";
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const spec of opts.config.bootstrap_default_apps.apps) {
|
|
105
|
+
try {
|
|
106
|
+
const added = await opts.add(spec);
|
|
107
|
+
result.bootstrapped.push(spec);
|
|
108
|
+
logger.log(`[app] bootstrap: installed ${spec} as ${added.name} at ${added.path}`);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
const msg = (e as Error).message ?? String(e);
|
|
111
|
+
result.failed.push({ pkg: spec, error: msg });
|
|
112
|
+
logger.warn(`[app] bootstrap: failed to install ${spec}: ${msg}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (result.bootstrapped.length > 0) {
|
|
117
|
+
logger.log(
|
|
118
|
+
`[app] bootstrap: installed ${result.bootstrapped.length} default app(s) — ${result.bootstrapped.join(", ")}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Predicate: does `uisDir` exist + contain at least one entry that
|
|
126
|
+
* looks like a UI install candidate? A pure missing-directory or a
|
|
127
|
+
* directory containing only hidden files (e.g. `.DS_Store`) counts as
|
|
128
|
+
* "empty" — operators don't deliberately seed UIs as dotfile dirs.
|
|
129
|
+
*
|
|
130
|
+
* We deliberately accept "exists + has at least one non-dotfile
|
|
131
|
+
* entry" — even an entry that doesn't pass `scanUis` (broken meta,
|
|
132
|
+
* missing dist/) signals "operator was here," and bootstrap shouldn't
|
|
133
|
+
* trample.
|
|
134
|
+
*/
|
|
135
|
+
function uisDirHasEntries(uisDir: string): boolean {
|
|
136
|
+
try {
|
|
137
|
+
const st = statSync(uisDir);
|
|
138
|
+
if (!st.isDirectory()) return false;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
let entries: string[];
|
|
143
|
+
try {
|
|
144
|
+
entries = readdirSync(uisDir);
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
for (const e of entries) {
|
|
149
|
+
if (e.startsWith(".")) continue;
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart cache headers per design doc section 18.
|
|
3
|
+
*
|
|
4
|
+
* The intent: solve [parachute-notes#151](https://github.com/ParachuteComputer/parachute-notes/issues/151)
|
|
5
|
+
* at the platform level so future apps inherit a clean default. The rules:
|
|
6
|
+
*
|
|
7
|
+
* - `index.html` (SPA entrypoints): `no-cache, no-store, must-revalidate`.
|
|
8
|
+
* Always-fresh; this is the document that points at the hashed assets.
|
|
9
|
+
* - Content-hashed assets (Vite/Webpack/esbuild/Parcel default convention,
|
|
10
|
+
* e.g. `app.a3b9f2.js`, `style.7e1c8d.css`): `public, max-age=31536000,
|
|
11
|
+
* immutable`. Cache forever — the filename changes on rebuild.
|
|
12
|
+
* - Non-hashed assets: `public, max-age=3600`. Sensible default.
|
|
13
|
+
* - PWA service worker (when `meta.pwa === true` and `filename` matches
|
|
14
|
+
* `meta.pwa_service_worker`): `no-cache`. SW updates need to propagate
|
|
15
|
+
* immediately on rebuild.
|
|
16
|
+
*
|
|
17
|
+
* The hash detector is conservative: require ≥8 hex characters in the
|
|
18
|
+
* second-to-last dot-separated segment. This matches Vite/Webpack output
|
|
19
|
+
* (`app.a3b9f2c1.js`) without false-positiving on filenames like
|
|
20
|
+
* `vendor-1234.js` or `image-2024.png`. We deliberately reject 6-7 char
|
|
21
|
+
* hashes — Vite + Webpack both default to ≥8 — to keep the false-positive
|
|
22
|
+
* rate low.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { UiMeta } from "./meta-schema.ts";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Regex testing whether a filename looks content-hashed. Examples that pass:
|
|
29
|
+
* - `app.a3b9f2c1.js`
|
|
30
|
+
* - `vendor.7e1c8d.chunk.js` (hash is in the middle)
|
|
31
|
+
* - `style.deadbeef12345.css`
|
|
32
|
+
*
|
|
33
|
+
* Examples that don't pass (caching at the 1-hour default):
|
|
34
|
+
* - `index.html` (handled separately)
|
|
35
|
+
* - `app.js`
|
|
36
|
+
* - `app-2024.js` (no hex run that long; "2024" is 4 chars)
|
|
37
|
+
* - `vendor-1234.js`
|
|
38
|
+
* - `app-20240101.js` (date stamp — 8 digits but pure-decimal, no a-f)
|
|
39
|
+
* - `icon.svg`
|
|
40
|
+
*
|
|
41
|
+
* The match looks for a dot-separated segment of ≥8 hex chars anywhere in
|
|
42
|
+
* the filename, AND requires at least one a-f character in that segment.
|
|
43
|
+
* The lookahead `(?=[a-f0-9]*[a-f])` is the load-bearing line: it filters
|
|
44
|
+
* out pure-digit runs (date stamps like `20240101`) that would otherwise
|
|
45
|
+
* masquerade as hex hashes. Vite/Webpack hashes are random hex so they
|
|
46
|
+
* almost always contain at least one letter — and on the rare run that
|
|
47
|
+
* doesn't, the 1-hour fallback is harmless. False-positive avoidance wins.
|
|
48
|
+
*/
|
|
49
|
+
const HASHED_ASSET_REGEX = /(^|[.\-_])(?=[a-f0-9]*[a-f])[a-f0-9]{8,}(\.|$)/;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Type signature exposed by section 5 of the brief — explicit per-asset
|
|
53
|
+
* shape for use anywhere we need to set headers. `filename` is the basename
|
|
54
|
+
* (e.g. `app.a3b9f2.js`) — pass the full URL path's basename, not the
|
|
55
|
+
* absolute filesystem path.
|
|
56
|
+
*
|
|
57
|
+
* `meta` is optional so caller can elide it for the `/.parachute/*` admin
|
|
58
|
+
* endpoints; those skip the PWA-aware branch.
|
|
59
|
+
*
|
|
60
|
+
* `devMode` (Phase 1.3) overrides every other branch with
|
|
61
|
+
* `no-cache, no-store, must-revalidate`. When the operator runs
|
|
62
|
+
* `parachute-app dev <name>` we want zero caching from any layer — index,
|
|
63
|
+
* hashed assets, SW, the lot. The smart-cache rules below are silent
|
|
64
|
+
* during dev iteration.
|
|
65
|
+
*/
|
|
66
|
+
export function cacheHeadersFor(
|
|
67
|
+
filename: string,
|
|
68
|
+
meta?: UiMeta,
|
|
69
|
+
devMode = false,
|
|
70
|
+
): Record<string, string> {
|
|
71
|
+
// Dev mode trumps every other branch — no caching anywhere. Even
|
|
72
|
+
// content-hashed assets get no-cache so an operator who manually
|
|
73
|
+
// edits a hashed bundle (e.g. swapped from disk) sees the change
|
|
74
|
+
// without reasoning about the filename.
|
|
75
|
+
if (devMode) {
|
|
76
|
+
return { "Cache-Control": "no-cache, no-store, must-revalidate" };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// PWA service worker — always no-cache so updates propagate immediately
|
|
80
|
+
// on rebuild. The SW path is meta-driven so each UI controls its own.
|
|
81
|
+
if (meta?.pwa && meta.pwa_service_worker && filename === meta.pwa_service_worker) {
|
|
82
|
+
return { "Cache-Control": "no-cache" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// index.html: always-fresh.
|
|
86
|
+
if (filename === "index.html") {
|
|
87
|
+
return { "Cache-Control": "no-cache, no-store, must-revalidate" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Content-hashed: cache forever.
|
|
91
|
+
if (HASHED_ASSET_REGEX.test(filename)) {
|
|
92
|
+
return { "Cache-Control": "public, max-age=31536000, immutable" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Non-hashed assets: 1-hour default.
|
|
96
|
+
return { "Cache-Control": "public, max-age=3600" };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Convenience predicate exposed for tests. Returns true when `filename`
|
|
101
|
+
* matches the hash-in-filename convention (used to confirm the regex stays
|
|
102
|
+
* tight as we extend it).
|
|
103
|
+
*/
|
|
104
|
+
export function looksContentHashed(filename: string): boolean {
|
|
105
|
+
return HASHED_ASSET_REGEX.test(filename);
|
|
106
|
+
}
|