mirror of
https://github.com/withastro/astro.git
synced 2025-03-10 23:01:26 -05:00
Merge branch 'main' into feat/fonts
This commit is contained in:
commit
e97e42a689
110 changed files with 2580 additions and 427 deletions
5
.changeset/itchy-buckets-dream.md
Normal file
5
.changeset/itchy-buckets-dream.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@astrojs/vue': patch
|
||||
---
|
||||
|
||||
Fixes a case where the compiler could not be resolved automatically
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
Fixes the wording of the an error message
|
62
.github/workflows/dispatch-event.yml
vendored
62
.github/workflows/dispatch-event.yml
vendored
|
@ -1,62 +0,0 @@
|
|||
name: Dispatch event
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- '!**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
ASTRO_ADAPTERS_REPO: withastro/adapters
|
||||
ASTRO_STARLIGHT_REPO: withastro/starlight
|
||||
ASTRO_PUSH_MAIN_EVENT: astro-push-main-event
|
||||
|
||||
jobs:
|
||||
repository-dispatch:
|
||||
name: Repository dispatch
|
||||
if: github.repository_owner == 'withastro'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch event on push - adapters
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
|
||||
with:
|
||||
token: ${{ secrets.ASTRO_REPOSITORY_DISPATCH }}
|
||||
repository: ${{ env.ASTRO_ADAPTERS_REPO }}
|
||||
event-type: ${{ env.ASTRO_PUSH_MAIN_EVENT }}
|
||||
client-payload: '{"event": ${{ toJson(github.event) }}}'
|
||||
- name: Dispatch event on push - starlight
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
|
||||
with:
|
||||
token: ${{ secrets.ASTRO_REPOSITORY_DISPATCH }}
|
||||
repository: ${{ env.ASTRO_STARLIGHT_REPO }}
|
||||
event-type: ${{ env.ASTRO_PUSH_MAIN_EVENT }}
|
||||
client-payload: '{"event": ${{ toJson(github.event) }}}'
|
||||
# For testing only, the payload is mocked
|
||||
- name: Dispatch event on workflow dispatch - adapters
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
|
||||
with:
|
||||
token: ${{ secrets.ASTRO_REPOSITORY_DISPATCH }}
|
||||
repository: ${{ env.ASTRO_ADAPTERS_REPO }}
|
||||
event-type: ${{ env.ASTRO_PUSH_MAIN_EVENT }}
|
||||
client-payload: '{"event": {"head_commit": {"id": "${{ env.GITHUB_SHA }}"}}}'
|
||||
- name: Dispatch event on workflow dispatch - starlight
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
|
||||
with:
|
||||
token: ${{ secrets.ASTRO_REPOSITORY_DISPATCH }}
|
||||
repository: ${{ env.ASTRO_STARLIGHT_REPO }}
|
||||
event-type: ${{ env.ASTRO_PUSH_MAIN_EVENT }}
|
||||
client-payload: '{"event": {"head_commit": {"id": "${{ env.GITHUB_SHA }}"}}}'
|
35
.github/workflows/preview-release.yml
vendored
35
.github/workflows/preview-release.yml
vendored
|
@ -3,7 +3,7 @@ name: Preview release
|
|||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, labeled, ready_for_review]
|
||||
types: [labeled]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.number }}
|
||||
|
@ -25,7 +25,7 @@ env:
|
|||
jobs:
|
||||
preview:
|
||||
if: |
|
||||
${{ github.repository_owner == 'withastro' && github.event.issue.pull_request && contains(github.event.pull_request.labels.*.name, 'pr: preview') }}
|
||||
${{ github.repository_owner == 'withastro' && contains(github.event.pull_request.labels.*.name, 'pr: preview') }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
@ -37,10 +37,9 @@ jobs:
|
|||
steps:
|
||||
- name: Disable git crlf
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PNPM
|
||||
uses: pnpm/action-setup@v3
|
||||
|
||||
|
@ -55,6 +54,28 @@ jobs:
|
|||
|
||||
- name: Build Packages
|
||||
run: pnpm run build
|
||||
|
||||
# - name: Changesets status
|
||||
# run: pnpm changeset status --output=changesets.json
|
||||
#
|
||||
# - name: Retrieve packages to publish
|
||||
# uses: actions/github-script@v7
|
||||
# id: packages
|
||||
# with:
|
||||
# script: |
|
||||
# const fs = require('fs');
|
||||
# let packages = JSON.parse(fs.readFileSync('changesets.json', 'utf8'));
|
||||
# const releases = packages.releases
|
||||
# .filter(p => {
|
||||
# return p.changesets.length > 0;
|
||||
# })
|
||||
# .map(p => p.name);
|
||||
# if (releases.length > 0) {
|
||||
# return releases.join(' ');
|
||||
# }
|
||||
# return ""
|
||||
# result-encoding: string
|
||||
|
||||
- name: Publish packages
|
||||
run: pnpx pkg-pr-new publish --pnpm './packages/*' './packages/integrations/*'
|
||||
run: |
|
||||
pnpx pkg-pr-new publish --pnpm --compact --no-template 'packages/astro' 'packages/integrations/node' 'packages/integrations/cloudflare' 'packages/integrations/netlify' 'packages/integrations/vercel'
|
||||
|
|
|
@ -95,12 +95,7 @@ Astro is free, open source software made possible by these wonderful sponsors.
|
|||
[❤️ Sponsor Astro! ❤️](https://github.com/withastro/.github/blob/main/FUNDING.md)
|
||||
|
||||
<p align="center">
|
||||
<a target="_blank" href="https://github.com/sponsors/withastro">
|
||||
|
||||
[](https://github.com/sponsors/withastro)
|
||||
|
||||
<a target="_blank" href="https://opencollective.com/astrodotbuild">
|
||||
<img src="https://astro.build/sponsors.png" alt="Sponsor logos including the current Astro Sponsors, Gold Sponsors, and Exclusive Partner Sponsors: Netlify, Sentry, and Project IDX." />
|
||||
</a>
|
||||
</p>
|
||||
|
|
|
@ -10,6 +10,6 @@
|
|||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^5.2.5"
|
||||
"astro": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,6 @@
|
|||
"@astrojs/mdx": "^4.0.8",
|
||||
"@astrojs/rss": "^4.0.11",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
"astro": "^5.2.5"
|
||||
"astro": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
],
|
||||
"scripts": {},
|
||||
"devDependencies": {
|
||||
"astro": "^5.2.5"
|
||||
"astro": "^5.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"astro": "^4.0.0 || ^5.0.0"
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@astrojs/react": "^4.2.0",
|
||||
"astro": "^5.2.5",
|
||||
"astro": "^5.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"vitest": "^3.0.5"
|
||||
|
|
|
@ -13,6 +13,6 @@
|
|||
"@astrojs/alpinejs": "^0.4.3",
|
||||
"@types/alpinejs": "^3.13.11",
|
||||
"alpinejs": "^3.14.8",
|
||||
"astro": "^5.2.5"
|
||||
"astro": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
"@astrojs/vue": "^5.0.6",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"astro": "^5.2.5",
|
||||
"astro": "^5.3.0",
|
||||
"preact": "^10.25.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
"dependencies": {
|
||||
"@astrojs/preact": "^4.0.4",
|
||||
"@preact/signals": "^2.0.1",
|
||||
"astro": "^5.2.5",
|
||||
"astro": "^5.3.0",
|
||||
"preact": "^10.25.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
"@astrojs/react": "^4.2.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"astro": "^5.2.5",
|
||||
"astro": "^5.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@astrojs/solid-js": "^5.0.4",
|
||||
"astro": "^5.2.5",
|
||||
"astro": "^5.3.0",
|
||||
"solid-js": "^1.9.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@astrojs/svelte": "^7.0.4",
|
||||
"astro": "^5.2.5",
|
||||
"astro": "^5.3.0",
|
||||
"svelte": "^5.19.7"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@astrojs/vue": "^5.0.6",
|
||||
"astro": "^5.2.5",
|
||||
"astro": "^5.3.0",
|
||||
"vue": "^3.5.13"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^9.0.2",
|
||||
"astro": "^5.2.5"
|
||||
"@astrojs/node": "^9.1.0",
|
||||
"astro": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
],
|
||||
"scripts": {},
|
||||
"devDependencies": {
|
||||
"astro": "^5.2.5"
|
||||
"astro": "^5.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"astro": "^4.0.0"
|
||||
|
|
|
@ -10,6 +10,6 @@
|
|||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^5.2.5"
|
||||
"astro": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,6 @@
|
|||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^5.2.5"
|
||||
"astro": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,9 +11,9 @@
|
|||
"server": "node dist/server/entry.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^9.0.2",
|
||||
"@astrojs/node": "^9.1.0",
|
||||
"@astrojs/svelte": "^7.0.4",
|
||||
"astro": "^5.2.5",
|
||||
"astro": "^5.3.0",
|
||||
"svelte": "^5.19.7"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^5.2.5",
|
||||
"astro": "^5.3.0",
|
||||
"sass": "^1.83.4",
|
||||
"sharp": "^0.33.3"
|
||||
}
|
||||
|
|
|
@ -16,6 +16,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.17.8",
|
||||
"astro": "^5.2.5"
|
||||
"astro": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,6 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@astrojs/markdoc": "^0.12.9",
|
||||
"astro": "^5.2.5"
|
||||
"astro": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.0.8",
|
||||
"@astrojs/preact": "^4.0.4",
|
||||
"astro": "^5.2.5",
|
||||
"astro": "^5.3.0",
|
||||
"preact": "^10.25.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
"dependencies": {
|
||||
"@astrojs/preact": "^4.0.4",
|
||||
"@nanostores/preact": "^0.5.2",
|
||||
"astro": "^5.2.5",
|
||||
"astro": "^5.3.0",
|
||||
"nanostores": "^0.11.3",
|
||||
"preact": "^10.25.4"
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
"@astrojs/mdx": "^4.0.8",
|
||||
"@tailwindcss/vite": "^4.0.3",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"astro": "^5.2.5",
|
||||
"astro": "^5.3.0",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"tailwindcss": "^4.0.3"
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^5.2.5",
|
||||
"astro": "^5.3.0",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,76 @@
|
|||
# astro
|
||||
|
||||
## 5.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#13210](https://github.com/withastro/astro/pull/13210) [`344e9bc`](https://github.com/withastro/astro/commit/344e9bc480a075161a7811b7733593556e7560da) Thanks [@VitaliyR](https://github.com/VitaliyR)! - Handle `HEAD` requests to an endpoint when a handler is not defined.
|
||||
|
||||
If an endpoint defines a handler for `GET`, but does not define a handler for `HEAD`, Astro will call the `GET` handler and return the headers and status but an empty body.
|
||||
|
||||
- [#13195](https://github.com/withastro/astro/pull/13195) [`3b66955`](https://github.com/withastro/astro/commit/3b669555d7ab9da5427e7b7037699d4f905d3536) Thanks [@MatthewLymer](https://github.com/MatthewLymer)! - Improves SSR performance for synchronous components by avoiding the use of Promises. With this change, SSR rendering of on-demand pages can be up to 4x faster.
|
||||
|
||||
- [#13145](https://github.com/withastro/astro/pull/13145) [`8d4e566`](https://github.com/withastro/astro/commit/8d4e566f5420c8a5406e1e40e8bae1c1f87cbe37) Thanks [@ascorbic](https://github.com/ascorbic)! - Adds support for adapters auto-configuring experimental session storage drivers.
|
||||
|
||||
Adapters can now configure a default session storage driver when the `experimental.session` flag is enabled. If a hosting platform has a storage primitive that can be used for session storage, the adapter can automatically configure the session storage using that driver. This allows Astro to provide a more seamless experience for users who want to use sessions without needing to manually configure the session storage.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#13145](https://github.com/withastro/astro/pull/13145) [`8d4e566`](https://github.com/withastro/astro/commit/8d4e566f5420c8a5406e1e40e8bae1c1f87cbe37) Thanks [@ascorbic](https://github.com/ascorbic)! - :warning: **BREAKING CHANGE FOR EXPERIMENTAL SESSIONS ONLY** :warning:
|
||||
|
||||
Changes the `experimental.session` option to a boolean flag and moves session config to a top-level value. This change is to allow the new automatic session driver support. You now need to separately enable the `experimental.session` flag, and then configure the session driver using the top-level `session` key if providing manual configuration.
|
||||
|
||||
```diff
|
||||
defineConfig({
|
||||
// ...
|
||||
experimental: {
|
||||
- session: {
|
||||
- driver: 'upstash',
|
||||
- },
|
||||
+ session: true,
|
||||
},
|
||||
+ session: {
|
||||
+ driver: 'upstash',
|
||||
+ },
|
||||
});
|
||||
```
|
||||
|
||||
You no longer need to configure a session driver if you are using an adapter that supports automatic session driver configuration and wish to use its default settings.
|
||||
|
||||
```diff
|
||||
defineConfig({
|
||||
adapter: node({
|
||||
mode: "standalone",
|
||||
}),
|
||||
experimental: {
|
||||
- session: {
|
||||
- driver: 'fs',
|
||||
- cookie: 'astro-cookie',
|
||||
- },
|
||||
+ session: true,
|
||||
},
|
||||
+ session: {
|
||||
+ cookie: 'astro-cookie',
|
||||
+ },
|
||||
});
|
||||
```
|
||||
|
||||
However, you can still manually configure additional driver options or choose a non-default driver to use with your adapter with the new top-level `session` config option. For more information, see the [experimental session docs](https://docs.astro.build/en/reference/experimental-flags/sessions/).
|
||||
|
||||
- [#13101](https://github.com/withastro/astro/pull/13101) [`2ed67d5`](https://github.com/withastro/astro/commit/2ed67d5dc5c8056f9ab1e29e539bf086b93c60c2) Thanks [@corneliusroemer](https://github.com/corneliusroemer)! - Fixes a bug where `HEAD` and `OPTIONS` requests for non-prerendered pages were incorrectly rejected with 403 FORBIDDEN
|
||||
|
||||
## 5.2.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#13188](https://github.com/withastro/astro/pull/13188) [`7bc8256`](https://github.com/withastro/astro/commit/7bc825649bfb790a0206abd31df1676513a03b22) Thanks [@ematipico](https://github.com/ematipico)! - Fixes the wording of the an error message
|
||||
|
||||
- [#13205](https://github.com/withastro/astro/pull/13205) [`9d56602`](https://github.com/withastro/astro/commit/9d5660223b46e024b4e8c8eafead8a4e20e28ec5) Thanks [@ematipico](https://github.com/ematipico)! - Fixes and issue where a server island component returns 404 when `base` is configured in i18n project.
|
||||
|
||||
- [#13212](https://github.com/withastro/astro/pull/13212) [`fb38840`](https://github.com/withastro/astro/commit/fb3884074f261523cd89fe6e1745a0e9c01198f2) Thanks [@joshmkennedy](https://github.com/joshmkennedy)! - An additional has been added during the build command to add clarity around output and buildOutput.
|
||||
|
||||
- [#13213](https://github.com/withastro/astro/pull/13213) [`6bac644`](https://github.com/withastro/astro/commit/6bac644241bc42bb565730955ffd575878a0e41b) Thanks [@joshmkennedy](https://github.com/joshmkennedy)! - Allows readonly arrays to be passed to the `paginate()` function
|
||||
|
||||
## 5.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "astro",
|
||||
"version": "5.2.5",
|
||||
"version": "5.3.0",
|
||||
"description": "Astro is a modern site builder with web best practices, performance, and DX front-of-mind.",
|
||||
"type": "module",
|
||||
"author": "withastro",
|
||||
|
|
|
@ -13,6 +13,9 @@ const FORM_CONTENT_TYPES = [
|
|||
'text/plain',
|
||||
];
|
||||
|
||||
// Note: TRACE is unsupported by undici/Node.js
|
||||
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
|
||||
|
||||
/**
|
||||
* Returns a middleware function in charge to check the `origin` header.
|
||||
*
|
||||
|
@ -25,26 +28,22 @@ export function createOriginCheckMiddleware(): MiddlewareHandler {
|
|||
if (isPrerendered) {
|
||||
return next();
|
||||
}
|
||||
if (request.method === 'GET') {
|
||||
// Safe methods don't require origin check
|
||||
if (SAFE_METHODS.includes(request.method)) {
|
||||
return next();
|
||||
}
|
||||
const sameOrigin =
|
||||
(request.method === 'POST' ||
|
||||
request.method === 'PUT' ||
|
||||
request.method === 'PATCH' ||
|
||||
request.method === 'DELETE') &&
|
||||
request.headers.get('origin') === url.origin;
|
||||
const isSameOrigin = request.headers.get('origin') === url.origin;
|
||||
|
||||
const hasContentType = request.headers.has('content-type');
|
||||
if (hasContentType) {
|
||||
const formLikeHeader = hasFormLikeHeader(request.headers.get('content-type'));
|
||||
if (formLikeHeader && !sameOrigin) {
|
||||
if (formLikeHeader && !isSameOrigin) {
|
||||
return new Response(`Cross-site ${request.method} form submissions are forbidden`, {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (!sameOrigin) {
|
||||
if (!isSameOrigin) {
|
||||
return new Response(`Cross-site ${request.method} form submissions are forbidden`, {
|
||||
status: 403,
|
||||
});
|
||||
|
|
|
@ -174,7 +174,8 @@ class AstroBuilder {
|
|||
await runHookBuildStart({ config: this.settings.config, logging: this.logger });
|
||||
this.validateConfig();
|
||||
|
||||
this.logger.info('build', `output: ${blue('"' + this.settings.buildOutput + '"')}`);
|
||||
this.logger.info('build', `output: ${blue('"' + this.settings.config.output + '"')}`);
|
||||
this.logger.info('build', `mode: ${blue('"' + this.settings.buildOutput + '"')}`);
|
||||
this.logger.info('build', `directory: ${blue(fileURLToPath(this.settings.config.outDir))}`);
|
||||
if (this.settings.adapter) {
|
||||
this.logger.info('build', `adapter: ${green(this.settings.adapter.name)}`);
|
||||
|
|
|
@ -54,9 +54,7 @@ function vitePluginManifest(options: StaticBuildOptions, internals: BuildInterna
|
|||
`import { _privateSetManifestDontUseThis } from 'astro:ssr-manifest'`,
|
||||
];
|
||||
|
||||
const resolvedDriver = await resolveSessionDriver(
|
||||
options.settings.config.experimental?.session?.driver,
|
||||
);
|
||||
const resolvedDriver = await resolveSessionDriver(options.settings.config.session?.driver);
|
||||
|
||||
const contents = [
|
||||
`const manifest = _deserializeManifest('${manifestReplace}');`,
|
||||
|
@ -304,6 +302,6 @@ function buildManifest(
|
|||
(settings.config.security?.checkOrigin && settings.buildOutput === 'server') ?? false,
|
||||
serverIslandNameMap: Array.from(settings.serverIslandNameMap),
|
||||
key: encodedKey,
|
||||
sessionConfig: settings.config.experimental.session,
|
||||
sessionConfig: settings.config.session,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -96,12 +96,14 @@ export const ASTRO_CONFIG_DEFAULTS = {
|
|||
schema: {},
|
||||
validateSecrets: false,
|
||||
},
|
||||
session: undefined,
|
||||
experimental: {
|
||||
clientPrerender: false,
|
||||
contentIntellisense: false,
|
||||
responsiveImages: false,
|
||||
svg: false,
|
||||
serializeConfig: false,
|
||||
session: false,
|
||||
},
|
||||
} satisfies AstroUserConfig & { server: { open: boolean } };
|
||||
|
||||
|
@ -526,6 +528,30 @@ export const AstroConfigSchema = z.object({
|
|||
.strict()
|
||||
.optional()
|
||||
.default(ASTRO_CONFIG_DEFAULTS.env),
|
||||
session: z
|
||||
.object({
|
||||
driver: z.string(),
|
||||
options: z.record(z.any()).optional(),
|
||||
cookie: z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
maxAge: z.number().optional(),
|
||||
sameSite: z.union([z.enum(['strict', 'lax', 'none']), z.boolean()]).optional(),
|
||||
secure: z.boolean().optional(),
|
||||
})
|
||||
.or(z.string())
|
||||
.transform((val) => {
|
||||
if (typeof val === 'string') {
|
||||
return { name: val };
|
||||
}
|
||||
return val;
|
||||
})
|
||||
.optional(),
|
||||
ttl: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
experimental: z
|
||||
.object({
|
||||
clientPrerender: z
|
||||
|
@ -540,32 +566,7 @@ export const AstroConfigSchema = z.object({
|
|||
.boolean()
|
||||
.optional()
|
||||
.default(ASTRO_CONFIG_DEFAULTS.experimental.responsiveImages),
|
||||
session: z
|
||||
.object({
|
||||
driver: z.string(),
|
||||
options: z.record(z.any()).optional(),
|
||||
cookie: z
|
||||
.union([
|
||||
z.object({
|
||||
name: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
maxAge: z.number().optional(),
|
||||
sameSite: z.union([z.enum(['strict', 'lax', 'none']), z.boolean()]).optional(),
|
||||
secure: z.boolean().optional(),
|
||||
}),
|
||||
z.string(),
|
||||
])
|
||||
.transform((val) => {
|
||||
if (typeof val === 'string') {
|
||||
return { name: val };
|
||||
}
|
||||
return val;
|
||||
})
|
||||
.optional(),
|
||||
ttl: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
session: z.boolean().optional(),
|
||||
svg: z
|
||||
.union([
|
||||
z.boolean(),
|
||||
|
|
|
@ -881,38 +881,6 @@ export const AstroResponseHeadersReassigned = {
|
|||
hint: 'Consider using `Astro.response.headers.add()`, and `Astro.response.headers.delete()`.',
|
||||
} satisfies ErrorData;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @message Error when initializing session storage with driver `DRIVER`. `ERROR`
|
||||
* @see
|
||||
* - [experimental.session](https://docs.astro.build/en/reference/experimental-flags/sessions/)
|
||||
* @description
|
||||
* Thrown when the session storage could not be initialized.
|
||||
*/
|
||||
export const SessionStorageInitError = {
|
||||
name: 'SessionStorageInitError',
|
||||
title: 'Session storage could not be initialized.',
|
||||
message: (error: string, driver?: string) =>
|
||||
`Error when initializing session storage${driver ? ` with driver \`${driver}\`` : ''}. \`${error ?? ''}\``,
|
||||
hint: 'For more information, see https://docs.astro.build/en/reference/experimental-flags/sessions/',
|
||||
} satisfies ErrorData;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @message Error when saving session data with driver `DRIVER`. `ERROR`
|
||||
* @see
|
||||
* - [experimental.session](https://docs.astro.build/en/reference/experimental-flags/sessions/)
|
||||
* @description
|
||||
* Thrown when the session data could not be saved.
|
||||
*/
|
||||
export const SessionStorageSaveError = {
|
||||
name: 'SessionStorageSaveError',
|
||||
title: 'Session data could not be saved.',
|
||||
message: (error: string, driver?: string) =>
|
||||
`Error when saving session data${driver ? ` with driver \`${driver}\`` : ''}. \`${error ?? ''}\``,
|
||||
hint: 'For more information, see https://docs.astro.build/en/reference/experimental-flags/sessions/',
|
||||
} satisfies ErrorData;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @description
|
||||
|
@ -1838,6 +1806,90 @@ export const ActionCalledFromServerError = {
|
|||
// Generic catch-all - Only use this in extreme cases, like if there was a cosmic ray bit flip.
|
||||
export const UnknownError = { name: 'UnknownError', title: 'Unknown Error.' } satisfies ErrorData;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @kind heading
|
||||
* @name Session Errors
|
||||
*/
|
||||
// Session Errors
|
||||
/**
|
||||
* @docs
|
||||
* @see
|
||||
* - [Server output adapter feature](https://docs.astro.build/en/reference/adapter-reference/#building-an-adapter)
|
||||
* @description
|
||||
* Your adapter must support server output to use sessions.
|
||||
*/
|
||||
export const SessionWithoutSupportedAdapterOutputError = {
|
||||
name: 'SessionWithoutSupportedAdapterOutputError',
|
||||
title: "Sessions cannot be used with an adapter that doesn't support server output.",
|
||||
message:
|
||||
'Sessions require an adapter that supports server output. The adapter must set `"server"` in the `buildOutput` adapter feature.',
|
||||
hint: 'Ensure your adapter supports `buildOutput: "server"`: https://docs.astro.build/en/reference/adapter-reference/#building-an-adapter',
|
||||
} satisfies ErrorData;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @message Error when initializing session storage with driver `DRIVER`. `ERROR`
|
||||
* @see
|
||||
* - [experimental.session](https://docs.astro.build/en/reference/experimental-flags/sessions/)
|
||||
* @description
|
||||
* Thrown when the session storage could not be initialized.
|
||||
*/
|
||||
export const SessionStorageInitError = {
|
||||
name: 'SessionStorageInitError',
|
||||
title: 'Session storage could not be initialized.',
|
||||
message: (error: string, driver?: string) =>
|
||||
`Error when initializing session storage${driver ? ` with driver \`${driver}\`` : ''}. \`${error ?? ''}\``,
|
||||
hint: 'For more information, see https://docs.astro.build/en/reference/experimental-flags/sessions/',
|
||||
} satisfies ErrorData;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @message Error when saving session data with driver `DRIVER`. `ERROR`
|
||||
* @see
|
||||
* - [experimental.session](https://docs.astro.build/en/reference/experimental-flags/sessions/)
|
||||
* @description
|
||||
* Thrown when the session data could not be saved.
|
||||
*/
|
||||
export const SessionStorageSaveError = {
|
||||
name: 'SessionStorageSaveError',
|
||||
title: 'Session data could not be saved.',
|
||||
message: (error: string, driver?: string) =>
|
||||
`Error when saving session data${driver ? ` with driver \`${driver}\`` : ''}. \`${error ?? ''}\``,
|
||||
hint: 'For more information, see https://docs.astro.build/en/reference/experimental-flags/sessions/',
|
||||
} satisfies ErrorData;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @message The `experimental.session` flag was set to `true`, but no storage was configured. Either configure the storage manually or use an adapter that provides session storage
|
||||
* @see
|
||||
* - [experimental.session](https://docs.astro.build/en/reference/experimental-flags/sessions/)
|
||||
* @description
|
||||
* Thrown when session storage is enabled but not configured.
|
||||
*/
|
||||
export const SessionConfigMissingError = {
|
||||
name: 'SessionConfigMissingError',
|
||||
title: 'Session storage was enabled but not configured.',
|
||||
message:
|
||||
'The `experimental.session` flag was set to `true`, but no storage was configured. Either configure the storage manually or use an adapter that provides session storage',
|
||||
hint: 'See https://docs.astro.build/en/reference/experimental-flags/sessions/',
|
||||
} satisfies ErrorData;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @message Session config was provided without enabling the `experimental.session` flag
|
||||
* @see
|
||||
* - [experimental.session](https://docs.astro.build/en/reference/experimental-flags/sessions/)
|
||||
* @description
|
||||
* Thrown when session storage is configured but the `experimental.session` flag is not enabled.
|
||||
*/
|
||||
export const SessionConfigWithoutFlagError = {
|
||||
name: 'SessionConfigWithoutFlagError',
|
||||
title: 'Session flag not set',
|
||||
message: 'Session config was provided without enabling the `experimental.session` flag',
|
||||
hint: 'See https://docs.astro.build/en/reference/experimental-flags/sessions/',
|
||||
} satisfies ErrorData;
|
||||
|
||||
/*
|
||||
* Adding an error? Follow these steps:
|
||||
* 1. Determine in which category it belongs (Astro, Vite, CSS, Content Collections etc.)
|
||||
|
|
|
@ -15,7 +15,7 @@ export function generatePaginateFunction(
|
|||
base: AstroConfig['base'],
|
||||
): (...args: Parameters<PaginateFunction>) => ReturnType<PaginateFunction> {
|
||||
return function paginateUtility(
|
||||
data: any[],
|
||||
data: readonly any[],
|
||||
args: PaginateOptions<Props, Params> = {},
|
||||
): ReturnType<PaginateFunction> {
|
||||
let { pageSize: _pageSize, params: _params, props: _props } = args;
|
||||
|
|
|
@ -59,7 +59,8 @@ export function isRouteServerIsland(route: RouteData): boolean {
|
|||
*/
|
||||
export function isRequestServerIsland(request: Request, base = ''): boolean {
|
||||
const url = new URL(request.url);
|
||||
const pathname = url.pathname.slice(base.length);
|
||||
const pathname =
|
||||
base === '/' ? url.pathname.slice(base.length) : url.pathname.slice(base.length + 1);
|
||||
|
||||
return pathname.startsWith(SERVER_ISLAND_BASE_PREFIX);
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
builtinDrivers,
|
||||
createStorage,
|
||||
} from 'unstorage';
|
||||
import type { AstroSettings } from '../types/astro.js';
|
||||
import type {
|
||||
ResolvedSessionConfig,
|
||||
SessionConfig,
|
||||
|
@ -13,7 +14,13 @@ import type {
|
|||
} from '../types/public/config.js';
|
||||
import type { AstroCookies } from './cookies/cookies.js';
|
||||
import type { AstroCookieSetOptions } from './cookies/cookies.js';
|
||||
import { SessionStorageInitError, SessionStorageSaveError } from './errors/errors-data.js';
|
||||
import {
|
||||
SessionConfigMissingError,
|
||||
SessionConfigWithoutFlagError,
|
||||
SessionStorageInitError,
|
||||
SessionStorageSaveError,
|
||||
SessionWithoutSupportedAdapterOutputError,
|
||||
} from './errors/errors-data.js';
|
||||
import { AstroError } from './errors/index.js';
|
||||
|
||||
export const PERSIST_SYMBOL = Symbol();
|
||||
|
@ -462,15 +469,39 @@ export class AstroSession<TDriver extends SessionDriverName = any> {
|
|||
}
|
||||
}
|
||||
// TODO: make this sync when we drop support for Node < 18.19.0
|
||||
export function resolveSessionDriver(driver: string | undefined): Promise<string> | string | null {
|
||||
export async function resolveSessionDriver(driver: string | undefined): Promise<string | null> {
|
||||
if (!driver) {
|
||||
return null;
|
||||
}
|
||||
if (driver === 'fs') {
|
||||
return import.meta.resolve(builtinDrivers.fsLite);
|
||||
}
|
||||
if (driver in builtinDrivers) {
|
||||
return import.meta.resolve(builtinDrivers[driver as keyof typeof builtinDrivers]);
|
||||
try {
|
||||
if (driver === 'fs') {
|
||||
return await import.meta.resolve(builtinDrivers.fsLite);
|
||||
}
|
||||
if (driver in builtinDrivers) {
|
||||
return await import.meta.resolve(builtinDrivers[driver as keyof typeof builtinDrivers]);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return driver;
|
||||
}
|
||||
|
||||
export function validateSessionConfig(settings: AstroSettings): void {
|
||||
const { experimental, session } = settings.config;
|
||||
const { buildOutput } = settings;
|
||||
let error: AstroError | undefined;
|
||||
if (experimental.session) {
|
||||
if (!session?.driver) {
|
||||
error = new AstroError(SessionConfigMissingError);
|
||||
} else if (buildOutput === 'static') {
|
||||
error = new AstroError(SessionWithoutSupportedAdapterOutputError);
|
||||
}
|
||||
} else if (session?.driver) {
|
||||
error = new AstroError(SessionConfigWithoutFlagError);
|
||||
}
|
||||
if (error) {
|
||||
error.stack = undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import { buildClientDirectiveEntrypoint } from '../core/client-directive/index.j
|
|||
import { mergeConfig } from '../core/config/index.js';
|
||||
import { validateSetAdapter } from '../core/dev/adapter-validation.js';
|
||||
import type { AstroIntegrationLogger, Logger } from '../core/logger/core.js';
|
||||
import { validateSessionConfig } from '../core/session.js';
|
||||
import type { AstroSettings } from '../types/astro.js';
|
||||
import type { AstroConfig } from '../types/public/config.js';
|
||||
import type {
|
||||
|
@ -370,6 +371,11 @@ export async function runHookConfigDone({
|
|||
});
|
||||
}
|
||||
}
|
||||
// Session config is validated after all integrations have had a chance to
|
||||
// register a default session driver, and we know the output type.
|
||||
// This can't happen in the Zod schema because it that happens before adapters run
|
||||
// and also doesn't know whether it's a server build or static build.
|
||||
validateSessionConfig(settings);
|
||||
}
|
||||
|
||||
export async function runHookServerSetup({
|
||||
|
|
|
@ -19,8 +19,12 @@ export async function renderEndpoint(
|
|||
|
||||
const method = request.method.toUpperCase();
|
||||
// use the exact match on `method`, fallback to ALL
|
||||
const handler = mod[method] ?? mod['ALL'];
|
||||
if (isPrerendered && method !== 'GET') {
|
||||
let handler = mod[method] ?? mod['ALL'];
|
||||
// use GET handler for HEAD requests
|
||||
if (!handler && method === 'HEAD' && mod['GET']) {
|
||||
handler = mod['GET'];
|
||||
}
|
||||
if (isPrerendered && !['GET', 'HEAD'].includes(method)) {
|
||||
logger.warn(
|
||||
'router',
|
||||
`${url.pathname} ${bold(
|
||||
|
@ -78,5 +82,10 @@ export async function renderEndpoint(
|
|||
}
|
||||
}
|
||||
|
||||
if (method === 'HEAD') {
|
||||
// make sure HEAD responses doesnt have body
|
||||
return new Response(null, response);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
|
|
@ -3,52 +3,134 @@ import { isPromise } from '../util.js';
|
|||
import { isAstroComponentInstance, isRenderTemplateResult } from './astro/index.js';
|
||||
import { type RenderDestination, isRenderInstance } from './common.js';
|
||||
import { SlotString } from './slot.js';
|
||||
import { renderToBufferDestination } from './util.js';
|
||||
import { createBufferedRenderer } from './util.js';
|
||||
|
||||
export async function renderChild(destination: RenderDestination, child: any) {
|
||||
export function renderChild(destination: RenderDestination, child: any): void | Promise<void> {
|
||||
if (isPromise(child)) {
|
||||
child = await child;
|
||||
return child.then((x) => renderChild(destination, x));
|
||||
}
|
||||
|
||||
if (child instanceof SlotString) {
|
||||
destination.write(child);
|
||||
} else if (isHTMLString(child)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHTMLString(child)) {
|
||||
destination.write(child);
|
||||
} else if (Array.isArray(child)) {
|
||||
// Render all children eagerly and in parallel
|
||||
const childRenders = child.map((c) => {
|
||||
return renderToBufferDestination((bufferDestination) => {
|
||||
return renderChild(bufferDestination, c);
|
||||
});
|
||||
});
|
||||
for (const childRender of childRenders) {
|
||||
if (!childRender) continue;
|
||||
await childRender.renderToFinalDestination(destination);
|
||||
}
|
||||
} else if (typeof child === 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(child)) {
|
||||
return renderArray(destination, child);
|
||||
}
|
||||
|
||||
if (typeof child === 'function') {
|
||||
// Special: If a child is a function, call it automatically.
|
||||
// This lets you do {() => ...} without the extra boilerplate
|
||||
// of wrapping it in a function and calling it.
|
||||
await renderChild(destination, child());
|
||||
} else if (typeof child === 'string') {
|
||||
destination.write(markHTMLString(escapeHTML(child)));
|
||||
} else if (!child && child !== 0) {
|
||||
return renderChild(destination, child());
|
||||
}
|
||||
|
||||
if (!child && child !== 0) {
|
||||
// do nothing, safe to ignore falsey values.
|
||||
} else if (isRenderInstance(child)) {
|
||||
await child.render(destination);
|
||||
} else if (isRenderTemplateResult(child)) {
|
||||
await child.render(destination);
|
||||
} else if (isAstroComponentInstance(child)) {
|
||||
await child.render(destination);
|
||||
} else if (ArrayBuffer.isView(child)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof child === 'string') {
|
||||
destination.write(markHTMLString(escapeHTML(child)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRenderInstance(child)) {
|
||||
return child.render(destination);
|
||||
}
|
||||
|
||||
if (isRenderTemplateResult(child)) {
|
||||
return child.render(destination);
|
||||
}
|
||||
|
||||
if (isAstroComponentInstance(child)) {
|
||||
return child.render(destination);
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(child)) {
|
||||
destination.write(child);
|
||||
} else if (
|
||||
typeof child === 'object' &&
|
||||
(Symbol.asyncIterator in child || Symbol.iterator in child)
|
||||
) {
|
||||
for await (const value of child) {
|
||||
await renderChild(destination, value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof child === 'object' && (Symbol.asyncIterator in child || Symbol.iterator in child)) {
|
||||
if (Symbol.asyncIterator in child) {
|
||||
return renderAsyncIterable(destination, child);
|
||||
}
|
||||
} else {
|
||||
destination.write(child);
|
||||
|
||||
return renderIterable(destination, child);
|
||||
}
|
||||
|
||||
destination.write(child);
|
||||
}
|
||||
|
||||
function renderArray(destination: RenderDestination, children: any[]): void | Promise<void> {
|
||||
// Render all children eagerly and in parallel
|
||||
const flushers = children.map((c) => {
|
||||
return createBufferedRenderer(destination, (bufferDestination) => {
|
||||
return renderChild(bufferDestination, c);
|
||||
});
|
||||
});
|
||||
|
||||
const iterator = flushers[Symbol.iterator]();
|
||||
|
||||
const iterate = (): void | Promise<void> => {
|
||||
for (;;) {
|
||||
const { value: flusher, done } = iterator.next();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
const result = flusher.flush();
|
||||
|
||||
if (isPromise(result)) {
|
||||
return result.then(iterate);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return iterate();
|
||||
}
|
||||
|
||||
function renderIterable(
|
||||
destination: RenderDestination,
|
||||
children: Iterable<any>,
|
||||
): void | Promise<void> {
|
||||
// although arrays and iterables may be similar, an iterable
|
||||
// may be unbounded, so rendering all children eagerly may not
|
||||
// be possible.
|
||||
const iterator = (children[Symbol.iterator] as () => Iterator<any>)();
|
||||
|
||||
const iterate = (): void | Promise<void> => {
|
||||
for (;;) {
|
||||
const { value, done } = iterator.next();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
const result = renderChild(destination, value);
|
||||
|
||||
if (isPromise(result)) {
|
||||
return result.then(iterate);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return iterate();
|
||||
}
|
||||
|
||||
async function renderAsyncIterable(
|
||||
destination: RenderDestination,
|
||||
children: AsyncIterable<any>,
|
||||
): Promise<void> {
|
||||
for await (const value of children) {
|
||||
await renderChild(destination, value);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type { ComponentSlots } from '../slot.js';
|
||||
import type { AstroComponentFactory } from './factory.js';
|
||||
import type { AstroComponentFactory, AstroFactoryReturnValue } from './factory.js';
|
||||
|
||||
import type { SSRResult } from '../../../../types/public/internal.js';
|
||||
import { isPromise } from '../../util.js';
|
||||
|
@ -46,9 +46,13 @@ export class AstroComponentInstance {
|
|||
}
|
||||
}
|
||||
|
||||
async init(result: SSRResult) {
|
||||
if (this.returnValue !== undefined) return this.returnValue;
|
||||
init(result: SSRResult) {
|
||||
if (this.returnValue !== undefined) {
|
||||
return this.returnValue;
|
||||
}
|
||||
|
||||
this.returnValue = this.factory(result, this.props, this.slotValues);
|
||||
|
||||
// Save the resolved value after promise is resolved for optimization
|
||||
if (isPromise(this.returnValue)) {
|
||||
this.returnValue
|
||||
|
@ -62,12 +66,21 @@ export class AstroComponentInstance {
|
|||
return this.returnValue;
|
||||
}
|
||||
|
||||
async render(destination: RenderDestination) {
|
||||
const returnValue = await this.init(this.result);
|
||||
render(destination: RenderDestination): void | Promise<void> {
|
||||
const returnValue = this.init(this.result);
|
||||
|
||||
if (isPromise(returnValue)) {
|
||||
return returnValue.then((x) => this.renderImpl(destination, x));
|
||||
}
|
||||
|
||||
return this.renderImpl(destination, returnValue);
|
||||
}
|
||||
|
||||
private renderImpl(destination: RenderDestination, returnValue: AstroFactoryReturnValue) {
|
||||
if (isHeadAndContent(returnValue)) {
|
||||
await returnValue.content.render(destination);
|
||||
return returnValue.content.render(destination);
|
||||
} else {
|
||||
await renderChild(destination, returnValue);
|
||||
return renderChild(destination, returnValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import { markHTMLString } from '../../escape.js';
|
|||
import { isPromise } from '../../util.js';
|
||||
import { renderChild } from '../any.js';
|
||||
import type { RenderDestination } from '../common.js';
|
||||
import { renderToBufferDestination } from '../util.js';
|
||||
import { createBufferedRenderer } from '../util.js';
|
||||
|
||||
const renderTemplateResultSym = Symbol.for('astro.renderTemplateResult');
|
||||
|
||||
|
@ -32,10 +32,10 @@ export class RenderTemplateResult {
|
|||
});
|
||||
}
|
||||
|
||||
async render(destination: RenderDestination) {
|
||||
render(destination: RenderDestination): void | Promise<void> {
|
||||
// Render all expressions eagerly and in parallel
|
||||
const expRenders = this.expressions.map((exp) => {
|
||||
return renderToBufferDestination((bufferDestination) => {
|
||||
const flushers = this.expressions.map((exp) => {
|
||||
return createBufferedRenderer(destination, (bufferDestination) => {
|
||||
// Skip render if falsy, except the number 0
|
||||
if (exp || exp === 0) {
|
||||
return renderChild(bufferDestination, exp);
|
||||
|
@ -43,15 +43,34 @@ export class RenderTemplateResult {
|
|||
});
|
||||
});
|
||||
|
||||
for (let i = 0; i < this.htmlParts.length; i++) {
|
||||
const html = this.htmlParts[i];
|
||||
const expRender = expRenders[i];
|
||||
let i = 0;
|
||||
|
||||
destination.write(markHTMLString(html));
|
||||
if (expRender) {
|
||||
await expRender.renderToFinalDestination(destination);
|
||||
const iterate = (): void | Promise<void> => {
|
||||
while (i < this.htmlParts.length) {
|
||||
const html = this.htmlParts[i];
|
||||
const flusher = flushers[i];
|
||||
|
||||
// increment here due to potential return in
|
||||
// Promise scenario
|
||||
i++;
|
||||
|
||||
if (html) {
|
||||
// only write non-empty strings
|
||||
|
||||
destination.write(markHTMLString(html));
|
||||
}
|
||||
|
||||
if (flusher) {
|
||||
const result = flusher.flush();
|
||||
|
||||
if (isPromise(result)) {
|
||||
return result.then(iterate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return iterate();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { AstroError, AstroErrorData } from '../../../../core/errors/index.js';
|
||||
import type { RouteData, SSRResult } from '../../../../types/public/internal.js';
|
||||
import { isPromise } from '../../util.js';
|
||||
import { type RenderDestination, chunkToByteArray, chunkToString, encoder } from '../common.js';
|
||||
import { promiseWithResolvers } from '../util.js';
|
||||
import type { AstroComponentFactory } from './factory.js';
|
||||
|
@ -317,16 +318,13 @@ export async function renderToAsyncIterable(
|
|||
},
|
||||
};
|
||||
|
||||
const renderPromise = templateResult.render(destination);
|
||||
renderPromise
|
||||
.then(() => {
|
||||
// Once rendering is complete, calling resolve() allows the iterator to finish running.
|
||||
renderingComplete = true;
|
||||
next?.resolve();
|
||||
})
|
||||
const renderResult = toPromise(() => templateResult.render(destination));
|
||||
|
||||
renderResult
|
||||
.catch((err) => {
|
||||
// If an error occurs, save it in the scope so that we throw it when next() is called.
|
||||
error = err;
|
||||
})
|
||||
.finally(() => {
|
||||
renderingComplete = true;
|
||||
next?.resolve();
|
||||
});
|
||||
|
@ -339,3 +337,12 @@ export async function renderToAsyncIterable(
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toPromise<T>(fn: () => T | Promise<T>): Promise<T> {
|
||||
try {
|
||||
const result = fn();
|
||||
return isPromise(result) ? result : Promise.resolve(result);
|
||||
} catch (err) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -446,29 +446,32 @@ function renderAstroComponent(
|
|||
}
|
||||
|
||||
const instance = createAstroComponentInstance(result, displayName, Component, props, slots);
|
||||
|
||||
return {
|
||||
async render(destination) {
|
||||
render(destination: RenderDestination): Promise<void> | void {
|
||||
// NOTE: This render call can't be pre-invoked outside of this function as it'll also initialize the slots
|
||||
// recursively, which causes each Astro components in the tree to be called bottom-up, and is incorrect.
|
||||
// The slots are initialized eagerly for head propagation.
|
||||
await instance.render(destination);
|
||||
return instance.render(destination);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function renderComponent(
|
||||
export function renderComponent(
|
||||
result: SSRResult,
|
||||
displayName: string,
|
||||
Component: unknown,
|
||||
props: Record<string | number, any>,
|
||||
slots: ComponentSlots = {},
|
||||
): Promise<RenderInstance> {
|
||||
): RenderInstance | Promise<RenderInstance> {
|
||||
if (isPromise(Component)) {
|
||||
Component = await Component.catch(handleCancellation);
|
||||
return Component.catch(handleCancellation).then((x) => {
|
||||
return renderComponent(result, displayName, x, props, slots);
|
||||
});
|
||||
}
|
||||
|
||||
if (isFragmentComponent(Component)) {
|
||||
return await renderFragmentComponent(result, slots).catch(handleCancellation);
|
||||
return renderFragmentComponent(result, slots).catch(handleCancellation);
|
||||
}
|
||||
|
||||
// Ensure directives (`class:list`) are processed
|
||||
|
@ -476,14 +479,14 @@ export async function renderComponent(
|
|||
|
||||
// .html components
|
||||
if (isHTMLComponent(Component)) {
|
||||
return await renderHTMLComponent(result, Component, props, slots).catch(handleCancellation);
|
||||
return renderHTMLComponent(result, Component, props, slots).catch(handleCancellation);
|
||||
}
|
||||
|
||||
if (isAstroComponentFactory(Component)) {
|
||||
return renderAstroComponent(result, displayName, Component, props, slots);
|
||||
}
|
||||
|
||||
return await renderFrameworkComponent(result, displayName, Component, props, slots).catch(
|
||||
return renderFrameworkComponent(result, displayName, Component, props, slots).catch(
|
||||
handleCancellation,
|
||||
);
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import type { RenderDestination, RenderDestinationChunk, RenderFunction } from '
|
|||
import { clsx } from 'clsx';
|
||||
import type { SSRElement } from '../../../types/public/internal.js';
|
||||
import { HTMLString, markHTMLString } from '../escape.js';
|
||||
import { isPromise } from '../util.js';
|
||||
|
||||
export const voidElementNames =
|
||||
/^(area|base|br|col|command|embed|hr|img|input|keygen|link|meta|param|source|track|wbr)$/i;
|
||||
|
@ -152,33 +153,54 @@ export function renderElement(
|
|||
const noop = () => {};
|
||||
|
||||
/**
|
||||
* Renders into a buffer until `renderToFinalDestination` is called (which
|
||||
* Renders into a buffer until `flush` is called (which
|
||||
* flushes the buffer)
|
||||
*/
|
||||
class BufferedRenderer implements RenderDestination {
|
||||
class BufferedRenderer implements RenderDestination, RendererFlusher {
|
||||
private chunks: RenderDestinationChunk[] = [];
|
||||
private renderPromise: Promise<void> | void;
|
||||
private destination?: RenderDestination;
|
||||
private destination: RenderDestination;
|
||||
|
||||
public constructor(bufferRenderFunction: RenderFunction) {
|
||||
this.renderPromise = bufferRenderFunction(this);
|
||||
// Catch here in case it throws before `renderToFinalDestination` is called,
|
||||
// to prevent an unhandled rejection.
|
||||
Promise.resolve(this.renderPromise).catch(noop);
|
||||
/**
|
||||
* Determines whether buffer has been flushed
|
||||
* to the final destination.
|
||||
*/
|
||||
private flushed = false;
|
||||
|
||||
public constructor(destination: RenderDestination, renderFunction: RenderFunction) {
|
||||
this.destination = destination;
|
||||
this.renderPromise = renderFunction(this);
|
||||
|
||||
if (isPromise(this.renderPromise)) {
|
||||
// Catch here in case it throws before `flush` is called,
|
||||
// to prevent an unhandled rejection.
|
||||
Promise.resolve(this.renderPromise).catch(noop);
|
||||
}
|
||||
}
|
||||
|
||||
public write(chunk: RenderDestinationChunk): void {
|
||||
if (this.destination) {
|
||||
// Before the buffer has been flushed, we want to
|
||||
// append to the buffer, afterwards we'll write
|
||||
// to the underlying destination if subsequent
|
||||
// writes arrive.
|
||||
|
||||
if (this.flushed) {
|
||||
this.destination.write(chunk);
|
||||
} else {
|
||||
this.chunks.push(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
public async renderToFinalDestination(destination: RenderDestination) {
|
||||
public flush(): void | Promise<void> {
|
||||
if (this.flushed) {
|
||||
throw new Error('The render buffer has already been flushed.');
|
||||
}
|
||||
|
||||
this.flushed = true;
|
||||
|
||||
// Write the buffered chunks to the real destination
|
||||
for (const chunk of this.chunks) {
|
||||
destination.write(chunk);
|
||||
this.destination.write(chunk);
|
||||
}
|
||||
|
||||
// NOTE: We don't empty `this.chunks` after it's written as benchmarks show
|
||||
|
@ -186,38 +208,43 @@ class BufferedRenderer implements RenderDestination {
|
|||
// instead of letting the garbage collector handle it automatically.
|
||||
// (Unsure how this affects on limited memory machines)
|
||||
|
||||
// Re-assign the real destination so `instance.render` will continue and write to the new destination
|
||||
this.destination = destination;
|
||||
|
||||
// Wait for render to finish entirely
|
||||
await this.renderPromise;
|
||||
return this.renderPromise;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the `bufferRenderFunction` to prerender it into a buffer destination, and return a promise
|
||||
* with an object containing the `renderToFinalDestination` function to flush the buffer to the final
|
||||
* with an object containing the `flush` function to flush the buffer to the final
|
||||
* destination.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Render components in parallel ahead of time
|
||||
* const finalRenders = [ComponentA, ComponentB].map((comp) => {
|
||||
* return renderToBufferDestination(async (bufferDestination) => {
|
||||
* return createBufferedRenderer(finalDestination, async (bufferDestination) => {
|
||||
* await renderComponentToDestination(bufferDestination);
|
||||
* });
|
||||
* });
|
||||
* // Render array of components serially
|
||||
* for (const finalRender of finalRenders) {
|
||||
* await finalRender.renderToFinalDestination(finalDestination);
|
||||
* await finalRender.flush();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function renderToBufferDestination(bufferRenderFunction: RenderFunction): {
|
||||
renderToFinalDestination: RenderFunction;
|
||||
} {
|
||||
const renderer = new BufferedRenderer(bufferRenderFunction);
|
||||
return renderer;
|
||||
export function createBufferedRenderer(
|
||||
destination: RenderDestination,
|
||||
renderFunction: RenderFunction,
|
||||
): RendererFlusher {
|
||||
return new BufferedRenderer(destination, renderFunction);
|
||||
}
|
||||
|
||||
export interface RendererFlusher {
|
||||
/**
|
||||
* Flushes the current renderer to the underlying renderer.
|
||||
*
|
||||
* See example of `createBufferedRenderer` for usage.
|
||||
*/
|
||||
flush(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export const isNode =
|
||||
|
|
|
@ -82,7 +82,7 @@ export type PaginateFunction = <
|
|||
AdditionalPaginateProps extends Props,
|
||||
AdditionalPaginateParams extends Params,
|
||||
>(
|
||||
data: PaginateData[],
|
||||
data: readonly PaginateData[],
|
||||
args?: PaginateOptions<AdditionalPaginateProps, AdditionalPaginateParams>,
|
||||
) => {
|
||||
params: Simplify<
|
||||
|
|
|
@ -565,6 +565,36 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
|
|||
checkOrigin?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @name session
|
||||
* @type {SessionConfig}
|
||||
* @version 5.3.0
|
||||
* @description
|
||||
*
|
||||
* Configures experimental session support by specifying a storage `driver` as well as any associated `options`.
|
||||
* You must enable the `experimental.session` flag to use this feature.
|
||||
* Some adapters may provide a default session driver, but you can override it with your own configuration.
|
||||
*
|
||||
* You can specify [any driver from Unstorage](https://unstorage.unjs.io/drivers) or provide a custom config which will override your adapter's default.
|
||||
*
|
||||
* See [the experimental session guide](https://docs.astro.build/en/reference/experimental-flags/sessions/) for more information.
|
||||
*
|
||||
* ```js title="astro.config.mjs"
|
||||
* {
|
||||
* session: {
|
||||
* // Required: the name of the Unstorage driver
|
||||
* driver: 'redis',
|
||||
* // The required options depend on the driver
|
||||
* options: {
|
||||
* url: process.env.REDIS_URL,
|
||||
* },
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
session?: SessionConfig<TSession>;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @name vite
|
||||
|
@ -1977,7 +2007,8 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
|
|||
/**
|
||||
*
|
||||
* @name experimental.session
|
||||
* @type {SessionConfig}
|
||||
* @type {boolean}
|
||||
* @default `false`
|
||||
* @version 5.0.0
|
||||
* @description
|
||||
*
|
||||
|
@ -1994,30 +2025,12 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
|
|||
* <a href="/checkout">🛒 {cart?.length ?? 0} items</a>
|
||||
*
|
||||
* ```
|
||||
* The object configures session management for your Astro site by specifying a `driver` as well as any `options` for your data storage.
|
||||
*
|
||||
* You can specify [any driver from Unstorage](https://unstorage.unjs.io/drivers) or provide a custom config which will override your adapter's default.
|
||||
*
|
||||
* ```js title="astro.config.mjs"
|
||||
* {
|
||||
* experimental: {
|
||||
* session: {
|
||||
* // Required: the name of the Unstorage driver
|
||||
* driver: "redis",
|
||||
* // The required options depend on the driver
|
||||
* options: {
|
||||
* url: process.env.REDIS_URL,
|
||||
* }
|
||||
* }
|
||||
* },
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* For more details, see [the Sessions RFC](https://github.com/withastro/roadmap/blob/sessions/proposals/0054-sessions.md).
|
||||
* For more details, see [the experimental session guide](https://docs.astro.build/en/reference/experimental-flags/sessions/).
|
||||
*
|
||||
*/
|
||||
|
||||
session?: SessionConfig<TSession>;
|
||||
session?: boolean;
|
||||
/**
|
||||
*
|
||||
* @name experimental.svg
|
||||
|
|
|
@ -203,6 +203,6 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest
|
|||
onRequest: NOOP_MIDDLEWARE_FN,
|
||||
};
|
||||
},
|
||||
sessionConfig: settings.config.experimental.session,
|
||||
sessionConfig: settings.config.experimental.session ? settings.config.session : undefined,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1135,6 +1135,18 @@ describe('astro:image', () => {
|
|||
assert.equal(response.headers.get('content-type'), 'image/webp');
|
||||
});
|
||||
|
||||
it('returns HEAD method ok for /_image', async () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('href', '/src/assets/penguin1.jpg?origWidth=207&origHeight=243&origFormat=jpg');
|
||||
params.set('f', 'webp');
|
||||
const response = await fixture.fetch('/some-base/_image?' + String(params), {
|
||||
method: 'HEAD',
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.body, null);
|
||||
assert.equal(response.headers.get('content-type'), 'image/webp');
|
||||
});
|
||||
|
||||
it('does not interfere with query params', async () => {
|
||||
let res = await fixture.fetch('/api?src=image.png');
|
||||
const html = await res.text();
|
||||
|
|
|
@ -176,6 +176,56 @@ describe('CSRF origin check', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("return a 200 when the origin doesn't match but calling HEAD", async () => {
|
||||
let request;
|
||||
let response;
|
||||
request = new Request('http://example.com/api/', {
|
||||
headers: { origin: 'http://loreum.com', 'content-type': 'multipart/form-data' },
|
||||
method: 'HEAD',
|
||||
});
|
||||
response = await app.render(request);
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
request = new Request('http://example.com/api/', {
|
||||
headers: { origin: 'http://loreum.com', 'content-type': 'application/x-www-form-urlencoded' },
|
||||
method: 'HEAD',
|
||||
});
|
||||
response = await app.render(request);
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
request = new Request('http://example.com/api/', {
|
||||
headers: { origin: 'http://loreum.com', 'content-type': 'text/plain' },
|
||||
method: 'HEAD',
|
||||
});
|
||||
response = await app.render(request);
|
||||
assert.equal(response.status, 200);
|
||||
});
|
||||
|
||||
it("return a 200 when the origin doesn't match but calling OPTIONS", async () => {
|
||||
let request;
|
||||
let response;
|
||||
request = new Request('http://example.com/api/', {
|
||||
headers: { origin: 'http://loreum.com', 'content-type': 'multipart/form-data' },
|
||||
method: 'OPTIONS',
|
||||
});
|
||||
response = await app.render(request);
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
request = new Request('http://example.com/api/', {
|
||||
headers: { origin: 'http://loreum.com', 'content-type': 'application/x-www-form-urlencoded' },
|
||||
method: 'OPTIONS',
|
||||
});
|
||||
response = await app.render(request);
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
request = new Request('http://example.com/api/', {
|
||||
headers: { origin: 'http://loreum.com', 'content-type': 'text/plain' },
|
||||
method: 'OPTIONS',
|
||||
});
|
||||
response = await app.render(request);
|
||||
assert.equal(response.status, 200);
|
||||
});
|
||||
|
||||
it('return 200 when calling POST/PUT/DELETE/PATCH with the correct origin', async () => {
|
||||
let request;
|
||||
let response;
|
||||
|
|
|
@ -27,3 +27,15 @@ export const PATCH = () => {
|
|||
something: 'true',
|
||||
});
|
||||
};
|
||||
|
||||
export const HEAD = () => {
|
||||
return Response.json({
|
||||
something: 'true',
|
||||
});
|
||||
};
|
||||
|
||||
export const OPTIONS = () => {
|
||||
return Response.json({
|
||||
something: 'true',
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,14 +1,6 @@
|
|||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import testAdapter from '../../test-adapter.js';
|
||||
|
||||
export default defineConfig({
|
||||
adapter: testAdapter(),
|
||||
output: 'server',
|
||||
experimental: {
|
||||
session: {
|
||||
driver: 'fs',
|
||||
ttl: 20,
|
||||
},
|
||||
},
|
||||
|
||||
});
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@netlify/blobs": "^8.1.0",
|
||||
"astro": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
const url = new URL(context.url, 'http://localhost');
|
||||
let value = url.searchParams.get('set');
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/base",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/assets/*": ["src/assets/*"]
|
||||
},
|
||||
},
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
|
@ -2194,3 +2194,30 @@ describe('i18n routing with server islands', () => {
|
|||
assert.equal(serverIslandScript.length, 1, 'has the island script');
|
||||
});
|
||||
});
|
||||
|
||||
describe('i18n routing with server islands and base path', () => {
|
||||
/** @type {import('./test-utils').Fixture} */
|
||||
let fixture;
|
||||
/** @type {import('./test-utils').DevServer} */
|
||||
let devServer;
|
||||
|
||||
before(async () => {
|
||||
fixture = await loadFixture({
|
||||
root: './fixtures/i18n-server-island/',
|
||||
base: '/custom',
|
||||
});
|
||||
devServer = await fixture.startDevServer();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await devServer.stop();
|
||||
});
|
||||
|
||||
it('should render the en locale with server island', async () => {
|
||||
const res = await fixture.fetch('/custom/en/island');
|
||||
const html = await res.text();
|
||||
const $ = cheerio.load(html);
|
||||
const serverIslandScript = $('script[data-island-id]');
|
||||
assert.equal(serverIslandScript.length, 1, 'has the island script');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,25 +1,33 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { before, describe, it } from 'node:test';
|
||||
import { after, before, describe, it } from 'node:test';
|
||||
import * as devalue from 'devalue';
|
||||
import testAdapter from './test-adapter.js';
|
||||
import { loadFixture } from './test-utils.js';
|
||||
|
||||
describe('Astro.session', () => {
|
||||
/** @type {import('./test-utils').Fixture} */
|
||||
let fixture;
|
||||
|
||||
before(async () => {
|
||||
fixture = await loadFixture({
|
||||
root: './fixtures/sessions/',
|
||||
output: 'server',
|
||||
adapter: testAdapter(),
|
||||
});
|
||||
});
|
||||
|
||||
describe('Production', () => {
|
||||
/** @type {import('./test-utils').Fixture} */
|
||||
let fixture;
|
||||
|
||||
before(async () => {
|
||||
fixture = await loadFixture({
|
||||
root: './fixtures/sessions/',
|
||||
output: 'server',
|
||||
adapter: testAdapter(),
|
||||
session: {
|
||||
driver: 'fs',
|
||||
ttl: 20,
|
||||
},
|
||||
experimental: {
|
||||
session: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/** @type {import('../src/core/app/index').App} response */
|
||||
let app;
|
||||
before(async () => {
|
||||
await fixture.build();
|
||||
await fixture.build({});
|
||||
app = await fixture.loadTestAdapterApp();
|
||||
});
|
||||
|
||||
|
@ -92,4 +100,152 @@ describe('Astro.session', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Development', () => {
|
||||
/** @type {import('./test-utils').Fixture} */
|
||||
let fixture;
|
||||
let devServer;
|
||||
before(async () => {
|
||||
fixture = await loadFixture({
|
||||
root: './fixtures/sessions/',
|
||||
output: 'server',
|
||||
adapter: testAdapter(),
|
||||
session: {
|
||||
driver: 'fs',
|
||||
ttl: 20,
|
||||
},
|
||||
experimental: {
|
||||
session: true,
|
||||
},
|
||||
});
|
||||
devServer = await fixture.startDevServer();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await devServer.stop();
|
||||
});
|
||||
|
||||
it('can regenerate session cookies upon request', async () => {
|
||||
const firstResponse = await fixture.fetch('/regenerate');
|
||||
// @ts-ignore
|
||||
const firstHeaders = firstResponse.headers.get('set-cookie').split(',');
|
||||
const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1];
|
||||
|
||||
const secondResponse = await fixture.fetch('/regenerate', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
cookie: `astro-session=${firstSessionId}`,
|
||||
},
|
||||
});
|
||||
// @ts-ignore
|
||||
const secondHeaders = secondResponse.headers.get('set-cookie').split(',');
|
||||
const secondSessionId = secondHeaders[0].split(';')[0].split('=')[1];
|
||||
assert.notEqual(firstSessionId, secondSessionId);
|
||||
});
|
||||
|
||||
it('can save session data by value', async () => {
|
||||
const firstResponse = await fixture.fetch('/update');
|
||||
const firstValue = await firstResponse.json();
|
||||
assert.equal(firstValue.previousValue, 'none');
|
||||
|
||||
// @ts-ignore
|
||||
const firstHeaders = firstResponse.headers.get('set-cookie').split(',');
|
||||
const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1];
|
||||
const secondResponse = await fixture.fetch('/update', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
cookie: `astro-session=${firstSessionId}`,
|
||||
},
|
||||
});
|
||||
const secondValue = await secondResponse.json();
|
||||
assert.equal(secondValue.previousValue, 'expected');
|
||||
});
|
||||
|
||||
it('can save and restore URLs in session data', async () => {
|
||||
const firstResponse = await fixture.fetch('/_actions/addUrl', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ favoriteUrl: 'https://domain.invalid' }),
|
||||
});
|
||||
|
||||
assert.equal(firstResponse.ok, true);
|
||||
// @ts-ignore
|
||||
const firstHeaders = firstResponse.headers.get('set-cookie').split(',');
|
||||
const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1];
|
||||
|
||||
const data = devalue.parse(await firstResponse.text());
|
||||
assert.equal(data.message, 'Favorite URL set to https://domain.invalid/ from nothing');
|
||||
const secondResponse = await fixture.fetch('/_actions/addUrl', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: `astro-session=${firstSessionId}`,
|
||||
},
|
||||
body: JSON.stringify({ favoriteUrl: 'https://example.com' }),
|
||||
});
|
||||
const secondData = devalue.parse(await secondResponse.text());
|
||||
assert.equal(
|
||||
secondData.message,
|
||||
'Favorite URL set to https://example.com/ from https://domain.invalid/',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configuration', () => {
|
||||
it('throws if flag is enabled but driver is not set', async () => {
|
||||
const fixture = await loadFixture({
|
||||
root: './fixtures/sessions/',
|
||||
output: 'server',
|
||||
adapter: testAdapter(),
|
||||
experimental: {
|
||||
session: true,
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
fixture.build({}),
|
||||
/Error: The `experimental.session` flag was set to `true`, but no storage was configured/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if session is configured but flag is not enabled', async () => {
|
||||
const fixture = await loadFixture({
|
||||
root: './fixtures/sessions/',
|
||||
output: 'server',
|
||||
adapter: testAdapter(),
|
||||
session: {
|
||||
driver: 'fs',
|
||||
},
|
||||
experimental: {
|
||||
session: false,
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
fixture.build({}),
|
||||
/Error: Session config was provided without enabling the `experimental.session` flag/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if output is static', async () => {
|
||||
const fixture = await loadFixture({
|
||||
root: './fixtures/sessions/',
|
||||
output: 'static',
|
||||
session: {
|
||||
driver: 'fs',
|
||||
ttl: 20,
|
||||
},
|
||||
experimental: {
|
||||
session: true,
|
||||
},
|
||||
});
|
||||
// Disable actions so we can do a static build
|
||||
await fixture.editFile('src/actions/index.ts', () => '');
|
||||
await assert.rejects(
|
||||
fixture.build({}),
|
||||
/Sessions require an adapter that supports server output/,
|
||||
);
|
||||
await fixture.resetAllFiles();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -49,6 +49,7 @@ process.env.ASTRO_TELEMETRY_DISABLED = true;
|
|||
* @property {typeof check} check
|
||||
* @property {typeof sync} sync
|
||||
* @property {AstroConfig} config
|
||||
* @property {() => void} resetAllFiles
|
||||
*
|
||||
* This function returns an instance of the Check
|
||||
*
|
||||
|
|
312
packages/astro/test/units/render/rendering.test.js
Normal file
312
packages/astro/test/units/render/rendering.test.js
Normal file
|
@ -0,0 +1,312 @@
|
|||
import * as assert from 'node:assert/strict';
|
||||
import { beforeEach, describe, it } from 'node:test';
|
||||
import { isPromise } from 'node:util/types';
|
||||
import * as cheerio from 'cheerio';
|
||||
import {
|
||||
HTMLString,
|
||||
createComponent,
|
||||
renderComponent,
|
||||
renderTemplate,
|
||||
} from '../../../dist/runtime/server/index.js';
|
||||
|
||||
describe('rendering', () => {
|
||||
const evaluated = [];
|
||||
|
||||
const Scalar = createComponent((_result, props) => {
|
||||
evaluated.push(props.id);
|
||||
return renderTemplate`<scalar id="${props.id}"></scalar>`;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
evaluated.length = 0;
|
||||
});
|
||||
|
||||
it('components are evaluated and rendered depth-first', async () => {
|
||||
const Root = createComponent((result, props) => {
|
||||
evaluated.push(props.id);
|
||||
return renderTemplate`<root id="${props.id}">
|
||||
${renderComponent(result, '', Scalar, { id: `${props.id}/scalar_1` })}
|
||||
${renderComponent(result, '', Nested, { id: `${props.id}/nested` })}
|
||||
${renderComponent(result, '', Scalar, { id: `${props.id}/scalar_2` })}
|
||||
</root>`;
|
||||
});
|
||||
|
||||
const Nested = createComponent((result, props) => {
|
||||
evaluated.push(props.id);
|
||||
return renderTemplate`<nested id="${props.id}">
|
||||
${renderComponent(result, '', Scalar, { id: `${props.id}/scalar` })}
|
||||
</nested>`;
|
||||
});
|
||||
|
||||
const result = await renderToString(Root({}, { id: 'root' }, {}));
|
||||
const rendered = getRenderedIds(result);
|
||||
|
||||
assert.deepEqual(evaluated, [
|
||||
'root',
|
||||
'root/scalar_1',
|
||||
'root/nested',
|
||||
'root/nested/scalar',
|
||||
'root/scalar_2',
|
||||
]);
|
||||
|
||||
assert.deepEqual(rendered, [
|
||||
'root',
|
||||
'root/scalar_1',
|
||||
'root/nested',
|
||||
'root/nested/scalar',
|
||||
'root/scalar_2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('synchronous component trees are rendered without promises', () => {
|
||||
const Root = createComponent((result, props) => {
|
||||
evaluated.push(props.id);
|
||||
return renderTemplate`<root id="${props.id}">
|
||||
${() => renderComponent(result, '', Scalar, { id: `${props.id}/scalar_1` })}
|
||||
${function* () {
|
||||
yield renderComponent(result, '', Scalar, { id: `${props.id}/scalar_2` });
|
||||
}}
|
||||
${[renderComponent(result, '', Scalar, { id: `${props.id}/scalar_3` })]}
|
||||
${renderComponent(result, '', Scalar, { id: `${props.id}/scalar_4` })}
|
||||
</root>`;
|
||||
});
|
||||
|
||||
const result = renderToString(Root({}, { id: 'root' }, {}));
|
||||
assert.ok(!isPromise(result));
|
||||
|
||||
const rendered = getRenderedIds(result);
|
||||
|
||||
assert.deepEqual(evaluated, [
|
||||
'root',
|
||||
'root/scalar_1',
|
||||
'root/scalar_2',
|
||||
'root/scalar_3',
|
||||
'root/scalar_4',
|
||||
]);
|
||||
|
||||
assert.deepEqual(rendered, [
|
||||
'root',
|
||||
'root/scalar_1',
|
||||
'root/scalar_2',
|
||||
'root/scalar_3',
|
||||
'root/scalar_4',
|
||||
]);
|
||||
});
|
||||
|
||||
it('async component children are deferred', async () => {
|
||||
const Root = createComponent((result, props) => {
|
||||
evaluated.push(props.id);
|
||||
return renderTemplate`<root id="${props.id}">
|
||||
${renderComponent(result, '', AsyncNested, { id: `${props.id}/asyncnested` })}
|
||||
${renderComponent(result, '', Scalar, { id: `${props.id}/scalar` })}
|
||||
</root>`;
|
||||
});
|
||||
|
||||
const AsyncNested = createComponent(async (result, props) => {
|
||||
evaluated.push(props.id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
return renderTemplate`<asyncnested id="${props.id}">
|
||||
${renderComponent(result, '', Scalar, { id: `${props.id}/scalar` })}
|
||||
</asyncnested>`;
|
||||
});
|
||||
|
||||
const result = await renderToString(Root({}, { id: 'root' }, {}));
|
||||
|
||||
const rendered = getRenderedIds(result);
|
||||
|
||||
assert.deepEqual(evaluated, [
|
||||
'root',
|
||||
'root/asyncnested',
|
||||
'root/scalar',
|
||||
'root/asyncnested/scalar',
|
||||
]);
|
||||
|
||||
assert.deepEqual(rendered, [
|
||||
'root',
|
||||
'root/asyncnested',
|
||||
'root/asyncnested/scalar',
|
||||
'root/scalar',
|
||||
]);
|
||||
});
|
||||
|
||||
it('adjacent async components are evaluated eagerly', async () => {
|
||||
const resetEvent = new ManualResetEvent();
|
||||
|
||||
const Root = createComponent((result, props) => {
|
||||
evaluated.push(props.id);
|
||||
return renderTemplate`<root id="${props.id}">
|
||||
${renderComponent(result, '', AsyncNested, { id: `${props.id}/asyncnested_1` })}
|
||||
${renderComponent(result, '', AsyncNested, { id: `${props.id}/asyncnested_2` })}
|
||||
</root>`;
|
||||
});
|
||||
|
||||
const AsyncNested = createComponent(async (result, props) => {
|
||||
evaluated.push(props.id);
|
||||
await resetEvent.wait();
|
||||
return renderTemplate`<asyncnested id="${props.id}">
|
||||
${renderComponent(result, '', Scalar, { id: `${props.id}/scalar` })}
|
||||
</asyncnested>`;
|
||||
});
|
||||
|
||||
const awaitableResult = renderToString(Root({}, { id: 'root' }, {}));
|
||||
|
||||
assert.deepEqual(evaluated, ['root', 'root/asyncnested_1', 'root/asyncnested_2']);
|
||||
|
||||
resetEvent.release();
|
||||
|
||||
// relinquish control after release
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(evaluated, [
|
||||
'root',
|
||||
'root/asyncnested_1',
|
||||
'root/asyncnested_2',
|
||||
'root/asyncnested_1/scalar',
|
||||
'root/asyncnested_2/scalar',
|
||||
]);
|
||||
|
||||
const result = await awaitableResult;
|
||||
const rendered = getRenderedIds(result);
|
||||
|
||||
assert.deepEqual(rendered, [
|
||||
'root',
|
||||
'root/asyncnested_1',
|
||||
'root/asyncnested_1/scalar',
|
||||
'root/asyncnested_2',
|
||||
'root/asyncnested_2/scalar',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skip rendering blank html fragments', async () => {
|
||||
const Root = createComponent(() => {
|
||||
const message = 'hello world';
|
||||
return renderTemplate`${message}`;
|
||||
});
|
||||
|
||||
const renderInstance = await renderComponent({}, '', Root, {});
|
||||
|
||||
const chunks = [];
|
||||
const destination = {
|
||||
write: (chunk) => {
|
||||
chunks.push(chunk);
|
||||
},
|
||||
};
|
||||
|
||||
await renderInstance.render(destination);
|
||||
|
||||
assert.deepEqual(chunks, [new HTMLString('hello world')]);
|
||||
});
|
||||
|
||||
it('all primitives are rendered in order', async () => {
|
||||
const Root = createComponent((result, props) => {
|
||||
evaluated.push(props.id);
|
||||
return renderTemplate`<root id="${props.id}">
|
||||
${renderComponent(result, '', Scalar, { id: `${props.id}/first` })}
|
||||
${() => renderComponent(result, '', Scalar, { id: `${props.id}/func` })}
|
||||
${new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(renderComponent(result, '', Scalar, { id: `${props.id}/promise` }));
|
||||
}, 0);
|
||||
})}
|
||||
${[
|
||||
() => renderComponent(result, '', Scalar, { id: `${props.id}/array_func` }),
|
||||
renderComponent(result, '', Scalar, { id: `${props.id}/array_scalar` }),
|
||||
]}
|
||||
${async function* () {
|
||||
yield await new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(renderComponent(result, '', Scalar, { id: `${props.id}/async_generator` }));
|
||||
}, 0);
|
||||
});
|
||||
}}
|
||||
${function* () {
|
||||
yield renderComponent(result, '', Scalar, { id: `${props.id}/generator` });
|
||||
}}
|
||||
${renderComponent(result, '', Scalar, { id: `${props.id}/last` })}
|
||||
</root>`;
|
||||
});
|
||||
|
||||
const result = await renderToString(Root({}, { id: 'root' }, {}));
|
||||
|
||||
const rendered = getRenderedIds(result);
|
||||
|
||||
assert.deepEqual(rendered, [
|
||||
'root',
|
||||
'root/first',
|
||||
'root/func',
|
||||
'root/promise',
|
||||
'root/array_func',
|
||||
'root/array_scalar',
|
||||
'root/async_generator',
|
||||
'root/generator',
|
||||
'root/last',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function renderToString(item) {
|
||||
if (isPromise(item)) {
|
||||
return item.then(renderToString);
|
||||
}
|
||||
|
||||
let result = '';
|
||||
|
||||
const destination = {
|
||||
write: (chunk) => {
|
||||
result += chunk.toString();
|
||||
},
|
||||
};
|
||||
|
||||
const renderResult = item.render(destination);
|
||||
|
||||
if (isPromise(renderResult)) {
|
||||
return renderResult.then(() => result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getRenderedIds(html) {
|
||||
return cheerio
|
||||
.load(
|
||||
html,
|
||||
null,
|
||||
false,
|
||||
)('*')
|
||||
.map((_, node) => node.attribs['id'])
|
||||
.toArray();
|
||||
}
|
||||
|
||||
class ManualResetEvent {
|
||||
#resolve;
|
||||
#promise;
|
||||
#done = false;
|
||||
|
||||
release() {
|
||||
if (this.#done) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#done = true;
|
||||
|
||||
if (this.#resolve) {
|
||||
this.#resolve();
|
||||
}
|
||||
}
|
||||
|
||||
wait() {
|
||||
// Promise constructor callbacks are called immediately
|
||||
// so retrieving the value of "resolve" should
|
||||
// be safe to do.
|
||||
|
||||
if (!this.#promise) {
|
||||
this.#promise = this.#done
|
||||
? Promise.resolve()
|
||||
: new Promise((resolve) => {
|
||||
this.#resolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
return this.#promise;
|
||||
}
|
||||
}
|
|
@ -12,6 +12,7 @@ import {
|
|||
const root = new URL('../../fixtures/api-routes/', import.meta.url);
|
||||
const fileSystem = {
|
||||
'/src/pages/incorrect.ts': `export const GET = _ => {}`,
|
||||
'/src/pages/headers.ts': `export const GET = () => { return new Response('content', { status: 201, headers: { Test: 'value' } }) }`,
|
||||
};
|
||||
|
||||
describe('endpoints', () => {
|
||||
|
@ -44,4 +45,36 @@ describe('endpoints', () => {
|
|||
await done;
|
||||
assert.equal(res.statusCode, 500);
|
||||
});
|
||||
|
||||
it('should respond with 404 if GET is not implemented', async () => {
|
||||
const { req, res, done } = createRequestAndResponse({
|
||||
method: 'HEAD',
|
||||
url: '/incorrect-route',
|
||||
});
|
||||
container.handle(req, res);
|
||||
await done;
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
|
||||
it('should respond with same code as GET response', async () => {
|
||||
const { req, res, done } = createRequestAndResponse({
|
||||
method: 'HEAD',
|
||||
url: '/incorrect',
|
||||
});
|
||||
container.handle(req, res);
|
||||
await done;
|
||||
assert.equal(res.statusCode, 500); // get not returns response
|
||||
});
|
||||
|
||||
it('should remove body and pass headers for HEAD requests', async () => {
|
||||
const { req, res, done } = createRequestAndResponse({
|
||||
method: 'HEAD',
|
||||
url: '/headers',
|
||||
});
|
||||
container.handle(req, res);
|
||||
await done;
|
||||
assert.equal(res.statusCode, 201);
|
||||
assert.equal(res.getHeaders().test, 'value');
|
||||
assert.equal(res.body, undefined);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,50 @@
|
|||
# @astrojs/netlify
|
||||
|
||||
## 6.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#13194](https://github.com/withastro/astro/pull/13194) [`1b5037b`](https://github.com/withastro/astro/commit/1b5037bd77d77817e5f821aee8ceccb49b00e0d9) Thanks [@dfdez](https://github.com/dfdez)! - Adds `includedFiles` and `excludedFiles` configuration options to customize SSR function bundle contents.
|
||||
|
||||
The `includeFiles` property allows you to explicitly specify additional files that should be bundled with your function. This is useful for files that aren't automatically detected as dependencies, such as:
|
||||
|
||||
- Data files loaded using `fs` operations
|
||||
- Configuration files
|
||||
- Template files
|
||||
|
||||
Similarly, you can use the `excludeFiles` property to prevent specific files from being bundled that would otherwise be included. This is helpful for:
|
||||
|
||||
- Reducing bundle size
|
||||
- Excluding large binaries
|
||||
- Preventing unwanted files from being deployed
|
||||
|
||||
```js
|
||||
import { defineConfig } from 'astro/config';
|
||||
import netlify from '@astrojs/netlify';
|
||||
|
||||
export default defineConfig({
|
||||
// ...
|
||||
output: 'server',
|
||||
adapter: netlify({
|
||||
includeFiles: ['./my-data.json'],
|
||||
excludeFiles: ['./node_modules/package/**/*', './src/**/*.test.js'],
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
See the [Netlify adapter documentation](https://docs.astro.build/en/guides/integrations-guide/netlify/#including-or-excluding-files) for detailed usage instructions and examples.
|
||||
|
||||
- [#13145](https://github.com/withastro/astro/pull/13145) [`8d4e566`](https://github.com/withastro/astro/commit/8d4e566f5420c8a5406e1e40e8bae1c1f87cbe37) Thanks [@ascorbic](https://github.com/ascorbic)! - Automatically configures Netlify Blobs storage when experimental session enabled
|
||||
|
||||
If the `experimental.session` flag is enabled when using the Netlify adapter, Astro will automatically configure the session storage using the Netlify Blobs driver. You can still manually configure the session storage if you need to use a different driver or want to customize the session storage configuration.
|
||||
|
||||
See [the experimental session docs](https://docs.astro.build/en/reference/experimental-flags/sessions/) for more information on configuring session storage.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @astrojs/underscore-redirects@0.6.0
|
||||
|
||||
## 6.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@astrojs/netlify",
|
||||
"description": "Deploy your site to Netlify",
|
||||
"version": "6.1.0",
|
||||
"version": "6.2.0",
|
||||
"type": "module",
|
||||
"types": "./dist/index.d.ts",
|
||||
"author": "withastro",
|
||||
|
@ -36,15 +36,16 @@
|
|||
"test:hosted": "astro-scripts test \"test/hosted/*.test.js\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/internal-helpers": "0.5.1",
|
||||
"@astrojs/underscore-redirects": "^0.6.0",
|
||||
"@astrojs/internal-helpers": "workspace:*",
|
||||
"@astrojs/underscore-redirects": "workspace:*",
|
||||
"@netlify/blobs": "^8.1.0",
|
||||
"@netlify/functions": "^2.8.0",
|
||||
"@vercel/nft": "^0.29.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"vite": "^6.0.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"astro": "^5.0.0"
|
||||
"astro": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@netlify/edge-functions": "^2.11.1",
|
||||
|
@ -53,6 +54,7 @@
|
|||
"astro": "workspace:*",
|
||||
"astro-scripts": "workspace:*",
|
||||
"cheerio": "1.0.0",
|
||||
"devalue": "^5.1.1",
|
||||
"execa": "^8.0.1",
|
||||
"fast-glob": "^3.3.3",
|
||||
"strip-ansi": "^7.1.0",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { emptyDir } from '@astrojs/internal-helpers/fs';
|
||||
import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects';
|
||||
import type { Context } from '@netlify/functions';
|
||||
|
@ -13,6 +13,7 @@ import type {
|
|||
IntegrationResolvedRoute,
|
||||
} from 'astro';
|
||||
import { build } from 'esbuild';
|
||||
import glob from 'fast-glob';
|
||||
import { copyDependenciesToFunction } from './lib/nft.js';
|
||||
import type { Args } from './ssr-function.js';
|
||||
|
||||
|
@ -139,6 +140,32 @@ async function writeNetlifyFrameworkConfig(config: AstroConfig, logger: AstroInt
|
|||
}
|
||||
|
||||
export interface NetlifyIntegrationConfig {
|
||||
/**
|
||||
* Force files to be bundled with your SSR function.
|
||||
* This is useful for including any type of file that is not directly detected by the bundler,
|
||||
* like configuration files or assets that are dynamically imported at runtime.
|
||||
*
|
||||
* Note: File paths are resolved relative to your project's `root`. Absolute paths may not work as expected.
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* includeFiles: ['./src/data/*.json', './src/locales/*.yml', './src/config/*.yaml']
|
||||
* ```
|
||||
*/
|
||||
includeFiles?: string[];
|
||||
|
||||
/**
|
||||
* Exclude files from the bundling process.
|
||||
* This is useful for excluding any type of file that is not intended to be bundled with your SSR function,
|
||||
* such as large assets, temporary files, or sensitive local configuration files.
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* excludeFiles: ['./src/secret/*.json', './src/temp/*.txt']
|
||||
* ```
|
||||
*/
|
||||
excludeFiles?: string[];
|
||||
|
||||
/**
|
||||
* If enabled, On-Demand-Rendered pages are cached for up to a year.
|
||||
* This is useful for pages that are not updated often, like a blog post,
|
||||
|
@ -191,6 +218,8 @@ export default function netlifyIntegration(
|
|||
let outDir: URL;
|
||||
let rootDir: URL;
|
||||
let astroMiddlewareEntryPoint: URL | undefined = undefined;
|
||||
// Extra files to be merged with `includeFiles` during build
|
||||
const extraFilesToInclude: URL[] = [];
|
||||
// Secret used to verify that the caller is the astro-generated edge middleware and not a third-party
|
||||
const middlewareSecret = randomUUID();
|
||||
|
||||
|
@ -246,6 +275,18 @@ export default function netlifyIntegration(
|
|||
}
|
||||
}
|
||||
|
||||
async function getFilesByGlob(
|
||||
include: Array<string> = [],
|
||||
exclude: Array<string> = [],
|
||||
): Promise<Array<URL>> {
|
||||
const files = await glob(include, {
|
||||
cwd: fileURLToPath(rootDir),
|
||||
absolute: true,
|
||||
ignore: exclude,
|
||||
});
|
||||
return files.map((file) => pathToFileURL(file));
|
||||
}
|
||||
|
||||
async function writeSSRFunction({
|
||||
notFoundContent,
|
||||
logger,
|
||||
|
@ -257,12 +298,38 @@ export default function netlifyIntegration(
|
|||
}) {
|
||||
const entry = new URL('./entry.mjs', ssrBuildDir());
|
||||
|
||||
const _includeFiles = integrationConfig?.includeFiles || [];
|
||||
const _excludeFiles = integrationConfig?.excludeFiles || [];
|
||||
|
||||
if (finalBuildOutput === 'server') {
|
||||
// Merge any includes from `vite.assetsInclude
|
||||
if (_config.vite.assetsInclude) {
|
||||
const mergeGlobbedIncludes = (globPattern: unknown) => {
|
||||
if (typeof globPattern === 'string') {
|
||||
const entries = glob.sync(globPattern).map((p) => pathToFileURL(p));
|
||||
extraFilesToInclude.push(...entries);
|
||||
} else if (Array.isArray(globPattern)) {
|
||||
for (const pattern of globPattern) {
|
||||
mergeGlobbedIncludes(pattern);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mergeGlobbedIncludes(_config.vite.assetsInclude);
|
||||
}
|
||||
}
|
||||
|
||||
const includeFiles = (await getFilesByGlob(_includeFiles, _excludeFiles)).concat(
|
||||
extraFilesToInclude,
|
||||
);
|
||||
const excludeFiles = await getFilesByGlob(_excludeFiles);
|
||||
|
||||
const { handler } = await copyDependenciesToFunction(
|
||||
{
|
||||
entry,
|
||||
outDir: ssrOutputDir(),
|
||||
includeFiles: [],
|
||||
excludeFiles: [],
|
||||
includeFiles: includeFiles,
|
||||
excludeFiles: excludeFiles,
|
||||
logger,
|
||||
root,
|
||||
},
|
||||
|
@ -443,7 +510,7 @@ export default function netlifyIntegration(
|
|||
return {
|
||||
name: '@astrojs/netlify',
|
||||
hooks: {
|
||||
'astro:config:setup': async ({ config, updateConfig }) => {
|
||||
'astro:config:setup': async ({ config, updateConfig, logger }) => {
|
||||
rootDir = config.root;
|
||||
await cleanFunctions();
|
||||
|
||||
|
@ -451,6 +518,32 @@ export default function netlifyIntegration(
|
|||
|
||||
const enableImageCDN = isRunningInNetlify && (integrationConfig?.imageCDN ?? true);
|
||||
|
||||
let session = config.session;
|
||||
|
||||
if (config.experimental.session && !session?.driver) {
|
||||
logger.info(
|
||||
`Configuring experimental session support using ${isRunningInNetlify ? 'Netlify Blobs' : 'filesystem storage'}`,
|
||||
);
|
||||
session = isRunningInNetlify
|
||||
? {
|
||||
...session,
|
||||
driver: 'netlify-blobs',
|
||||
options: {
|
||||
name: 'astro-sessions',
|
||||
consistency: 'strong',
|
||||
...session?.options,
|
||||
},
|
||||
}
|
||||
: {
|
||||
...session,
|
||||
driver: 'fs-lite',
|
||||
options: {
|
||||
base: fileURLToPath(new URL('sessions', config.cacheDir)),
|
||||
...session?.options,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
updateConfig({
|
||||
outDir,
|
||||
build: {
|
||||
|
@ -458,6 +551,7 @@ export default function netlifyIntegration(
|
|||
client: outDir,
|
||||
server: ssrBuildDir(),
|
||||
},
|
||||
session,
|
||||
vite: {
|
||||
server: {
|
||||
watch: {
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
import netlify from '@astrojs/netlify';
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
adapter: netlify(),
|
||||
site: "http://example.com",
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
1,2,3
|
|
|
@ -0,0 +1 @@
|
|||
{}
|
|
@ -0,0 +1 @@
|
|||
{}
|
|
@ -0,0 +1 @@
|
|||
hello
|
|
@ -0,0 +1 @@
|
|||
1,2,3
|
|
|
@ -0,0 +1 @@
|
|||
1,2,3
|
|
|
@ -0,0 +1 @@
|
|||
hello
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "@test/netlify-includes",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@astrojs/netlify": "workspace:",
|
||||
"cowsay": "1.6.0"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
export const prerender = false
|
||||
const header = Astro.request.headers.get("x-test")
|
||||
---
|
||||
|
||||
<p>This is my custom 404 page</p>
|
||||
<p>x-test: {header}</p>
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
import { promises as fs } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const loadFile = Astro.url.searchParams.get('file');
|
||||
|
||||
const file = await fs.readFile(join(__dirname, `../../../files/${loadFile}`), 'utf-8');
|
||||
|
||||
async function moo() {
|
||||
const cow = await import('cowsay');
|
||||
return cow.say({ text: 'Moo!' });
|
||||
}
|
||||
|
||||
if (Astro.url.searchParams.get('moo')) {
|
||||
await moo();
|
||||
}
|
||||
---
|
||||
<html>
|
||||
<head><title>Testing</title></head>
|
||||
<body>
|
||||
{loadFile && <h1>{file}</h1>}
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,11 @@
|
|||
import netlify from '@astrojs/netlify';
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
adapter: netlify(),
|
||||
site: `http://example.com`,
|
||||
experimental: {
|
||||
session: true,
|
||||
}
|
||||
});
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "@test/netlify-session",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@astrojs/netlify": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"astro": "workspace:*"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "astro build",
|
||||
"start": "astro dev"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import { defineAction } from 'astro:actions';
|
||||
import { z } from 'astro:schema';
|
||||
|
||||
export const server = {
|
||||
addToCart: defineAction({
|
||||
accept: 'form',
|
||||
input: z.object({ productId: z.string() }),
|
||||
handler: async (input, context) => {
|
||||
const cart: Array<string> = (await context.session.get('cart')) || [];
|
||||
cart.push(input.productId);
|
||||
await context.session.set('cart', cart);
|
||||
return { cart, message: 'Product added to cart at ' + new Date().toTimeString() };
|
||||
},
|
||||
}),
|
||||
getCart: defineAction({
|
||||
handler: async (input, context) => {
|
||||
return await context.session.get('cart');
|
||||
},
|
||||
}),
|
||||
clearCart: defineAction({
|
||||
accept: 'json',
|
||||
handler: async (input, context) => {
|
||||
await context.session.set('cart', []);
|
||||
return { cart: [], message: 'Cart cleared at ' + new Date().toTimeString() };
|
||||
},
|
||||
}),
|
||||
addUrl: defineAction({
|
||||
input: z.object({ favoriteUrl: z.string().url() }),
|
||||
handler: async (input, context) => {
|
||||
const previousFavoriteUrl = await context.session.get<URL>('favoriteUrl');
|
||||
const url = new URL(input.favoriteUrl);
|
||||
context.session.set('favoriteUrl', url);
|
||||
return { message: 'Favorite URL set to ' + url.href + ' from ' + (previousFavoriteUrl?.href ?? "nothing") };
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
import { defineMiddleware } from 'astro:middleware';
|
||||
import { getActionContext } from 'astro:actions';
|
||||
|
||||
const ACTION_SESSION_KEY = 'actionResult'
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
// Skip requests for prerendered pages
|
||||
if (context.isPrerendered) return next();
|
||||
|
||||
const { action, setActionResult, serializeActionResult } =
|
||||
getActionContext(context);
|
||||
|
||||
console.log(action?.name)
|
||||
|
||||
const actionPayload = await context.session.get(ACTION_SESSION_KEY);
|
||||
|
||||
if (actionPayload) {
|
||||
setActionResult(actionPayload.actionName, actionPayload.actionResult);
|
||||
context.session.delete(ACTION_SESSION_KEY);
|
||||
return next();
|
||||
}
|
||||
|
||||
// If an action was called from an HTML form action,
|
||||
// call the action handler and redirect to the destination page
|
||||
if (action?.calledFrom === "form") {
|
||||
const actionResult = await action.handler();
|
||||
|
||||
context.session.set(ACTION_SESSION_KEY, {
|
||||
actionName: action.name,
|
||||
actionResult: serializeActionResult(actionResult),
|
||||
});
|
||||
|
||||
|
||||
// Redirect back to the previous page on error
|
||||
if (actionResult.error) {
|
||||
const referer = context.request.headers.get("Referer");
|
||||
if (!referer) {
|
||||
throw new Error(
|
||||
"Internal: Referer unexpectedly missing from Action POST request.",
|
||||
);
|
||||
}
|
||||
return context.redirect(referer);
|
||||
}
|
||||
// Redirect to the destination page on success
|
||||
return context.redirect(context.originPathname);
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
|
@ -0,0 +1,13 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
const url = new URL(context.url, 'http://localhost');
|
||||
let value = url.searchParams.get('set');
|
||||
if (value) {
|
||||
context.session.set('value', value);
|
||||
} else {
|
||||
value = await context.session.get('value');
|
||||
}
|
||||
const cart = await context.session.get('cart');
|
||||
return Response.json({ value, cart });
|
||||
};
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
import { actions } from "astro:actions";
|
||||
|
||||
const result = Astro.getActionResult(actions.addToCart);
|
||||
|
||||
const cart = result?.data?.cart ?? await Astro.session.get('cart');
|
||||
const message = result?.data?.message ?? 'Add something to your cart!';
|
||||
---
|
||||
<p>Cart: <span id="cart">{JSON.stringify(cart)}</span></p>
|
||||
<p id="message">{message}</p>
|
||||
<form action={actions.addToCart} method="POST">
|
||||
<input type="text" name="productId" value="shoe" />
|
||||
<button type="submit">Add to Cart</button>
|
||||
</form>
|
||||
<input type="button" value="Clear Cart" id="clearCart" />
|
||||
<script>
|
||||
import { actions } from "astro:actions";
|
||||
async function clearCart() {
|
||||
const result = await actions.clearCart({});
|
||||
document.getElementById('cart').textContent = JSON.stringify(result.data.cart);
|
||||
document.getElementById('message').textContent = result.data.message;
|
||||
}
|
||||
document.getElementById('clearCart').addEventListener('click', clearCart);
|
||||
</script>
|
|
@ -0,0 +1,6 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
await context.session.destroy();
|
||||
return Response.json({});
|
||||
};
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
const value = await Astro.session.get('value');
|
||||
---
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Hi</title>
|
||||
</head>
|
||||
|
||||
<h1>Hi</h1>
|
||||
<p>{value}</p>
|
||||
<a href="/cart" style="font-size: 36px">🛒</a>
|
||||
</html>
|
|
@ -0,0 +1,6 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
await context.session.regenerate();
|
||||
return Response.json({});
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
const previousObject = await context.session.get("key") ?? { value: "none" };
|
||||
const previousValue = previousObject.value;
|
||||
const sessionData = { value: "expected" };
|
||||
context.session.set("key", sessionData);
|
||||
sessionData.value = "unexpected";
|
||||
return Response.json({previousValue});
|
||||
};
|
|
@ -0,0 +1,184 @@
|
|||
import * as assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { after, before, describe, it } from 'node:test';
|
||||
import netlify from '@astrojs/netlify';
|
||||
import * as cheerio from 'cheerio';
|
||||
import glob from 'fast-glob';
|
||||
import { loadFixture } from '../../../../astro/test/test-utils.js';
|
||||
|
||||
describe(
|
||||
'Included vite assets files',
|
||||
() => {
|
||||
let fixture;
|
||||
|
||||
const root = new URL('./fixtures/includes/', import.meta.url);
|
||||
const expectedCwd = new URL('.netlify/v1/functions/ssr/packages/integrations/netlify/', root);
|
||||
|
||||
const expectedAssetsInclude = ['./*.json'];
|
||||
const excludedAssets = ['./files/exclude-asset.json'];
|
||||
|
||||
before(async () => {
|
||||
fixture = await loadFixture({
|
||||
root,
|
||||
vite: {
|
||||
assetsInclude: expectedAssetsInclude,
|
||||
},
|
||||
adapter: netlify({
|
||||
excludeFiles: excludedAssets,
|
||||
}),
|
||||
});
|
||||
await fixture.build();
|
||||
});
|
||||
|
||||
it('Emits vite assets files', async () => {
|
||||
for (const pattern of expectedAssetsInclude) {
|
||||
const files = glob.sync(pattern);
|
||||
for (const file of files) {
|
||||
assert.ok(
|
||||
existsSync(new URL(file, expectedCwd)),
|
||||
`Expected file ${pattern} to exist in build`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('Does not include vite assets files when excluded', async () => {
|
||||
for (const file of excludedAssets) {
|
||||
assert.ok(
|
||||
!existsSync(new URL(file, expectedCwd)),
|
||||
`Expected file ${file} to not exist in build`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await fixture.clean();
|
||||
});
|
||||
},
|
||||
{
|
||||
timeout: 120000,
|
||||
},
|
||||
);
|
||||
|
||||
describe(
|
||||
'Included files',
|
||||
() => {
|
||||
let fixture;
|
||||
|
||||
const root = new URL('./fixtures/includes/', import.meta.url);
|
||||
const expectedCwd = new URL(
|
||||
'.netlify/v1/functions/ssr/packages/integrations/netlify/test/functions/fixtures/includes/',
|
||||
root,
|
||||
);
|
||||
|
||||
const expectedFiles = [
|
||||
'./files/include-this.txt',
|
||||
'./files/also-this.csv',
|
||||
'./files/subdirectory/and-this.csv',
|
||||
];
|
||||
|
||||
before(async () => {
|
||||
fixture = await loadFixture({
|
||||
root,
|
||||
adapter: netlify({
|
||||
includeFiles: expectedFiles,
|
||||
}),
|
||||
});
|
||||
await fixture.build();
|
||||
});
|
||||
|
||||
it('Emits include files', async () => {
|
||||
for (const file of expectedFiles) {
|
||||
assert.ok(existsSync(new URL(file, expectedCwd)), `Expected file ${file} to exist`);
|
||||
}
|
||||
});
|
||||
|
||||
it('Can load included files correctly', async () => {
|
||||
const entryURL = new URL(
|
||||
'./fixtures/includes/.netlify/v1/functions/ssr/ssr.mjs',
|
||||
import.meta.url,
|
||||
);
|
||||
const { default: handler } = await import(entryURL);
|
||||
const resp = await handler(new Request('http://example.com/?file=include-this.txt'), {});
|
||||
const html = await resp.text();
|
||||
const $ = cheerio.load(html);
|
||||
assert.equal($('h1').text(), 'hello');
|
||||
});
|
||||
|
||||
it('Includes traced node modules with symlinks', async () => {
|
||||
const expected = new URL(
|
||||
'.netlify/v1/functions/ssr/node_modules/.pnpm/cowsay@1.6.0/node_modules/cowsay/cows/happy-whale.cow',
|
||||
root,
|
||||
);
|
||||
assert.ok(existsSync(expected, 'Expected excluded file to exist in default build'));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await fixture.clean();
|
||||
});
|
||||
},
|
||||
{
|
||||
timeout: 120000,
|
||||
},
|
||||
);
|
||||
|
||||
describe(
|
||||
'Excluded files',
|
||||
() => {
|
||||
let fixture;
|
||||
|
||||
const root = new URL('./fixtures/includes/', import.meta.url);
|
||||
const expectedCwd = new URL(
|
||||
'.netlify/v1/functions/ssr/packages/integrations/netlify/test/functions/fixtures/includes/',
|
||||
root,
|
||||
);
|
||||
|
||||
const includeFiles = ['./files/**/*.txt'];
|
||||
const excludedTxt = ['./files/subdirectory/not-this.txt', './files/subdirectory/or-this.txt'];
|
||||
const excludeFiles = [...excludedTxt, '../../../../../../../node_modules/.pnpm/cowsay@*/**'];
|
||||
|
||||
before(async () => {
|
||||
fixture = await loadFixture({
|
||||
root,
|
||||
adapter: netlify({
|
||||
includeFiles: includeFiles,
|
||||
excludeFiles: excludeFiles,
|
||||
}),
|
||||
});
|
||||
await fixture.build();
|
||||
});
|
||||
|
||||
it('Excludes traced node modules', async () => {
|
||||
const expected = new URL(
|
||||
'.netlify/v1/functions/ssr/node_modules/.pnpm/cowsay@1.6.0/node_modules/cowsay/cows/happy-whale.cow',
|
||||
root,
|
||||
);
|
||||
assert.ok(!existsSync(expected), 'Expected excluded file to not exist in build');
|
||||
});
|
||||
|
||||
it('Does not include files when excluded', async () => {
|
||||
for (const pattern of includeFiles) {
|
||||
const files = glob.sync(pattern, { ignore: excludedTxt });
|
||||
for (const file of files) {
|
||||
assert.ok(
|
||||
existsSync(new URL(file, expectedCwd)),
|
||||
`Expected file ${pattern} to exist in build`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const file of excludedTxt) {
|
||||
assert.ok(
|
||||
!existsSync(new URL(file, expectedCwd)),
|
||||
`Expected file ${file} to not exist in build`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await fixture.clean();
|
||||
});
|
||||
},
|
||||
{
|
||||
timeout: 120000,
|
||||
},
|
||||
);
|
129
packages/integrations/netlify/test/functions/sessions.test.js
Normal file
129
packages/integrations/netlify/test/functions/sessions.test.js
Normal file
|
@ -0,0 +1,129 @@
|
|||
// @ts-check
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, rm } from 'node:fs/promises';
|
||||
import { after, before, describe, it } from 'node:test';
|
||||
import { BlobsServer } from '@netlify/blobs/server';
|
||||
import * as devalue from 'devalue';
|
||||
import { loadFixture } from '../../../../astro/test/test-utils.js';
|
||||
import netlify from '../../dist/index.js';
|
||||
const token = 'mock';
|
||||
const siteID = '1';
|
||||
const dataDir = '.netlify/sessions';
|
||||
const options = {
|
||||
name: 'test',
|
||||
uncachedEdgeURL: `http://localhost:8971`,
|
||||
edgeURL: `http://localhost:8971`,
|
||||
token,
|
||||
siteID,
|
||||
region: 'us-east-1',
|
||||
};
|
||||
|
||||
describe('Astro.session', () => {
|
||||
describe('Production', () => {
|
||||
/** @type {import('../../../../astro/test/test-utils.js').Fixture} */
|
||||
let fixture;
|
||||
|
||||
/** @type {BlobsServer} */
|
||||
let blobServer;
|
||||
before(async () => {
|
||||
process.env.NETLIFY = '1';
|
||||
await rm(dataDir, { recursive: true, force: true }).catch(() => {});
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
blobServer = new BlobsServer({
|
||||
directory: dataDir,
|
||||
token,
|
||||
port: 8971,
|
||||
});
|
||||
await blobServer.start();
|
||||
fixture = await loadFixture({
|
||||
// @ts-ignore
|
||||
root: new URL('./fixtures/sessions/', import.meta.url),
|
||||
output: 'server',
|
||||
adapter: netlify(),
|
||||
experimental: {
|
||||
session: true,
|
||||
},
|
||||
// @ts-ignore
|
||||
session: { driver: '', options },
|
||||
});
|
||||
await fixture.build({});
|
||||
const entryURL = new URL(
|
||||
'./fixtures/sessions/.netlify/v1/functions/ssr/ssr.mjs',
|
||||
import.meta.url,
|
||||
);
|
||||
const mod = await import(entryURL.href);
|
||||
handler = mod.default;
|
||||
});
|
||||
let handler;
|
||||
after(async () => {
|
||||
await blobServer.stop();
|
||||
delete process.env.NETLIFY;
|
||||
});
|
||||
async function fetchResponse(path, requestInit) {
|
||||
return handler(new Request(new URL(path, 'http://example.com'), requestInit), {});
|
||||
}
|
||||
|
||||
it('can regenerate session cookies upon request', async () => {
|
||||
const firstResponse = await fetchResponse('/regenerate', { method: 'GET' });
|
||||
const firstHeaders = firstResponse.headers.get('set-cookie').split(',');
|
||||
const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1];
|
||||
|
||||
const secondResponse = await fetchResponse('/regenerate', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
cookie: `astro-session=${firstSessionId}`,
|
||||
},
|
||||
});
|
||||
const secondHeaders = secondResponse.headers.get('set-cookie').split(',');
|
||||
const secondSessionId = secondHeaders[0].split(';')[0].split('=')[1];
|
||||
assert.notEqual(firstSessionId, secondSessionId);
|
||||
});
|
||||
|
||||
it('can save session data by value', async () => {
|
||||
const firstResponse = await fetchResponse('/update', { method: 'GET' });
|
||||
const firstValue = await firstResponse.json();
|
||||
assert.equal(firstValue.previousValue, 'none');
|
||||
|
||||
const firstHeaders = firstResponse.headers.get('set-cookie').split(',');
|
||||
const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1];
|
||||
const secondResponse = await fetchResponse('/update', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
cookie: `astro-session=${firstSessionId}`,
|
||||
},
|
||||
});
|
||||
const secondValue = await secondResponse.json();
|
||||
assert.equal(secondValue.previousValue, 'expected');
|
||||
});
|
||||
|
||||
it('can save and restore URLs in session data', async () => {
|
||||
const firstResponse = await fetchResponse('/_actions/addUrl', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ favoriteUrl: 'https://domain.invalid' }),
|
||||
});
|
||||
|
||||
assert.equal(firstResponse.ok, true);
|
||||
const firstHeaders = firstResponse.headers.get('set-cookie').split(',');
|
||||
const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1];
|
||||
|
||||
const data = devalue.parse(await firstResponse.text());
|
||||
assert.equal(data.message, 'Favorite URL set to https://domain.invalid/ from nothing');
|
||||
const secondResponse = await fetchResponse('/_actions/addUrl', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: `astro-session=${firstSessionId}`,
|
||||
},
|
||||
body: JSON.stringify({ favoriteUrl: 'https://example.com' }),
|
||||
});
|
||||
const secondData = devalue.parse(await secondResponse.text());
|
||||
assert.equal(
|
||||
secondData.message,
|
||||
'Favorite URL set to https://example.com/ from https://domain.invalid/',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,5 +1,21 @@
|
|||
# @astrojs/node
|
||||
|
||||
## 9.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#13145](https://github.com/withastro/astro/pull/13145) [`8d4e566`](https://github.com/withastro/astro/commit/8d4e566f5420c8a5406e1e40e8bae1c1f87cbe37) Thanks [@ascorbic](https://github.com/ascorbic)! - Automatically configures filesystem storage when experimental session enabled
|
||||
|
||||
If the `experimental.session` flag is enabled when using the Node adapter, Astro will automatically configure session storage using the filesystem driver. You can still manually configure session storage if you need to use a different driver or want to customize the session storage configuration.
|
||||
|
||||
See [the experimental session docs](https://docs.astro.build/en/reference/experimental-flags/sessions/) for more information on configuring session storage.
|
||||
|
||||
## 9.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#13223](https://github.com/withastro/astro/pull/13223) [`23094a1`](https://github.com/withastro/astro/commit/23094a1f48d0dfb12c5866a3713f52106ef927dd) Thanks [@ascorbic](https://github.com/ascorbic)! - Fixes a bug that caused incorrect redirects for static files with numbers in the file extension
|
||||
|
||||
## 9.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@astrojs/node",
|
||||
"description": "Deploy your site to a Node.js server",
|
||||
"version": "9.0.2",
|
||||
"version": "9.1.0",
|
||||
"type": "module",
|
||||
"types": "./dist/index.d.ts",
|
||||
"author": "withastro",
|
||||
|
@ -31,11 +31,12 @@
|
|||
"test": "astro-scripts test \"test/**/*.test.js\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/internal-helpers": "workspace:*",
|
||||
"send": "^1.1.0",
|
||||
"server-destroy": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"astro": "^5.0.0"
|
||||
"astro": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.6",
|
||||
|
@ -44,6 +45,7 @@
|
|||
"astro": "workspace:*",
|
||||
"astro-scripts": "workspace:*",
|
||||
"cheerio": "1.0.0",
|
||||
"devalue": "^5.1.1",
|
||||
"express": "^4.21.2",
|
||||
"node-mocks-http": "^1.16.2"
|
||||
},
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import type { AstroAdapter, AstroIntegration } from 'astro';
|
||||
import { AstroError } from 'astro/errors';
|
||||
import type { Options, UserOptions } from './types.js';
|
||||
|
@ -33,11 +34,25 @@ export default function createIntegration(userOptions: UserOptions): AstroIntegr
|
|||
return {
|
||||
name: '@astrojs/node',
|
||||
hooks: {
|
||||
'astro:config:setup': async ({ updateConfig, config }) => {
|
||||
'astro:config:setup': async ({ updateConfig, config, logger }) => {
|
||||
let session = config.session;
|
||||
|
||||
if (config.experimental.session && !session?.driver) {
|
||||
logger.info('Configuring experimental session support using filesystem storage');
|
||||
session = {
|
||||
...session,
|
||||
driver: 'fs-lite',
|
||||
options: {
|
||||
base: fileURLToPath(new URL('sessions', config.cacheDir)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
updateConfig({
|
||||
image: {
|
||||
endpoint: config.image.endpoint ?? 'astro/assets/endpoint/node',
|
||||
},
|
||||
session,
|
||||
vite: {
|
||||
ssr: {
|
||||
noExternal: ['@astrojs/node'],
|
||||
|
|
|
@ -2,13 +2,11 @@ import fs from 'node:fs';
|
|||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import path from 'node:path';
|
||||
import url from 'node:url';
|
||||
import { hasFileExtension } from '@astrojs/internal-helpers/path';
|
||||
import type { NodeApp } from 'astro/app/node';
|
||||
import send from 'send';
|
||||
import type { Options } from './types.js';
|
||||
|
||||
// check for a dot followed by a extension made up of lowercase characters
|
||||
const isSubresourceRegex = /.+\.[a-z]+$/i;
|
||||
|
||||
/**
|
||||
* Creates a Node.js http listener for static files and prerendered pages.
|
||||
* In standalone mode, the static handler is queried first for the static files.
|
||||
|
@ -56,7 +54,7 @@ export function createStaticHandler(app: NodeApp, options: Options) {
|
|||
}
|
||||
case 'always': {
|
||||
// trailing slash is not added to "subresources"
|
||||
if (!hasSlash && !isSubresourceRegex.test(urlPath)) {
|
||||
if (!hasSlash && !hasFileExtension(urlPath)) {
|
||||
pathname = urlPath + '/' + (urlQuery ? '?' + urlQuery : '');
|
||||
res.statusCode = 301;
|
||||
res.setHeader('Location', pathname);
|
||||
|
|
6
packages/integrations/node/test/fixtures/sessions/astro.config.mjs
vendored
Normal file
6
packages/integrations/node/test/fixtures/sessions/astro.config.mjs
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
});
|
9
packages/integrations/node/test/fixtures/sessions/package.json
vendored
Normal file
9
packages/integrations/node/test/fixtures/sessions/package.json
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "@test/node-sessions",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@astrojs/node": "workspace:*",
|
||||
"astro": "workspace:*"
|
||||
}
|
||||
}
|
36
packages/integrations/node/test/fixtures/sessions/src/actions/index.ts
vendored
Normal file
36
packages/integrations/node/test/fixtures/sessions/src/actions/index.ts
vendored
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { defineAction } from 'astro:actions';
|
||||
import { z } from 'astro:schema';
|
||||
|
||||
export const server = {
|
||||
addToCart: defineAction({
|
||||
accept: 'form',
|
||||
input: z.object({ productId: z.string() }),
|
||||
handler: async (input, context) => {
|
||||
const cart: Array<string> = (await context.session.get('cart')) || [];
|
||||
cart.push(input.productId);
|
||||
await context.session.set('cart', cart);
|
||||
return { cart, message: 'Product added to cart at ' + new Date().toTimeString() };
|
||||
},
|
||||
}),
|
||||
getCart: defineAction({
|
||||
handler: async (input, context) => {
|
||||
return await context.session.get('cart');
|
||||
},
|
||||
}),
|
||||
clearCart: defineAction({
|
||||
accept: 'json',
|
||||
handler: async (input, context) => {
|
||||
await context.session.set('cart', []);
|
||||
return { cart: [], message: 'Cart cleared at ' + new Date().toTimeString() };
|
||||
},
|
||||
}),
|
||||
addUrl: defineAction({
|
||||
input: z.object({ favoriteUrl: z.string().url() }),
|
||||
handler: async (input, context) => {
|
||||
const previousFavoriteUrl = await context.session.get<URL>('favoriteUrl');
|
||||
const url = new URL(input.favoriteUrl);
|
||||
context.session.set('favoriteUrl', url);
|
||||
return { message: 'Favorite URL set to ' + url.href + ' from ' + (previousFavoriteUrl?.href ?? "nothing") };
|
||||
}
|
||||
})
|
||||
}
|
49
packages/integrations/node/test/fixtures/sessions/src/middleware.ts
vendored
Normal file
49
packages/integrations/node/test/fixtures/sessions/src/middleware.ts
vendored
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { defineMiddleware } from 'astro:middleware';
|
||||
import { getActionContext } from 'astro:actions';
|
||||
|
||||
const ACTION_SESSION_KEY = 'actionResult'
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
// Skip requests for prerendered pages
|
||||
if (context.isPrerendered) return next();
|
||||
|
||||
const { action, setActionResult, serializeActionResult } =
|
||||
getActionContext(context);
|
||||
|
||||
console.log(action?.name)
|
||||
|
||||
const actionPayload = await context.session.get(ACTION_SESSION_KEY);
|
||||
|
||||
if (actionPayload) {
|
||||
setActionResult(actionPayload.actionName, actionPayload.actionResult);
|
||||
context.session.delete(ACTION_SESSION_KEY);
|
||||
return next();
|
||||
}
|
||||
|
||||
// If an action was called from an HTML form action,
|
||||
// call the action handler and redirect to the destination page
|
||||
if (action?.calledFrom === "form") {
|
||||
const actionResult = await action.handler();
|
||||
|
||||
context.session.set(ACTION_SESSION_KEY, {
|
||||
actionName: action.name,
|
||||
actionResult: serializeActionResult(actionResult),
|
||||
});
|
||||
|
||||
|
||||
// Redirect back to the previous page on error
|
||||
if (actionResult.error) {
|
||||
const referer = context.request.headers.get("Referer");
|
||||
if (!referer) {
|
||||
throw new Error(
|
||||
"Internal: Referer unexpectedly missing from Action POST request.",
|
||||
);
|
||||
}
|
||||
return context.redirect(referer);
|
||||
}
|
||||
// Redirect to the destination page on success
|
||||
return context.redirect(context.originPathname);
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
13
packages/integrations/node/test/fixtures/sessions/src/pages/api.ts
vendored
Normal file
13
packages/integrations/node/test/fixtures/sessions/src/pages/api.ts
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
const url = new URL(context.url, 'http://localhost');
|
||||
let value = url.searchParams.get('set');
|
||||
if (value) {
|
||||
context.session.set('value', value);
|
||||
} else {
|
||||
value = await context.session.get('value');
|
||||
}
|
||||
const cart = await context.session.get('cart');
|
||||
return Response.json({ value, cart });
|
||||
};
|
24
packages/integrations/node/test/fixtures/sessions/src/pages/cart.astro
vendored
Normal file
24
packages/integrations/node/test/fixtures/sessions/src/pages/cart.astro
vendored
Normal file
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
import { actions } from "astro:actions";
|
||||
|
||||
const result = Astro.getActionResult(actions.addToCart);
|
||||
|
||||
const cart = result?.data?.cart ?? await Astro.session.get('cart');
|
||||
const message = result?.data?.message ?? 'Add something to your cart!';
|
||||
---
|
||||
<p>Cart: <span id="cart">{JSON.stringify(cart)}</span></p>
|
||||
<p id="message">{message}</p>
|
||||
<form action={actions.addToCart} method="POST">
|
||||
<input type="text" name="productId" value="shoe" />
|
||||
<button type="submit">Add to Cart</button>
|
||||
</form>
|
||||
<input type="button" value="Clear Cart" id="clearCart" />
|
||||
<script>
|
||||
import { actions } from "astro:actions";
|
||||
async function clearCart() {
|
||||
const result = await actions.clearCart({});
|
||||
document.getElementById('cart').textContent = JSON.stringify(result.data.cart);
|
||||
document.getElementById('message').textContent = result.data.message;
|
||||
}
|
||||
document.getElementById('clearCart').addEventListener('click', clearCart);
|
||||
</script>
|
6
packages/integrations/node/test/fixtures/sessions/src/pages/destroy.ts
vendored
Normal file
6
packages/integrations/node/test/fixtures/sessions/src/pages/destroy.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
await context.session.destroy();
|
||||
return Response.json({});
|
||||
};
|
13
packages/integrations/node/test/fixtures/sessions/src/pages/index.astro
vendored
Normal file
13
packages/integrations/node/test/fixtures/sessions/src/pages/index.astro
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
const value = await Astro.session.get('value');
|
||||
---
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Hi</title>
|
||||
</head>
|
||||
|
||||
<h1>Hi</h1>
|
||||
<p>{value}</p>
|
||||
<a href="/cart" style="font-size: 36px">🛒</a>
|
||||
</html>
|
6
packages/integrations/node/test/fixtures/sessions/src/pages/regenerate.ts
vendored
Normal file
6
packages/integrations/node/test/fixtures/sessions/src/pages/regenerate.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
await context.session.regenerate();
|
||||
return Response.json({});
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue