Skip to content

Commit acb344e

Browse files
authored
feat(devframe): remote client assets — serve SPA dists through a caching CDN back-proxy (#236)
* feat(devframe): remote client assets — serve SPA dists through a caching CDN back-proxy Every static-assets seam (cli.distDir, hostStatic, mountStatic, the serve-static engine) now accepts a version-locked RemoteAssets declaration ({ package, version }) alongside a local directory, so a plugin's browser bundle can live in its own npm package instead of shipping inside the node tarball. Resolution order per request: a locally installed copy of the assets package (resolved from the declaration's resolveFrom, warning on minor/patch skew and rejecting a major mismatch), the per-file cache under <project storage>/.remote-assets/<pkg>@<version>/, then the CDN provider (jsdelivr by default, unpkg or a custom mirror via provider) — streaming through to the browser while teeing into the cache. Request paths resolve against the provider's file listing (correct 404s and SPA fallback), degrading to per-file probing when the listing is unreachable. HTML navigations that cannot be satisfied get a styled error page pointing at the local-install fix; static builds materialize the full file set so their output stays self-contained. New diagnostics DF0058–DF0063 cover listing/fetch/cache/materialization failures and version skew, each with a docs page. * refactor(devframe): shrink remote-assets surface and LOC Collapse the remote-assets module's public API from eight exports to one (resolveStaticAssetsSource) — cache-path helpers, createRemoteAssetsStore, resolveInstalledRemoteAssets, and the store options interfaces are now private, and the error page moves into serve-static as an internal helper. resolveStaticAssetsSource takes the project storage dir directly (dropping the options object), so every call site loses the cacheRoot plumbing; the build adapter reuses it too instead of hand-rolling install/materialize. RemoteAssetsStore.serve now returns a web Response, letting serve-static drop the RemoteAssetsServedFile / RemoteAssetsServeOptions types and the bespoke miss/stream/cancel handling. Net ~370 fewer lines. * test(devframe): normalize path separators in remote-assets install tests The installed-package resolution returns pathe (forward-slash) paths, but the tests built the expected distDir with node:path — backslashes on Windows — so the equality assertions failed only on windows-latest. Compare both sides normalized to forward slashes. * feat(devframe): validate remote-assets package name and version A remote source's `package` and `version` are interpolated into CDN URLs and the on-disk cache path, so `resolveStaticAssetsSource` now rejects a value that isn't a valid npm package name / exact semver version (new `DF0065`) — closing off malformed URLs and cache-path traversal (e.g. a `..` version segment).
1 parent f471b6c commit acb344e

43 files changed

Lines changed: 1365 additions & 60 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

