@jk2908/solas 0.7.0 → 0.8.0-canary.1791495022678

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.
Files changed (105) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +157 -3
  3. package/dist/adapters/cloudflare/index.d.ts +2 -0
  4. package/dist/adapters/cloudflare/index.js +2 -0
  5. package/dist/adapters/cloudflare/runtime.d.ts +34 -0
  6. package/dist/adapters/cloudflare/runtime.js +75 -0
  7. package/dist/adapters/cloudflare/vite.d.ts +21 -0
  8. package/dist/adapters/cloudflare/vite.js +151 -0
  9. package/dist/adapters/cloudflare/worker.d.ts +18 -0
  10. package/dist/adapters/cloudflare/worker.js +46 -0
  11. package/dist/adapters/node.d.ts +43 -0
  12. package/dist/adapters/node.js +144 -0
  13. package/dist/config.d.ts +3 -3
  14. package/dist/config.js +12 -3
  15. package/dist/index.js +36 -45
  16. package/dist/internal/browser-router/history.d.ts +9 -9
  17. package/dist/internal/browser-router/history.js +9 -9
  18. package/dist/internal/browser-router/link.d.ts +5 -5
  19. package/dist/internal/browser-router/response-cache.d.ts +8 -8
  20. package/dist/internal/browser-router/response-cache.js +8 -8
  21. package/dist/internal/browser-router/router.js +9 -9
  22. package/dist/internal/browser-router/shared.d.ts +11 -11
  23. package/dist/internal/browser-router/shared.js +2 -2
  24. package/dist/internal/build.d.ts +10 -10
  25. package/dist/internal/build.js +23 -23
  26. package/dist/internal/codegen/config.d.ts +1 -1
  27. package/dist/internal/codegen/config.js +4 -2
  28. package/dist/internal/codegen/environments.d.ts +11 -3
  29. package/dist/internal/codegen/environments.js +26 -6
  30. package/dist/internal/codegen/format.d.ts +1 -1
  31. package/dist/internal/codegen/format.js +1 -1
  32. package/dist/internal/codegen/manifest.d.ts +1 -1
  33. package/dist/internal/codegen/manifest.js +1 -1
  34. package/dist/internal/codegen/maps.d.ts +1 -1
  35. package/dist/internal/codegen/maps.js +1 -1
  36. package/dist/internal/codegen/types.d.ts +1 -1
  37. package/dist/internal/codegen/types.js +1 -1
  38. package/dist/internal/codegen/utils.d.ts +7 -7
  39. package/dist/internal/codegen/utils.js +10 -10
  40. package/dist/internal/env/browser.d.ts +1 -1
  41. package/dist/internal/env/browser.js +1 -1
  42. package/dist/internal/env/flight.d.ts +3 -3
  43. package/dist/internal/env/flight.js +5 -5
  44. package/dist/internal/env/rsc.d.ts +15 -3
  45. package/dist/internal/env/rsc.js +37 -34
  46. package/dist/internal/env/ssr.d.ts +4 -4
  47. package/dist/internal/env/ssr.js +4 -4
  48. package/dist/internal/http-router/create-http-router.d.ts +1 -1
  49. package/dist/internal/http-router/create-http-router.js +1 -1
  50. package/dist/internal/http-router/router.d.ts +8 -7
  51. package/dist/internal/http-router/router.js +23 -24
  52. package/dist/internal/http-router/utils.d.ts +3 -3
  53. package/dist/internal/http-router/utils.js +4 -4
  54. package/dist/internal/metadata.d.ts +5 -5
  55. package/dist/internal/metadata.js +15 -15
  56. package/dist/internal/navigation/http-exception.d.ts +5 -5
  57. package/dist/internal/navigation/http-exception.js +6 -6
  58. package/dist/internal/navigation/redirect-boundary.d.ts +1 -1
  59. package/dist/internal/navigation/redirect-boundary.js +1 -1
  60. package/dist/internal/navigation/redirect.d.ts +5 -5
  61. package/dist/internal/navigation/redirect.js +6 -6
  62. package/dist/internal/postbuild.d.ts +1 -0
  63. package/dist/internal/postbuild.js +37 -28
  64. package/dist/internal/prerender.d.ts +25 -22
  65. package/dist/internal/prerender.js +61 -37
  66. package/dist/internal/public-files.d.ts +1 -1
  67. package/dist/internal/public-files.js +1 -1
  68. package/dist/internal/render/head.d.ts +1 -1
  69. package/dist/internal/render/head.js +3 -3
  70. package/dist/internal/render/tree.d.ts +1 -1
  71. package/dist/internal/render/tree.js +1 -1
  72. package/dist/internal/resolver.d.ts +6 -6
  73. package/dist/internal/resolver.js +12 -12
  74. package/dist/internal/runtimes/assets.d.ts +35 -0
  75. package/dist/internal/runtimes/assets.js +37 -0
  76. package/dist/internal/server/actions.d.ts +4 -4
  77. package/dist/internal/server/actions.js +4 -4
  78. package/dist/internal/server/cookies.d.ts +3 -3
  79. package/dist/internal/server/cookies.js +3 -3
  80. package/dist/internal/server/csrf.d.ts +3 -3
  81. package/dist/internal/server/csrf.js +4 -4
  82. package/dist/internal/server/dynamic.d.ts +3 -3
  83. package/dist/internal/server/dynamic.js +3 -3
  84. package/dist/internal/server/headers.d.ts +2 -2
  85. package/dist/internal/server/headers.js +2 -2
  86. package/dist/internal/server/url.d.ts +2 -2
  87. package/dist/internal/server/url.js +2 -2
  88. package/dist/internal/ui/error-boundary.d.ts +6 -6
  89. package/dist/internal/ui/error-boundary.js +6 -6
  90. package/dist/manifest.d.ts +2 -1
  91. package/dist/manifest.js +13 -37
  92. package/dist/types.d.ts +11 -0
  93. package/dist/utils/base-path.d.ts +3 -3
  94. package/dist/utils/base-path.js +3 -3
  95. package/dist/utils/compress.d.ts +1 -1
  96. package/dist/utils/compress.js +2 -3
  97. package/dist/utils/export-reader.d.ts +11 -10
  98. package/dist/utils/export-reader.js +10 -10
  99. package/dist/utils/hash.d.ts +4 -0
  100. package/dist/utils/hash.js +7 -0
  101. package/dist/utils/logger.d.ts +9 -9
  102. package/dist/utils/logger.js +9 -9
  103. package/package.json +21 -4
  104. package/dist/internal/runtimes/runtime.d.ts +0 -15
  105. package/dist/internal/runtimes/runtime.js +0 -37
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Features
6
+
7
+ - Added a platform `adapter` option to `solas()`. `solas({ adapter: cloudflare() })` registers the adapter's plugins alongside Solas's own instead of spreading them into the Vite plugins array.
8
+ - Added a Cloudflare adapter for the Cloudflare Vite plugin beta and the `cf` CLI (`cloudflare.config.ts`). `@jk2908/solas/cloudflare/vite` exports `cloudflare()`, and `@jk2908/solas/cloudflare` exports `createWorker()` and `createCloudflareAssets()`.
9
+ - Fully prerendered routes are now written to `dist/static` at their route paths. The Cloudflare adapter copies them under `/_solas-artifacts` so the Worker serves them.
10
+ - Added `@jk2908/solas/node`, a `serve()` helper that runs the built handler on a Node or Bun HTTP server.
11
+ - Request-time reads use an injected `Assets` store with logical `{ namespace, path }` references (`client`, `static`, `artifact`) instead of filesystem paths.
12
+
13
+ ### Breaking
14
+
15
+ - Requires Vite 8 (Rolldown). The `vite` peer is now `^8.0.0`. Use Vite's `resolve.tsconfigPaths` instead of `vite-tsconfig-paths`, which breaks Rolldown's handling of the `file://` imports `@vitejs/plugin-rsc` emits.
16
+ - Removed the `Runtime` facade (`Runtime.set`/`Runtime.reset`) and the `Runtime` export from `@jk2908/solas/env/rsc`.
17
+ - `createHandler` now takes an options object as its fourth argument: `createHandler(config, manifest, importMap, { assets, runtimeManifest })`.
18
+
19
+ ### Fixes
20
+
21
+ - Route modules are now executed in a standalone Vite environment resolved for `serve`, instead of a second Vite server that reloaded the config and cloned the plugin graph.
22
+ - The Cloudflare adapter pre-bundles the Worker environments so workerd never re-optimises dependencies mid-request, which previously split React across two instances in `vite dev`.
23
+ - The Cloudflare adapter sets `htmlHandling: none` and stores prerenders under `/_solas-artifacts`, so the asset layer no longer serves a prerendered document at its route path. Previously an RSC navigation (`Accept: text/x-component`) to a fully-prerendered route received HTML and failed to parse ("Connection closed").
24
+
3
25
  ## 0.7.0
