@foldkit/vite-plugin 0.15.0 → 0.16.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @foldkit/vite-plugin
2
2
 
3
- Vite plugin for Foldkit: view identity branding for the differ, plus hot module reloading with model preservation.
3
+ Vite plugin for Foldkit: view identity branding for the differ, plus hot module reloading with Model preservation.
4
4
 
5
5
  ## Installation
6
6
 
@@ -30,7 +30,7 @@ export default defineConfig({
30
30
 
31
31
  Foldkit's differ tracks two independent kinds of identity: user keys, which match siblings in dynamic lists, and a framework-managed identity, which decides whether a matched position is still the same thing. When the producing view function changes, the differ replaces the node instead of patching it, so DOM state cannot bleed across an identity change. Branches rendered inline by one view function share that function's identity and patch in place, exactly as same-type elements do in React; extracting the branches into named view functions makes them identity boundaries.
32
32
 
33
- This plugin supplies that identity. At build time, in dev and production alike, it wraps every function return in your application modules with a branding call that stamps returned vnodes with the function's id (module path plus function name), set-if-absent. Identity therefore attaches at view-function boundaries, and any branching syntax behaves the same: if/else, ternaries, Effect Match, switch statements, and pattern-matching libraries are all equivalent, because identity belongs to the function that produced the subtree, not to the branch that selected it.
33
+ This plugin supplies that identity. At build time, in dev and production alike, it wraps every function return in your application modules with a branding call that stamps returned vnodes with the function's id (module path plus function name), set-if-absent. The identity is nothing else: it ships in the client bundle, so anything derived from the module's contents would be a published check against those contents. Identity therefore attaches at view-function boundaries, and any branching syntax behaves the same: if/else, ternaries, Effect Match, switch statements, and pattern-matching libraries are all equivalent, because identity belongs to the function that produced the subtree, not to the branch that selected it.
34
34
 
35
35
  Foldkit core modules are never instrumented, and functions that never return vnodes are wrapped inertly. Builds without this plugin fall back to positional matching plus keys, where branch points need hand-written keys.
36
36
 
@@ -38,20 +38,20 @@ Foldkit core modules are never instrumented, and functions that never return vno
38
38
 
39
39
  When you save a file during development, the plugin:
40
40
 
41
- 1. Preserves your application's current state (model)
41
+ 1. Preserves your application's current Model
42
42
  2. Triggers a full page reload
43
- 3. Restores the preserved model after reload
43
+ 3. Restores the preserved Model after reload
44
44
 
45
- This means you can make code changes without losing your application's state - forms stay filled, counters keep their values, game positions are maintained, etc.
45
+ Code changes do not reset the application. Forms stay filled, counters keep their values, and games keep their positions.
46
46
 
47
47
  ## How it works
48
48
 
49
49
  The plugin uses Vite's WebSocket connection to communicate between the dev server and browser:
50
50
 
51
- - **On file change**: The browser sends the current model to the Vite server for preservation
52
- - **On reload**: The browser requests the preserved model from the server and initializes the Foldkit runtime with it
51
+ - **On file change**: The browser sends the current Model to the Vite server for preservation.
52
+ - **On reload**: The browser requests the preserved Model from the server and initializes the Foldkit Runtime with it.
53
53
 
54
- Model is preserved across hot reloads but cleared on manual browser refreshes, giving you control over when to reset your app.
54
+ The Model survives hot reloads but clears on a manual browser refresh, so a refresh still resets the application.
55
55
 
56
56
  ## Server rendering dev host
57
57
 
@@ -61,13 +61,52 @@ Pass `ssr` with the path to your server entry to render page requests through it
61
61
  plugins: [foldkit({ ssr: { serverEntry: '/src/entry.server.ts' } })]
62
62
  ```
63
63
 
64
- With this set, the dev server converts HTML page requests to Web `Request` values, passes them to the entry's `renderPage`, and serves the returned `Response`. Vite continues to serve the client entry, HMR, and assets, and the server entry runs through Vite's module graph, so edits to it apply without a restart. The client side of the handoff needs no plugin configuration: a server-rendered application's client entry calls `Runtime.hydrate` instead of `Runtime.run`, and hydrate adopts the served HTML in place. The option shapes only the dev server; production hosts import the built server entry themselves. See the [Server Rendering documentation](https://foldkit.dev/core/server-rendering) for the full contract.
64
+ With this set, the dev server converts HTML page requests to Web `Request` values, passes them to the entry's `renderPage`, and serves the returned `Response`. The request URL retains Vite's configured `base` prefix and the browser's query string. Vite continues to serve the client entry, HMR, and assets, and the server entry runs through Vite's module graph, so edits to it apply without a restart. The client side of the handoff needs no plugin configuration: a server-rendered application's client entry calls `Runtime.hydrate` instead of `Runtime.run`. Hydration adopts matching DOM, rebuilds mismatched subtrees, and refuses an invalid or cross-deployment handoff. The option shapes only the dev server; production hosts import the built server entry themselves. See the [Server Rendering documentation](https://foldkit.dev/core/server-rendering) for the full contract.
65
+
66
+ Vite retains ownership of configured proxy routes before Foldkit handles application requests. Vite's `server.cors` option applies to Vite-owned source modules, assets, and HMR. It does not add headers to application responses or answer their preflights. Preflight ownership follows `Access-Control-Request-Method`, so a preflight for an application `POST` reaches `renderPage` even when its path looks like an asset. An `OPTIONS` request without both `Origin` and `Access-Control-Request-Method` is not a preflight and also reaches `renderPage`. Define application CORS in `renderPage`, where development and the deployed host share one policy. Vite's `allowedHosts` check runs before proxy and application handling, including `OPTIONS` and methods the Web `Request` API cannot represent.
67
+
68
+ ## Build id
69
+
70
+ The build id does not make hydration correct. It makes hydration refuse when it would otherwise be incorrect.
71
+
72
+ Server-rendered HTML carries the deployment id, and the client bundle carries its own copy. `Runtime.hydrate` compares them before reading the Flags payload or adopting DOM. When they differ, startup stops and the document body is marked `inert`, `aria-hidden`, and `data-foldkit-refused`. A nondismissable modal shield covers its controls and existing top-layer content, then takes focus without closing author-owned dialogs.
73
+
74
+ Nothing moves, so no custom element reconnects and no frame reloads. The containment blocks native page interaction; it is not a script or global-event sandbox. A client already running in an open tab is not rechecked when a deployment lands because the comparison happens only when a client boots against a page.
75
+
76
+ The plugin compiles the id into application code as `import.meta.env.FOLDKIT_BUILD_ID`, from its `buildId` option or from the `FOLDKIT_BUILD_ID` environment variable:
77
+
78
+ ```typescript
79
+ plugins: [foldkit({ buildId: process.env.DEPLOYMENT_SHA })]
80
+ ```
81
+
82
+ The entries pass it explicitly, because Vite externalizes an installed dependency from a server build, where a compile-time define never reaches the framework itself:
83
+
84
+ ```typescript
85
+ // src/entry.server.ts
86
+ Server.renderToString(config, {
87
+ flags,
88
+ buildId: import.meta.env.FOLDKIT_BUILD_ID,
89
+ })
90
+
91
+ // src/entry.ts
92
+ Runtime.hydrate(application, { buildId: import.meta.env.FOLDKIT_BUILD_ID })
93
+ ```
94
+
95
+ Use a public value the deployment already has, such as a commit, release tag, or container digest. Three things have to be true:
96
+
97
+ - The id appears in the HTML every visitor receives, so it must never contain a secret.
98
+ - Two deployments must never share an id.
99
+ - The same value must reach the client and server builds, which run as separate commands.
100
+
101
+ A hydratable render given no id fails with `MissingBuildId`. Only a build takes the id from the deployment. The dev server compiles a fixed one because one live source session supplies both transforms and has no deployment identity to derive.
102
+
103
+ The standalone `foldkitSsr({ serverEntry, buildId })` export compiles the same define for its server entry. When it runs in development without an explicit value, it uses the fixed development id too. The aggregate `foldkit({ buildId, ssr })` plugin passes its top-level value through automatically.
65
104
 
66
105
  ## DevTools overlay
67
106
 
68
107
  When `@foldkit/devtools` is installed as a development dependency, the plugin mounts its overlay automatically during development and leaves it out of production builds. No application import or `devTools.overlay` field is needed.
69
108
 
70
- To include the overlay in production, list `@foldkit/devtools` in regular `dependencies` and set `devTools.show` to `'Always'`. Dependency placement controls whether Vite includes the overlay, and `show` controls whether the Foldkit runtime mounts it.
109
+ To include the overlay in production, list `@foldkit/devtools` in regular `dependencies` and set `devTools.show` to `'Always'`. Dependency placement controls whether Vite includes the overlay, and `show` controls whether the Foldkit Runtime mounts it.
71
110
 
72
111
  ## DevTools MCP relay
73
112
 
@@ -77,7 +116,7 @@ Pass `devToolsMcpPort` to enable the relay that exposes your running Foldkit app
77
116
  plugins: [foldkit({ devToolsMcpPort: 9988 })]
78
117
  ```
79
118
 
80
- When set, the plugin opens a separate WebSocket server on the given port. The MCP server connects to it and forwards typed `Request` and `Response` frames between AI agents and your runtime. Without `devToolsMcpPort` (the default), the relay is not started and the plugin behaves exactly as before.
119
+ When set, the plugin opens a separate WebSocket server on the given port. The MCP server connects to it and forwards typed `Request` and `Response` frames between AI agents and your Runtime. Without `devToolsMcpPort` (the default), the relay is not started and the plugin behaves exactly as before.
81
120
 
82
121
  See the [DevTools MCP documentation](https://foldkit.dev/ai/mcp) for setup, the available tools, and how dispatch validation works.
83
122
 
@@ -0,0 +1,26 @@
1
+ import type { Plugin } from 'vite';
2
+ /** The build id this build was given, from the plugin option or
3
+ * `FOLDKIT_BUILD_ID`, or `undefined` when the deployment supplied neither.
4
+ *
5
+ * @internal Exported for tests.
6
+ */
7
+ export declare const resolveBuildId: (configured?: string) => string | undefined;
8
+ /** The value `import.meta.env.FOLDKIT_BUILD_ID` compiles to, or `undefined`
9
+ * when a build was given no id and must refuse to render a hydratable page.
10
+ *
11
+ * @internal Exported for tests.
12
+ */
13
+ export declare const buildIdForCommand: (command: "build" | "serve", configured?: string) => string | undefined;
14
+ /**
15
+ * Compiles the deployment's build id into application code as
16
+ * `import.meta.env.FOLDKIT_BUILD_ID`, for the client entry and the server entry
17
+ * to hand to `Runtime.hydrate` and `renderToString`.
18
+ *
19
+ * A build takes the id from the `buildId` option or `FOLDKIT_BUILD_ID` and
20
+ * compiles nothing when it was given neither, so a hydratable render fails with
21
+ * `MissingBuildId` rather than serving a page hydration cannot place.
22
+ * Development serves a fixed id instead because one live source session
23
+ * supplies both transforms and has no deployment identity to derive.
24
+ */
25
+ export declare const foldkitBuildToken: (buildId?: string) => Plugin;
26
+ //# sourceMappingURL=buildToken.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"buildToken.d.ts","sourceRoot":"","sources":["../src/buildToken.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAA;AAqClC;;;;GAIG;AACH,eAAO,MAAM,cAAc,GAAI,aAAa,MAAM,KAAG,MAAM,GAAG,SAQ7D,CAAA;AAcD;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAC5B,SAAS,OAAO,GAAG,OAAO,EAC1B,aAAa,MAAM,KAClB,MAAM,GAAG,SAMX,CAAA;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,GAAI,UAAU,MAAM,KAAG,MAYnD,CAAA"}
@@ -0,0 +1,94 @@
1
+ // The build id names the deployment a page came from. The server stamps it on
2
+ // the rendered root, the client carries it, and hydration refuses a page whose
3
+ // id is not its own before it adopts any DOM.
4
+ //
5
+ // Per-view identities cannot answer that question. They move when the view they
6
+ // name changes, but what a view renders also depends on the constants it
7
+ // imports, the configuration it reads, the dependencies it calls, and the
8
+ // arguments its caller passes. A component whose own source is untouched renders
9
+ // something different when its caller changes, and its identity is the one that
10
+ // wins on the element, so a stale page's `<input name="email">` can otherwise be
11
+ // adopted for a new build's `<input name="ssn">`, carrying what a visitor typed
12
+ // into a field that submits under a different name.
13
+ //
14
+ // The id is supplied by the deployment rather than derived from the project.
15
+ // Deriving it was tried and does not hold: a digest of the files under the Vite
16
+ // root misses shared modules from elsewhere in a monorepo, untracked inputs, and
17
+ // environment-derived configuration, so two deployments that render differently
18
+ // can share an id; it moves when a build writes output the next build reads, so
19
+ // one deployment can produce two ids; and hashing whatever files happen to sit
20
+ // in the project turns a value published in HTML into an oracle for the secrets
21
+ // among them. A value the deployment already has (a commit, a release tag, a
22
+ // container digest) has none of those problems.
23
+ //
24
+ // This plugin only compiles the id into application code. Foldkit itself is an
25
+ // ordinary dependency that Vite externalizes from a server build, where a
26
+ // compile-time define never reaches it, so the id is handed to `renderToString`
27
+ // and `Runtime.hydrate` explicitly rather than read from inside the framework.
28
+ //
29
+ // Development is compiled an id too. The dev SSR host renders through
30
+ // `renderToString` like any other, and a hydratable render refuses to run
31
+ // without one, so leaving development unnamed would fail every dev page
32
+ // request.
33
+ const BUILD_ID_ENVIRONMENT_VARIABLE = 'FOLDKIT_BUILD_ID';
34
+ /** The build id this build was given, from the plugin option or
35
+ * `FOLDKIT_BUILD_ID`, or `undefined` when the deployment supplied neither.
36
+ *
37
+ * @internal Exported for tests.
38
+ */
39
+ export const resolveBuildId = (configured) => {
40
+ if (configured !== undefined && configured !== '') {
41
+ return configured;
42
+ }
43
+ const fromEnvironment = process.env[BUILD_ID_ENVIRONMENT_VARIABLE];
44
+ return fromEnvironment !== undefined && fromEnvironment !== ''
45
+ ? fromEnvironment
46
+ : undefined;
47
+ };
48
+ // The id development serves. Development is the one place a constant is right:
49
+ // one live source session supplies both the server and client transforms rather
50
+ // than producing independently deployable artifacts. A value that moved would
51
+ // only make the dev server disagree with the tab already open against it. A
52
+ // hydratable render still requires an id, so development has to be given one
53
+ // rather than left without.
54
+ //
55
+ // It is exactly wrong for a build, which is why a build with no id is refused
56
+ // rather than defaulted: two deployments sharing an id is the case the id
57
+ // exists to catch.
58
+ const DEVELOPMENT_BUILD_ID = 'development';
59
+ /** The value `import.meta.env.FOLDKIT_BUILD_ID` compiles to, or `undefined`
60
+ * when a build was given no id and must refuse to render a hydratable page.
61
+ *
62
+ * @internal Exported for tests.
63
+ */
64
+ export const buildIdForCommand = (command, configured) => {
65
+ const resolved = resolveBuildId(configured);
66
+ if (resolved !== undefined) {
67
+ return resolved;
68
+ }
69
+ return command === 'serve' ? DEVELOPMENT_BUILD_ID : undefined;
70
+ };
71
+ /**
72
+ * Compiles the deployment's build id into application code as
73
+ * `import.meta.env.FOLDKIT_BUILD_ID`, for the client entry and the server entry
74
+ * to hand to `Runtime.hydrate` and `renderToString`.
75
+ *
76
+ * A build takes the id from the `buildId` option or `FOLDKIT_BUILD_ID` and
77
+ * compiles nothing when it was given neither, so a hydratable render fails with
78
+ * `MissingBuildId` rather than serving a page hydration cannot place.
79
+ * Development serves a fixed id instead because one live source session
80
+ * supplies both transforms and has no deployment identity to derive.
81
+ */
82
+ export const foldkitBuildToken = (buildId) => ({
83
+ name: 'foldkit:build-token',
84
+ config: (_config, { command }) => {
85
+ const resolved = buildIdForCommand(command, buildId);
86
+ return resolved === undefined
87
+ ? {}
88
+ : {
89
+ define: {
90
+ 'import.meta.env.FOLDKIT_BUILD_ID': JSON.stringify(resolved),
91
+ },
92
+ };
93
+ },
94
+ });
package/dist/index.d.ts CHANGED
@@ -18,7 +18,21 @@ export type FoldkitPluginOptions = Readonly<{
18
18
  * `renderPage` from the module at `ssr.serverEntry`. When `undefined` (the
19
19
  * default), the dev server serves the client entry only.
20
20
  */
21
- ssr?: FoldkitSsrOptions;
21
+ ssr?: Omit<FoldkitSsrOptions, 'buildId'>;
22
+ /**
23
+ * The deployment this build belongs to, compiled into application code as
24
+ * `import.meta.env.FOLDKIT_BUILD_ID` for the entries to pass to
25
+ * `renderToString` and `Runtime.hydrate`. Hydration compares it against the id
26
+ * the server stamped and refuses a page from another deployment rather than
27
+ * adopting it: startup stops and the page is contained, with the document's
28
+ * body marked `inert`.
29
+ *
30
+ * Defaults to the `FOLDKIT_BUILD_ID` environment variable. Use a value the
31
+ * deployment already has, such as a commit or a release tag, and give the
32
+ * client build and the server build the same one. It is published in the
33
+ * page, so it must not be a secret.
34
+ */
35
+ buildId?: string;
22
36
  }>;
23
37
  /**
24
38
  * Foldkit's Vite plugin set: the view-identity branding transform and
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAoCA,OAAO,KAAK,EACV,MAAM,EAIP,MAAM,MAAM,CAAA;AAIb,OAAO,EAAE,KAAK,iBAAiB,EAAc,MAAM,UAAU,CAAA;AAG7D,OAAO,EAAE,KAAK,eAAe,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AACzE,OAAO,EAAE,KAAK,iBAAiB,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAC7D,OAAO,EACL,KAAK,2BAA2B,EAChC,mBAAmB,EACnB,qBAAqB,GACtB,MAAM,mBAAmB,CAAA;AAE1B,6CAA6C;AAC7C,MAAM,MAAM,oBAAoB,GAAG,QAAQ,CAAC;IAC1C;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,iBAAiB,CAAA;CACxB,CAAC,CAAA;AAilBF;;;;;;GAMG;AACH,eAAO,MAAM,OAAO,GAAI,UAAS,oBAAyB,KAAG,KAAK,CAAC,MAAM,CAoExE,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAoCA,OAAO,KAAK,EACV,MAAM,EAIP,MAAM,MAAM,CAAA;AAKb,OAAO,EAAE,KAAK,iBAAiB,EAAc,MAAM,UAAU,CAAA;AAG7D,OAAO,EAAE,KAAK,eAAe,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AACzE,OAAO,EAAE,KAAK,iBAAiB,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAC7D,OAAO,EACL,KAAK,2BAA2B,EAChC,mBAAmB,EACnB,qBAAqB,GACtB,MAAM,mBAAmB,CAAA;AAE1B,6CAA6C;AAC7C,MAAM,MAAM,oBAAoB,GAAG,QAAQ,CAAC;IAC1C;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,IAAI,CAAC,iBAAiB,EAAE,SAAS,CAAC,CAAA;IACxC;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAC,CAAA;AAilBF;;;;;;GAMG;AACH,eAAO,MAAM,OAAO,GAAI,UAAS,oBAAyB,KAAG,KAAK,CAAC,MAAM,CA+ExE,CAAA"}
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { PreserveModelMessage, RequestModelMessage, RestoreModelMessage, } from
4
4
  import { createRequire } from 'node:module';
5
5
  import { resolve } from 'node:path';
6
6
  import { WebSocketServer } from 'ws';
7
+ import { foldkitBuildToken } from './buildToken.js';
7
8
  import { devToolsOverlayPlugin } from './devToolsOverlay.js';
8
9
  import { foldkitSsr } from './ssr.js';
9
10
  import { foldkitViewIdentity } from './viewIdentity.js';
@@ -377,11 +378,22 @@ export const foldkit = (options = {}) => {
377
378
  },
378
379
  };
379
380
  return options.ssr === undefined
380
- ? [foldkitViewIdentity(), devToolsOverlayPlugin(), hmrPlugin]
381
+ ? [
382
+ foldkitBuildToken(options.buildId),
383
+ foldkitViewIdentity(),
384
+ devToolsOverlayPlugin(),
385
+ hmrPlugin,
386
+ ]
381
387
  : [
388
+ foldkitBuildToken(options.buildId),
382
389
  foldkitViewIdentity(),
383
390
  devToolsOverlayPlugin(),
384
391
  hmrPlugin,
385
- foldkitSsr(options.ssr),
392
+ foldkitSsr({
393
+ ...options.ssr,
394
+ ...(options.buildId === undefined
395
+ ? {}
396
+ : { buildId: options.buildId }),
397
+ }),
386
398
  ];
387
399
  };
package/dist/ssr.d.ts CHANGED
@@ -13,15 +13,41 @@ export type FoldkitSsrOptions = Readonly<{
13
13
  * markup replaces. Defaults to `'root'`.
14
14
  */
15
15
  containerId?: string;
16
+ /**
17
+ * The origin the entry sees as `Request.url`, such as
18
+ * `'https://app.example'`. Defaults to the origin the dev server itself
19
+ * resolved from its own configuration.
20
+ *
21
+ * The origin is deployment configuration rather than something a request
22
+ * carries. A client chooses its own `Host` header, and Vite accepts IP
23
+ * literals and (with `allowedHosts`) arbitrary names, so deriving the origin
24
+ * from the request would let the client pick the redirects, canonical URLs,
25
+ * and cookie domains an entry builds from `Request.url`. Set this when the
26
+ * dev server sits behind a proxy or TLS terminator that serves a different
27
+ * public origin.
28
+ */
29
+ origin?: string;
30
+ /**
31
+ * The deployment id compiled into a server entry when `foldkitSsr` is used
32
+ * as a standalone plugin. It defaults to `FOLDKIT_BUILD_ID`, or to the fixed
33
+ * development id in serve mode. The aggregate `foldkit` plugin supplies its
34
+ * top-level `buildId` here automatically.
35
+ *
36
+ * The value is public in rendered HTML, so it must not be a secret.
37
+ */
38
+ buildId?: string;
16
39
  }>;
17
40
  /**
18
41
  * Serves server-rendered pages from the Vite dev server.
19
42
  *
20
- * Registered after Vite's own middleware, so the client entry, HMR, and
21
- * assets are untouched. HTML navigations that fall through, plus non-GET
22
- * requests, load the server entry through Vite's SSR module loader, call its
23
- * `renderPage` with a Web `Request`, and send the resulting Web `Response`.
24
- * Server entry edits take effect without a restart.
43
+ * Vite retains ownership of configured proxies, source modules, HMR, and
44
+ * assets. Requests that fall through load the server entry through Vite's SSR
45
+ * module loader, call its `renderPage` with a Web `Request`, and send the
46
+ * resulting Web `Response`. Vite's host validation applies before either
47
+ * owner. Foldkit then validates and normalizes the request target before Vite
48
+ * or the server entry can resolve it. Vite's CORS option applies only to
49
+ * Vite-owned responses, while the server entry owns CORS for application
50
+ * responses. Server entry edits take effect without a restart.
25
51
  */
26
52
  export declare const foldkitSsr: (options: FoldkitSsrOptions) => Plugin;
27
53
  //# sourceMappingURL=ssr.d.ts.map
package/dist/ssr.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"ssr.d.ts","sourceRoot":"","sources":["../src/ssr.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAW,MAAM,EAAiB,MAAM,MAAM,CAAA;AAE1D,0EAA0E;AAC1E,MAAM,MAAM,iBAAiB,GAAG,QAAQ,CAAC;IACvC;;;;;OAKG;IACH,WAAW,EAAE,MAAM,CAAA;IACnB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,CAAC,CAAA;AAyLF;;;;;;;;GAQG;AACH,eAAO,MAAM,UAAU,GAAI,SAAS,iBAAiB,KAAG,MA+CtD,CAAA"}
1
+ {"version":3,"file":"ssr.d.ts","sourceRoot":"","sources":["../src/ssr.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAW,MAAM,EAA+B,MAAM,MAAM,CAAA;AAIxE,0EAA0E;AAC1E,MAAM,MAAM,iBAAiB,GAAG,QAAQ,CAAC;IACvC;;;;;OAKG;IACH,WAAW,EAAE,MAAM,CAAA;IACnB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAC,CAAA;AA4rBF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,UAAU,GAAI,SAAS,iBAAiB,KAAG,MA4CvD,CAAA"}
package/dist/ssr.js CHANGED
@@ -1,12 +1,41 @@
1
1
  import { Array, Effect, Predicate } from 'effect';
2
- import { Server } from 'foldkit/experimental';
2
+ import * as Server from 'foldkit/experimental/server';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { resolve } from 'node:path';
5
5
  import { Readable } from 'node:stream';
6
+ import { buildIdForCommand } from './buildToken.js';
6
7
  const isEntryModule = (loadedModule) => Predicate.isObject(loadedModule) &&
7
8
  Predicate.hasProperty(loadedModule, 'renderPage') &&
8
9
  Predicate.isFunction(loadedModule.renderPage);
9
- const toWebRequest = (url, nodeRequest) => {
10
+ // The origin the dev server serves, taken from the plugin option when the
11
+ // deployment sets one and otherwise from the server's own resolved
12
+ // configuration. The request never contributes: `Host` is a value the client
13
+ // writes, and Vite accepts IP literals by default and any name at all under
14
+ // `allowedHosts`, so a request could otherwise name the origin the entry builds
15
+ // redirects and canonical URLs from.
16
+ const DEV_SERVER_FALLBACK_ORIGIN = 'http://localhost';
17
+ const configuredOrigin = (server, options) => {
18
+ if (options.origin !== undefined) {
19
+ return options.origin;
20
+ }
21
+ const [resolvedUrl] = server.resolvedUrls?.local ?? [];
22
+ if (resolvedUrl !== undefined) {
23
+ // Vite prints these with a trailing slash; `new URL` normalizes either form
24
+ // and `origin` drops the path, so both are safe to read here.
25
+ try {
26
+ return new URL(resolvedUrl).origin;
27
+ }
28
+ catch {
29
+ return DEV_SERVER_FALLBACK_ORIGIN;
30
+ }
31
+ }
32
+ const { https, port } = server.config.server;
33
+ const scheme = https === undefined ? 'http' : 'https';
34
+ return port === undefined
35
+ ? DEV_SERVER_FALLBACK_ORIGIN
36
+ : `${scheme}://localhost:${port}`;
37
+ };
38
+ const toWebRequest = (requestUrl, nodeRequest) => {
10
39
  const headers = new Headers();
11
40
  for (const [name, value] of Object.entries(nodeRequest.headers)) {
12
41
  if (Predicate.isString(value)) {
@@ -18,7 +47,6 @@ const toWebRequest = (url, nodeRequest) => {
18
47
  }
19
48
  }
20
49
  }
21
- const host = nodeRequest.headers.host ?? 'localhost';
22
50
  const method = nodeRequest.method ?? 'GET';
23
51
  const requestInit = { headers, method };
24
52
  if (method !== 'GET' && method !== 'HEAD') {
@@ -28,27 +56,76 @@ const toWebRequest = (url, nodeRequest) => {
28
56
  requestInit.body = Readable.toWeb(nodeRequest);
29
57
  requestInit.duplex = 'half';
30
58
  }
31
- return new Request(new URL(url, `http://${host}`), requestInit);
59
+ return new Request(requestUrl, requestInit);
32
60
  };
33
- const renderDecision = (nodeRequest) => {
61
+ const originalRequestTargetOf = (nodeRequest) => nodeRequest.originalUrl ?? nodeRequest.url ?? '/';
62
+ const requestTargetOf = (nodeRequest) => nodeRequest.url ?? nodeRequest.originalUrl ?? '/';
63
+ // The request target resolved against the configured origin, or `undefined`
64
+ // when it names a different one (an absolute-form target, or a network-path
65
+ // reference such as `//elsewhere.example/page`). The dev host refuses those
66
+ // rather than handing the entry an origin the client chose, which is what a
67
+ // generated production host does too.
68
+ const resolvedRequestUrl = (server, options, nodeRequest) => Server.resolveRequestUrl(originalRequestTargetOf(nodeRequest), configuredOrigin(server, options)) === undefined
69
+ ? undefined
70
+ : Server.resolveRequestUrl(requestTargetOf(nodeRequest), configuredOrigin(server, options));
71
+ const prepareRequestTarget = (server, options, requestUrls, nodeRequest) => {
72
+ const requestUrl = requestUrls.get(nodeRequest) ??
73
+ resolvedRequestUrl(server, options, nodeRequest);
74
+ if (requestUrl === undefined) {
75
+ return undefined;
76
+ }
77
+ requestUrls.set(nodeRequest, requestUrl);
78
+ const resolved = new URL(requestUrl);
79
+ nodeRequest.url = `${resolved.pathname}${resolved.search}`;
80
+ return requestUrl;
81
+ };
82
+ const requestTargetMiddleware = (server, options, requestUrls, render) => (nodeRequest, nodeResponse, next) => {
83
+ const requestUrl = prepareRequestTarget(server, options, requestUrls, nodeRequest);
84
+ if (requestUrl === undefined) {
85
+ nodeResponse.statusCode = 400;
86
+ nodeResponse.end();
87
+ return;
88
+ }
89
+ if (nodeRequest.method === 'OPTIONS') {
90
+ const proxy = server.config.server.proxy;
91
+ if (isProxyRequest(nodeRequest, proxy) ||
92
+ shouldViteCorsAnswerPreflight(nodeRequest, proxy, server.config.base)) {
93
+ next();
94
+ return;
95
+ }
96
+ render(nodeRequest, nodeResponse, next);
97
+ return;
98
+ }
99
+ next();
100
+ };
101
+ const renderDecision = (nodeRequest, requestUrl) => {
34
102
  const method = nodeRequest.method ?? 'GET';
35
- const url = nodeRequest.originalUrl ?? nodeRequest.url ?? '/';
36
103
  if (method === 'GET' || method === 'HEAD') {
37
- if (Server.resolvesToIndexHtml(url)) {
104
+ if (Server.resolvesToIndexHtml(requestUrl)) {
38
105
  return 'Render';
39
106
  }
107
+ // A request Vite did not serve and that names an asset is a miss, not a
108
+ // navigation. Browsers fetch scripts and stylesheets with `Accept: */*`, so
109
+ // without this a stale hashed asset would be answered with the app shell at
110
+ // 200 and read as a blank page instead of the 404 it is.
111
+ const fetchDestination = nodeRequest.headers['sec-fetch-dest'];
112
+ const classification = Server.classifyRequest(requestUrl, Predicate.isString(fetchDestination) ? fetchDestination : undefined);
113
+ if (classification === 'PathAsset') {
114
+ return 'RefusedPathAsset';
115
+ }
116
+ if (classification === 'DestinationAsset') {
117
+ return 'RefusedDestinationAsset';
118
+ }
40
119
  const accept = nodeRequest.headers.accept;
41
120
  return Server.acceptsHtml(Predicate.isString(accept) ? accept : undefined)
42
- ? 'RenderVaryAccept'
43
- : 'RefusedVaryAccept';
44
- }
45
- if (method === 'OPTIONS' || method === 'TRACE') {
46
- return 'Skip';
121
+ ? 'RenderNegotiated'
122
+ : 'RefusedNegotiated';
47
123
  }
48
124
  return 'Render';
49
125
  };
50
- const renderRequest = (server, options, nodeRequest) => Effect.gen(function* () {
51
- const url = nodeRequest.originalUrl ?? nodeRequest.url ?? '/';
126
+ const renderRequest = (server, options, nodeRequest, requestUrl) => Effect.gen(function* () {
127
+ const { pathname, search } = new URL(requestUrl);
128
+ const route = `${pathname}${search}`;
52
129
  const rawTemplate = yield* Effect.promise(() => readFile(resolve(server.config.root, 'index.html'), 'utf-8'));
53
130
  // NOTE: the first argument tells Vite where the HTML lives, and Vite
54
131
  // resolves the template's relative URLs (such as a `./src/entry.ts`
@@ -56,12 +133,12 @@ const renderRequest = (server, options, nodeRequest) => Effect.gen(function* ()
56
133
  // that argument must stay `/index.html` no matter which route is being
57
134
  // rendered. The third argument, named `originalUrl` in Vite's signature,
58
135
  // carries the route actually being requested.
59
- const template = yield* Effect.promise(() => server.transformIndexHtml('/index.html', rawTemplate, url));
136
+ const template = yield* Effect.promise(() => server.transformIndexHtml('/index.html', rawTemplate, route));
60
137
  const loadedModule = yield* Effect.promise(() => server.ssrLoadModule(options.serverEntry));
61
138
  if (!isEntryModule(loadedModule)) {
62
139
  return yield* Effect.die(new Error(`[foldkit] '${options.serverEntry}' does not export a renderPage function, so the dev server cannot render pages.`));
63
140
  }
64
- const result = yield* Effect.promise(() => loadedModule.renderPage(toWebRequest(url, nodeRequest)));
141
+ const result = yield* Effect.promise(() => loadedModule.renderPage(toWebRequest(requestUrl, nodeRequest)));
65
142
  return Server.toResponse(template, result, options.containerId === undefined
66
143
  ? {}
67
144
  : { containerId: options.containerId });
@@ -75,7 +152,203 @@ const varyHeaderValue = (value) => {
75
152
  }
76
153
  return undefined;
77
154
  };
78
- const sendWebResponse = async (webResponse, nodeRequest, nodeResponse, varyAccept) => {
155
+ // NOTE: every outcome a static miss negotiates declares both headers the
156
+ // negotiation read. Declaring only Accept would let a shared cache store the
157
+ // rendered page from a document request and serve it to a later script request
158
+ // carrying the same Accept, which never reaches the asset classification, and
159
+ // the reverse for the 404.
160
+ // Every field name in `incoming`, folded into `existing`. The application's own
161
+ // Vary is a list, and `Server.varyWith` merges one name at a time.
162
+ const mergeVary = (existing, incoming) => incoming
163
+ .split(',')
164
+ .map(token => token.trim())
165
+ .filter(token => token !== '')
166
+ .reduce((merged, fieldName) => Server.varyWith(merged, fieldName), existing ?? '');
167
+ const isHeaderList = (value) => typeof value === 'object';
168
+ const cloneHeaderValue = (value) => isHeaderList(value) ? [...value] : value;
169
+ const responseHeaders = (nodeResponse) => Object.fromEntries(Object.entries(nodeResponse.getHeaders()).map(([name, value]) => [
170
+ name,
171
+ cloneHeaderValue(value),
172
+ ]));
173
+ const isSameHeaderValue = (left, right) => {
174
+ if (isHeaderList(left) && isHeaderList(right)) {
175
+ return (left.length === right.length &&
176
+ left.every((value, index) => value === right.at(index)));
177
+ }
178
+ return left === right;
179
+ };
180
+ const headerMutations = (before, after) => Object.keys(after)
181
+ .filter(name => !isSameHeaderValue(before[name], after[name]))
182
+ .map(name => ({ name, before: before[name], after: after[name] }));
183
+ const varyFields = (value) => {
184
+ if (typeof value === 'string') {
185
+ return value.split(',').map(fieldName => fieldName.trim());
186
+ }
187
+ if (isHeaderList(value)) {
188
+ return value.flatMap(item => item.split(',').map(fieldName => fieldName.trim()));
189
+ }
190
+ return [];
191
+ };
192
+ const restoreVary = (nodeResponse, mutation) => {
193
+ const fieldsBeforeCors = new Set(varyFields(mutation.before).map(fieldName => fieldName.toLowerCase()));
194
+ const fieldsAddedByCors = new Set(varyFields(mutation.after)
195
+ .filter(fieldName => !fieldsBeforeCors.has(fieldName.toLowerCase()))
196
+ .map(fieldName => fieldName.toLowerCase()));
197
+ const remainingFields = varyFields(nodeResponse.getHeader(mutation.name)).filter(fieldName => !fieldsAddedByCors.has(fieldName.toLowerCase()));
198
+ if (Array.isArrayEmpty(remainingFields)) {
199
+ nodeResponse.removeHeader(mutation.name);
200
+ }
201
+ else {
202
+ nodeResponse.setHeader(mutation.name, remainingFields.join(', '));
203
+ }
204
+ };
205
+ const restoreResponseHeaders = (nodeResponse, mutations, headersWrittenAfterCors) => {
206
+ for (const mutation of mutations) {
207
+ if (headersWrittenAfterCors.has(mutation.name.toLowerCase())) {
208
+ continue;
209
+ }
210
+ const current = cloneHeaderValue(nodeResponse.getHeader(mutation.name));
211
+ if (mutation.name.toLowerCase() === 'vary') {
212
+ restoreVary(nodeResponse, mutation);
213
+ }
214
+ else if (isSameHeaderValue(current, mutation.after)) {
215
+ if (mutation.before === undefined) {
216
+ nodeResponse.removeHeader(mutation.name);
217
+ }
218
+ else {
219
+ nodeResponse.setHeader(mutation.name, mutation.before);
220
+ }
221
+ }
222
+ }
223
+ };
224
+ const trackHeaderWrites = (nodeResponse) => {
225
+ const names = new Set();
226
+ const setHeader = nodeResponse.setHeader;
227
+ const appendHeader = nodeResponse.appendHeader;
228
+ const removeHeader = nodeResponse.removeHeader;
229
+ nodeResponse.setHeader = function (name, value) {
230
+ names.add(String(name).toLowerCase());
231
+ return Reflect.apply(setHeader, this, [name, value]);
232
+ };
233
+ nodeResponse.appendHeader = function (name, value) {
234
+ names.add(String(name).toLowerCase());
235
+ return Reflect.apply(appendHeader, this, [name, value]);
236
+ };
237
+ nodeResponse.removeHeader = function (name) {
238
+ names.add(String(name).toLowerCase());
239
+ return Reflect.apply(removeHeader, this, [name]);
240
+ };
241
+ return {
242
+ names,
243
+ stop: () => {
244
+ nodeResponse.setHeader = setHeader;
245
+ nodeResponse.appendHeader = appendHeader;
246
+ nodeResponse.removeHeader = removeHeader;
247
+ },
248
+ };
249
+ };
250
+ const isProxyRequest = (nodeRequest, proxy) => {
251
+ const requestUrl = nodeRequest.url;
252
+ if (requestUrl === undefined || proxy === undefined) {
253
+ return false;
254
+ }
255
+ return Object.entries(proxy).some(([context, proxyOptions]) => {
256
+ if (proxyOptions === undefined) {
257
+ return false;
258
+ }
259
+ return context.startsWith('^')
260
+ ? new RegExp(context).test(requestUrl)
261
+ : requestUrl.startsWith(context);
262
+ });
263
+ };
264
+ const VITE_SOURCE_EXTENSIONS = new Set([
265
+ 'astro',
266
+ 'cts',
267
+ 'jsx',
268
+ 'mdx',
269
+ 'mts',
270
+ 'svelte',
271
+ 'ts',
272
+ 'tsx',
273
+ 'vue',
274
+ ]);
275
+ const hasViteSourceExtension = (path) => {
276
+ const lastSegment = path.slice(path.lastIndexOf('/') + 1);
277
+ const separator = lastSegment.lastIndexOf('.');
278
+ return (separator >= 0 &&
279
+ VITE_SOURCE_EXTENSIONS.has(lastSegment.slice(separator + 1).toLowerCase()));
280
+ };
281
+ const shouldViteCorsAnswerPreflight = (nodeRequest, proxy, base) => {
282
+ if (isProxyRequest(nodeRequest, proxy)) {
283
+ return false;
284
+ }
285
+ const requestedMethod = nodeRequest.headers['access-control-request-method'];
286
+ const requestOrigin = nodeRequest.headers.origin;
287
+ if (!Predicate.isString(requestedMethod) ||
288
+ !Predicate.isString(requestOrigin) ||
289
+ requestOrigin === '') {
290
+ return false;
291
+ }
292
+ const normalizedMethod = requestedMethod.toUpperCase();
293
+ if (normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD') {
294
+ return false;
295
+ }
296
+ const requestTarget = requestTargetOf(nodeRequest);
297
+ const path = new URL(requestTarget, DEV_SERVER_FALLBACK_ORIGIN).pathname;
298
+ const vitePath = base !== '/' && path.startsWith(base) ? `/${path.slice(base.length)}` : path;
299
+ if (vitePath.startsWith('/@') ||
300
+ vitePath.startsWith('/__vite') ||
301
+ vitePath.startsWith('/node_modules/') ||
302
+ hasViteSourceExtension(vitePath)) {
303
+ return true;
304
+ }
305
+ const fetchDestination = nodeRequest.headers['sec-fetch-dest'];
306
+ return (Server.classifyRequest(requestTarget, Predicate.isString(fetchDestination) ? fetchDestination : undefined) !== 'Page');
307
+ };
308
+ const wrapViteCors = (server, options, requestUrls, stateByRequest) => {
309
+ if (server.config.server.cors === false) {
310
+ return;
311
+ }
312
+ const corsLayer = server.middlewares.stack.find(layer => Predicate.isFunction(layer.handle) &&
313
+ layer.handle.name.startsWith('corsMiddleware'));
314
+ if (corsLayer === undefined || !Predicate.isFunction(corsLayer.handle)) {
315
+ throw new Error('[foldkit] Could not find Vite\u2019s CORS middleware at the expected ' +
316
+ 'ownership boundary. This Vite version is not compatible with ' +
317
+ '@foldkit/vite-plugin.');
318
+ }
319
+ const viteCors = corsLayer.handle;
320
+ const wrappedCors = (nodeRequest, nodeResponse, next) => {
321
+ if (prepareRequestTarget(server, options, requestUrls, nodeRequest) ===
322
+ undefined) {
323
+ nodeResponse.statusCode = 400;
324
+ nodeResponse.end();
325
+ return;
326
+ }
327
+ if (nodeRequest.method === 'OPTIONS' &&
328
+ !shouldViteCorsAnswerPreflight(nodeRequest, server.config.server.proxy, server.config.base)) {
329
+ next();
330
+ return;
331
+ }
332
+ const before = responseHeaders(nodeResponse);
333
+ Reflect.apply(viteCors, undefined, [
334
+ nodeRequest,
335
+ nodeResponse,
336
+ (error) => {
337
+ stateByRequest.set(nodeRequest, {
338
+ mutations: headerMutations(before, responseHeaders(nodeResponse)),
339
+ tracking: trackHeaderWrites(nodeResponse),
340
+ });
341
+ next(error);
342
+ },
343
+ ]);
344
+ };
345
+ corsLayer.handle = wrappedCors;
346
+ };
347
+ const setNegotiatedVary = (nodeResponse) => {
348
+ const existing = varyHeaderValue(nodeResponse.getHeader('vary'));
349
+ nodeResponse.setHeader('vary', Server.varyWith(Server.varyWithAccept(existing), 'Sec-Fetch-Dest'));
350
+ };
351
+ const sendWebResponse = async (webResponse, nodeRequest, nodeResponse, isNegotiated) => {
79
352
  nodeResponse.statusCode = webResponse.status;
80
353
  if (webResponse.statusText !== '') {
81
354
  nodeResponse.statusMessage = webResponse.statusText;
@@ -87,17 +360,23 @@ const sendWebResponse = async (webResponse, nodeRequest, nodeResponse, varyAccep
87
360
  ? getSetCookie.call(webResponse.headers)
88
361
  : [];
89
362
  for (const [name, value] of webResponse.headers) {
90
- if (name !== 'set-cookie' || Array.isArrayEmpty(setCookieHeaders)) {
363
+ if (name === 'vary') {
364
+ // NOTE: merged rather than set. Middleware that ran before the entry may
365
+ // already have declared a field, and a plain setHeader here would replace
366
+ // it when the application declares its own Vary.
367
+ nodeResponse.setHeader('vary', mergeVary(varyHeaderValue(nodeResponse.getHeader('vary')), value));
368
+ }
369
+ else if (name !== 'set-cookie' || Array.isArrayEmpty(setCookieHeaders)) {
91
370
  nodeResponse.setHeader(name, value);
92
371
  }
93
372
  }
94
373
  if (Array.isArrayNonEmpty(setCookieHeaders)) {
95
374
  nodeResponse.setHeader('set-cookie', setCookieHeaders);
96
375
  }
97
- if (varyAccept) {
98
- // Merge into whatever Vary already sits on the node response (the app's
99
- // own, plus Vite's own Vary: Origin), so declaring Accept does not drop it.
100
- nodeResponse.setHeader('vary', Server.varyWithAccept(varyHeaderValue(nodeResponse.getHeader('vary'))));
376
+ if (isNegotiated) {
377
+ // Merge into whatever Vary already sits on the node response, including
378
+ // the application's own fields, so declaring these does not drop it.
379
+ setNegotiatedVary(nodeResponse);
101
380
  }
102
381
  if (nodeRequest.method === 'HEAD' || webResponse.body === null) {
103
382
  nodeResponse.end();
@@ -107,47 +386,105 @@ const sendWebResponse = async (webResponse, nodeRequest, nodeResponse, varyAccep
107
386
  nodeResponse.end(body);
108
387
  }
109
388
  };
389
+ // The handler that turns an application-owned request into a response. OPTIONS
390
+ // reaches it at the host boundary. Other methods reach it after Vite's modules
391
+ // and assets have had an opportunity to answer.
392
+ const renderMiddleware = (server, options, requestUrls, stateByRequest) => (nodeRequest, nodeResponse, next) => {
393
+ const corsState = stateByRequest.get(nodeRequest);
394
+ stateByRequest.delete(nodeRequest);
395
+ corsState?.tracking.stop();
396
+ restoreResponseHeaders(nodeResponse, corsState?.mutations ?? [], corsState?.tracking.names ?? new Set());
397
+ const requestUrl = requestUrls.get(nodeRequest) ??
398
+ resolvedRequestUrl(server, options, nodeRequest);
399
+ requestUrls.delete(nodeRequest);
400
+ if (requestUrl === undefined) {
401
+ // The target names an origin other than the one being served, so there is
402
+ // no request to render: answering it would hand the entry a client-chosen
403
+ // origin to build redirects and canonical URLs from.
404
+ nodeResponse.statusCode = 400;
405
+ nodeResponse.end();
406
+ return;
407
+ }
408
+ const method = nodeRequest.method ?? 'GET';
409
+ if (Server.isHostSettledMethod(method)) {
410
+ nodeResponse.statusCode = Server.HOST_METHOD_ANSWERS.refusedStatus;
411
+ nodeResponse.setHeader('allow', Server.HOST_METHOD_ANSWERS.allow);
412
+ nodeResponse.end();
413
+ return;
414
+ }
415
+ const decision = renderDecision(nodeRequest, requestUrl);
416
+ if (decision === 'RefusedPathAsset') {
417
+ // The path names an asset whatever the request headers say, so this
418
+ // refusal is the same for every client and needs no Vary.
419
+ nodeResponse.statusCode = 404;
420
+ nodeResponse.end();
421
+ return;
422
+ }
423
+ if (decision === 'RefusedDestinationAsset') {
424
+ nodeResponse.statusCode = 404;
425
+ setNegotiatedVary(nodeResponse);
426
+ nodeResponse.end();
427
+ return;
428
+ }
429
+ if (decision === 'RefusedNegotiated') {
430
+ nodeResponse.statusCode = 404;
431
+ setNegotiatedVary(nodeResponse);
432
+ nodeResponse.end();
433
+ return;
434
+ }
435
+ const isNegotiated = decision === 'RenderNegotiated';
436
+ void Effect.runPromise(renderRequest(server, options, nodeRequest, requestUrl))
437
+ .then(response => sendWebResponse(response, nodeRequest, nodeResponse, isNegotiated))
438
+ .catch((error) => {
439
+ if (error instanceof Error) {
440
+ server.ssrFixStacktrace(error);
441
+ }
442
+ next(error);
443
+ });
444
+ };
110
445
  /**
111
446
  * Serves server-rendered pages from the Vite dev server.
112
447
  *
113
- * Registered after Vite's own middleware, so the client entry, HMR, and
114
- * assets are untouched. HTML navigations that fall through, plus non-GET
115
- * requests, load the server entry through Vite's SSR module loader, call its
116
- * `renderPage` with a Web `Request`, and send the resulting Web `Response`.
117
- * Server entry edits take effect without a restart.
448
+ * Vite retains ownership of configured proxies, source modules, HMR, and
449
+ * assets. Requests that fall through load the server entry through Vite's SSR
450
+ * module loader, call its `renderPage` with a Web `Request`, and send the
451
+ * resulting Web `Response`. Vite's host validation applies before either
452
+ * owner. Foldkit then validates and normalizes the request target before Vite
453
+ * or the server entry can resolve it. Vite's CORS option applies only to
454
+ * Vite-owned responses, while the server entry owns CORS for application
455
+ * responses. Server entry edits take effect without a restart.
118
456
  */
119
- export const foldkitSsr = (options) => ({
120
- name: 'foldkit-ssr',
121
- // NOTE: `vite preview` also resolves with command 'serve', but it serves
122
- // built output and never runs configureServer, so applying there would
123
- // only set appType 'custom' and strip preview's HTML middleware.
124
- apply: (_config, env) => env.command === 'serve' && env.isPreview !== true,
125
- config: () => ({ appType: 'custom' }),
126
- configureServer: server => () => {
127
- server.middlewares.use((nodeRequest, nodeResponse, next) => {
128
- const decision = renderDecision(nodeRequest);
129
- if (decision === 'Skip') {
130
- next();
131
- return;
132
- }
133
- if (decision === 'RefusedVaryAccept') {
134
- // The 404 depends on Accept (an HTML-accepting client would have
135
- // rendered), so it varies on Accept for a shared cache, the same as
136
- // a production host's refused response.
137
- nodeResponse.statusCode = 404;
138
- nodeResponse.setHeader('vary', Server.varyWithAccept(varyHeaderValue(nodeResponse.getHeader('vary'))));
139
- nodeResponse.end();
140
- return;
141
- }
142
- const varyAccept = decision === 'RenderVaryAccept';
143
- void Effect.runPromise(renderRequest(server, options, nodeRequest))
144
- .then(response => sendWebResponse(response, nodeRequest, nodeResponse, varyAccept))
145
- .catch((error) => {
146
- if (error instanceof Error) {
147
- server.ssrFixStacktrace(error);
148
- }
149
- next(error);
150
- });
151
- });
152
- },
153
- });
457
+ export const foldkitSsr = (options) => {
458
+ return {
459
+ name: 'foldkit-ssr',
460
+ config: (_config, { command, isPreview }) => {
461
+ const buildId = buildIdForCommand(command, options.buildId);
462
+ return {
463
+ // NOTE: `vite preview` also resolves with command 'serve', but it
464
+ // serves built output and never runs configureServer. Setting custom
465
+ // there would strip preview's HTML middleware. A build still needs the
466
+ // define below even though only a dev server needs this app type.
467
+ ...(command === 'serve' && isPreview !== true
468
+ ? { appType: 'custom' }
469
+ : {}),
470
+ ...(buildId === undefined
471
+ ? {}
472
+ : {
473
+ define: {
474
+ 'import.meta.env.FOLDKIT_BUILD_ID': JSON.stringify(buildId),
475
+ },
476
+ }),
477
+ };
478
+ },
479
+ configureServer: server => {
480
+ const requestUrls = new WeakMap();
481
+ const stateByRequest = new WeakMap();
482
+ const render = renderMiddleware(server, options, requestUrls, stateByRequest);
483
+ server.middlewares.use(requestTargetMiddleware(server, options, requestUrls, render));
484
+ wrapViteCors(server, options, requestUrls, stateByRequest);
485
+ return () => {
486
+ server.middlewares.use(render);
487
+ };
488
+ },
489
+ };
490
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"viewIdentity.d.ts","sourceRoot":"","sources":["../src/viewIdentity.ts"],"names":[],"mappings":"AAAA,OAAoB,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAA;AAI1D,OAAO,EAAE,KAAK,MAAM,EAAY,MAAM,MAAM,CAAA;AAwZ5C,iFAAiF;AACjF,MAAM,MAAM,2BAA2B,GAAG,QAAQ,CAAC;IACjD,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,SAAS,CAAA;CACf,CAAC,CAAA;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,eAAO,MAAM,qBAAqB,GAChC,MAAM,MAAM,EACZ,IAAI,MAAM,EACV,MAAM,MAAM,EACZ,UAAU,QAAQ,CAAC;IAAE,qBAAqB,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,KACtD,2BAA2B,GAAG,IAyChC,CAAA;AA8DD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,mBAAmB,QAAO,MA0CtC,CAAA"}
1
+ {"version":3,"file":"viewIdentity.d.ts","sourceRoot":"","sources":["../src/viewIdentity.ts"],"names":[],"mappings":"AAAA,OAAoB,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAA;AAI1D,OAAO,EAAE,KAAK,MAAM,EAAY,MAAM,MAAM,CAAA;AA6a5C,iFAAiF;AACjF,MAAM,MAAM,2BAA2B,GAAG,QAAQ,CAAC;IACjD,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,SAAS,CAAA;CACf,CAAC,CAAA;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,eAAO,MAAM,qBAAqB,GAChC,MAAM,MAAM,EACZ,IAAI,MAAM,EACV,MAAM,MAAM,EACZ,UAAU,QAAQ,CAAC;IACjB,qBAAqB,CAAC,EAAE,OAAO,CAAA;CAChC,CAAC,KACD,2BAA2B,GAAG,IAyChC,CAAA;AA8DD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,mBAAmB,QAAO,MA0CtC,CAAA"}
@@ -176,6 +176,27 @@ const rawFunctionName = (functionNode, parentByNode) => {
176
176
  }
177
177
  return ANONYMOUS_FUNCTION_NAME;
178
178
  };
179
+ // NOTE: a view identity names a source position, and a source position outlives
180
+ // the code that occupied it. Hydration compares identities to decide whether the
181
+ // server DOM and the client's first render describe the same logical element, so
182
+ // an identity that stays the same while the function's body changes tells it two
183
+ // different views are the same one: a stale page's `<input name="email">` is
184
+ // adopted by a new build's `<input name="ssn">`, and the value the visitor typed
185
+ // before hydration is carried into a field that means something else and submits
186
+ // under a new name. That is what the deployment's build id answers: it is
187
+ // compared before hydration reads anything the page carries, so a page from a
188
+ // build whose views mean something else is refused as a whole.
189
+ //
190
+ // An identity carries no digest of the module's source, and must not. Mixing
191
+ // one in would make a changed view rebuild its own subtree, which is the reason
192
+ // to want it, but the identity is emitted into the client bundle every visitor
193
+ // downloads, and a truncated hash of a whole source file is a check against that
194
+ // file's contents. A build that tree-shakes a low-entropy server-only value out
195
+ // of the client (a PIN behind `import.meta.env.SSR`) would still ship a digest
196
+ // of the source that contained it, and the value could be recovered by hashing
197
+ // candidates until one matched. The build id costs nothing to compare and
198
+ // reveals nothing about the source. `check:packed-ssr-consumer` asserts the
199
+ // absence against a real built bundle.
179
200
  const assignFunctionIds = (functionNodes, parentByNode, modulePath) => {
180
201
  const functionIds = new Map();
181
202
  const occurrenceCountsByName = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldkit/vite-plugin",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "Vite plugin for Foldkit hot module reloading with state preservation",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "peerDependencies": {
19
19
  "effect": "4.0.0-rc.109",
20
- "foldkit": "^0",
20
+ "foldkit": ">=0.148.0",
21
21
  "vite": "^7.0.0 || ^8.0.0"
22
22
  },
23
23
  "dependencies": {
@@ -32,8 +32,9 @@
32
32
  "rimraf": "^6.1.3",
33
33
  "typescript": "^6.0.3",
34
34
  "vite": "^8.0.16",
35
+ "vite7": "npm:vite@^7.0.0",
35
36
  "vitest": "^4.1.9",
36
- "foldkit": "0.147.0"
37
+ "foldkit": "0.148.2"
37
38
  },
38
39
  "keywords": [
39
40
  "vite",
@@ -53,7 +54,7 @@
53
54
  "access": "public"
54
55
  },
55
56
  "engines": {
56
- "node": ">=18.0.0"
57
+ "node": ">=20.19.0"
57
58
  },
58
59
  "scripts": {
59
60
  "clean": "rimraf dist *.tsbuildinfo",