@foldkit/vite-plugin 0.15.0 → 0.16.0
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 +31 -2
- package/dist/buildToken.d.ts +26 -0
- package/dist/buildToken.d.ts.map +1 -0
- package/dist/buildToken.js +94 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -2
- package/dist/ssr.d.ts +31 -5
- package/dist/ssr.d.ts.map +1 -1
- package/dist/ssr.js +399 -62
- package/dist/viewIdentity.d.ts.map +1 -1
- package/dist/viewIdentity.js +21 -0
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -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
|
|
|
@@ -61,7 +61,36 @@ 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
|
|
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
|
+
A server-rendered build carries an id naming the deployment it came from. `renderToString` stamps it on the rendered root and `Runtime.hydrate` compares it before accessing the Flags payload text or adopting DOM, so a page served by an earlier deployment is refused rather than reconciled against a client that no longer means the same thing by it. Startup stops, and the page is contained: the document's body is marked `inert`, and a nondismissable modal shield covers its controls and existing top-layer content without closing author-owned dialogs. Nothing is moved, so no custom element reconnects and no frame reloads. This boundary 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: the comparison happens when a client boots against a page.
|
|
71
|
+
|
|
72
|
+
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:
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
plugins: [foldkit({ buildId: process.env.DEPLOYMENT_SHA })]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
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:
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
// src/entry.server.ts
|
|
82
|
+
Server.renderToString(config, {
|
|
83
|
+
flags,
|
|
84
|
+
buildId: import.meta.env.FOLDKIT_BUILD_ID,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// src/entry.ts
|
|
88
|
+
Runtime.hydrate(application, { buildId: import.meta.env.FOLDKIT_BUILD_ID })
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Nothing is derived from the project. Use a value the deployment already has, such as a commit, a release tag, or a container digest. Three rules govern it: the id is published in the HTML every visitor receives, so it must never contain a secret; it must identify one deployment, so two deployments can never share one; and the same value must reach the client build and the server build, which run as separate commands. 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.
|
|
92
|
+
|
|
93
|
+
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
94
|
|
|
66
95
|
## DevTools overlay
|
|
67
96
|
|
|
@@ -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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
? [
|
|
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(
|
|
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
|
-
*
|
|
21
|
-
* assets
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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,
|
|
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
|
|
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
|
-
|
|
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(
|
|
59
|
+
return new Request(requestUrl, requestInit);
|
|
32
60
|
};
|
|
33
|
-
const
|
|
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(
|
|
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
|
-
? '
|
|
43
|
-
: '
|
|
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
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 (
|
|
98
|
-
// Merge into whatever Vary already sits on the node response
|
|
99
|
-
//
|
|
100
|
-
|
|
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
|
-
*
|
|
114
|
-
* assets
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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;
|
|
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"}
|
package/dist/viewIdentity.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.16.0",
|
|
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": "
|
|
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.
|
|
37
|
+
"foldkit": "0.148.0"
|
|
37
38
|
},
|
|
38
39
|
"keywords": [
|
|
39
40
|
"vite",
|
|
@@ -53,7 +54,7 @@
|
|
|
53
54
|
"access": "public"
|
|
54
55
|
},
|
|
55
56
|
"engines": {
|
|
56
|
-
"node": ">=
|
|
57
|
+
"node": ">=20.19.0"
|
|
57
58
|
},
|
|
58
59
|
"scripts": {
|
|
59
60
|
"clean": "rimraf dist *.tsbuildinfo",
|