@pathmx/core 0.5.0 → 0.5.1

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/document.ts CHANGED
@@ -19,9 +19,12 @@ export function documentAssetRank(asset: DocumentAsset) {
19
19
  export function documentAssetKey(asset: DocumentAsset) {
20
20
  if (asset.type === "style") return `style:${asset.text}`
21
21
  if (asset.type === "script") {
22
- if ("name" in asset && asset.name) return `script:name:${asset.name}`
23
- if ("source" in asset) return `script:source:${asset.source}`
24
- return "href" in asset ? `script:${asset.href}` : `script:${asset.text}`
22
+ const mode = asset.module ? "module" : "classic"
23
+ if ("name" in asset) return `script:name:${asset.name}:${mode}`
24
+ if ("source" in asset) return `script:source:${asset.source}:${mode}`
25
+ return "href" in asset
26
+ ? `script:href:${asset.href}:${mode}`
27
+ : `script:inline:${asset.text}:${mode}`
25
28
  }
26
29
  if (asset.type === "title") return "title"
27
30
  if (asset.type === "preload") {
@@ -31,8 +34,10 @@ export function documentAssetKey(asset: DocumentAsset) {
31
34
  const { charset, name, property } = asset.attrs
32
35
  return `meta:${charset != null ? "charset" : (name ?? property ?? JSON.stringify(asset.attrs))}:${asset.attrs.media ?? ""}`
33
36
  }
34
- if ("name" in asset && asset.name) return `stylesheet:name:${asset.name}`
35
- return `stylesheet:${"href" in asset ? asset.href : ""}`
37
+ const media = asset.media ?? "all"
38
+ if ("name" in asset) return `stylesheet:name:${asset.name}:${media}`
39
+ if ("source" in asset) return `stylesheet:source:${asset.source}:${media}`
40
+ return `stylesheet:href:${"href" in asset ? asset.href : ""}:${media}`
36
41
  }
37
42
 
38
43
  function pushUniqueAsset(assets: DocumentAsset[], asset: DocumentAsset) {
@@ -81,6 +86,11 @@ export function renderDocumentAssets(assets: readonly DocumentAsset[]) {
81
86
  return `<link rel="preload" href="${escapeHtml(asset.href)}" as="${escapeHtml(asset.as)}"${contentType}${crossorigin}>`
82
87
  }
83
88
  if (asset.type === "stylesheet") {
89
+ if ("source" in asset) {
90
+ throw new Error(
91
+ `Unresolved authored stylesheet asset: ${asset.source}`,
92
+ )
93
+ }
84
94
  const href = "href" in asset ? asset.href : ""
85
95
  const media = asset.media ? ` media="${escapeHtml(asset.media)}"` : ""
86
96
  return `<link rel="stylesheet" href="${escapeHtml(href)}"${media}>`
@@ -0,0 +1,98 @@
1
+ import { canonicalPath } from "../canonical.ts"
2
+ import type { PathMXEnvironment } from "../environment.ts"
3
+ import type { Repository } from "../repository.ts"
4
+ import { resolveRoute } from "../server/router.ts"
5
+ import type { AssetStore } from "../assets.ts"
6
+
7
+ type Resolution = Readonly<{
8
+ generation: number
9
+ href: Promise<string>
10
+ }>
11
+
12
+ export class AuthoredAssetError extends Error {
13
+ constructor(
14
+ readonly sourcePath: string,
15
+ message: string,
16
+ ) {
17
+ super(`Authored asset ${sourcePath}: ${message}`)
18
+ this.name = "AuthoredAssetError"
19
+ }
20
+ }
21
+
22
+ /** Resolve repository assets once per source revision without pinning old Blobs. */
23
+ export class AuthoredAssets {
24
+ private generations = new Map<string, number>()
25
+ private resolutions = new Map<string, Map<string, Resolution>>()
26
+
27
+ constructor(
28
+ private repo: Repository,
29
+ private environment: PathMXEnvironment,
30
+ private store: AssetStore,
31
+ ) {}
32
+
33
+ async resolve(source: string, contentType: string) {
34
+ const sourcePath = canonicalPath(source)
35
+
36
+ while (true) {
37
+ const generation = this.generations.get(sourcePath) ?? 0
38
+ const byContentType = this.resolutions.get(sourcePath) ?? new Map()
39
+ let resolution = byContentType.get(contentType)
40
+ if (!resolution || resolution.generation !== generation) {
41
+ resolution = {
42
+ generation,
43
+ href: this.publish(sourcePath, contentType),
44
+ }
45
+ byContentType.set(contentType, resolution)
46
+ this.resolutions.set(sourcePath, byContentType)
47
+ }
48
+
49
+ let href: string
50
+ try {
51
+ href = await resolution.href
52
+ } catch (error) {
53
+ if (byContentType.get(contentType) === resolution) {
54
+ byContentType.delete(contentType)
55
+ if (!byContentType.size) this.resolutions.delete(sourcePath)
56
+ }
57
+ throw error
58
+ }
59
+
60
+ if ((this.generations.get(sourcePath) ?? 0) !== generation) continue
61
+ if (this.store.get(href)) return href
62
+
63
+ if (byContentType.get(contentType) === resolution) {
64
+ byContentType.delete(contentType)
65
+ if (!byContentType.size) this.resolutions.delete(sourcePath)
66
+ }
67
+ }
68
+ }
69
+
70
+ invalidate(paths: Iterable<string>) {
71
+ for (const path of paths) {
72
+ const sourcePath = canonicalPath(this.repo.graphPath(path))
73
+ this.generations.set(
74
+ sourcePath,
75
+ (this.generations.get(sourcePath) ?? 0) + 1,
76
+ )
77
+ this.resolutions.delete(sourcePath)
78
+ }
79
+ }
80
+
81
+ private async publish(sourcePath: string, contentType: string) {
82
+ const storagePath = this.repo.storagePath(sourcePath)
83
+ if (!storagePath || !(await this.repo.isFile(sourcePath))) {
84
+ throw new AuthoredAssetError(sourcePath, "source file was not found.")
85
+ }
86
+
87
+ const blob = await this.environment.storage.readBlob(storagePath)
88
+ const href = await this.store.publishAuthored(sourcePath, blob, contentType)
89
+ if ((await this.repo.isFile(href)) || resolveRoute(this.repo, href)) {
90
+ this.store.delete(href)
91
+ throw new AuthoredAssetError(
92
+ sourcePath,
93
+ `delivery URL conflicts with an authored route: ${href}`,
94
+ )
95
+ }
96
+ return href
97
+ }
98
+ }
package/host/engine.ts CHANGED
@@ -54,10 +54,11 @@ import {
54
54
  documentAssetRank,
55
55
  renderDocumentAssets,
56
56
  } from "../document.ts"
57
- import { GeneratedAssets } from "../assets.ts"
57
+ import { AssetStore } from "../assets.ts"
58
58
  import { PluginHost } from "./plugin-host.ts"
59
59
  import { PageCompiler } from "./compiler.ts"
60
60
  import { SourceSynchronizer } from "./source-sync.ts"
61
+ import { AuthoredAssets } from "./authored-assets.ts"
61
62
  import { PluginDatabases } from "../database.ts"
62
63
  import { projectDirectory } from "../project.ts"
63
64
  import { browserBundleAssets } from "../browser-bundle.ts"
@@ -88,8 +89,8 @@ export class Engine {
88
89
  perf = new Perf()
89
90
  log: Log
90
91
  private bundles = new Map<string, Promise<BundledAsset[]>>()
91
- private assetRevisions = new Map<string, number>()
92
- private generatedAssets: GeneratedAssets
92
+ private assetStore: AssetStore
93
+ private authoredAssets: AuthoredAssets
93
94
  private actorIndex: KnownActorIndex
94
95
  private accessPolicy: SourceAccessPolicy
95
96
  private authorityErrors = new Map<string, string>()
@@ -113,10 +114,11 @@ export class Engine {
113
114
  this.repo = repo
114
115
  this.environment = environment
115
116
  this.runtime = createRuntimeProfile(runtime)
116
- this.generatedAssets =
117
+ this.assetStore =
117
118
  this.runtime.target === "static"
118
- ? new GeneratedAssets(Number.POSITIVE_INFINITY)
119
- : new GeneratedAssets()
119
+ ? new AssetStore(Number.POSITIVE_INFINITY)
120
+ : new AssetStore()
121
+ this.authoredAssets = new AuthoredAssets(repo, environment, this.assetStore)
120
122
  this.graph = new Graph(repo)
121
123
  this.actorIndex = actors
122
124
  this.profiles = profiles
@@ -212,30 +214,33 @@ export class Engine {
212
214
  const ctx = { page, addAsset, addBrowserBundle }
213
215
  await this.pluginHost.documentAssets(ctx, this)
214
216
  addAsset(...(page?.assets ?? []))
215
- return assets
216
- .map((asset) => this.resolveDocumentAsset(asset))
217
- .sort((a, b) => documentAssetRank(a) - documentAssetRank(b))
217
+ const resolved = await Promise.all(
218
+ assets.map((asset) => this.resolveDocumentAsset(asset)),
219
+ )
220
+ return resolved.sort((a, b) => documentAssetRank(a) - documentAssetRank(b))
218
221
  }
219
222
 
220
223
  servedAsset(pathname: string) {
221
- return this.generatedAssets.get(pathname)
224
+ return this.assetStore.get(pathname)
222
225
  }
223
226
 
224
- /** @internal Enumerate retained generated artifacts during static publication. */
225
- generatedAssetEntries() {
226
- return this.generatedAssets.entries()
227
+ /** @internal Enumerate retained artifacts during static publication. */
228
+ servedAssetEntries() {
229
+ return this.assetStore.entries()
227
230
  }
228
231
 
229
232
  /** Publish an immutable, content-hashed binary asset under `/.pmx/`. */
230
233
  async publishAsset(name: string, blob: Blob, contentType = blob.type) {
231
- return this.generatedAssets.publishBlob(name, blob, contentType)
234
+ return this.assetStore.publishBlob(name, blob, contentType)
232
235
  }
233
236
 
234
237
  onSync(listener: (event: RouteInvalidation) => void) {
235
238
  return this.sourceSync.onSync(listener)
236
239
  }
237
240
 
238
- private resolveDocumentAsset(asset: DocumentAsset): DocumentAsset {
241
+ private async resolveDocumentAsset(
242
+ asset: DocumentAsset,
243
+ ): Promise<DocumentAsset> {
239
244
  if (asset.type === "stylesheet" && "name" in asset) {
240
245
  const href = this.storeNamed(
241
246
  asset.name,
@@ -248,6 +253,17 @@ export class Engine {
248
253
  ...(asset.media ? { media: asset.media } : {}),
249
254
  }
250
255
  }
256
+ if (asset.type === "stylesheet" && "source" in asset) {
257
+ const href = await this.authoredAssets.resolve(
258
+ asset.source,
259
+ "text/css; charset=utf-8",
260
+ )
261
+ return {
262
+ type: "stylesheet",
263
+ href,
264
+ ...(asset.media ? { media: asset.media } : {}),
265
+ }
266
+ }
251
267
  if (asset.type === "script" && "name" in asset) {
252
268
  const href = this.storeNamed(
253
269
  asset.name,
@@ -257,25 +273,21 @@ export class Engine {
257
273
  return { type: "script", href, module: asset.module }
258
274
  }
259
275
  if (asset.type === "script" && "source" in asset) {
260
- const revision = this.assetRevisions.get(asset.source)
261
- const href = revision ? `${asset.source}?pmx=${revision}` : asset.source
276
+ const href = await this.authoredAssets.resolve(
277
+ asset.source,
278
+ "text/javascript; charset=utf-8",
279
+ )
262
280
  return { type: "script", href, module: asset.module }
263
281
  }
264
282
  return asset
265
283
  }
266
284
 
267
- bumpAssetRevisions(paths: Iterable<string>) {
268
- for (const path of paths) {
269
- const graphPath = this.repo.graphPath(path)
270
- this.assetRevisions.set(
271
- graphPath,
272
- (this.assetRevisions.get(graphPath) ?? 0) + 1,
273
- )
274
- }
285
+ invalidateAuthoredAssets(paths: Iterable<string>) {
286
+ this.authoredAssets.invalidate(paths)
275
287
  }
276
288
 
277
289
  private storeNamed(name: string, text: string, contentType: string) {
278
- return this.generatedAssets.publishText(name, text, contentType)
290
+ return this.assetStore.publishText(name, text, contentType)
279
291
  }
280
292
 
281
293
  async documentHead(page?: CompiledPage) {
package/host/shell.html CHANGED
@@ -11,5 +11,6 @@
11
11
  >
12
12
  {{ content }}
13
13
  </div>
14
+ <div data-pmx-augmentations></div>
14
15
  </body>
15
16
  </html>
@@ -229,7 +229,7 @@ export class SourceSynchronizer {
229
229
 
230
230
  invalidateAssets(paths: Iterable<string>) {
231
231
  const changed = [...paths]
232
- this.engine.bumpAssetRevisions(changed)
232
+ this.engine.invalidateAuthoredAssets(changed)
233
233
  const invalidations = new RouteInvalidations()
234
234
  for (const path of changed) {
235
235
  const graphPath = this.engine.repo.graphPath(path)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pathmx/core",
3
3
  "description": "A minimal, plugin-first framework for Paths and learning applications.",
4
- "version": "0.5.0",
4
+ "version": "0.5.1",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "repository": {
7
7
  "type": "git",
@@ -42,6 +42,15 @@ export class PathMXReloadRequiredError extends PathMXDocumentError {
42
42
  }
43
43
  }
44
44
 
45
+ const AUGMENTATIONS_SELECTOR = "body > [data-pmx-augmentations]"
46
+
47
+ function augmentationLayer(target: Document) {
48
+ const layers = target.querySelectorAll(AUGMENTATIONS_SELECTOR)
49
+ return layers.length === 1 && layers[0] instanceof HTMLElement
50
+ ? layers[0]
51
+ : undefined
52
+ }
53
+
45
54
  function assetUrl(element: Element, attribute: "href" | "src") {
46
55
  try {
47
56
  return new URL(element.getAttribute(attribute) || "", location.href)
@@ -55,21 +64,23 @@ function stylesheetKey(link: Element) {
55
64
  if (!href) return link.getAttribute("href") || ""
56
65
  href.search = ""
57
66
  if (isHashedAsset(href.pathname)) {
58
- return href.pathname.replace(/-[0-9a-f]{16}(?=\.[^/]+$)/, "")
67
+ return href.pathname.replace(HASHED_ASSET, "")
59
68
  }
60
69
  return href.pathname
61
70
  }
62
71
 
63
72
  function isHashedAsset(pathname: string) {
64
- return pathname.startsWith(publicationPath("/.pmx/"))
73
+ return HASHED_ASSET.test(pathname)
65
74
  }
66
75
 
76
+ const HASHED_ASSET = /-[0-9a-f]{16}(?=\.[^/]+$)/
77
+
67
78
  function scriptKey(script: Element) {
68
79
  const src = assetUrl(script, "src")
69
80
  if (!src) return script.getAttribute("src") || ""
70
81
  src.search = ""
71
82
  if (isHashedAsset(src.pathname)) {
72
- return src.pathname.replace(/-[0-9a-f]{16}(?=\.[^/]+$)/, "")
83
+ return src.pathname.replace(HASHED_ASSET, "")
73
84
  }
74
85
  return src.href
75
86
  }
@@ -157,7 +168,7 @@ function reconcileStylesheets(
157
168
  current.append(incomingLink.cloneNode(true))
158
169
  continue
159
170
  }
160
- if (isHashedAsset(key)) {
171
+ if (isHashedAsset(assetUrl(incomingLink, "href")?.pathname ?? "")) {
161
172
  if (currentLink.getAttribute("href") === nextHref) continue
162
173
  const added = incomingLink.cloneNode(true) as HTMLLinkElement
163
174
  added.addEventListener("load", () => currentLink.remove(), { once: true })
@@ -415,6 +426,11 @@ export async function readPathMXDocument(
415
426
  if (views.length !== 1 || !(views[0] instanceof HTMLElement)) {
416
427
  throw new PathMXDocumentError("The response has no canonical PathMX view.")
417
428
  }
429
+ if (!augmentationLayer(incoming)) {
430
+ throw new PathMXDocumentError(
431
+ "The response has no canonical PathMX augmentation layer.",
432
+ )
433
+ }
418
434
  return {
419
435
  document: incoming,
420
436
  requestedUrl,
@@ -446,6 +462,9 @@ export async function reconcilePathMXDocument(
446
462
  viewTransitionTypes?: readonly string[]
447
463
  },
448
464
  ) {
465
+ if (!augmentationLayer(document)) {
466
+ throw new PathMXReloadRequiredError("The PathMX document shell changed.")
467
+ }
449
468
  const viewTransitionTypes = options.viewTransitionTypes ?? []
450
469
  const apply = () => {
451
470
  const canonicalView = window.__pmxCanonicalView ?? currentView()
@@ -0,0 +1,6 @@
1
+ body > [data-pmx-augmentations] {
2
+ position: fixed;
3
+ z-index: var(--pmx-augmentations-z-index, 1000);
4
+ inset: 0;
5
+ pointer-events: none;
6
+ }
@@ -1,3 +1,4 @@
1
+ import "./runtime.css"
1
2
  import {
2
3
  fetchPathMXDocument,
3
4
  PathMXReloadRequiredError,
package/plugins/styles.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  import { cachedRouteTargets } from "../invalidation.ts"
2
2
  import { parseLinks, type Source, type SourceLink } from "../source/index.ts"
3
- import type {
4
- DependencyContext,
5
- DocumentAsset,
6
- DocumentStylesheet,
7
- Plugin,
8
- } from "./types.ts"
3
+ import type { DependencyContext, Plugin } from "./types.ts"
9
4
  import { directiveDefinition } from "./directive-definitions.ts"
10
5
 
11
6
  const MEDIA = new Set(["print", "screen"])
12
7
  const EXTERNAL = /^[a-z][a-z\d+.-]*:/i
8
+ type AuthoredStylesheet = Readonly<{
9
+ type: "stylesheet"
10
+ source: string
11
+ media?: string
12
+ }>
13
13
 
14
14
  function resolveTarget(source: Source, target: string) {
15
15
  return target.startsWith("/")
@@ -24,7 +24,7 @@ function styleLinks(source: Source, links: readonly SourceLink[]) {
24
24
  function resolveStylesheet(
25
25
  source: Source,
26
26
  link: SourceLink,
27
- ): DocumentStylesheet | undefined {
27
+ ): AuthoredStylesheet | undefined {
28
28
  if (link.directive?.name !== "styles") return
29
29
  const raw = link.href.split("#")[0]?.split("?")[0] ?? ""
30
30
  if (
@@ -34,28 +34,31 @@ function resolveStylesheet(
34
34
  raw.startsWith("~/")
35
35
  )
36
36
  return
37
- const href = resolveTarget(source, raw)
38
- if (!href.endsWith(".css")) return
37
+ const sourcePath = resolveTarget(source, raw)
38
+ if (!sourcePath.endsWith(".css")) return
39
39
  const media = link.directive.qualifiers.find((item) => MEDIA.has(item))
40
- return { type: "stylesheet", href, ...(media ? { media } : {}) }
40
+ return {
41
+ type: "stylesheet",
42
+ source: sourcePath,
43
+ ...(media ? { media } : {}),
44
+ }
41
45
  }
42
46
 
43
- function stylesheetHref(asset: DocumentAsset | DocumentStylesheet) {
44
- return asset.type === "stylesheet" && "href" in asset ? asset.href : undefined
47
+ function stylesheetKey(asset: AuthoredStylesheet) {
48
+ return `${asset.source}\0${asset.media ?? ""}`
45
49
  }
46
50
 
47
- function pushUnique(assets: DocumentAsset[], asset: DocumentStylesheet) {
48
- const href = stylesheetHref(asset)
49
- if (href && assets.some((item) => stylesheetHref(item) === href)) return
51
+ function pushUnique(assets: AuthoredStylesheet[], asset: AuthoredStylesheet) {
52
+ const key = stylesheetKey(asset)
53
+ if (assets.some((item) => stylesheetKey(item) === key)) return
50
54
  assets.push(asset)
51
55
  }
52
56
 
53
57
  function addStylesheetDependency(
54
58
  dependencies: DependencyContext,
55
- asset: DocumentStylesheet,
59
+ asset: AuthoredStylesheet,
56
60
  ) {
57
- const href = stylesheetHref(asset)
58
- if (href) dependencies.depend({ type: "source", path: href })
61
+ dependencies.depend({ type: "source", path: asset.source })
59
62
  }
60
63
 
61
64
  function sourceStyleLinks(source: Source) {
@@ -65,7 +68,7 @@ function sourceStyleLinks(source: Source) {
65
68
  }
66
69
 
67
70
  function globalStylesheets(source: Source, links: readonly SourceLink[]) {
68
- const assets: DocumentStylesheet[] = []
71
+ const assets: AuthoredStylesheet[] = []
69
72
  for (const link of styleLinks(source, links)) {
70
73
  if (!link.directive?.qualifiers.includes("global")) continue
71
74
  const asset = resolveStylesheet(source, link)
@@ -74,10 +77,8 @@ function globalStylesheets(source: Source, links: readonly SourceLink[]) {
74
77
  return assets
75
78
  }
76
79
 
77
- function stylesheetSignature(assets: readonly DocumentStylesheet[]) {
78
- return assets
79
- .map((asset) => `${stylesheetHref(asset) ?? ""}\0${asset.media ?? ""}`)
80
- .join("\n")
80
+ function stylesheetSignature(assets: readonly AuthoredStylesheet[]) {
81
+ return assets.map(stylesheetKey).join("\n")
81
82
  }
82
83
 
83
84
  function authoredGlobalSignature(source?: Source) {
@@ -87,7 +88,7 @@ function authoredGlobalSignature(source?: Source) {
87
88
  }
88
89
 
89
90
  export function StylesPlugin(): Plugin {
90
- const global = new Map<string, DocumentStylesheet[]>()
91
+ const global = new Map<string, AuthoredStylesheet[]>()
91
92
 
92
93
  return {
93
94
  id: "styles",
package/plugins/types.ts CHANGED
@@ -47,6 +47,7 @@ export type CompileContext = DependencyContext & {
47
47
 
48
48
  export type DocumentStylesheet =
49
49
  | Readonly<{ type: "stylesheet"; href: string; media?: string }>
50
+ | Readonly<{ type: "stylesheet"; source: string; media?: string }>
50
51
  | Readonly<{
51
52
  type: "stylesheet"
52
53
  name: string
package/publish/build.ts CHANGED
@@ -257,20 +257,30 @@ async function addAuthoredAssets(
257
257
  plan.addRoute({
258
258
  pathname: mounted,
259
259
  type: "asset",
260
- representations: [fileRepresentation(artifact, type)],
260
+ representations: [
261
+ fileRepresentation(artifact, type, {
262
+ "cache-control": "public, max-age=0, must-revalidate",
263
+ }),
264
+ ],
261
265
  })
262
266
  }
263
267
  }
264
268
 
265
- function addGeneratedAssets(
269
+ function addStoredAssets(
266
270
  app: Engine,
267
271
  plan: PublicationPlan,
268
272
  location: PublicationLocation,
269
273
  ) {
270
- for (const [pathname, asset] of app.generatedAssetEntries()) {
274
+ for (const [pathname, asset] of app.servedAssetEntries()) {
275
+ if (
276
+ asset.type === "authored" &&
277
+ app.readAccess(anonymousPrincipal, asset.sourcePath) !== "public"
278
+ ) {
279
+ continue
280
+ }
271
281
  const mounted = location.pathname(pathname)
272
282
  if (plan.hasRoute(mounted)) {
273
- throw new PublicationError(`Generated asset route collision: ${pathname}`)
283
+ throw new PublicationError(`Stored asset route collision: ${pathname}`)
274
284
  }
275
285
  const artifact = exactArtifact(mounted)
276
286
  plan.addArtifact({
@@ -384,7 +394,7 @@ export async function createPublication(
384
394
  await addPublishedRoutes(app, plan, location, published)
385
395
  await addSources(app, plan, store, location)
386
396
  await addAuthoredAssets(app, plan, location)
387
- addGeneratedAssets(app, plan, location)
397
+ addStoredAssets(app, plan, location)
388
398
  await plan.materialize(store)
389
399
  const build = await plan.buildRoot(location.origin, location.basePath)
390
400
  const pages = addPageMap(plan, build, location)
package/server/http.ts CHANGED
@@ -216,7 +216,7 @@ async function dispatchRequest(
216
216
  function serveGenerated(app: Engine, pathname: string, head: boolean) {
217
217
  if (!pathname.startsWith("/.pmx/")) return
218
218
  const asset = app.servedAsset(pathname)
219
- if (!asset) return
219
+ if (asset?.type !== "generated") return
220
220
  return new Response(head ? null : asset.body, {
221
221
  headers: {
222
222
  "Content-Type": asset.contentType,
@@ -248,6 +248,15 @@ async function dispatch(
248
248
  })
249
249
  }
250
250
 
251
+ const authored = serveAuthored(
252
+ app,
253
+ url.pathname,
254
+ req.method === "HEAD",
255
+ session,
256
+ principal,
257
+ )
258
+ if (authored) return authored
259
+
251
260
  const route = resolveRoute(app.repo, url.pathname)
252
261
  if (route?.type === "source") {
253
262
  return serveSource(
@@ -292,6 +301,39 @@ async function dispatch(
292
301
  )
293
302
  }
294
303
 
304
+ function decodePathname(pathname: string) {
305
+ try {
306
+ return decodeURIComponent(pathname)
307
+ } catch {
308
+ return
309
+ }
310
+ }
311
+
312
+ function serveAuthored(
313
+ app: Engine,
314
+ pathname: string,
315
+ head: boolean,
316
+ session: CredentialSession,
317
+ principal: Principal,
318
+ ) {
319
+ const path = decodePathname(pathname)
320
+ if (!path) return
321
+ const asset = app.servedAsset(path)
322
+ if (asset?.type !== "authored") return
323
+ const access = app.readAccess(principal, asset.sourcePath)
324
+ if (access === "denied") return pageNotFound()
325
+ return withReadCache(
326
+ new Response(head ? null : asset.body, {
327
+ headers: {
328
+ "Content-Type": asset.contentType,
329
+ "Cache-Control": "public, max-age=31536000, immutable",
330
+ },
331
+ }),
332
+ access,
333
+ session,
334
+ )
335
+ }
336
+
295
337
  function serveSource(
296
338
  app: Engine,
297
339
  head: boolean,
@@ -317,17 +359,19 @@ async function serveAsset(
317
359
  session: CredentialSession,
318
360
  principal: Principal,
319
361
  ) {
320
- let path: string
321
- try {
322
- path = decodeURIComponent(pathname)
323
- } catch {
324
- return
325
- }
362
+ const path = decodePathname(pathname)
363
+ if (!path) return
326
364
  if (path.includes("..")) return
327
365
  const storagePath = app.repo.storagePath(path)
328
366
  if (!storagePath || !(await app.repo.isFile(path))) return
329
367
  const access = app.readAccess(principal, path)
330
368
  if (access === "denied") return pageNotFound()
331
369
  const body = head ? null : await app.environment.storage.readBlob(storagePath)
332
- return withReadCache(new Response(body), access, session)
370
+ return withReadCache(
371
+ new Response(body, {
372
+ headers: { "Cache-Control": "public, max-age=0, must-revalidate" },
373
+ }),
374
+ access,
375
+ session,
376
+ )
333
377
  }