4
26
 
5
27
  ### Breaking
package/README.md CHANGED
@@ -6,11 +6,15 @@ Solas is experimental and under active development, so expect rough edges.
6
6
 
7
7
  ## Install
8
8
 
9
+ Solas requires **Vite 8** (which uses Rolldown as the bundler).
10
+
9
11
  ```sh
10
- npm install @jk2908/solas react react-dom react-server-dom-webpack vite
11
- npm install -D @vitejs/plugin-react typescript vite-tsconfig-paths
12
+ npm install @jk2908/solas react react-dom react-server-dom-webpack vite@^8
13
+ npm install -D @vitejs/plugin-react@^6 typescript
12
14
  ```
13
15
 
16
+ Use Vite's built-in TypeScript path resolution (`resolve.tsconfigPaths: true`) rather than `vite-tsconfig-paths`, which is not compatible with Rolldown's handling of the `file://` runtime imports the RSC plugin emits.
17
+
14
18
  ## Use
15
19
 
16
20
  Create a Vite config that registers Solas.
@@ -26,6 +30,20 @@ export default defineConfig({
26
30
  })
27
31
  ```
28
32
 
33
+ Platform deployment is configured with the `adapter` option, which registers the platform's plugins alongside Solas's own instead of adding them to the plugins array:
34
+
35
+ ```ts
36
+ import { defineConfig } from 'vite'
37
+
38
+ import solas from '@jk2908/solas'
39
+ import { cloudflare } from '@jk2908/solas/cloudflare/vite'
40
+ import react from '@vitejs/plugin-react'
41
+
42
+ export default defineConfig({
43
+ plugins: [solas({ adapter: cloudflare() }), react()],
44
+ })
45
+ ```
46
+
29
47
  ## Structure
30
48
 
31
49
  Put your routes in `app/`.
@@ -507,9 +525,145 @@ export default defineConfig({
507
525
  })
508
526
  ```
