@ultimat3/cli 22.1.0 → 22.2.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/package.json +28 -28
- package/src/app-load.ts +7 -2
- package/src/app-permissions.ts +0 -0
- package/src/dev-route-table.ts +5 -3
- package/src/runtime-render.ts +35 -6
- package/src/runtime-storage-upload.ts +100 -0
- package/src/runtime-storage.ts +29 -0
- package/src/serve-boot.ts +5 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "22.1
|
|
3
|
+
"version": "22.2.1",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,33 +38,33 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@babel/core": "^8.0.1",
|
|
41
|
-
"@ultimat3/action": "22.1
|
|
42
|
-
"@ultimat3/admin": "22.1
|
|
43
|
-
"@ultimat3/ai": "22.1
|
|
44
|
-
"@ultimat3/auth": "22.1
|
|
45
|
-
"@ultimat3/cache": "22.1
|
|
46
|
-
"@ultimat3/core": "22.1
|
|
47
|
-
"@ultimat3/db": "22.1
|
|
48
|
-
"@ultimat3/entity": "22.1
|
|
49
|
-
"@ultimat3/flags": "22.1
|
|
50
|
-
"@ultimat3/http": "22.1
|
|
51
|
-
"@ultimat3/i18n": "22.1
|
|
52
|
-
"@ultimat3/jobs": "22.1
|
|
53
|
-
"@ultimat3/mail": "22.1
|
|
54
|
-
"@ultimat3/manifest": "22.1
|
|
55
|
-
"@ultimat3/mcp": "22.1
|
|
56
|
-
"@ultimat3/money": "22.1
|
|
57
|
-
"@ultimat3/notify": "22.1
|
|
58
|
-
"@ultimat3/policy": "22.1
|
|
59
|
-
"@ultimat3/pwa": "22.1
|
|
60
|
-
"@ultimat3/query": "22.1
|
|
61
|
-
"@ultimat3/realtime": "22.1
|
|
62
|
-
"@ultimat3/render": "22.1
|
|
63
|
-
"@ultimat3/schema": "22.1
|
|
64
|
-
"@ultimat3/seo": "22.1
|
|
65
|
-
"@ultimat3/storage": "22.1
|
|
66
|
-
"@ultimat3/testing": "22.1
|
|
67
|
-
"@ultimat3/time": "22.1
|
|
41
|
+
"@ultimat3/action": "22.2.1",
|
|
42
|
+
"@ultimat3/admin": "22.2.1",
|
|
43
|
+
"@ultimat3/ai": "22.2.1",
|
|
44
|
+
"@ultimat3/auth": "22.2.1",
|
|
45
|
+
"@ultimat3/cache": "22.2.1",
|
|
46
|
+
"@ultimat3/core": "22.2.1",
|
|
47
|
+
"@ultimat3/db": "22.2.1",
|
|
48
|
+
"@ultimat3/entity": "22.2.1",
|
|
49
|
+
"@ultimat3/flags": "22.2.1",
|
|
50
|
+
"@ultimat3/http": "22.2.1",
|
|
51
|
+
"@ultimat3/i18n": "22.2.1",
|
|
52
|
+
"@ultimat3/jobs": "22.2.1",
|
|
53
|
+
"@ultimat3/mail": "22.2.1",
|
|
54
|
+
"@ultimat3/manifest": "22.2.1",
|
|
55
|
+
"@ultimat3/mcp": "22.2.1",
|
|
56
|
+
"@ultimat3/money": "22.2.1",
|
|
57
|
+
"@ultimat3/notify": "22.2.1",
|
|
58
|
+
"@ultimat3/policy": "22.2.1",
|
|
59
|
+
"@ultimat3/pwa": "22.2.1",
|
|
60
|
+
"@ultimat3/query": "22.2.1",
|
|
61
|
+
"@ultimat3/realtime": "22.2.1",
|
|
62
|
+
"@ultimat3/render": "22.2.1",
|
|
63
|
+
"@ultimat3/schema": "22.2.1",
|
|
64
|
+
"@ultimat3/seo": "22.2.1",
|
|
65
|
+
"@ultimat3/storage": "22.2.1",
|
|
66
|
+
"@ultimat3/testing": "22.2.1",
|
|
67
|
+
"@ultimat3/time": "22.2.1",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/app-load.ts
CHANGED
|
@@ -19,8 +19,9 @@ import { isRouteConfig, pageComponentOf, registerRoute, routeEntries } from '@ul
|
|
|
19
19
|
// every app module below is loaded by the dynamic `import()` in this file. Before the render
|
|
20
20
|
// barrel split it came free with the line above; after it, the only other path to `/server` from
|
|
21
21
|
// here is six hops through `error-contract` → `fix-command` → the command registry, which is an
|
|
22
|
-
// accident one refactor away from compiling every app's `.tsx` to `React.createElement`.
|
|
23
|
-
import
|
|
22
|
+
// accident one refactor away from compiling every app's `.tsx` to `React.createElement`. The named
|
|
23
|
+
// import is a value import, so the side effect holds without the bare line it replaced.
|
|
24
|
+
import { setStylesheetRoot } from '@ultimat3/render/server';
|
|
24
25
|
import { APP_CONFIG_FILE } from './app-root';
|
|
25
26
|
import { collectDeclaredCodes } from './error-contract';
|
|
26
27
|
import type { Finding } from './output';
|
|
@@ -108,6 +109,10 @@ export function resetAppLoad(): void {
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
export async function loadApp(root: string): Promise<LoadedApp> {
|
|
112
|
+
// Before any import: a stylesheet's surface is read below the app root, never off its absolute
|
|
113
|
+
// path — under the container's `WORKDIR /app` that path's first segment is `app/`, and every
|
|
114
|
+
// sheet, the site's included, classified as app CSS.
|
|
115
|
+
setStylesheetRoot(root);
|
|
111
116
|
const files: string[] = [];
|
|
112
117
|
const findings: Finding[] = [];
|
|
113
118
|
|
package/src/app-permissions.ts
CHANGED
|
Binary file
|
package/src/dev-route-table.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { pageSync } from './page-sync';
|
|
|
18
18
|
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
19
19
|
import { assetRoutes } from './runtime-assets';
|
|
20
20
|
import { appRoutes } from './runtime-render';
|
|
21
|
-
import { storageRoutes } from './runtime-storage';
|
|
21
|
+
import { servedStorage, storageRoutes } from './runtime-storage';
|
|
22
22
|
import { styleBundle } from './style-bundle';
|
|
23
23
|
import { styleRoutes } from './style-routes';
|
|
24
24
|
import { serviceWorkerArtifacts } from './sw-artifacts';
|
|
@@ -72,6 +72,8 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
72
72
|
// call `runRole` makes — `POST /mcp` answered 404 in every process the framework booted until
|
|
73
73
|
// one of them asked. Warned once here when `expose` is true and nothing can be mounted.
|
|
74
74
|
const mcpMount = await mountAppMcp(input.root);
|
|
75
|
+
// `serve-boot.ts`'s answer: the app's declared disks when it has any, read per request.
|
|
76
|
+
const served = servedStorage(input.storage);
|
|
75
77
|
const routes: readonly Route[] = [
|
|
76
78
|
...devDashboardRoutes(input.dashboard),
|
|
77
79
|
// The same API table the container serves: a read that answers here and 404s in production
|
|
@@ -83,10 +85,10 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
83
85
|
// never shadow `/icons` or `/media`.
|
|
84
86
|
...assetRoutes({
|
|
85
87
|
root: input.root,
|
|
86
|
-
storage:
|
|
88
|
+
storage: served,
|
|
87
89
|
...(pwa === undefined ? {} : { pwa }),
|
|
88
90
|
}),
|
|
89
|
-
...storageRoutes({ storage:
|
|
91
|
+
...storageRoutes({ storage: served }),
|
|
90
92
|
// The chunks the documents below name. Mounted before the app's routes for the reason
|
|
91
93
|
// `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
|
|
92
94
|
...islandRoutes(() => input.islands()),
|
package/src/runtime-render.ts
CHANGED
|
@@ -19,8 +19,13 @@ import { posix } from 'node:path';
|
|
|
19
19
|
import { clientScopeOf } from '@ultimat3/auth';
|
|
20
20
|
import type { Ctx } from '@ultimat3/core';
|
|
21
21
|
import { CLIENT_SCOPE_HEADER } from '@ultimat3/core';
|
|
22
|
-
import type {
|
|
23
|
-
|
|
22
|
+
import type {
|
|
23
|
+
RouteMeta as HttpRouteMeta,
|
|
24
|
+
RedirectIntent,
|
|
25
|
+
Route,
|
|
26
|
+
RouteParams,
|
|
27
|
+
} from '@ultimat3/http';
|
|
28
|
+
import { asCtx, html, NO_STORE, redirect, stream, takeRedirect } from '@ultimat3/http';
|
|
24
29
|
import { currentLocale } from '@ultimat3/i18n';
|
|
25
30
|
import type {
|
|
26
31
|
ClientSyncHead,
|
|
@@ -289,14 +294,12 @@ async function documentFrom(
|
|
|
289
294
|
async function resultFor(
|
|
290
295
|
entry: RouteEntry,
|
|
291
296
|
request: DevRouteData,
|
|
297
|
+
data: RouteData,
|
|
292
298
|
options: DevRenderOptions,
|
|
293
299
|
isr: IsrController,
|
|
294
300
|
ctx: Ctx,
|
|
295
301
|
): Promise<RenderResult> {
|
|
296
302
|
const url = new URL(request.url);
|
|
297
|
-
// ONCE per request, before the mode is chosen. Every branch below reads this same object, so a
|
|
298
|
-
// route's `load` runs exactly once however its mode splits head from body.
|
|
299
|
-
const data = await routeDataFor(entry.config, request);
|
|
300
303
|
// The status the loader answered through `withStatus`, 200 when it said nothing. Read once,
|
|
301
304
|
// here, and handed to every mode: this file mints the `Response`, render owns the seam.
|
|
302
305
|
const status = routeStatusOf(data);
|
|
@@ -380,6 +383,18 @@ const withScope = (result: RenderResult, scope: string | undefined): RenderResul
|
|
|
380
383
|
? result
|
|
381
384
|
: { ...result, headers: { ...result.headers, [CLIENT_SCOPE_HEADER]: scope } };
|
|
382
385
|
|
|
386
|
+
/**
|
|
387
|
+
* A loader's redirect. No declared `cache` means `private, no-store`: the loader decided it for
|
|
388
|
+
* THIS request (it recorded an open, it checked a token), and the mode's default would offer that
|
|
389
|
+
* decision to a CDN, which would then answer every later request without running the loader. A
|
|
390
|
+
* declared one is the pipeline's to apply, off `meta.cache`, exactly as for any handler.
|
|
391
|
+
*/
|
|
392
|
+
function loadRedirect(entry: RouteEntry, to: RedirectIntent): Response {
|
|
393
|
+
const response = redirect(to.location, to.status);
|
|
394
|
+
if (entry.config.cache === undefined) response.headers.set('cache-control', 'private, no-store');
|
|
395
|
+
return response;
|
|
396
|
+
}
|
|
397
|
+
|
|
383
398
|
const responseOf = (result: RenderResult): Response =>
|
|
384
399
|
typeof result.body === 'string'
|
|
385
400
|
? html(result.body, { status: result.status, headers: result.headers })
|
|
@@ -395,6 +410,12 @@ const metaOf = (entry: RouteEntry): HttpRouteMeta => ({
|
|
|
395
410
|
render: entry.config.render,
|
|
396
411
|
tags: [entry.surface],
|
|
397
412
|
...(entry.config.policy === undefined ? {} : { policy: entry.config.policy.permission }),
|
|
413
|
+
// Projected so the router screens its ages at mount (`assertRouteCache`) and the `cache-headers`
|
|
414
|
+
// stage applies it to a response that wrote none — a loader's redirect. The rendered document
|
|
415
|
+
// carries its own header, from `ssrHeaders`, which the stage reviews rather than replaces.
|
|
416
|
+
...(entry.config.cache === undefined
|
|
417
|
+
? {}
|
|
418
|
+
: { cache: entry.config.cache === 'no-store' ? NO_STORE : entry.config.cache }),
|
|
398
419
|
});
|
|
399
420
|
|
|
400
421
|
/**
|
|
@@ -424,7 +445,15 @@ export function appRoutes(options: DevRenderOptions): readonly Route[] {
|
|
|
424
445
|
handler: async (request, ctx): Promise<Response> => {
|
|
425
446
|
const entry = routeFor(registered.path) ?? registered;
|
|
426
447
|
const data: DevRouteData = { url: request.url.href, params: ctx.params };
|
|
427
|
-
|
|
448
|
+
// ONCE per request, before the mode is chosen: every branch of `resultFor` reads this same
|
|
449
|
+
// object, so a route's `load` runs exactly once however its mode splits head from body.
|
|
450
|
+
const loaded = await routeDataFor(entry.config, data);
|
|
451
|
+
// `setRedirect()` inside `load` — the same slot an action's handler fills — answers here,
|
|
452
|
+
// before a document is rendered for a page the visitor is leaving. `withStatus` refuses a
|
|
453
|
+
// 3xx because a rendered document has no `Location`; this is the path that has one.
|
|
454
|
+
const to = takeRedirect(ctx);
|
|
455
|
+
if (to !== undefined) return loadRedirect(entry, to);
|
|
456
|
+
return responseOf(await resultFor(entry, data, loaded, options, isr, asCtx(ctx)));
|
|
428
457
|
},
|
|
429
458
|
}));
|
|
430
459
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Single responsibility: the upload half of `/_storage` — the `PUT` a `grantUpload` URL names.
|
|
2
|
+
// `@ultimat3/storage` owns every accept decision (`acceptSignedUpload`); this file is where those
|
|
3
|
+
// decisions meet a `Route`, beside the GET in `runtime-storage.ts`, so `x dev` and `runRole` mount
|
|
4
|
+
// one write path through the one `storageRoutes()` both tables already call (#523).
|
|
5
|
+
|
|
6
|
+
import { finiteCount, readWithinLimit } from '@ultimat3/core';
|
|
7
|
+
import type { RequestContext, Route, UltimateRequest } from '@ultimat3/http';
|
|
8
|
+
import { json, NO_STORE } from '@ultimat3/http';
|
|
9
|
+
import type { Storage } from '@ultimat3/storage';
|
|
10
|
+
import {
|
|
11
|
+
acceptSignedUpload,
|
|
12
|
+
DEFAULT_MAX_UPLOAD_BYTES,
|
|
13
|
+
DEFAULT_SIGNED_URL_BASE,
|
|
14
|
+
objectNotFound,
|
|
15
|
+
signedUploadConstraints,
|
|
16
|
+
tooLarge,
|
|
17
|
+
uploadPolicy,
|
|
18
|
+
} from '@ultimat3/storage';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The route's URL is the capability, so the one thing read off the request before it is verified
|
|
22
|
+
* is the URL itself — path and query exactly as the grant signed them.
|
|
23
|
+
*/
|
|
24
|
+
const signedUrlOf = (request: UltimateRequest): string =>
|
|
25
|
+
`${request.url.pathname}${request.url.search}`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The GRANT is the policy. `grantUpload` refused a type and a size the app's `uploadPolicy` did not
|
|
29
|
+
* allow before it signed, and the signature carries both — so the accept validates against what
|
|
30
|
+
* was signed, never against `uploadPolicy()`'s image-only default, which would refuse every PDF an
|
|
31
|
+
* app granted on purpose. The magic-byte sniff still runs: a signature bounds the declared type,
|
|
32
|
+
* the bytes still have to be it.
|
|
33
|
+
*/
|
|
34
|
+
async function acceptUpload(
|
|
35
|
+
storage: Storage,
|
|
36
|
+
request: UltimateRequest,
|
|
37
|
+
ctx: RequestContext,
|
|
38
|
+
): Promise<Response> {
|
|
39
|
+
const diskName = request.params['disk'] ?? '';
|
|
40
|
+
const key = request.params['key'] ?? '';
|
|
41
|
+
// The GET's answer for a disk this app does not have, for the GET's reason: a refusal that lists
|
|
42
|
+
// the configured disks tells a caller which names exist.
|
|
43
|
+
if (!storage.diskNames.includes(diskName)) throw objectNotFound(diskName, key);
|
|
44
|
+
const disk = storage.disk(diskName);
|
|
45
|
+
const url = signedUrlOf(request);
|
|
46
|
+
// The actor's org, never one off the request: `acceptSignedUpload` holds the verified key to it.
|
|
47
|
+
const orgId = ctx.actor.orgId ?? '';
|
|
48
|
+
|
|
49
|
+
// Verified BEFORE the body is read, so the signed ceiling caps the read — not `bodyLimitBytes`,
|
|
50
|
+
// which is an RPC body's limit and would refuse every upload over a megabyte the app granted,
|
|
51
|
+
// and never an unverified `?max=` a forger could raise to make this process hold gigabytes.
|
|
52
|
+
const constraints = await signedUploadConstraints({ url, disk, orgId });
|
|
53
|
+
// Screened as every defaulted count is: a signed ceiling is verified, but `NaN` would still read
|
|
54
|
+
// as "no cap" to the counting reader below.
|
|
55
|
+
const ceiling = finiteCount(
|
|
56
|
+
'storageUploadRoute',
|
|
57
|
+
'maxBytes',
|
|
58
|
+
constraints.maxBytes === undefined ? DEFAULT_MAX_UPLOAD_BYTES : constraints.maxBytes,
|
|
59
|
+
1,
|
|
60
|
+
);
|
|
61
|
+
const read = await readWithinLimit(request.raw.body, ceiling);
|
|
62
|
+
if ('over' in read) throw tooLarge(constraints.key, read.over, ceiling);
|
|
63
|
+
|
|
64
|
+
const stored = await acceptSignedUpload({
|
|
65
|
+
url,
|
|
66
|
+
disk,
|
|
67
|
+
orgId,
|
|
68
|
+
bytes: read.bytes,
|
|
69
|
+
declaredContentType: request.header('content-type') ?? undefined,
|
|
70
|
+
policy: uploadPolicy({
|
|
71
|
+
maxBytes: ceiling,
|
|
72
|
+
...(constraints.contentType === undefined
|
|
73
|
+
? {}
|
|
74
|
+
: { allowedContentTypes: [constraints.contentType] }),
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
return json({ key: stored.key }, { status: 201 });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `auth: 'required'` and `enforcedBy: 'handler'`, the GET's posture and for its reasons. No
|
|
82
|
+
* permission: the grant is the authorization, decided by the action that minted it under its own
|
|
83
|
+
* policy, and this route re-proves the three things a leaked URL must not buy — the signature,
|
|
84
|
+
* the expiry, and the actor's tenant. `no-store`: a 201 for one actor's write is nothing to cache.
|
|
85
|
+
*/
|
|
86
|
+
export function storageUploadRoute(storage: Storage): Route {
|
|
87
|
+
return {
|
|
88
|
+
method: 'PUT',
|
|
89
|
+
path: `${DEFAULT_SIGNED_URL_BASE}/:disk/*key`,
|
|
90
|
+
meta: {
|
|
91
|
+
name: 'storage.upload',
|
|
92
|
+
auth: 'required',
|
|
93
|
+
enforcedBy: 'handler',
|
|
94
|
+
cache: NO_STORE,
|
|
95
|
+
tags: ['storage'],
|
|
96
|
+
},
|
|
97
|
+
handler: (request: UltimateRequest, ctx: RequestContext): Promise<Response> =>
|
|
98
|
+
acceptUpload(storage, request, ctx),
|
|
99
|
+
};
|
|
100
|
+
}
|
package/src/runtime-storage.ts
CHANGED
|
@@ -17,11 +17,13 @@ import type { Storage, StorageRead } from '@ultimat3/storage';
|
|
|
17
17
|
import {
|
|
18
18
|
assertSafeKey,
|
|
19
19
|
DEFAULT_SIGNED_URL_BASE,
|
|
20
|
+
definedStorage,
|
|
20
21
|
isTenantScoped,
|
|
21
22
|
isWithinOrg,
|
|
22
23
|
objectNotFound,
|
|
23
24
|
orgMismatch,
|
|
24
25
|
} from '@ultimat3/storage';
|
|
26
|
+
import { storageUploadRoute } from './runtime-storage-upload';
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* The one capability that gates reading a stored object, on every disk. A permission and not a
|
|
@@ -211,6 +213,30 @@ export function storageResponse(request: UltimateRequest, read: StorageRead): Re
|
|
|
211
213
|
return new Response(new Uint8Array(slice), { status: 206, headers });
|
|
212
214
|
}
|
|
213
215
|
|
|
216
|
+
/**
|
|
217
|
+
* The `Storage` the framework's own routes serve: the process's ONE registry when there is one —
|
|
218
|
+
* the last `defineStorage()`, which is the app's whenever an app module declares its disks — and
|
|
219
|
+
* the host's env-selected disk only when nothing did. Resolved per call, never captured at mount:
|
|
220
|
+
* the boot builds its disk before the app's modules load, and `x dev` re-imports them on a save.
|
|
221
|
+
*
|
|
222
|
+
* Why not the host's disk alone (#524): an app that declared `defineStorage({ disks: { uploads,
|
|
223
|
+
* evidence } })` wrote through `disk('evidence')` while `/_storage` and `/media` read the host's
|
|
224
|
+
* `local`/`object`, so every URL the app's own disks signed answered 404. The app's code and the
|
|
225
|
+
* framework's routes must be reading one registry, and `defineStorage` says there is only one.
|
|
226
|
+
*/
|
|
227
|
+
export function servedStorage(host: Storage): Storage {
|
|
228
|
+
const current = (): Storage => definedStorage() ?? host;
|
|
229
|
+
return {
|
|
230
|
+
get defaultDisk(): string {
|
|
231
|
+
return current().defaultDisk;
|
|
232
|
+
},
|
|
233
|
+
get diskNames(): readonly string[] {
|
|
234
|
+
return current().diskNames;
|
|
235
|
+
},
|
|
236
|
+
disk: (name?: string) => current().disk(name),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
214
240
|
export interface StorageRoutesOptions {
|
|
215
241
|
/** The configured disks. The driver seam only — this route never assumes a filesystem. */
|
|
216
242
|
readonly storage: Storage;
|
|
@@ -249,5 +275,8 @@ export function storageRoutes(options: StorageRoutesOptions): readonly Route[] {
|
|
|
249
275
|
return storageResponse(request, await readStorageObject(options.storage, input, ctx.actor));
|
|
250
276
|
},
|
|
251
277
|
},
|
|
278
|
+
// The upload half: the `PUT` every `grantUpload` URL names (#523). One call mounts both, so
|
|
279
|
+
// no route table can serve the reads of a disk it cannot write to.
|
|
280
|
+
storageUploadRoute(options.storage),
|
|
252
281
|
];
|
|
253
282
|
}
|
package/src/serve-boot.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { assetRoutes } from './runtime-assets';
|
|
|
24
24
|
import { appRoutes } from './runtime-render';
|
|
25
25
|
import { replicaOverrides } from './runtime-replica';
|
|
26
26
|
import type { RunningServices } from './runtime-services';
|
|
27
|
-
import { storageRoutes } from './runtime-storage';
|
|
27
|
+
import { servedStorage, storageRoutes } from './runtime-storage';
|
|
28
28
|
import { loadDrainConfig } from './serve-drain';
|
|
29
29
|
import { configureReporting, containerBinding, metricsPortFor, portFromEnv } from './serve-env';
|
|
30
30
|
import type { ServedApp, ServeOptions } from './serve-types';
|
|
@@ -156,17 +156,19 @@ async function webSurface(
|
|
|
156
156
|
});
|
|
157
157
|
// The app's own MCP endpoint, through the same call `x dev` makes — see `app-mcp.ts`.
|
|
158
158
|
const mcpMount = await mountAppMcp(options.root);
|
|
159
|
+
// The app's own disks when it declared any (`defineStorage` in an app module), else this boot's.
|
|
160
|
+
const served = servedStorage(runtime.storage);
|
|
159
161
|
const routes: readonly Route[] = [
|
|
160
162
|
...apiRoutes(),
|
|
161
163
|
...mcpMount.routes,
|
|
162
164
|
...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
|
|
163
165
|
...assetRoutes({
|
|
164
166
|
root: options.root,
|
|
165
|
-
storage:
|
|
167
|
+
storage: served,
|
|
166
168
|
...(options.runtime?.images === undefined ? {} : { images: options.runtime.images }),
|
|
167
169
|
...(pwa === undefined ? {} : { pwa }),
|
|
168
170
|
}),
|
|
169
|
-
...storageRoutes({ storage:
|
|
171
|
+
...storageRoutes({ storage: served }),
|
|
170
172
|
...islandRoutes(() => islands),
|
|
171
173
|
// The surface stylesheets the documents link. Built from the registry the `loadApp` above
|
|
172
174
|
// filled, so this process serves exactly the CSS it renders against.
|