alias.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const alias = {
3030
'devframe/utils/launch-editor': r('devframe/src/utils/launch-editor.ts'),
3131
'devframe/utils/nanoid': r('devframe/src/utils/nanoid.ts'),
3232
'devframe/utils/open': r('devframe/src/utils/open.ts'),
33+
'devframe/utils/remote-assets': r('devframe/src/utils/remote-assets.ts'),
3334
'devframe/utils/simple-schema': r('devframe/src/utils/simple-schema.ts'),
3435
'devframe/utils/serve-static': r('devframe/src/utils/serve-static.ts'),
3536
'devframe/utils/shared-state': r('devframe/src/utils/shared-state.ts'),

docs/errors/DF0059.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0059: Remote Assets File Listing Failed
6+
7+
## Message
8+
9+
> Failed to fetch the file listing for "`{package}`@`{version}`" from `{provider}`: `{reason}`
10+
11+
## Cause
12+
13+
A remote-assets source (`{ package, version }` passed where a static mount accepts a dist directory) resolves request paths against the CDN provider's file-listing API — `data.jsdelivr.com` for jsDelivr, `?meta` for unpkg, or a custom provider's `listFiles`. That listing request failed, typically because the provider is unreachable (offline machine, blocked domain) or returned an error status.
14+
15+
## Example
16+
17+
```ts
18+
defineDevframe({
19+
cli: {
20+
distDir: {
21+
package: '@devframes/plugin-git-client',
22+
version: '1.2.3',
23+
},
24+
},
25+
})
26+
```
27+
28+
Starting this devframe without network access to `data.jsdelivr.com` reports `DF0059` on the first request.
29+
30+
## Fix
31+
32+
Requests keep working in a degraded probe mode (each candidate path is tried against the provider directly). To resolve it:
33+
34+
- Check network access to the configured provider, or switch providers (`provider: 'unpkg'` or a custom mirror).
35+
- Install the assets package locally (`npm install <package>`) — a locally installed copy is served with zero network and needs no listing.
36+
37+
## Source
38+
39+
- [`packages/devframe/src/utils/remote-assets.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/utils/remote-assets.ts)`createRemoteAssetsStore()` reports this (once per store) when the provider's file listing cannot be fetched or parsed.

docs/errors/DF0060.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0060: Remote Asset Fetch Failed
6+
7+
## Message
8+
9+
> Failed to fetch a remote asset of "`{package}`" (`{url}`): `{reason}`
10+
11+
## Cause
12+
13+
A file of a remote-assets source was requested that is neither in the locally installed assets package nor in the on-disk cache, and streaming it through the CDN provider failed — the network request errored, the provider returned a non-OK status, or the source is `offline: true` while the file is missing from the cache.
14+
15+
## Example
16+
17+
```ts
18+
defineDevframe({
19+
cli: {
20+
distDir: {
21+
package: '@devframes/plugin-git-client',
22+
version: '1.2.3',
23+
},
24+
},
25+
})
26+
```
27+
28+
Opening the tool's UI with `cdn.jsdelivr.net` unreachable throws `DF0060` for each uncached file; HTML navigations respond with a styled error page carrying this code.
29+
30+
## Fix
31+
32+
- Install the assets package locally (`npm install <package>`) to serve it with zero network — the recommended path for offline and air-gapped machines.
33+
- Otherwise check network access to the configured provider, or point `provider` at a reachable mirror.
34+
35+
## Source
36+
37+
- [`packages/devframe/src/utils/remote-assets.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/utils/remote-assets.ts)`createRemoteAssetsStore()`'s `serve()` throws this when a provider fetch fails, returns a non-OK status, or an `offline` store misses its cache.

docs/errors/DF0061.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0061: Installed Assets Package Major Version Mismatch
6+
7+
## Message
8+
9+
> The locally installed "`{package}`@`{installed}`" is a different major version than the required "`{required}`".
10+
11+
## Cause
12+
13+
A remote-assets source found a locally installed copy of its assets package (resolved from the declaration's `resolveFrom` module), but the installed version differs from the declared one by a **major** version. Assets and node code are published in lockstep; across a major boundary the served UI can be incompatible with its node backend, so devframe refuses to serve it.
14+
15+
## Example
16+
17+
```ts
18+
defineDevframe({
19+
cli: {
20+
distDir: {
21+
package: '@devframes/plugin-git-client',
22+
version: '2.0.0',
23+
resolveFrom: import.meta.url,
24+
},
25+
},
26+
})
27+
```
28+
29+
With `@devframes/plugin-git-client@1.9.0` installed locally, mounting this devframe throws `DF0061`.
30+
31+
## Fix
32+
33+
Install the assets package at the version its node package declares (they are published in lockstep):
34+
35+
```sh
36+
npm install @devframes/plugin-git-client@2.0.0
37+
```
38+
39+
Or uninstall the stale local copy so the assets stream from the CDN back-proxy at the exact declared version.
40+
41+
## Source
42+
43+
- [`packages/devframe/src/utils/remote-assets.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/utils/remote-assets.ts)`resolveInstalledRemoteAssets()` throws this when the installed package's major version differs from the declared one.

docs/errors/DF0062.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0062: Installed Assets Package Version Skew
6+
7+
## Message
8+
9+
> The locally installed "`{package}`@`{installed}`" differs from the required "`{required}`" — serving the installed one.
10+
11+
## Cause
12+
13+
A remote-assets source found a locally installed copy of its assets package whose version differs from the declared one within the same major version. The local install wins — it keeps offline and air-gapped setups working — but the served assets are not byte-identical to the declared release, so the skew is surfaced.
14+
15+
## Example
16+
17+
With the node package declaring `version: '1.2.3'` and `@devframes/plugin-git-client@1.2.4` installed locally, the installed `1.2.4` assets are served and `DF0062` is reported.
18+
19+
## Fix
20+
21+
Install the exact declared version to serve byte-identical assets:
22+
23+
```sh
24+
npm install @devframes/plugin-git-client@1.2.3
25+
```
26+
27+
A major-version mismatch is rejected instead — see [DF0061](./DF0061.md).
28+
29+
## Source
30+
31+
- [`packages/devframe/src/utils/remote-assets.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/utils/remote-assets.ts)`resolveInstalledRemoteAssets()` reports this when the installed version differs from the declared one within the same major.

docs/errors/DF0063.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0063: Remote Asset Cache Write Failed
6+
7+
## Message
8+
9+
> Failed to persist a remote asset into the cache at "`{filepath}`": `{reason}`
10+
11+
## Cause
12+
13+
A remote asset streamed through the CDN back-proxy to the browser, but writing the teed copy into the local cache directory (`<project storage>/.remote-assets/<package>@<version>/…`) failed — usually a permissions problem, a full disk, or a removed `node_modules`.
14+
15+
The response itself was served; only caching failed, so the same file will stream through the provider again on the next request.
16+
17+
## Fix
18+
19+
Check that the project storage directory (conventionally `node_modules/.<app>/devframe/`) is writable and has free space.
20+
21+
## Source
22+
23+
- [`packages/devframe/src/utils/remote-assets.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/utils/remote-assets.ts)`createRemoteAssetsStore()`'s background cache write reports this when persisting a fetched file fails.

docs/errors/DF0064.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0064: Remote Assets Materialization Failed
6+
7+
## Message
8+
9+
> Failed to materialize the remote assets of "`{package}`@`{version}`": `{reason}`
10+
11+
## Cause
12+
13+
A static build (`createBuild`) with a remote-assets `distDir` needs every asset file up front — the output must be self-contained. Materialization walks the provider's file listing and downloads each file, and one of those steps failed: the provider has no `listFiles` (custom providers may omit it), the listing request failed, or an individual file download errored.
14+
15+
## Example
16+
17+
```sh
18+
my-tool build
19+
```
20+
21+
Running a static build on a machine without network access to the CDN provider — and without the assets package installed locally — throws `DF0064`.
22+
23+
## Fix
24+
25+
- Install the assets package locally (`npm install <package>@<version>`) — builds copy from the local install and touch no network.
26+
- Otherwise ensure the provider and its file-listing API are reachable during the build, or configure a custom provider that implements `listFiles`.
27+
28+
## Source
29+
30+
- [`packages/devframe/src/utils/remote-assets.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/utils/remote-assets.ts)`createRemoteAssetsStore()`'s `materialize()` throws this when the file listing is unavailable or a download fails.

docs/errors/DF0065.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0065: Invalid Remote Assets Package Or Version
6+
7+
## Message
8+
9+
> Invalid remote-assets `{field}` "`{value}`".
10+
11+
## Cause
12+
13+
A remote-assets source's `package` and `version` are interpolated into CDN URLs (`https://cdn.jsdelivr.net/npm/<package>@<version>/…`) and into the on-disk cache path (`.remote-assets/<package>@<version>/`). To keep those safe and well-formed, the `package` must be a valid npm package name and the `version` an exact semver version — a value carrying path separators, `@`, whitespace, or traversal segments (`..`) is rejected.
14+
15+
## Example
16+
17+
```ts
18+
defineDevframe({
19+
cli: {
20+
distDir: {
21+
package: '@devframes/plugin-git-client',
22+
version: '../etc', // ✗ not a semver version
23+
},
24+
},
25+
})
26+
```
27+
28+
## Fix
29+
30+
Use a valid npm package name and an exact version:
31+
32+
```ts
33+
defineDevframe({
34+
cli: {
35+
distDir: {
36+
package: '@devframes/plugin-git-client',
37+
version: '1.2.3',
38+
},
39+
},
40+
})
41+
```
42+
43+
## Source
44+
45+
- [`packages/devframe/src/utils/remote-assets.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/utils/remote-assets.ts)`resolveStaticAssetsSource()` validates a remote source before resolving it.

examples/files-inspector/tests/_utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export async function startInspectorServer(
6060
{ cwd }: { cwd: string },
6161
): Promise<InspectorServer> {
6262
const distDir = devframe.cli!.distDir!
63+
if (typeof distDir !== 'string')
64+
throw new TypeError('these tests serve the local dist directory — build the SPA first')
6365
const basePath = devframe.basePath!
6466
const host = '127.0.0.1'
6567
const port = await getPort({ host, random: true })

examples/next-runtime-snapshot/tests/_utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export interface SnapshotServer extends StartedServer {
2424
*/
2525
export async function startSnapshotServer(): Promise<SnapshotServer> {
2626
const distDir = devframe.cli!.distDir!
27+
if (typeof distDir !== 'string')
28+
throw new TypeError('these tests serve the local dist directory — build the SPA first')
2729
const basePath = devframe.basePath!
2830
const host = '127.0.0.1'
2931
const port = await getPort({ host, random: true })

0 commit comments

Comments
 (0)