509
527
 
528
+ ## Cloudflare
529
+
530
+ Solas ships a Cloudflare adapter that runs the RSC server as a Worker and serves prerender artifacts and client assets through the Cloudflare assets binding.
531
+
532
+ It targets the **Cloudflare Vite plugin beta** and the **`cf` CLI** workflow, where the Worker is configured by `cloudflare.config.ts`. Install the beta plugin (and `cf` for deploys):
533
+
534
+ ```sh
535
+ npm install -D @cloudflare/vite-plugin@beta cf
536
+ ```
537
+
538
+ ```ts
539
+ // vite.config.ts
540
+ import { defineConfig } from 'vite'
541
+
542
+ import solas from '@jk2908/solas'
543
+ import { cloudflare } from '@jk2908/solas/cloudflare/vite'
544
+ import react from '@vitejs/plugin-react'
545
+
546
+ export default defineConfig({
547
+ plugins: [solas({ prerender: 'full', adapter: cloudflare() }), react()],
548
+ })
549
+ ```
550
+
551
+ The adapter is passed to `solas()` rather than added to the plugins array directly, so platform wiring stays in one place (the same shape as SvelteKit's `sveltekit({ adapter: adapter() })`).
552
+
553
+ Give the Worker a name and a compatibility date that enables `nodejs_compat`:
554
+
555
+ ```ts
556
+ // cloudflare.config.ts
557
+ import { defineConfig, defineWorker } from 'cf/config'
558
+
559
+ export default defineConfig({
560
+ worker: defineWorker({
561
+ name: 'my-solas-app',
562
+ compatibilityDate: '2026-10-02',
563
+ compatibilityFlags: ['nodejs_compat'],
564
+ }),
565
+ })
566
+ ```
567
+
568
+ > `cloudflare.config.ts` is an open beta and must be loaded with Node 22.18+ (Bun is not supported), so the Cloudflare build and deploy commands run under Node. See the [`cf` documentation](https://developers.cloudflare.com/cf/) for the format and the `cf dev` / `cf build` / `cf deploy` commands.
569
+
570
+ ### Bindings
571
+
572
+ Bindings are declared in `cloudflare.config.ts` and read with the standard Cloudflare global. The adapter does not manage them:
573
+
574
+ ```ts
575
+ // cloudflare.config.ts
576
+ export default defineConfig({
577
+ worker: defineWorker({
578
+ name: 'my-solas-app',
579
+ compatibilityDate: '2026-10-02',
580
+ compatibilityFlags: ['nodejs_compat'],
581
+ env: {
582
+ MY_KV: bindings.kv({ id: '...' }),
583
+ },
584
+ }),
585
+ })
586
+ ```
587
+
588
+ ```tsx
589
+ // server component
590
+ import { env } from 'cloudflare:workers'
591
+
592
+ const value = await env.MY_KV.get('key')
593
+ ```
594
+
595
+ The Cloudflare Vite plugin emulates bindings in `cf dev` and `vite preview`, so there is nothing extra to configure locally.
596
+
597
+ For types, the plugin writes `.cloudflare/types/index.d.ts` (bindings inferred from `cloudflare.config.ts`). Include it in `tsconfig.json` so `cloudflare:workers`'s `env` is typed:
598
+
599
+ ```jsonc
600
+ {
601
+ "include": ["app", ".solas/*", ".cloudflare/types"],
602
+ }
603
+ ```
604
+
605
+ The adapter:
606
+
607
+ - configures `@cloudflare/vite-plugin` to build the `rsc` environment as the Worker, and injects the generated entrypoint, the `ASSETS` binding, and `htmlHandling: none` / `notFoundHandling: none` into the Worker config
608
+ - generates the Worker entry (a virtual module) that delegates to the typed `createWorker` helper
609
+ - points Solas's prerender step at the Cloudflare Worker bundle
610
+ - copies `dist/static` (full prerenders) and `dist/.solas` (runtime manifest + ppr artifacts) into the asset output under `/_solas-artifacts`
611
+
612
+ `htmlHandling: none` stops the asset layer serving a prerendered document at its route path. That matters because the asset layer ignores `Accept`, so an RSC navigation (`Accept: text/x-component`) to a fully-prerendered route would otherwise receive HTML and fail to parse. Routing documents through the Worker lets Solas serve the prerender for `text/html` and render RSC otherwise, while non-HTML assets (JS, CSS, fonts, images) are still served straight from the asset layer.
613
+
614
+ Because workers have no filesystem, the Worker reads its own output through the `ASSETS` binding. Reads use logical namespaces rather than filesystem paths: `client` maps onto asset-root URLs, and `static` (prerenders) and `artifact` (manifest, ppr) map onto the artifact prefix. During the build-time prerender pass the Worker is invoked without bindings, so the Node asset store is used and reads/writes artifacts on disk.
615
+
616
+ `cloudflare()` forwards its `cloudflare` option to `@cloudflare/vite-plugin`. To build a Worker manually, `createWorker` composes the entry behaviour, and `createCloudflareAssets` builds the store directly:
617
+
618
+ ```ts
619
+ import { createCloudflareAssets } from '@jk2908/solas/cloudflare'
620
+
621
+ const assets = createCloudflareAssets(env)
622
+ ```
623
+
624
+ ## Node
625
+
626
+ `vite dev` and `vite preview` already run the built handler in Node (via `@vitejs/plugin-rsc`'s default server handler), so nothing extra is needed locally. For a production Node server, use `@jk2908/solas/node`:
627
+
628
+ ```ts
629
+ // server.ts
630
+ import { serve } from '@jk2908/solas/node'
631
+
632
+ await serve()
633
+ ```
634
+
635
+ ```json
636
+ {
637
+ "scripts": {
638
+ "build": "vite build",
639
+ "start": "node server.ts"
640
+ }
641
+ }
642
+ ```
643
+
644
+ `serve()` loads the built RSC entry (`dist/rsc/index.js` by default) and forwards requests to its fetch handler, which already serves prerendered routes, client assets, and RSC/SSR responses. Options: `port` (defaults to `PORT` or `3000`), `host`, `outDir`, and `entry`. Run from the project root so the handler can resolve `dist`.
645
+
646
+ Bun and Deno do not need the helper. Their native servers pass the handler a `Request` directly and expect a `Response`, so the Node-specific HTTP-to-Fetch bridging (streams, `duplex`, backpressure) is not needed:
647
+
648
+ ```ts
649
+ // Bun
650
+ import handler from './dist/rsc/index.js'
651
+
652
+ Bun.serve({ fetch: request => handler.fetch(request) })
653
+ ```
654
+
655
+ ```ts
656
+ // Deno
657
+ import handler from './dist/rsc/index.js'
658
+
659
+ Deno.serve(request => handler.fetch(request))
660
+ ```
661
+
662
+ The handler reads `dist` through `node:fs`, which Bun and Deno (2.x, via Node compat) provide. Run from the project root. Bun can also run `@jk2908/solas/node` as-is, since it implements `node:http`.
663
+
510
664
  ## Runtime
511
665
 
512
- This is not a deployment or packaging adapter. Platform adapters are not available yet.
666
+ At request time Solas reads its output through an `Assets` interface (`exists`/`readText`/`readBuffer`/`mimeType`) injected into `createHandler`, defaulting to the Node standard library (which Bun runs natively). References are logical (`{ namespace, path }`), where the namespaces are `client` (Vite client output), `static` (full prerenders), and `artifact` (runtime manifest and ppr files). Platforms without a filesystem provide their own store; `@jk2908/solas/cloudflare` builds one around the `ASSETS` binding via `createWorker`. Build-time reads and writes use `node:fs` directly and are not abstracted.
513
667
 
514
668
  ## Scripts
515
669
 
@@ -0,0 +1,2 @@
1
+ export { ARTIFACT_PREFIX, createCloudflareAssets, type CloudflareAssets, type CloudflareEnv, } from './runtime.js';
2
+ export { createWorker, type CreateWorkerOptions } from './worker.js';
@@ -0,0 +1,2 @@
1
+ export { ARTIFACT_PREFIX, createCloudflareAssets, } from './runtime.js';
2
+ export { createWorker } from './worker.js';
@@ -0,0 +1,34 @@
1
+ import type { Assets } from '../../internal/runtimes/assets.js';
2
+ /**
3
+ * The subset of the Cloudflare `assets` Fetcher binding that Solas needs.
4
+ */
5
+ export type CloudflareAssets = {
6
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
7
+ };
8
+ /**
9
+ * The bindings shape the Cloudflare asset store reads from.
10
+ */
11
+ export type CloudflareEnv = {
12
+ ASSETS?: CloudflareAssets;
13
+ [key: string]: unknown;
14
+ };
15
+ /**
16
+ * Path prefix the adapter copies `dist/.solas` prerender artifacts under in the
17
+ * asset output.
18
+ */
19
+ export declare const ARTIFACT_PREFIX = "_solas-artifacts";
20
+ /**
21
+ * Prerendered documents live under the artifact prefix rather than the asset
22
+ * root. If they sat at their route paths the asset layer would serve them for
23
+ * every request to that path, so an RSC navigation (`Accept:
24
+ * text/x-component`) would receive HTML instead of reaching the Worker.
25
+ */
26
+ export declare const PRERENDER_PREFIX = "_solas-artifacts/static";
27
+ /**
28
+ * Create a Solas asset store backed by the Cloudflare assets binding.
29
+ *
30
+ * Workers have no writable filesystem, so this is read-only. `client` files are
31
+ * served from the asset root; `static` and `artifact` files are served from the
32
+ * adapter's artifact prefix.
33
+ */
34
+ export declare function createCloudflareAssets(env: CloudflareEnv): Assets;
@@ -0,0 +1,75 @@
1
+ import { getMimeTypeFromPath } from '../../internal/runtimes/mime.js';
2
+ /**
3
+ * Path prefix the adapter copies `dist/.solas` prerender artifacts under in the
4
+ * asset output.
5
+ */
6
+ export const ARTIFACT_PREFIX = '_solas-artifacts';
7
+ /**
8
+ * Prerendered documents live under the artifact prefix rather than the asset
9
+ * root. If they sat at their route paths the asset layer would serve them for
10
+ * every request to that path, so an RSC navigation (`Accept:
11
+ * text/x-component`) would receive HTML instead of reaching the Worker.
12
+ */
13
+ export const PRERENDER_PREFIX = `${ARTIFACT_PREFIX}/static`;
14
+ // The assets binding matches on pathname only, so this is only used to satisfy
15
+ // the URL constructor
16
+ const ASSET_ORIGIN = 'https://solas-assets.invalid';
17
+ /**
18
+ * Create a Solas asset store backed by the Cloudflare assets binding.
19
+ *
20
+ * Workers have no writable filesystem, so this is read-only. `client` files are
21
+ * served from the asset root; `static` and `artifact` files are served from the
22
+ * adapter's artifact prefix.
23
+ */
24
+ export function createCloudflareAssets(env) {
25
+ const assets = env.ASSETS;
26
+ if (!assets || typeof assets.fetch !== 'function') {
27
+ throw new Error('[solas] createCloudflareAssets requires an `ASSETS` binding. Add an assets binding to your Worker.');
28
+ }
29
+ /**
30
+ * Where each namespace lives in the asset output: `client` files sit at the
31
+ * asset root, prerenders and artifacts under the artifact prefix.
32
+ */
33
+ function toAssetPath(asset) {
34
+ switch (asset.namespace) {
35
+ case 'artifact':
36
+ return `${ARTIFACT_PREFIX}/${asset.path}`;
37
+ case 'static':
38
+ return `${PRERENDER_PREFIX}/${asset.path}`;
39
+ default:
40
+ return asset.path;
41
+ }
42
+ }
43
+ function toUrl(asset) {
44
+ return new URL(`/${toAssetPath(asset)}`, ASSET_ORIGIN).toString();
45
+ }
46
+ async function read(asset) {
47
+ const res = await assets.fetch(toUrl(asset), {
48
+ // asset responses should be passed through verbatim
49
+ headers: { 'accept-encoding': 'identity' },
50
+ });
51
+ return res.ok ? res : null;
52
+ }
53
+ return {
54
+ async exists(asset) {
55
+ return (await read(asset)) !== null;
56
+ },
57
+ async readText(asset) {
58
+ const res = await read(asset);
59
+ if (!res) {
60
+ throw new Error(`[solas] asset not found: ${asset.namespace}/${asset.path}`);
61
+ }
62
+ return res.text();
63
+ },
64
+ async readBuffer(asset) {
65
+ const res = await read(asset);
66
+ if (!res) {
67
+ throw new Error(`[solas] asset not found: ${asset.namespace}/${asset.path}`);
68
+ }
69
+ return res.arrayBuffer();
70
+ },
71
+ mimeType(filePath) {
72
+ return getMimeTypeFromPath(filePath);
73
+ },
74
+ };
75
+ }
@@ -0,0 +1,21 @@
1
+ import { cloudflare as cloudflareVitePlugin } from '@cloudflare/vite-plugin';
2
+ import type { Adapter } from '../../types.js';
3
+ /**
4
+ * @see {@link https://developers.cloudflare.com/workers/vite-plugin/}
5
+ */
6
+ type CloudflarePluginConfig = Parameters<typeof cloudflareVitePlugin>[0];
7
+ export type SolasCloudflareOptions = {
8
+ /**
9
+ * Options forwarded to `@cloudflare/vite-plugin`.
10
+ */
11
+ cloudflare?: CloudflarePluginConfig;
12
+ };
13
+ /**
14
+ * Cloudflare adapter for Solas.
15
+ *
16
+ * Wraps `@cloudflare/vite-plugin`, generates the Worker entry that installs the
17
+ * asset-backed store, points Solas's prerender step at the Cloudflare Worker
18
+ * bundle, and copies prerender artifacts into the asset output.
19
+ */
20
+ export declare function cloudflare(options?: SolasCloudflareOptions): Adapter;
21
+ export {};
@@ -0,0 +1,151 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { cloudflare as cloudflareVitePlugin } from '@cloudflare/vite-plugin';
4
+ import * as Config from '../../config.js';
5
+ import { writeCloudflareEntry } from '../../internal/codegen/environments.js';
6
+ import { setRscEntry } from '../../internal/postbuild.js';
7
+ import { ARTIFACT_PREFIX, PRERENDER_PREFIX } from './runtime.js';
8
+ const VIRTUAL_ENTRY = 'virtual:solas-cloudflare-entry';
9
+ /**
10
+ * Solas dependencies that are imported at request time from the Worker
11
+ * environment. They must be pre-bundled up front: the Cloudflare dev Worker
12
+ * runs in workerd and does not survive a mid-request dependency
13
+ * re-optimization, which otherwise leaves React split across two instances
14
+ * ("Invalid hook call").
15
+ */
16
+ const WORKER_OPTIMIZE_DEPS = ['path-to-regexp'];
17
+ async function pathExists(filePath) {
18
+ try {
19
+ await fs.access(filePath);
20
+ return true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ /**
27
+ * Recursively find directories named `assets` beneath Cloudflare's build output.
28
+ */
29
+ async function findAssetDirs(root, found = []) {
30
+ const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
31
+ for (const entry of entries) {
32
+ if (!entry.isDirectory())
33
+ continue;
34
+ const full = path.join(root, entry.name);
35
+ if (entry.name === 'assets') {
36
+ found.push(full);
37
+ continue;
38
+ }
39
+ await findAssetDirs(full, found);
40
+ }
41
+ return found;
42
+ }
43
+ /**
44
+ * Copy Solas's output into every Cloudflare asset directory, all under the
45
+ * artifact prefix so the asset layer never answers a document request itself:
46
+ * - `dist/static/**` (full prerenders) at `<prefix>/static`, read back through
47
+ * the `ASSETS` binding so the Worker serves the HTML for `text/html` and
48
+ * renders RSC otherwise
49
+ * - `dist/.solas/**` (runtime manifest + ppr artifacts) at `<prefix>`, read back
50
+ * through the `ASSETS` binding
51
+ */
52
+ async function copyOutput(cwd) {
53
+ const outDir = path.join(cwd, Config.OUT_DIR);
54
+ const artifactSource = path.join(outDir, Config.GENERATED_DIR);
55
+ const staticSource = path.join(outDir, 'static');
56
+ const assetDirs = await findAssetDirs(path.join(cwd, '.cloudflare', 'output'));
57
+ for (const assetDir of assetDirs) {
58
+ if (await pathExists(artifactSource)) {
59
+ await fs.cp(artifactSource, path.join(assetDir, ARTIFACT_PREFIX), {
60
+ recursive: true,
61
+ force: true,
62
+ });
63
+ }
64
+ if (await pathExists(staticSource)) {
65
+ await fs.cp(staticSource, path.join(assetDir, PRERENDER_PREFIX), {
66
+ recursive: true,
67
+ force: true,
68
+ });
69
+ }
70
+ }
71
+ }
72
+ function withAdapterConfig(config) {
73
+ return (workerConfig) => {
74
+ const base = typeof config === 'function' ? (config(workerConfig) ?? {}) : (config ?? {});
75
+ return {
76
+ ...base,
77
+ entrypoint: VIRTUAL_ENTRY,
78
+ assets: { htmlHandling: 'none', notFoundHandling: 'none', ...base.assets },
79
+ env: { ...base.env, ASSETS: { type: 'assets' } },
80
+ };
81
+ };
82
+ }
83
+ /**
84
+ * Cloudflare adapter for Solas.
85
+ *
86
+ * Wraps `@cloudflare/vite-plugin`, generates the Worker entry that installs the
87
+ * asset-backed store, points Solas's prerender step at the Cloudflare Worker
88
+ * bundle, and copies prerender artifacts into the asset output.
89
+ */
90
+ export function cloudflare(options = {}) {
91
+ const cwd = process.cwd();
92
+ // back the virtual entry id with a path inside the generated dir so the
93
+ // generated entry's relative imports (`./manifest.js`, ...) resolve
94
+ const virtualEntryPath = path.join(cwd, Config.GENERATED_DIR, 'entry.cloudflare.tsx');
95
+ const cfPlugins = cloudflareVitePlugin({
96
+ // Solas runs its server in the `rsc` environment; Cloudflare's worker
97
+ // environment must match so the RSC runtime (and its `ssr` child) is
98
+ // bundled into the Worker
99
+ viteEnvironment: { name: 'rsc', childEnvironments: ['ssr'] },
100
+ ...options.cloudflare,
101
+ config: withAdapterConfig(options.cloudflare?.config),
102
+ });
103
+ const adapter = {
104
+ name: 'solas-cloudflare',
105
+ enforce: 'post',
106
+ configResolved(config) {
107
+ // the Cloudflare plugin points the `rsc` environment's build output at
108
+ // the Worker bundle during its `config` hook, so by now we know where
109
+ // Solas's prerender step should import the Worker from
110
+ const rscOutDir = config.environments.rsc?.build.outDir;
111
+ if (rscOutDir) {
112
+ setRscEntry(path.resolve(cwd, rscOutDir, 'index.js'));
113
+ }
114
+ },
115
+ configEnvironment(name, environment) {
116
+ // Pre-bundle the Worker environments so workerd never has to re-optimize
117
+ // dependencies in the middle of a request. Scanning the app directory
118
+ // surfaces any additional request-time dependencies too
119
+ if (name !== 'rsc' && name !== 'ssr')
120
+ return;
121
+ environment.optimizeDeps ??= {};
122
+ environment.optimizeDeps.include = [
123
+ ...new Set([
124
+ ...(environment.optimizeDeps.include ?? []),
125
+ ...WORKER_OPTIMIZE_DEPS,
126
+ ]),
127
+ ];
128
+ environment.optimizeDeps.entries = [
129
+ ...new Set([
130
+ ...(environment.optimizeDeps.entries ?? []),
131
+ `${Config.APP_DIR}/**/*.{tsx,ts,jsx,js}`,
132
+ ]),
133
+ ];
134
+ },
135
+ resolveId(source) {
136
+ return source === VIRTUAL_ENTRY ? virtualEntryPath : null;
137
+ },
138
+ load(id) {
139
+ return id === virtualEntryPath ? writeCloudflareEntry() : null;
140
+ },
141
+ buildApp: {
142
+ order: 'post',
143
+ async handler() {
144
+ // runs after Solas's own `post` buildApp (adapter comes later in the
145
+ // plugin array), so `dist/.solas` artifacts already exist
146
+ await copyOutput(cwd);
147
+ },
148
+ },
149
+ };
150
+ return { name: 'cloudflare', plugins: [adapter, ...cfPlugins] };
151
+ }
@@ -0,0 +1,18 @@
1
+ import type { ImportMap, Manifest, RuntimeConfig } from '../../types.js';
2
+ import { type CloudflareEnv } from './runtime.js';
3
+ export type CreateWorkerOptions = {
4
+ config: RuntimeConfig;
5
+ manifest: Manifest;
6
+ importMap: ImportMap;
7
+ };
8
+ /**
9
+ * Build a Cloudflare Worker around Solas's RSC handler.
10
+ *
11
+ * The asset store and handler depend on `env`, so handlers are cached per `env`
12
+ * (not once per isolate). A failed build is evicted so a transient error does
13
+ * not poison the Worker. During build-time prerender the Worker is invoked
14
+ * without bindings, in which case the Node asset store is used.
15
+ */
16
+ export declare function createWorker(options: CreateWorkerOptions): {
17
+ fetch(request: Request, env?: CloudflareEnv | undefined, _ctx?: unknown): Promise<Response>;
18
+ };
@@ -0,0 +1,46 @@
1
+ import { createHandler, loadManifest } from '../../internal/env/rsc.js';
2
+ import { nodeAssets } from '../../internal/runtimes/assets.js';
3
+ import { createCloudflareAssets } from './runtime.js';
4
+ /**
5
+ * Build a Cloudflare Worker around Solas's RSC handler.
6
+ *
7
+ * The asset store and handler depend on `env`, so handlers are cached per `env`
8
+ * (not once per isolate). A failed build is evicted so a transient error does
9
+ * not poison the Worker. During build-time prerender the Worker is invoked
10
+ * without bindings, in which case the Node asset store is used.
11
+ */
12
+ export function createWorker(options) {
13
+ const handlers = new WeakMap();
14
+ let nodeHandler;
15
+ async function build(assets) {
16
+ const runtimeManifest = await loadManifest(assets);
17
+ return createHandler(options.config, options.manifest, options.importMap, {
18
+ assets,
19
+ runtimeManifest,
20
+ });
21
+ }
22
+ function getHandler(env) {
23
+ if (!env) {
24
+ nodeHandler ??= build(nodeAssets).catch(err => {
25
+ nodeHandler = undefined;
26
+ throw err;
27
+ });
28
+ return nodeHandler;
29
+ }
30
+ let promise = handlers.get(env);
31
+ if (!promise) {
32
+ promise = build(createCloudflareAssets(env)).catch(err => {
33
+ handlers.delete(env);
34
+ throw err;
35
+ });
36
+ handlers.set(env, promise);
37
+ }
38
+ return promise;
39
+ }
40
+ return {
41
+ async fetch(request, env, _ctx) {
42
+ const handler = await getHandler(env);
43
+ return handler.fetch(request);
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,43 @@
1
+ import http from 'node:http';
2
+ export type ServeOptions = {
3
+ /**
4
+ * Port to listen on. Defaults to `process.env.PORT` or `3000`.
5
+ */
6
+ port?: number;
7
+ /**
8
+ * Host to bind to. Defaults to `0.0.0.0`.
9
+ */
10
+ host?: string;
11
+ /**
12
+ * Build output directory the handler reads from. Defaults to `dist`, resolved
13
+ * relative to the current working directory.
14
+ */
15
+ outDir?: string;
16
+ /**
17
+ * Explicit path to the built RSC entry. Defaults to `<outDir>/rsc/index.js`.
18
+ */
19
+ entry?: string;
20
+ /**
21
+ * Called once the server is listening.
22
+ */
23
+ onListen?: (info: {
24
+ port: number;
25
+ host: string;
26
+ }) => void;
27
+ };
28
+ export type Server = http.Server & AsyncDisposable;
29
+ /**
30
+ * Start a production HTTP server for a Node (or Bun) deployment.
31
+ *
32
+ * Loads the built RSC entry (`dist/rsc/index.js` by default) and forwards
33
+ * requests to its fetch handler, which already serves prerendered routes,
34
+ * client assets, and RSC/SSR responses. Run it from the project root so the
35
+ * handler can resolve `dist`. The returned server is `AsyncDisposable`, so it
36
+ * can be released with `await using`.
37
+ *
38
+ * Deno and Bun do not need this helper: use `Deno.serve(handler.fetch)` or
39
+ * `Bun.serve({ fetch: handler.fetch })` directly.
40
+ */
41
+ export declare function serve(options?: ServeOptions): Promise<http.Server<typeof http.IncomingMessage, typeof http.ServerResponse> & {
42
+ [Symbol.asyncDispose](): Promise<void>;
43
+ }>;