@astrale-os/adapter-cloudflare 0.5.0-beta.148 → 0.5.0-beta.149

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.
@@ -29,7 +29,9 @@ async function prepare(params, context, direct) {
29
29
  const name = workerName(params, context.build.schema.compiled.root.origin, context.environment);
30
30
  const revision = context.build.schema.compiled.root.revision;
31
31
  const configuredIssuer = deploymentAddressing(params);
32
- const frontend = await frontendFiles({
32
+ // `frontend` stays the served file list every branch below already reads; `immutable` rides
33
+ // alongside it into the Worker, where it decides which of those files may be cached forever.
34
+ const { files: frontend, immutable } = await frontendFiles({
33
35
  frontend: context.build.frontend,
34
36
  projectDir: context.projectDir,
35
37
  output: join(directory, 'frontend'),
@@ -45,6 +47,7 @@ async function prepare(params, context, direct) {
45
47
  configuredIssuer,
46
48
  router,
47
49
  ...(frontend.length === 0 ? {} : { frontendRevision: frontendRevision(frontend) }),
50
+ ...(immutable.length === 0 ? {} : { frontendImmutable: immutable }),
48
51
  });
49
52
  const releaseConfig = generateWranglerConfig({
50
53
  workerName: name,
@@ -1,9 +1,19 @@
1
1
  import type { Build } from '@astrale-os/sdk/deployment/build';
2
2
  import { frontend as frontendBuild } from '@astrale-os/sdk/deployment/build';
3
+ /** The served frontend, and which of its files a browser may keep without revalidating. */
4
+ export interface FrontendOutput {
5
+ readonly files: readonly frontendBuild.FrontendFile[];
6
+ /**
7
+ * Served paths the bundler content-addressed, so their bytes can never change under a given name.
8
+ * A frontend Astrale does not build leaves this empty, which is what keeps the absence of evidence
9
+ * indistinguishable from evidence of mutability.
10
+ */
11
+ readonly immutable: readonly string[];
12
+ }
3
13
  export declare function frontendFiles(input: {
4
14
  readonly frontend: Build['frontend'];
5
15
  readonly projectDir: string;
6
16
  readonly output: string;
7
17
  readonly recordSource?: (path: string) => void;
8
18
  readonly signal: AbortSignal;
9
- }): Promise<readonly frontendBuild.FrontendFile[]>;
19
+ }): Promise<FrontendOutput>;
@@ -5,18 +5,23 @@ import { buildViteFrontend } from './vite.js';
5
5
  export async function frontendFiles(input) {
6
6
  const frontend = input.frontend;
7
7
  if (frontend === undefined)
8
- return [];
9
- if (frontend.source.kind === 'external')
10
- return frontendBuild.files(frontend, []);
8
+ return { files: [], immutable: [] };
9
+ if (frontend.source.kind === 'external') {
10
+ return { files: frontendBuild.files(frontend, []), immutable: [] };
11
+ }
11
12
  await rm(input.output, { recursive: true, force: true });
12
- await buildViteFrontend({
13
+ const built = await buildViteFrontend({
13
14
  projectDir: input.projectDir,
14
15
  output: input.output,
15
16
  ...(frontend.source.config === undefined ? {} : { config: frontend.source.config }),
16
17
  recordSource: input.recordSource,
17
18
  signal: input.signal,
18
19
  });
19
- return frontendBuild.files(frontend, await readFiles(input.output));
20
+ const files = frontendBuild.files(frontend, await readFiles(input.output));
21
+ // A name the bundler reported but that never reached disk is not a served path; keeping only the
22
+ // intersection means the evidence describes exactly what this deployment will answer with.
23
+ const served = new Set(files.map((file) => file.path));
24
+ return { files, immutable: built.immutable.filter((path) => served.has(path)) };
20
25
  }
21
26
  async function readFiles(root, current = '') {
22
27
  const entries = await readdir(join(root, current), { withFileTypes: true });
@@ -1,8 +1,14 @@
1
- /** Build a Vite frontend in an isolated process and report its authored inputs. */
1
+ /**
2
+ * Build a Vite frontend in an isolated process and report its authored inputs, along with the
3
+ * outputs the bundler content-addressed. Those are the files a browser may keep forever, and only
4
+ * the build that named them can say which they are.
5
+ */
2
6
  export declare function buildViteFrontend(input: {
3
7
  readonly projectDir: string;
4
8
  readonly output: string;
5
9
  readonly config?: string;
6
10
  readonly recordSource?: (path: string) => void;
7
11
  readonly signal: AbortSignal;
8
- }): Promise<void>;
12
+ }): Promise<{
13
+ readonly immutable: readonly string[];
14
+ }>;
@@ -3,16 +3,21 @@ import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { runViteProcess } from './vite.process.js';
6
- /** Build a Vite frontend in an isolated process and report its authored inputs. */
6
+ /**
7
+ * Build a Vite frontend in an isolated process and report its authored inputs, along with the
8
+ * outputs the bundler content-addressed. Those are the files a browser may keep forever, and only
9
+ * the build that named them can say which they are.
10
+ */
7
11
  export async function buildViteFrontend(input) {
8
12
  const parent = dirname(input.output);
9
13
  await mkdir(parent, { recursive: true });
10
14
  const scratch = await mkdtemp(join(parent, 'vite-run-'));
11
15
  const evidence = join(scratch, 'sources.json');
16
+ const addressed = join(scratch, 'addressed.json');
12
17
  try {
13
18
  await runViteProcess({
14
19
  cwd: input.projectDir,
15
- arguments: [runnerPath(), input.output, evidence, input.config ?? ''],
20
+ arguments: [runnerPath(), input.output, evidence, input.config ?? '', addressed],
16
21
  signal: input.signal,
17
22
  });
18
23
  const sources = JSON.parse(await readFile(evidence, 'utf8'));
@@ -21,6 +26,11 @@ export async function buildViteFrontend(input) {
21
26
  }
22
27
  for (const path of sources)
23
28
  input.recordSource?.(path);
29
+ const immutable = JSON.parse(await readFile(addressed, 'utf8'));
30
+ if (!Array.isArray(immutable) || immutable.some((path) => typeof path !== 'string')) {
31
+ throw new TypeError('Vite preparation output evidence is invalid.');
32
+ }
33
+ return { immutable: Object.freeze([...immutable]) };
24
34
  }
25
35
  finally {
26
36
  await rm(scratch, { recursive: true, force: true });
@@ -12,13 +12,14 @@ const CONFIG_FILES = Object.freeze([
12
12
  ]);
13
13
  await main();
14
14
  async function main() {
15
- const [output, evidence, configured] = process.argv.slice(2);
15
+ const [output, evidence, configured, addressed] = process.argv.slice(2);
16
16
  if (output === undefined || evidence === undefined) {
17
17
  throw new TypeError('Vite preparation runner arguments are invalid.');
18
18
  }
19
19
  const projectDir = process.cwd();
20
20
  const vite = await projectVite(projectDir);
21
21
  const sources = new Set();
22
+ const immutable = new Set();
22
23
  let publicDir = false;
23
24
  let environment = {
24
25
  directory: false,
@@ -45,6 +46,16 @@ async function main() {
45
46
  if (path !== undefined)
46
47
  sources.add(path);
47
48
  },
49
+ // The bundler is the only party that knows which outputs it content-addressed: a served path
50
+ // is safe to cache forever exactly when its name changes with its bytes. Reading that here,
51
+ // from the build that produced them, is what keeps the answer a fact rather than a guess
52
+ // about directory layout or file naming.
53
+ generateBundle(options, bundle) {
54
+ for (const [fileName, entry] of Object.entries(bundle)) {
55
+ if (addressedName(options, entry))
56
+ immutable.add(fileName);
57
+ }
58
+ },
48
59
  },
49
60
  ],
50
61
  });
@@ -55,6 +66,23 @@ async function main() {
55
66
  if (publicDir !== false)
56
67
  await collectTree(publicDir, sources);
57
68
  await writeFile(evidence, JSON.stringify([...sources]));
69
+ if (addressed !== undefined)
70
+ await writeFile(addressed, JSON.stringify([...immutable].sort()));
71
+ }
72
+ /**
73
+ * Whether this output's name carries a content hash, read from the template the bundler resolved.
74
+ *
75
+ * A template that is not a plain string, or that omits `[hash]`, leaves the answer unknown, and
76
+ * unknown must read as "not content-addressed": losing the optimization costs a revalidation, while
77
+ * claiming it wrongly pins a stale file in browsers for as long as the header says.
78
+ */
79
+ function addressedName(options, entry) {
80
+ const template = entry.type === 'asset'
81
+ ? options.assetFileNames
82
+ : entry.isEntry === true
83
+ ? options.entryFileNames
84
+ : options.chunkFileNames;
85
+ return typeof template === 'string' && template.includes('[hash]');
58
86
  }
59
87
  function environmentCandidates(input) {
60
88
  const directory = input.directory;
@@ -6,6 +6,7 @@ export interface ExecutionCodegenOptions {
6
6
  readonly workerSpecifier?: string;
7
7
  readonly build: Build;
8
8
  readonly frontendRevision?: string;
9
+ readonly frontendImmutable?: readonly string[];
9
10
  readonly maximumRequestBytes?: number;
10
11
  readonly invocationTimeoutMs?: number;
11
12
  readonly identityIssuer?: string;
@@ -36,7 +36,7 @@ type WorkerEnv = CloudflareEnv & CloudflareAdapterBindings & {
36
36
  type RuntimeEnv = Omit<WorkerEnv, 'ASTRALE_SIGNING_IDENTITY'>
37
37
  ${routerPredicate}
38
38
  export default generatedCloudflareWorkerEntry<WorkerEnv, RuntimeEnv>()({
39
- ${frontend}${options.frontendRevision === undefined ? '' : ` frontendRevision: ${JSON.stringify(options.frontendRevision)},\n`} load: async () => ({
39
+ ${frontend}${options.frontendRevision === undefined ? '' : ` frontendRevision: ${JSON.stringify(options.frontendRevision)},\n`}${options.frontendImmutable === undefined ? '' : ` frontendImmutable: ${JSON.stringify(options.frontendImmutable)},\n`} load: async () => ({
40
40
  runtime: (await import(${JSON.stringify(options.runtimeSpecifier)})).default,
41
41
  material: ${JSON.stringify(generated)} as unknown as GeneratedBuildMaterial,
42
42
  }),
@@ -10,6 +10,7 @@ export declare function prepareExecution(input: {
10
10
  readonly name: string;
11
11
  readonly direct: boolean;
12
12
  readonly frontendRevision?: string;
13
+ readonly frontendImmutable?: readonly string[];
13
14
  readonly configuredIssuer?: string;
14
15
  readonly router: ReturnType<typeof resolveRouter>;
15
16
  }): Promise<{
@@ -25,6 +25,9 @@ export async function prepareExecution(input) {
25
25
  workerSpecifier,
26
26
  build: input.context.build,
27
27
  ...(input.frontendRevision === undefined ? {} : { frontendRevision: input.frontendRevision }),
28
+ ...(input.frontendImmutable === undefined
29
+ ? {}
30
+ : { frontendImmutable: input.frontendImmutable }),
28
31
  ...(maximumRequestBytes === undefined ? {} : { maximumRequestBytes }),
29
32
  ...(invocationTimeoutMs === undefined ? {} : { invocationTimeoutMs }),
30
33
  ...(input.params.tokenExchange === undefined
@@ -20,6 +20,8 @@ export interface CloudflareWorkerConfig<RuntimeValue extends Runtime, Bindings e
20
20
  readonly frontend?: BuildMaterial['frontend'];
21
21
  /** Generated browser-file identity used only for deployment propagation checks. */
22
22
  readonly frontendRevision?: string;
23
+ /** Served frontend paths the bundler content-addressed; absent means none may be cached forever. */
24
+ readonly frontendImmutable?: readonly string[];
23
25
  /** Load the exact Runtime and Build once, after the isolate has accepted a request. */
24
26
  readonly load: () => CloudflareWorkerSource<RuntimeValue, Material> | Promise<CloudflareWorkerSource<RuntimeValue, Material>>;
25
27
  readonly privateKey: JsonWebKey | ((bindings: Bindings) => JsonWebKey);
@@ -87,13 +87,13 @@ function createCloudflareWorkerEntry(restore) {
87
87
  }
88
88
  currentPublicUrl = publicUrl;
89
89
  if (config.frontend !== undefined) {
90
- const frontend = await frontendAsset(config.frontend, bindings.ASSETS, url, request, config.frontendRevision);
90
+ const frontend = await frontendAsset(config.frontend, bindings.ASSETS, url, request, config.frontendRevision, config.frontendImmutable);
91
91
  if (frontend !== undefined)
92
92
  return withProviderVersion(frontend, bindings);
93
93
  }
94
94
  const { application, build } = await realize();
95
95
  if (config.frontend === undefined) {
96
- const frontend = await frontendAsset(build.frontend, bindings.ASSETS, url, request, config.frontendRevision);
96
+ const frontend = await frontendAsset(build.frontend, bindings.ASSETS, url, request, config.frontendRevision, config.frontendImmutable);
97
97
  if (frontend !== undefined)
98
98
  return withProviderVersion(frontend, bindings);
99
99
  }
@@ -1,4 +1,4 @@
1
1
  import type { BuildMaterial } from '@astrale-os/sdk/deployment/build/material';
2
2
  import type { Fetcher } from './entry.js';
3
3
  /** Serve public frontend files without realizing the Application's execution Runtime. */
4
- export declare function frontendAsset(frontend: BuildMaterial['frontend'], assets: Fetcher | undefined, url: URL, request: Request, revision?: string): Promise<Response | undefined>;
4
+ export declare function frontendAsset(frontend: BuildMaterial['frontend'], assets: Fetcher | undefined, url: URL, request: Request, revision?: string, immutable?: readonly string[]): Promise<Response | undefined>;
@@ -1,5 +1,5 @@
1
1
  /** Serve public frontend files without realizing the Application's execution Runtime. */
2
- export async function frontendAsset(frontend, assets, url, request, revision) {
2
+ export async function frontendAsset(frontend, assets, url, request, revision, immutable) {
3
3
  if (frontend === undefined || frontend.source.kind === 'external')
4
4
  return undefined;
5
5
  if (reserved(url.pathname) || (request.method !== 'GET' && request.method !== 'HEAD')) {
@@ -18,16 +18,32 @@ export async function frontendAsset(frontend, assets, url, request, revision) {
18
18
  return undefined;
19
19
  if (route === undefined) {
20
20
  const response = await assets.fetch(request);
21
- return assetPath || response.status !== 404 ? publicAsset(response, revision) : undefined;
21
+ return assetPath || response.status !== 404
22
+ ? publicAsset(response, revision, cacheForever(url, immutable))
23
+ : undefined;
22
24
  }
23
25
  if (route.path !== url.pathname) {
24
26
  const response = await assets.fetch(request);
25
27
  if (response.status !== 404)
26
- return publicAsset(response, revision);
28
+ return publicAsset(response, revision, cacheForever(url, immutable));
27
29
  }
28
- return publicAsset(await assets.fetch(new Request(new URL(`/${route.document}${url.search}`, url.origin), request)), revision);
30
+ // A route serves its document, never the requested path, so the document decides: it is named by
31
+ // the route rather than by its bytes, and `publicAsset` already refuses to retain it.
32
+ return publicAsset(await assets.fetch(new Request(new URL(`/${route.document}${url.search}`, url.origin), request)), revision, false);
29
33
  }
30
- function publicAsset(response, revision) {
34
+ /**
35
+ * Whether this exact served path is one the build content-addressed.
36
+ *
37
+ * The set is evidence from the bundler that named the file, not an inference from where it sits: a
38
+ * path under the bundle's own directory may still have been copied there verbatim from `public/`,
39
+ * and a content-addressed output may sit anywhere the project's configuration puts it.
40
+ */
41
+ function cacheForever(url, immutable) {
42
+ if (immutable === undefined || immutable.length === 0)
43
+ return false;
44
+ return immutable.includes(decodeURIComponent(url.pathname).replace(/^\//u, ''));
45
+ }
46
+ function publicAsset(response, revision, immutable) {
31
47
  // Plain Views have an opaque sandbox origin. Their modules, CSS and fonts still need
32
48
  // public reads; this neither enables credentials nor changes execution endpoint CORS.
33
49
  const readable = new Response(response.body, response);
@@ -39,6 +55,12 @@ function publicAsset(response, revision) {
39
55
  if (!response.ok || response.headers.get('Content-Type')?.split(';')[0] === 'text/html') {
40
56
  readable.headers.set('Cache-Control', 'no-store');
41
57
  }
58
+ else if (immutable) {
59
+ // The bytes behind this name can never change, so a revalidation could only ever confirm what
60
+ // the name already states. Anything not proved content-addressed keeps the platform default,
61
+ // which revalidates and is therefore never wrong, only slower.
62
+ readable.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
63
+ }
42
64
  return readable;
43
65
  }
44
66
  function reserved(path) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/adapter-cloudflare",
3
- "version": "0.5.0-beta.148",
3
+ "version": "0.5.0-beta.149",
4
4
  "description": "Deploy an Astrale application through Cloudflare Workers",
5
5
  "keywords": [
6
6
  "adapter",
@@ -53,7 +53,7 @@
53
53
  "registry": "https://registry.npmjs.org/"
54
54
  },
55
55
  "dependencies": {
56
- "@astrale-os/sdk": "0.5.0-beta.145",
56
+ "@astrale-os/sdk": "0.5.0-beta.146",
57
57
  "@noble/hashes": "2.3.0",
58
58
  "hono": "^4.6.20",
59
59
  "jose": "^6.1.3",